Files
Cascade/main.go
2026-05-02 02:16:46 +04:00

479 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"unsafe"
)
const (
appVersion = 0.01
maxInputLen = 16
maxConfigLineLen = 4096
)
// appConfig holds values read from the application config file.
type appConfig struct {
shell string // shell to use when running commands, e.g. /bin/bash
menuPath string // explicit path to the menu config
}
// loadKV reads a simple key=value file, skipping blank lines and # comments.
func loadKV(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
result := map[string]string{}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, maxConfigLineLen), maxConfigLineLen)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
if key != "" {
result[key] = val
}
}
return result, scanner.Err()
}
// resolveAppConfig loads the application config using the cascade:
// 1. $CASCADE_CONF
// 2. $HOME/.cascade/config.ini
// 3. /opt/cascade/config.ini
// 4. built-in defaults (shell=/bin/sh, no menu)
func resolveAppConfig() appConfig {
cfg := appConfig{shell: "/bin/sh"}
var candidates []string
if v := os.Getenv("CASCADE_CONF"); v != "" {
candidates = append(candidates, v)
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates, filepath.Join(home, ".cascade", "config.ini"))
}
candidates = append(candidates, "/opt/cascade/config.ini")
for _, path := range candidates {
kv, err := loadKV(path)
if err != nil {
continue // not found or unreadable — try next
}
if v := kv["shell"]; v != "" {
cfg.shell = v
}
if v := kv["menu"]; v != "" {
cfg.menuPath = v
}
break
}
return cfg
}
// resolveMenuPath finds the menu file using the cascade:
// 1. $CASCADE_MENU
// 2. menu= value from appConfig
// 3. $HOME/.cascade/menu.ini
// 4. /opt/cascade/menu.ini
// 5. error
func resolveMenuPath(cfg appConfig) (string, error) {
if v := os.Getenv("CASCADE_MENU"); v != "" {
return v, nil
}
if cfg.menuPath != "" {
return cfg.menuPath, nil
}
if home, err := os.UserHomeDir(); err == nil {
p := filepath.Join(home, ".cascade", "menu.ini")
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
if _, err := os.Stat("/opt/cascade/menu.ini"); err == nil {
return "/opt/cascade/menu.ini", nil
}
return "", fmt.Errorf(
"menu config not found: set CASCADE_MENU env var, add 'menu=' to app config, " +
"or place menu.ini in ~/.cascade/ or /opt/cascade/",
)
}
// Node represents one level in the menu tree.
type Node struct {
children map[string]*Node
command string
}
func newNode() *Node {
return &Node{children: make(map[string]*Node)}
}
// loadTree reads the menu file and builds a tree from dot-separated keys.
func loadTree(path string) (*Node, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("cannot open menu file %s: %w", path, err)
}
defer f.Close()
root := newNode()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, maxConfigLineLen), maxConfigLineLen)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx < 0 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
if key == "" || val == "" {
continue
}
parts := strings.Split(key, ".")
node := root
for i, part := range parts {
if _, ok := node.children[part]; !ok {
node.children[part] = newNode()
}
if i == len(parts)-1 {
node.children[part].command = val
} else {
node = node.children[part]
}
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("error reading menu: %w", err)
}
return root, nil
}
// selfKey is a synthetic sentinel used when a node has both its own command and children.
const selfKey = "\x00"
func sortedKeys(node *Node) []string {
keys := make([]string, 0, len(node.children))
for k := range node.children {
keys = append(keys, k)
}
sort.Strings(keys)
if node.command != "" && len(keys) > 0 {
keys = append([]string{selfKey}, keys...)
}
return keys
}
func navigateTo(root *Node, pathKeys []string) (*Node, []string) {
current := root
for _, key := range pathKeys {
child, ok := current.children[key]
if !ok {
return root, pathKeys[:0]
}
current = child
}
return current, pathKeys
}
func printMenu(keys []string, pathKeys []string, cursor int, numBuf string) {
fmt.Print("\033[H\033[2J")
if len(pathKeys) > 0 {
fmt.Printf("[ %s ]\n", strings.Join(pathKeys, " > "))
} else {
fmt.Printf("[ Cascade v%.2f ]\n", appVersion)
}
fmt.Printf("\nSelect Option:\n\n")
for i, k := range keys {
var label string
if k == selfKey {
label = "(run directly)"
} else {
label = k
}
if i == cursor {
fmt.Printf(" %d) %s <-\n", i+1, label)
} else {
fmt.Printf(" %d) %s\n", i+1, label)
}
}
fmt.Println()
exitLabel := "Exit"
if len(pathKeys) > 0 {
exitLabel = "Back"
}
if cursor == len(keys) {
fmt.Printf(" 0) %s <-\n", exitLabel)
} else {
fmt.Printf(" 0) %s\n", exitLabel)
}
fmt.Print("\nEnter number: ")
if numBuf != "" {
fmt.Print(numBuf)
}
}
func isDigitsOnly(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
func runCommand(shell, command string) error {
cmd := exec.Command(shell, "-c", command)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// makeRaw switches fd to raw mode (no line buffering, no echo) and returns
// the previous terminal state so it can be restored later.
func makeRaw(fd int) (syscall.Termios, error) {
var old syscall.Termios
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
uintptr(fd), syscall.TCGETS, uintptr(unsafe.Pointer(&old))); errno != 0 {
return old, errno
}
raw := old
raw.Lflag &^= syscall.ICANON | syscall.ECHO
raw.Cc[syscall.VMIN] = 1
raw.Cc[syscall.VTIME] = 0
if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&raw))); errno != 0 {
return old, errno
}
return old, nil
}
// restoreTerminal restores fd to the given terminal state.
func restoreTerminal(fd int, old syscall.Termios) {
syscall.Syscall(syscall.SYS_IOCTL,
uintptr(fd), syscall.TCSETS, uintptr(unsafe.Pointer(&old)))
}
// Key kind constants used by readKey.
const (
keyUp = iota
keyDown
keyEnter
keyBackspace
keyDigit
keyOther
)
// keyEvent represents a single logical key press.
type keyEvent struct {
kind int
digit byte // valid when kind == keyDigit
}
// readKey reads one logical key press from r.
// It recognises Up/Down arrow escape sequences, Enter, Backspace/Delete,
// and digit characters ('0''9'). Everything else returns keyOther.
func readKey(r *bufio.Reader) keyEvent {
b, err := r.ReadByte()
if err != nil {
return keyEvent{kind: keyOther}
}
switch b {
case '\r', '\n':
return keyEvent{kind: keyEnter}
case 127, 8: // DEL or BS
return keyEvent{kind: keyBackspace}
case 0x1B: // start of CSI escape sequence
b2, err := r.ReadByte()
if err != nil || b2 != '[' {
return keyEvent{kind: keyOther}
}
b3, err := r.ReadByte()
if err != nil {
return keyEvent{kind: keyOther}
}
switch b3 {
case 'A':
return keyEvent{kind: keyUp}
case 'B':
return keyEvent{kind: keyDown}
}
return keyEvent{kind: keyOther}
default:
if b >= '0' && b <= '9' {
return keyEvent{kind: keyDigit, digit: b}
}
return keyEvent{kind: keyOther}
}
}
func main() {
reader := bufio.NewReader(os.Stdin)
termFd := int(os.Stdin.Fd())
cfg := resolveAppConfig()
menuPath, err := resolveMenuPath(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var pathKeys []string
cursor := 0
numBuf := ""
for {
root, err := loadTree(menuPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
current, pathKeys2 := navigateTo(root, pathKeys)
pathKeys = pathKeys2
keys := sortedKeys(current)
if len(keys) == 0 {
fmt.Fprintln(os.Stderr, "No variants found in menu file.")
os.Exit(1)
}
if cursor > len(keys) {
cursor = len(keys)
}
printMenu(keys, pathKeys, cursor, numBuf)
oldTermios, rawErr := makeRaw(termFd)
key := readKey(reader)
if rawErr == nil {
restoreTerminal(termFd, oldTermios)
}
switch key.kind {
case keyUp:
numBuf = ""
if cursor > 0 {
cursor--
} else {
cursor = len(keys)
}
case keyDown:
numBuf = ""
if cursor < len(keys) {
cursor++
} else {
cursor = 0
}
case keyDigit:
if len(numBuf) < maxInputLen {
numBuf += string(key.digit)
}
if n, err := strconv.Atoi(numBuf); err == nil && n >= 0 && n <= len(keys) {
if n == 0 {
cursor = len(keys)
} else {
cursor = n - 1
}
}
case keyBackspace:
if len(numBuf) > 0 {
numBuf = numBuf[:len(numBuf)-1]
if numBuf != "" {
if n, err := strconv.Atoi(numBuf); err == nil && n >= 0 && n <= len(keys) {
if n == 0 {
cursor = len(keys)
} else {
cursor = n - 1
}
}
}
}
case keyEnter:
var n int
if numBuf != "" {
parsed, err := strconv.Atoi(numBuf)
numBuf = ""
if err != nil || parsed < 0 || parsed > len(keys) {
continue
}
n = parsed
} else {
if cursor == len(keys) {
n = 0
} else {
n = cursor + 1
}
}
if n == 0 {
if len(pathKeys) == 0 {
fmt.Print("\033[H\033[2J")
fmt.Println("Exiting.")
os.Exit(0)
}
pathKeys = pathKeys[:len(pathKeys)-1]
cursor = 0
continue
}
chosen := keys[n-1]
cursor = n - 1
if chosen == selfKey {
fmt.Print("\033[H\033[2J")
if err := runCommand(cfg.shell, current.command); err != nil {
fmt.Fprintf(os.Stderr, "Command exited with error: %v\n", err)
}
continue
}
child := current.children[chosen]
if len(child.children) > 0 {
pathKeys = append(pathKeys, chosen)
cursor = 0
continue
}
fmt.Print("\033[H\033[2J")
if err := runCommand(cfg.shell, child.command); err != nil {
fmt.Fprintf(os.Stderr, "Command exited with error: %v\n", err)
}
}
}
}