479 lines
10 KiB
Go
479 lines
10 KiB
Go
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)
|
||
}
|
||
}
|
||
}
|
||
}
|