prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

shell.go (14976B)


      1 package tui
      2 
      3 import (
      4 	"sort"
      5 	"strconv"
      6 	"strings"
      7 	"time"
      8 	"unicode"
      9 
     10 	tea "github.com/charmbracelet/bubbletea"
     11 	"github.com/charmbracelet/lipgloss"
     12 	"github.com/charmbracelet/x/ansi"
     13 
     14 	"git.prime8.dev/moncheeta/prime8dev/internal/vfs"
     15 )
     16 
     17 const shellMaxHistory = 200
     18 
     19 var (
     20 	shellDirStyle   = lipgloss.NewStyle().Foreground(colorBlue).Bold(true)
     21 	shellErrorStyle = lipgloss.NewStyle().Foreground(colorRed)
     22 )
     23 
     24 type shellEntry struct {
     25 	cwd    string
     26 	input  string
     27 	output string
     28 	err    bool
     29 	// bare entries print output without a prompt line (output after clear).
     30 	bare bool
     31 }
     32 
     33 // shellState is the interactive prompt at the bottom of the about page.
     34 type shellState struct {
     35 	user   string
     36 	home   string
     37 	cwd    string
     38 	oldpwd string
     39 	input  string
     40 	// lastKey restarts the cursor blink: solid while typing, like a real terminal.
     41 	lastKey time.Time
     42 	history []shellEntry
     43 	// cleared hides the about-page intro, like `clear` in a real terminal.
     44 	cleared bool
     45 
     46 	// cmdHistory is what `history` lists; it survives `clear`.
     47 	cmdHistory []string
     48 	status     int
     49 	vars       map[string]string
     50 	started    time.Time
     51 	// overlay holds this session's writes over the shared read-only vfs. It
     52 	// is a pointer so copies of the model (bubbletea passes it by value)
     53 	// share one overlay; it lives as long as the SSH session.
     54 	overlay *vfs.Overlay
     55 }
     56 
     57 func newShellState(user string) shellState {
     58 	user = vfs.SanitizeUser(user)
     59 	home := vfs.HomeDir(user)
     60 	return shellState{
     61 		user:    user,
     62 		home:    home,
     63 		cwd:     home,
     64 		vars:    map[string]string{},
     65 		started: time.Now(),
     66 		overlay: vfs.NewOverlay(),
     67 	}
     68 }
     69 
     70 type shellResult struct {
     71 	output string
     72 	err    bool
     73 	clear  bool
     74 	quit   bool
     75 }
     76 
     77 // shellEnv is what a command line runs against.
     78 type shellEnv struct {
     79 	root  *vfs.Node // the shared, read-only generated tree; never mutated
     80 	now   time.Time
     81 	fetch func(tty bool) string
     82 }
     83 
     84 func (s *shellState) envMap() map[string]string {
     85 	env := map[string]string{
     86 		"HOME":     s.home,
     87 		"USER":     s.user,
     88 		"LOGNAME":  s.user,
     89 		"HOSTNAME": "prime8",
     90 		"SHELL":    "/bin/bash",
     91 		"TERM":     "xterm-256color",
     92 		"LANG":     "en_US.UTF-8",
     93 		"PATH":     "/usr/local/bin:/usr/bin:/bin",
     94 		"PWD":      s.cwd,
     95 	}
     96 	if s.oldpwd != "" {
     97 		env["OLDPWD"] = s.oldpwd
     98 	}
     99 	for k, v := range s.vars {
    100 		env[k] = v
    101 	}
    102 	return env
    103 }
    104 
    105 func (s *shellState) envValue(name string) string {
    106 	switch name {
    107 	case "?":
    108 		return strconv.Itoa(s.status)
    109 	case "$":
    110 		return "4242"
    111 	}
    112 	return s.envMap()[name]
    113 }
    114 
    115 func (s *shellState) setVar(name, value string) {
    116 	if s.vars == nil {
    117 		s.vars = map[string]string{}
    118 	}
    119 	if len(s.vars) < 256 && len(value) <= 4096 {
    120 		s.vars[name] = value
    121 	}
    122 }
    123 
    124 const shellMaxCmdHistory = 500
    125 
    126 // exec runs one input line: ; && || lists of pipelines with redirects.
    127 func (s *shellState) exec(env shellEnv, line string) shellResult {
    128 	if strings.TrimSpace(line) == "" {
    129 		return shellResult{}
    130 	}
    131 	if s.overlay == nil {
    132 		s.overlay = vfs.NewOverlay()
    133 	}
    134 	s.cmdHistory = append(s.cmdHistory, line)
    135 	if len(s.cmdHistory) > shellMaxCmdHistory {
    136 		s.cmdHistory = s.cmdHistory[len(s.cmdHistory)-shellMaxCmdHistory:]
    137 	}
    138 
    139 	var disp strings.Builder
    140 	res := shellResult{}
    141 	errOut := func(msg string) {
    142 		for _, l := range strings.SplitAfter(msg, "\n") {
    143 			if l == "" {
    144 				continue
    145 			}
    146 			nl := strings.HasSuffix(l, "\n")
    147 			disp.WriteString(shellErrorStyle.Render(strings.TrimSuffix(l, "\n")))
    148 			if nl {
    149 				disp.WriteString("\n")
    150 			}
    151 		}
    152 	}
    153 	cleared := false
    154 	finish := func() shellResult {
    155 		res.output = strings.TrimSuffix(disp.String(), "\n")
    156 		res.clear = res.clear || cleared
    157 		return res
    158 	}
    159 
    160 	toks, perr := shellTokenize(line, s.home)
    161 	var list []shellListItem
    162 	if perr == "" {
    163 		list, perr = shellParse(toks)
    164 	}
    165 	if perr != "" {
    166 		errOut("bash: " + perr + "\n")
    167 		s.status = 2
    168 		return finish()
    169 	}
    170 
    171 	for _, item := range list {
    172 		if (item.conn == "&&" && s.status != 0) || (item.conn == "||" && s.status == 0) {
    173 			continue
    174 		}
    175 		fs := vfs.NewFS(env.root, s.home, s.overlay)
    176 		stdin := ""
    177 		for i, cmd := range item.pipeline {
    178 			last := i == len(item.pipeline)-1
    179 			piped, status := s.runSimple(env, fs, cmd, stdin, last, &disp, errOut, &res)
    180 			s.status = status
    181 			stdin = piped
    182 			if res.quit {
    183 				return finish()
    184 			}
    185 			if res.clear {
    186 				// later output still shows, on the fresh screen
    187 				disp.Reset()
    188 				cleared = true
    189 				res.clear = false
    190 			}
    191 		}
    192 	}
    193 	return finish()
    194 }
    195 
    196 // runSimple runs one command of a pipeline. It returns the command's stdout
    197 // when that feeds the next command, and its exit status.
    198 func (s *shellState) runSimple(env shellEnv, fs *vfs.FS, cmd shellSimple, stdin string, last bool, disp *strings.Builder, errOut func(string), res *shellResult) (string, int) {
    199 	expand := func(words []shellWord) []string {
    200 		var argv []string
    201 		for _, raw := range words {
    202 			w := resolveVars(raw, s.envValue)
    203 			if !w.quoted && w.v == "" {
    204 				continue
    205 			}
    206 			argv = append(argv, expandGlob(w, fs.Root(), s.home, s.cwd)...)
    207 		}
    208 		return argv
    209 	}
    210 	argv := expand(cmd.words)
    211 
    212 	// NAME=value with no command sets a shell variable
    213 	if len(argv) > 0 && len(cmd.redirs) == 0 {
    214 		all := true
    215 		for _, a := range argv {
    216 			all = all && shellAssignRE.MatchString(a)
    217 		}
    218 		if all {
    219 			for _, a := range argv {
    220 				m := shellAssignRE.FindStringSubmatch(a)
    221 				s.setVar(m[1], m[2])
    222 			}
    223 			return "", 0
    224 		}
    225 	}
    226 	for len(argv) > 1 && shellAssignRE.MatchString(argv[0]) {
    227 		argv = argv[1:]
    228 	}
    229 
    230 	c := &shellCtx{s: s, fs: fs, env: env, argv: argv, stdin: stdin, res: res}
    231 	type fileOut struct {
    232 		path string
    233 		buf  strings.Builder
    234 		app  bool
    235 	}
    236 	var outFile, errFile *fileOut
    237 	errToOut := false
    238 	for _, r := range cmd.redirs {
    239 		if r.op == "2>&1" {
    240 			errToOut = true
    241 			continue
    242 		}
    243 		targets := expand([]shellWord{r.target})
    244 		if len(targets) != 1 {
    245 			errOut("bash: " + resolveVars(r.target, s.envValue).v + ": ambiguous redirect\n")
    246 			return "", 1
    247 		}
    248 		t := targets[0]
    249 		if r.op == "<" {
    250 			if c.resolve(t) == "/dev/null" {
    251 				c.stdin = ""
    252 				continue
    253 			}
    254 			n := fs.Get(c.resolve(t))
    255 			if n == nil || n.Dir {
    256 				why := "No such file or directory"
    257 				if n != nil {
    258 					why = "Is a directory"
    259 				}
    260 				errOut("bash: " + t + ": " + why + "\n")
    261 				return "", 1
    262 			}
    263 			c.stdin = fileText(n)
    264 			continue
    265 		}
    266 		app := strings.HasSuffix(r.op, ">>")
    267 		// the shell creates (and truncates) the target before the command runs
    268 		if err := writeFile(c, t, "", true); err != nil {
    269 			errOut("bash: " + t + ": " + errText(err) + "\n")
    270 			return "", 1
    271 		}
    272 		if !app {
    273 			_ = writeFile(c, t, "", false)
    274 		}
    275 		f := &fileOut{path: t, app: true}
    276 		if strings.HasPrefix(r.op, "2") {
    277 			errFile = f
    278 		} else {
    279 			outFile = f
    280 		}
    281 	}
    282 	if len(argv) == 0 {
    283 		return "", 0
    284 	}
    285 
    286 	toTerm := last && outFile == nil
    287 	c.tty = toTerm
    288 	var piped strings.Builder
    289 	c.out = func(str string) {
    290 		switch {
    291 		case outFile != nil:
    292 			outFile.buf.WriteString(str)
    293 		case toTerm:
    294 			disp.WriteString(str)
    295 		default:
    296 			piped.WriteString(str)
    297 		}
    298 	}
    299 	c.err = func(str string) {
    300 		switch {
    301 		case errToOut:
    302 			c.out(str)
    303 		case errFile != nil:
    304 			errFile.buf.WriteString(str)
    305 		default:
    306 			errOut(str)
    307 		}
    308 	}
    309 
    310 	name := argv[0]
    311 	status := 0
    312 	if fn, ok := shellCommandTable[name]; ok {
    313 		status = fn(c)
    314 	} else if strings.Contains(name, "/") {
    315 		n := fs.Get(c.resolve(name))
    316 		switch {
    317 		case n == nil:
    318 			c.err("bash: " + name + ": No such file or directory\n")
    319 			status = 127
    320 		case n.Dir:
    321 			c.err("bash: " + name + ": Is a directory\n")
    322 			status = 126
    323 		default:
    324 			c.err("bash: " + name + ": Permission denied\n")
    325 			status = 126
    326 		}
    327 	} else {
    328 		c.err(name + ": command not found\n")
    329 		status = 127
    330 	}
    331 
    332 	for _, f := range []*fileOut{outFile, errFile} {
    333 		if f == nil || f.buf.Len() == 0 {
    334 			continue
    335 		}
    336 		if err := writeFile(c, f.path, f.buf.String(), f.app); err != nil {
    337 			errOut("bash: " + f.path + ": " + errText(err) + "\n")
    338 			status = 1
    339 		}
    340 	}
    341 	return piped.String(), status
    342 }
    343 
    344 // complete expands the last word of the input: a command name in command
    345 // position (line start, after | ; &&), otherwise a path in the vfs.
    346 func (s *shellState) complete(root *vfs.Node) {
    347 	if s.input == "" {
    348 		return
    349 	}
    350 	idx := strings.LastIndexFunc(s.input, func(r rune) bool {
    351 		return unicode.IsSpace(r) || strings.ContainsRune(";|&<>", r)
    352 	})
    353 	word := s.input[idx+1:]
    354 	before := strings.TrimRightFunc(s.input[:idx+1], unicode.IsSpace)
    355 	if before == "" || strings.ContainsAny(before[len(before)-1:], ";|&") {
    356 		matches := []string{}
    357 		for _, c := range shellCommands {
    358 			if strings.HasPrefix(c, word) {
    359 				matches = append(matches, c)
    360 			}
    361 		}
    362 		if len(matches) == 1 {
    363 			s.input = s.input[:idx+1] + matches[0] + " "
    364 		} else if p := commonPrefix(matches); len(p) > len(word) {
    365 			s.input = s.input[:idx+1] + p
    366 		}
    367 		return
    368 	}
    369 
    370 	dirPart, base := "", word
    371 	if i := strings.LastIndex(word, "/"); i >= 0 {
    372 		dirPart, base = word[:i+1], word[i+1:]
    373 	}
    374 	lookupDir := dirPart
    375 	if lookupDir == "" {
    376 		lookupDir = "." // vfs.Resolve("") means home, but completion is relative to cwd
    377 	}
    378 	dir := root.Lookup(vfs.Resolve(s.home, s.cwd, lookupDir))
    379 	if dir == nil || !dir.Dir {
    380 		return
    381 	}
    382 	var matches []string
    383 	nodes := map[string]*vfs.Node{}
    384 	for _, c := range dir.SortedChildren() {
    385 		if strings.HasPrefix(c.Name, ".") && !strings.HasPrefix(base, ".") {
    386 			continue
    387 		}
    388 		if strings.HasPrefix(c.Name, base) {
    389 			matches = append(matches, c.Name)
    390 			nodes[c.Name] = c
    391 		}
    392 	}
    393 	if len(matches) == 0 {
    394 		return
    395 	}
    396 	completed := commonPrefix(matches)
    397 	if len(matches) == 1 {
    398 		if nodes[completed].Dir {
    399 			completed += "/"
    400 		} else {
    401 			completed += " "
    402 		}
    403 	}
    404 	s.input = s.input[:idx+1] + dirPart + completed
    405 }
    406 
    407 func commonPrefix(words []string) string {
    408 	if len(words) == 0 {
    409 		return ""
    410 	}
    411 	sorted := append([]string(nil), words...)
    412 	sort.Strings(sorted)
    413 	first, last := sorted[0], sorted[len(sorted)-1]
    414 	i := 0
    415 	for i < len(first) && i < len(last) && first[i] == last[i] {
    416 		i++
    417 	}
    418 	return first[:i]
    419 }
    420 
    421 // handleShellKey routes a key on the about page to the prompt. It reports
    422 // whether the key was consumed; unconsumed keys fall through to the global
    423 // bindings (tab switching, scrolling, quit, ...).
    424 func (m *portfolioModel) handleShellKey(msg tea.KeyMsg) (tea.Cmd, bool) {
    425 	m.shell.lastKey = time.Now()
    426 	s := &m.shell
    427 	switch msg.Type {
    428 	case tea.KeyRunes, tea.KeySpace:
    429 		r := string(msg.Runes)
    430 		if msg.Type == tea.KeySpace {
    431 			r = " "
    432 		}
    433 		// with an empty prompt, keep the digit tab shortcuts and ? help
    434 		if s.input == "" && !msg.Paste {
    435 			if _, ok := tabIndexFromKey(r); ok {
    436 				return nil, false
    437 			}
    438 			if r == "?" || r == " " {
    439 				return nil, false
    440 			}
    441 		}
    442 		s.input += strings.Map(func(r rune) rune {
    443 			if unicode.IsControl(r) {
    444 				return -1
    445 			}
    446 			return r
    447 		}, r)
    448 	case tea.KeyBackspace, tea.KeyCtrlH:
    449 		if s.input == "" {
    450 			return nil, true
    451 		}
    452 		rs := []rune(s.input)
    453 		s.input = string(rs[:len(rs)-1])
    454 	case tea.KeyCtrlU:
    455 		s.input = ""
    456 	case tea.KeyCtrlW:
    457 		trimmed := strings.TrimRightFunc(s.input, unicode.IsSpace)
    458 		if i := strings.LastIndexFunc(trimmed, unicode.IsSpace); i >= 0 {
    459 			s.input = trimmed[:i+1]
    460 		} else {
    461 			s.input = ""
    462 		}
    463 	case tea.KeyCtrlL:
    464 		s.history = nil
    465 		s.cleared = true
    466 	case tea.KeyCtrlD:
    467 		if s.input == "" {
    468 			return tea.Quit, true
    469 		}
    470 		return nil, true
    471 	case tea.KeyEsc:
    472 		if s.input == "" {
    473 			return nil, false
    474 		}
    475 		s.input = ""
    476 	case tea.KeyTab:
    477 		if s.input == "" {
    478 			return nil, false
    479 		}
    480 		s.complete(m.shellFS().Root())
    481 	case tea.KeyEnter:
    482 		return m.runShellLine(), true
    483 	default:
    484 		return nil, false
    485 	}
    486 	m.syncShellViewport()
    487 	return nil, true
    488 }
    489 
    490 func (m *portfolioModel) runShellLine() tea.Cmd {
    491 	s := &m.shell
    492 	line := s.input
    493 	s.input = ""
    494 	cwd := s.cwd
    495 	res := s.exec(m.shellEnv(), line)
    496 	if res.quit {
    497 		return tea.Quit
    498 	}
    499 	entry := shellEntry{cwd: cwd, input: line, output: res.output, err: res.err}
    500 	if res.clear {
    501 		s.history = nil
    502 		s.cleared = true
    503 		entry.bare = true
    504 	}
    505 	if !entry.bare || entry.output != "" {
    506 		s.history = append(s.history, entry)
    507 	}
    508 	if len(s.history) > shellMaxHistory {
    509 		s.history = s.history[len(s.history)-shellMaxHistory:]
    510 	}
    511 	m.syncShellViewport()
    512 	return m.prefetchAllProjectReadmesCmd()
    513 }
    514 
    515 // shellEnv is what a shell line runs against: a freshly generated base tree
    516 // (the session's overlay goes on top of a private clone of it).
    517 func (m portfolioModel) shellEnv() shellEnv {
    518 	now := m.now
    519 	if now.IsZero() {
    520 		now = time.Now()
    521 	}
    522 	return shellEnv{
    523 		root: vfs.Build(m.vfsSource()),
    524 		now:  now,
    525 		fetch: func(tty bool) string {
    526 			if tty {
    527 				return m.aboutFetch()
    528 			}
    529 			return ansi.Strip(m.aboutFetchLines())
    530 		},
    531 	}
    532 }
    533 
    534 // shellFS is the session's merged view, for completion.
    535 func (m portfolioModel) shellFS() *vfs.FS {
    536 	return vfs.NewFS(vfs.Build(m.vfsSource()), m.shell.home, m.shell.overlay)
    537 }
    538 
    539 // prefetchAllProjectReadmesCmd fills the projects/*/README.md files.
    540 func (m *portfolioModel) prefetchAllProjectReadmesCmd() tea.Cmd {
    541 	cmds := []tea.Cmd{}
    542 	for _, p := range m.projects {
    543 		if _, ok := m.projectReadmes[p.ID]; ok || m.projectReadmeLoads[p.ID] || m.gitService == nil {
    544 			continue
    545 		}
    546 		m.projectReadmeLoads[p.ID] = true
    547 		cmds = append(cmds, fetchProjectReadmeCmd(m.gitService, p))
    548 	}
    549 	if len(cmds) == 0 {
    550 		return nil
    551 	}
    552 	return tea.Batch(cmds...)
    553 }
    554 
    555 // syncShellViewport re-renders the about page and keeps the prompt in view.
    556 func (m *portfolioModel) syncShellViewport() {
    557 	if m.activeTab != tabAbout {
    558 		return
    559 	}
    560 	m.viewport.SetContent(m.detailContent())
    561 	m.viewport.GotoBottom()
    562 }
    563 
    564 // shellView renders past commands, their output, and the live prompt line.
    565 func (m portfolioModel) shellView(width int) string {
    566 	wrap := lipgloss.NewStyle().Width(max(width, 10))
    567 	parts := make([]string, 0, len(m.shell.history)*2+1)
    568 	for _, e := range m.shell.history {
    569 		if !e.bare {
    570 			parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, e.cwd, e.input))
    571 		}
    572 		if e.output == "" {
    573 			continue
    574 		}
    575 		out := e.output
    576 		if e.err {
    577 			out = shellErrorStyle.Render(out)
    578 		}
    579 		parts = append(parts, wrap.Render(out))
    580 	}
    581 	parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, m.shell.cwd, m.shell.input)+m.shell.cursor(time.Now()))
    582 	return strings.Join(parts, "\n")
    583 }
    584 
    585 func renderShellPrompt(user, home, cwd, input string) string {
    586 	return promptUserStyle.Render(user) +
    587 		promptStyle.Render("@") +
    588 		promptHostStyle.Render("prime8") +
    589 		promptStyle.Render(":") +
    590 		promptPathStyle.Render(vfs.DisplayPath(home, cwd)) +
    591 		promptDollarStyle.Render("$") +
    592 		promptStyle.Render(" ") +
    593 		promptCmdStyle.Render(input)
    594 }
    595 
    596 // cursorBlink is the half period of the prompt cursor's blink (xterm's 530 ms).
    597 const cursorBlink = 530 * time.Millisecond
    598 
    599 type cursorBlinkMsg struct{}
    600 
    601 func cursorBlinkCmd() tea.Cmd {
    602 	return tea.Tick(cursorBlink, func(time.Time) tea.Msg { return cursorBlinkMsg{} })
    603 }
    604 
    605 // cursor draws the block cursor: solid while keys arrive, then blinking.
    606 func (s shellState) cursor(now time.Time) string {
    607 	idle := now.Sub(s.lastKey)
    608 	if idle < cursorBlink || (idle/cursorBlink)%2 == 0 {
    609 		return rowStyle.Render("█")
    610 	}
    611 	return " "
    612 }