Version 0.01

This commit is contained in:
2026-05-02 02:16:46 +04:00
commit 3265107079
9 changed files with 876 additions and 0 deletions

478
main.go Normal file
View 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)
}
}
}
}