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 }