Version 0.01
This commit is contained in:
478
main.go
Normal file
478
main.go
Normal file
@@ -0,0 +1,478 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user