prime8dev

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

commit e35aee3f64f90a56fe310caf4fb5176fc43458c8
parent a8835e2210d48a6c03aa4b52ce734fbb650444d1
Author: Damian Myrda <damian@prime8.dev>
Date:   Thu, 22 Oct 2026 00:20:18 +0000

feat(tui): real shell, terminal-palette theme, aligned graph, eager readmes

- shell: port of the web shell's parser (pipes, ; && ||, redirects,
  quotes, $VARS, globs) and commands (fetch, tree, touch, mkdir, rm, cp,
  mv, tee, head, tail, wc, grep, sort, uniq, history, ...); writes go to
  a per-session vfs overlay (~ and /tmp, 1 MiB cap), never the shared tree
- theme: chrome uses the terminal's default fg/bg and ANSI 0-15 only, so
  it follows light and dark palettes; active tab is reverse video
- experience: every | sits in the * column
- readmes render synchronously before draw, never shown raw

Diffstat:
Minternal/tui/content.go | 81+++++++++++++++++++++++++++-----------------------------------------------------
Ainternal/tui/experience_test.go | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/hscroll_test.go | 10----------
Minternal/tui/layout.go | 1+
Minternal/tui/program.go | 4++++
Minternal/tui/project_markdown_render.go | 80+++++++++++++++++++++++++++++++++----------------------------------------------
Ainternal/tui/readme_render_test.go | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/shell.go | 449+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Ainternal/tui/shell_cmds.go | 1199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/shell_cmds_test.go | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/shell_lang.go | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/shell_test.go | 83++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Minternal/tui/styles.go | 49++++++++++++++++++++++++++++---------------------
Minternal/tui/tags.go | 3++-
Minternal/tui/types.go | 20+++++++++-----------
Minternal/tui/update.go | 16+++-------------
Minternal/tui/view.go | 14+++++++-------
Ainternal/vfs/overlay.go | 274+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/vfs/overlay_test.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
19 files changed, 2765 insertions(+), 310 deletions(-)

diff --git a/internal/tui/content.go b/internal/tui/content.go @@ -84,18 +84,11 @@ func (m portfolioModel) projectDetail() string { p := m.projects[m.projectIndex] if readme, ok := m.projectReadmes[p.ID]; ok && strings.TrimSpace(readme) != "" { - width := m.markdownWidth() - if rendered, ok := m.cachedProjectMarkdown(p.ID, width); ok && strings.TrimSpace(rendered) != "" { + if rendered, ok := m.cachedProjectMarkdown(p.ID, m.markdownWidth()); ok && strings.TrimSpace(rendered) != "" { return rendered } - - patched := replaceFirstH1WithSourceLink(readme, p.Name, p.URL) - fallback := patched - if tech := renderTagList(p.Tech); tech != "" { - fallback = insertTechRowBelowMarkdownH1(fallback, tech) - } - - return fallback + "\n\n" + dimStyle.Render("formatting markdown...") + // syncViewport renders before this runs; never fall back to raw markdown + return m.projectReadmeFallback(p, infoStyle.Render("rendering README...")) } if m.projectReadmeLoads[p.ID] { @@ -137,41 +130,6 @@ func defaultProjectHeader(name string) string { return name } -func insertTechRowBelowMarkdownH1(markdown string, techRow string) string { - if strings.TrimSpace(techRow) == "" { - return markdown - } - - lines := strings.Split(markdown, "\n") - headIdx := -1 - for i, line := range lines { - if strings.TrimSpace(line) != "" { - headIdx = i - break - } - } - if headIdx == -1 { - return techRow - } - - if !strings.HasPrefix(strings.TrimSpace(lines[headIdx]), "# ") { - return techRow + "\n\n" + markdown - } - - bodyStart := headIdx + 1 - for bodyStart < len(lines) && strings.TrimSpace(lines[bodyStart]) == "" { - bodyStart++ - } - - header := strings.Join(lines[:headIdx+1], "\n") - if bodyStart >= len(lines) { - return header + "\n\n" + techRow - } - - body := strings.Join(lines[bodyStart:], "\n") - return header + "\n\n" + techRow + "\n\n" + body -} - func replaceFirstH1WithSourceLink(markdown string, projectName string, projectURL string) string { if projectName == "" { projectName = "Project Source" @@ -221,34 +179,49 @@ func (m portfolioModel) experiencePage() string { return "No experience available." } + // Every line starts with a two-column gutter ("> " marks the selection), + // then the graph column: "*" on a commit, "|" under it down to the next + // commit. The root commit has nothing below it, like git log --graph. rows := []string{renderPrompt(m.user, "git log --oneline --graph --decorate")} for i, e := range m.experience { decorator := "" if e.Current { decorator = " (HEAD -> now)" } - label := fmt.Sprintf("* %s %-20s %s%s", utilsYearLabel(e), fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator) - rangeLine := " " + dateRangeLabel(e) + gutter := " " if i == m.experienceIndex { - rows = append(rows, selectedRowStyle.Render("> "+label)) - rows = append(rows, selectedRowStyle.Render(rangeLine)) + gutter = "> " + } + edge := "|" + if i == len(m.experience)-1 { + edge = " " + } + body := dimStyle.Render(experienceGraphGutter + edge + " ") + label := fmt.Sprintf("%-4s %-20s %s%s", utilsYearLabel(e), fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator) + rangeLabel := dateRangeLabel(e) + if i == m.experienceIndex { + rows = append(rows, selectedRowStyle.Render(gutter+"* "+label)) + rows = append(rows, body+selectedRowStyle.Render(rangeLabel)) for _, d := range e.Description { - rows = append(rows, rowStyle.Render(" | "+d)) + rows = append(rows, body+rowStyle.Render(d)) } if len(e.Tech) > 0 { - rows = append(rows, rowStyle.Render(" | ")+dimStyle.Render("tech: ")+renderTagList(e.Tech)) + rows = append(rows, body+dimStyle.Render("tech: ")+renderTagList(e.Tech)) } if e.URL != "" { - rows = append(rows, rowStyle.Render(" | ")+dimStyle.Render("url: ")+linkStyle.Render(e.URL)) + rows = append(rows, body+dimStyle.Render("url: ")+linkStyle.Render(e.URL)) } } else { - rows = append(rows, rowStyle.Render(" "+label)) - rows = append(rows, dimStyle.Render(rangeLine)) + rows = append(rows, rowStyle.Render(gutter+"* "+label)) + rows = append(rows, body+dimStyle.Render(rangeLabel)) } } return strings.Join(rows, "\n") } +// experienceGraphGutter is the selection gutter before the graph column. +const experienceGraphGutter = " " + func coloredList(labels []string) string { parts := make([]string, len(labels)) for i, l := range labels { diff --git a/internal/tui/experience_test.go b/internal/tui/experience_test.go @@ -0,0 +1,63 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// Every "|" of the experience git graph sits in the same terminal column as +// the "*" commit markers, in the fully rendered view (borders, padding, +// selection marker and all). +func TestExperienceGraphColumnsLineUp(t *testing.T) { + for _, w := range []int{80, 120} { + for sel := 0; sel < 3; sel++ { + m := testModel(t, w, 60) + m.activateTab(tabExperience) + m.experienceIndex = sel + m.syncViewport() + lines := strings.Split(ansi.Strip(m.View()), "\n") + + starCol, stars, pipes := -1, 0, 0 + for _, l := range lines { + // the graph lives inside the panel: "│ " + content + i := strings.Index(l, "│ ") + if i < 0 { + continue + } + content := l[i+len("│ "):] + col := ansi.StringWidth(l[:i+len("│ ")]) + cells := []rune(content) + if len(cells) < 3 { + continue + } + g := string(cells[2]) + switch { + case g == "*" && strings.TrimSpace(string(cells[:2])) == "" || g == "*" && string(cells[:2]) == "> ": + if starCol < 0 { + starCol = col + 2 + } + if col+2 != starCol { + t.Fatalf("w=%d sel=%d: * at col %d, want %d:\n%s", w, sel, col+2, starCol, l) + } + stars++ + case g == "|": + if col+2 != starCol { + t.Fatalf("w=%d sel=%d: | at col %d, want %d:\n%s", w, sel, col+2, starCol, l) + } + pipes++ + } + // no stray connector anywhere else on the line + for j, r := range cells[:min(len(cells), 6)] { + if r == '|' && j != 2 { + t.Fatalf("w=%d sel=%d: stray | at offset %d:\n%s", w, sel, j, l) + } + } + } + if stars != len(m.experience) || pipes == 0 { + t.Fatalf("w=%d sel=%d: stars=%d pipes=%d", w, sel, stars, pipes) + } + } + } +} diff --git a/internal/tui/hscroll_test.go b/internal/tui/hscroll_test.go @@ -46,10 +46,6 @@ func TestReadmeHorizontalScroll(t *testing.T) { id := m.projects[0].ID m.projectReadmes[id] = wideReadme m.activateTab(tabProjects) - w := m.projectMarkdownWidth() - rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg) - next, _ := m.Update(rendered) - m = next.(portfolioModel) m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter}) view := ansi.Strip(m.View()) @@ -92,9 +88,6 @@ func readmeModel(t *testing.T) portfolioModel { id := m.projects[0].ID m.projectReadmes[id] = wideReadme m.activateTab(tabProjects) - w := m.projectMarkdownWidth() - rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg) - m = send(t, m, rendered) m = send(t, m, tea.KeyMsg{Type: tea.KeyEnter}) if !m.projectReadmeScroll { t.Fatal("enter did not focus the readme") @@ -188,9 +181,6 @@ func TestReadmeOffsetSurvivesAsyncRerender(t *testing.T) { } id := m.projects[0].ID - w := m.projectMarkdownWidth() - rendered := renderProjectMarkdownCmd(id, replaceFirstH1WithSourceLink(wideReadme, "janitor", ""), "", w)().(projectMarkdownRenderedMsg) - m = send(t, m, rendered) m = send(t, m, projectReadmeMsg{projectID: id, readme: wideReadme}) m = send(t, m, tea.WindowSizeMsg{Width: 100, Height: 40}) m = send(t, m, tickMsg(m.now)) diff --git a/internal/tui/layout.go b/internal/tui/layout.go @@ -20,6 +20,7 @@ func (m *portfolioModel) syncViewport() { same := m.detailViewSet && key == m.detailView m.detailView, m.detailViewSet = key, true + m.ensureSelectedProjectMarkdown() content := m.detailContent() m.viewport.SetContent(content) m.detailLineWidths = lineWidths(content) diff --git a/internal/tui/program.go b/internal/tui/program.go @@ -10,6 +10,10 @@ import ( ) func SSHHandler(sess ssh.Session) (tea.Model, []tea.ProgramOption) { + // TrueColor keeps the logo's 24-bit escapes intact. It does not touch the + // chrome: styles.go only uses the terminal default colors and ANSI 0-15, + // which every profile emits as plain SGR 30-37/90-97 so the terminal's + // own palette (light or dark) decides how they look. lipgloss.SetColorProfile(termenv.TrueColor) model := newPortfolioModel(defaultUser(sess.User())) opts := wish.MakeOptions(sess) diff --git a/internal/tui/project_markdown_render.go b/internal/tui/project_markdown_render.go @@ -1,17 +1,15 @@ package tui -import ( - "strconv" - "strings" +import "strings" - tea "github.com/charmbracelet/bubbletea" -) - -type projectMarkdownRenderedMsg struct { - projectID string - width int - rendered string -} +// README rendering is synchronous and on demand: whenever the detail pane is +// about to show the selected project (syncViewport: selection, tab switch, +// resize, a README arriving), its README is rendered for the current width if +// that width is not cached yet. Rendering takes a few milliseconds, and doing +// it inline means the pane can never show the raw markdown while an async +// render is in flight, or keep showing it because a render result was +// dropped or landed for a width/README that was no longer current (what the +// old async path did until something happened to re-render the pane). func (m portfolioModel) cachedProjectMarkdown(projectID string, width int) (string, bool) { byWidth, ok := m.projectReadmeRender[projectID] @@ -23,9 +21,13 @@ func (m portfolioModel) cachedProjectMarkdown(projectID string, width int) (stri return rendered, ok } +// maxCachedReadmeWidths bounds the per-project cache while a window is +// being resized through many widths. +const maxCachedReadmeWidths = 8 + func (m *portfolioModel) setCachedProjectMarkdown(projectID string, width int, rendered string) { byWidth := m.projectReadmeRender[projectID] - if byWidth == nil { + if byWidth == nil || len(byWidth) >= maxCachedReadmeWidths { byWidth = make(map[int]string) m.projectReadmeRender[projectID] = byWidth } @@ -33,59 +35,43 @@ func (m *portfolioModel) setCachedProjectMarkdown(projectID string, width int, r byWidth[width] = rendered } -func projectMarkdownLoadKey(projectID string, width int) string { - return projectID + ":" + strconv.Itoa(width) -} - -func (m *portfolioModel) maybeRenderSelectedProjectMarkdownCmd() tea.Cmd { - if m.activeTab != tabProjects { - return nil +// ensureSelectedProjectMarkdown renders the selected project's README for the +// current detail width when it is not cached. It reports whether it rendered. +func (m *portfolioModel) ensureSelectedProjectMarkdown() bool { + if m.activeTab != tabProjects || m.projectReadmeRender == nil { + return false } project := m.selectedProject() if project == nil { - return nil + return false } readme, ok := m.projectReadmes[project.ID] if !ok || strings.TrimSpace(readme) == "" { - return nil + return false } width := m.projectMarkdownWidth() if _, ok := m.cachedProjectMarkdown(project.ID, width); ok { - return nil - } - - loadKey := projectMarkdownLoadKey(project.ID, width) - if m.projectMarkdownLoads[loadKey] { - return nil + return false } - tagRow := renderTagList(project.Tech) patched := replaceFirstH1WithSourceLink(readme, project.Name, project.URL) - - m.projectMarkdownLoads[loadKey] = true - return renderProjectMarkdownCmd(project.ID, patched, tagRow, width) + m.setCachedProjectMarkdown(project.ID, width, renderProjectMarkdown(patched, renderTagList(project.Tech), width)) + return true } -func renderProjectMarkdownCmd(projectID string, markdown string, techRow string, width int) tea.Cmd { - return func() tea.Msg { - rendered := renderMarkdown(markdown, width) - if rendered == "" { - rendered = markdown - } - rendered = outdentRenderedBody(rendered) - if strings.TrimSpace(techRow) != "" { - rendered = insertTechRowBelowRenderedH1(rendered, techRow) - } - - return projectMarkdownRenderedMsg{ - projectID: projectID, - width: width, - rendered: rendered, - } +func renderProjectMarkdown(markdown string, techRow string, width int) string { + rendered := renderMarkdown(markdown, width) + if rendered == "" { + rendered = markdown + } + rendered = outdentRenderedBody(rendered) + if strings.TrimSpace(techRow) != "" { + rendered = insertTechRowBelowRenderedH1(rendered, techRow) } + return rendered } func insertTechRowBelowRenderedH1(rendered string, techRow string) string { diff --git a/internal/tui/readme_render_test.go b/internal/tui/readme_render_test.go @@ -0,0 +1,71 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +const rawReadme = "# janitor\n\nsorts **audio** files.\n\n## usage\n\n- `janitor ~/music`\n" + +func assertRenderedReadme(t *testing.T, m portfolioModel, when string) { + t.Helper() + view := ansi.Strip(m.View()) + for _, raw := range []string{"**audio**", "## usage", "`janitor"} { + if strings.Contains(view, raw) { + t.Fatalf("%s: raw markdown %q on screen:\n%s", when, raw, view) + } + } + for _, want := range []string{"audio", "usage"} { + if !strings.Contains(view, want) { + t.Fatalf("%s: rendered README missing %q:\n%s", when, want, view) + } + } + if strings.Contains(view, "rendering README") || strings.Contains(view, "formatting markdown") { + t.Fatalf("%s: still waiting on a render:\n%s", when, view) + } +} + +// The first frame after a README arrives is already rendered markdown, and a +// resize re-renders for the new width; there is no async window showing raw +// text. +func TestReadmeRendersOnFirstViewAndResize(t *testing.T) { + m := testModel(t, 120, 40) + id := m.projects[0].ID + delete(m.projectReadmes, id) + m.activateTab(tabProjects) + + m = send(t, m, projectReadmeMsg{projectID: id, readme: rawReadme}) + assertRenderedReadme(t, m, "after load") + w1 := m.projectMarkdownWidth() + + m = send(t, m, tea.WindowSizeMsg{Width: 90, Height: 40}) + if w2 := m.projectMarkdownWidth(); w2 == w1 { + t.Fatalf("resize did not change the readme width (%d)", w2) + } else if _, ok := m.cachedProjectMarkdown(id, w2); !ok { + t.Fatalf("no render cached for the new width %d", w2) + } + assertRenderedReadme(t, m, "after resize") + + // a changed README (new HEAD) replaces the cached render right away + m = send(t, m, projectReadmeMsg{projectID: id, readme: strings.Replace(rawReadme, "audio", "video", 1)}) + if view := ansi.Strip(m.View()); !strings.Contains(view, "video") || strings.Contains(view, "**video**") { + t.Fatalf("updated README not re-rendered:\n%s", view) + } +} + +// Selecting a project whose README was prefetched while another tab was +// active shows it rendered immediately. +func TestPrefetchedReadmeRendersOnSelect(t *testing.T) { + m := testModel(t, 120, 40) + id := m.projects[1].ID + m = send(t, m, projectReadmeMsg{projectID: id, readme: rawReadme}) // on the about tab + m.activateTab(tabProjects) + m = send(t, m, tea.KeyMsg{Type: tea.KeyDown}) + if m.selectedProject().ID != id { + t.Fatalf("selected %s, want %s", m.selectedProject().ID, id) + } + assertRenderedReadme(t, m, "after select") +} diff --git a/internal/tui/shell.go b/internal/tui/shell.go @@ -2,11 +2,14 @@ package tui import ( "sort" + "strconv" "strings" + "time" "unicode" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" "git.prime8.dev/moncheeta/prime8dev/internal/vfs" ) @@ -15,7 +18,7 @@ const shellMaxHistory = 200 var ( shellDirStyle = lipgloss.NewStyle().Foreground(colorBlue).Bold(true) - shellErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("167")) + shellErrorStyle = lipgloss.NewStyle().Foreground(colorRed) ) type shellEntry struct { @@ -23,6 +26,8 @@ type shellEntry struct { input string output string err bool + // bare entries print output without a prompt line (output after clear). + bare bool } // shellState is the interactive prompt at the bottom of the about page. @@ -30,16 +35,34 @@ type shellState struct { user string home string cwd string + oldpwd string input string history []shellEntry // cleared hides the about-page intro, like `clear` in a real terminal. cleared bool + + // cmdHistory is what `history` lists; it survives `clear`. + cmdHistory []string + status int + vars map[string]string + started time.Time + // overlay holds this session's writes over the shared read-only vfs. It + // is a pointer so copies of the model (bubbletea passes it by value) + // share one overlay; it lives as long as the SSH session. + overlay *vfs.Overlay } func newShellState(user string) shellState { user = vfs.SanitizeUser(user) home := vfs.HomeDir(user) - return shellState{user: user, home: home, cwd: home} + return shellState{ + user: user, + home: home, + cwd: home, + vars: map[string]string{}, + started: time.Now(), + overlay: vfs.NewOverlay(), + } } type shellResult struct { @@ -49,151 +72,299 @@ type shellResult struct { quit bool } -func (s *shellState) exec(root *vfs.Node, line string) shellResult { - fields := strings.Fields(line) - if len(fields) == 0 { - return shellResult{} +// shellEnv is what a command line runs against. +type shellEnv struct { + root *vfs.Node // the shared, read-only generated tree; never mutated + now time.Time + fetch func(tty bool) string +} + +func (s *shellState) envMap() map[string]string { + env := map[string]string{ + "HOME": s.home, + "USER": s.user, + "LOGNAME": s.user, + "HOSTNAME": "prime8", + "SHELL": "/bin/bash", + "TERM": "xterm-256color", + "LANG": "en_US.UTF-8", + "PATH": "/usr/local/bin:/usr/bin:/bin", + "PWD": s.cwd, } - cmd, args := fields[0], fields[1:] - errf := func(msg string) shellResult { return shellResult{output: msg, err: true} } - - switch cmd { - case "exit", "logout": - return shellResult{quit: true} - case "clear": - return shellResult{clear: true} - case "help": - return shellResult{output: shellHelpText} - case "pwd": - return shellResult{output: s.cwd} - case "whoami": - return shellResult{output: s.user} - case "echo": - return shellResult{output: strings.Join(args, " ")} - case "cd": - target := "" - if len(args) > 0 { - target = args[0] - } - if len(args) > 1 { - return errf("cd: too many arguments") - } - abs := vfs.Resolve(s.home, s.cwd, target) - n := root.Lookup(abs) - switch { - case n == nil: - return errf("cd: " + target + ": No such file or directory") - case !n.Dir: - return errf("cd: " + target + ": Not a directory") - } - s.cwd = abs + if s.oldpwd != "" { + env["OLDPWD"] = s.oldpwd + } + for k, v := range s.vars { + env[k] = v + } + return env +} + +func (s *shellState) envValue(name string) string { + switch name { + case "?": + return strconv.Itoa(s.status) + case "$": + return "4242" + } + return s.envMap()[name] +} + +func (s *shellState) setVar(name, value string) { + if s.vars == nil { + s.vars = map[string]string{} + } + if len(s.vars) < 256 && len(value) <= 4096 { + s.vars[name] = value + } +} + +const shellMaxCmdHistory = 500 + +// exec runs one input line: ; && || lists of pipelines with redirects. +func (s *shellState) exec(env shellEnv, line string) shellResult { + if strings.TrimSpace(line) == "" { return shellResult{} - case "ls": - paths := make([]string, 0, len(args)) - for _, a := range args { - if !strings.HasPrefix(a, "-") { - paths = append(paths, a) + } + if s.overlay == nil { + s.overlay = vfs.NewOverlay() + } + s.cmdHistory = append(s.cmdHistory, line) + if len(s.cmdHistory) > shellMaxCmdHistory { + s.cmdHistory = s.cmdHistory[len(s.cmdHistory)-shellMaxCmdHistory:] + } + + var disp strings.Builder + res := shellResult{} + errOut := func(msg string) { + for _, l := range strings.SplitAfter(msg, "\n") { + if l == "" { + continue + } + nl := strings.HasSuffix(l, "\n") + disp.WriteString(shellErrorStyle.Render(strings.TrimSuffix(l, "\n"))) + if nl { + disp.WriteString("\n") } } - if len(paths) == 0 { - paths = []string{"."} + } + cleared := false + finish := func() shellResult { + res.output = strings.TrimSuffix(disp.String(), "\n") + res.clear = res.clear || cleared + return res + } + + toks, perr := shellTokenize(line, s.home) + var list []shellListItem + if perr == "" { + list, perr = shellParse(toks) + } + if perr != "" { + errOut("bash: " + perr + "\n") + s.status = 2 + return finish() + } + + for _, item := range list { + if (item.conn == "&&" && s.status != 0) || (item.conn == "||" && s.status == 0) { + continue } - var out []string - failed := false - for _, p := range paths { - n := root.Lookup(vfs.Resolve(s.home, s.cwd, p)) - if n == nil { - out = append(out, shellErrorStyle.Render("ls: cannot access '"+p+"': No such file or directory")) - failed = true - continue - } - listing := renderLs(n) - if len(paths) > 1 && n.Dir { - listing = p + ":\n" + listing + fs := vfs.NewFS(env.root, s.home, s.overlay) + stdin := "" + for i, cmd := range item.pipeline { + last := i == len(item.pipeline)-1 + piped, status := s.runSimple(env, fs, cmd, stdin, last, &disp, errOut, &res) + s.status = status + stdin = piped + if res.quit { + return finish() } - out = append(out, listing) - } - return shellResult{output: strings.Join(out, "\n"), err: failed && len(paths) == 1} - case "cat": - if len(args) == 0 { - return errf("cat: missing operand") - } - var out []string - failed := false - for _, p := range args { - n := root.Lookup(vfs.Resolve(s.home, s.cwd, p)) - switch { - case n == nil: - out = append(out, shellErrorStyle.Render("cat: "+p+": No such file or directory")) - failed = true - case n.Dir: - out = append(out, shellErrorStyle.Render("cat: "+p+": Is a directory")) - failed = true - default: - out = append(out, rowStyle.Render(n.Content)) + if res.clear { + // later output still shows, on the fresh screen + disp.Reset() + cleared = true + res.clear = false } } - return shellResult{output: strings.Join(out, "\n"), err: failed && len(args) == 1} - case "sudo": - return errf(s.user + " is not in the sudoers file. This incident will be reported.") - default: - return errf(cmd + ": command not found") } + return finish() } -const shellHelpText = `commands: - ls [path] list a directory - cd [path] change directory (~ is home) - pwd print working directory - cat <file> print a file - clear clear the screen - help show this - exit close the session - -try: cat about, ls projects, cat projects/janitor/README.md` +// runSimple runs one command of a pipeline. It returns the command's stdout +// when that feeds the next command, and its exit status. +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) { + expand := func(words []shellWord) []string { + var argv []string + for _, raw := range words { + w := resolveVars(raw, s.envValue) + if !w.quoted && w.v == "" { + continue + } + argv = append(argv, expandGlob(w, fs.Root(), s.home, s.cwd)...) + } + return argv + } + argv := expand(cmd.words) -func renderLs(n *vfs.Node) string { - if !n.Dir { - return rowStyle.Render(n.Name) + // NAME=value with no command sets a shell variable + if len(argv) > 0 && len(cmd.redirs) == 0 { + all := true + for _, a := range argv { + all = all && shellAssignRE.MatchString(a) + } + if all { + for _, a := range argv { + m := shellAssignRE.FindStringSubmatch(a) + s.setVar(m[1], m[2]) + } + return "", 0 + } } - children := n.SortedChildren() - if len(children) == 0 { - return "" + for len(argv) > 1 && shellAssignRE.MatchString(argv[0]) { + argv = argv[1:] + } + + c := &shellCtx{s: s, fs: fs, env: env, argv: argv, stdin: stdin, res: res} + type fileOut struct { + path string + buf strings.Builder + app bool } - parts := make([]string, 0, len(children)) - for _, c := range children { - if c.Dir { - parts = append(parts, shellDirStyle.Render(c.Name+"/")) + var outFile, errFile *fileOut + errToOut := false + for _, r := range cmd.redirs { + if r.op == "2>&1" { + errToOut = true + continue + } + targets := expand([]shellWord{r.target}) + if len(targets) != 1 { + errOut("bash: " + resolveVars(r.target, s.envValue).v + ": ambiguous redirect\n") + return "", 1 + } + t := targets[0] + if r.op == "<" { + if c.resolve(t) == "/dev/null" { + c.stdin = "" + continue + } + n := fs.Get(c.resolve(t)) + if n == nil || n.Dir { + why := "No such file or directory" + if n != nil { + why = "Is a directory" + } + errOut("bash: " + t + ": " + why + "\n") + return "", 1 + } + c.stdin = fileText(n) + continue + } + app := strings.HasSuffix(r.op, ">>") + // the shell creates (and truncates) the target before the command runs + if err := writeFile(c, t, "", true); err != nil { + errOut("bash: " + t + ": " + errText(err) + "\n") + return "", 1 + } + if !app { + _ = writeFile(c, t, "", false) + } + f := &fileOut{path: t, app: true} + if strings.HasPrefix(r.op, "2") { + errFile = f } else { - parts = append(parts, rowStyle.Render(c.Name)) + outFile = f + } + } + if len(argv) == 0 { + return "", 0 + } + + toTerm := last && outFile == nil + c.tty = toTerm + var piped strings.Builder + c.out = func(str string) { + switch { + case outFile != nil: + outFile.buf.WriteString(str) + case toTerm: + disp.WriteString(str) + default: + piped.WriteString(str) + } + } + c.err = func(str string) { + switch { + case errToOut: + c.out(str) + case errFile != nil: + errFile.buf.WriteString(str) + default: + errOut(str) + } + } + + name := argv[0] + status := 0 + if fn, ok := shellCommandTable[name]; ok { + status = fn(c) + } else if strings.Contains(name, "/") { + n := fs.Get(c.resolve(name)) + switch { + case n == nil: + c.err("bash: " + name + ": No such file or directory\n") + status = 127 + case n.Dir: + c.err("bash: " + name + ": Is a directory\n") + status = 126 + default: + c.err("bash: " + name + ": Permission denied\n") + status = 126 + } + } else { + c.err(name + ": command not found\n") + status = 127 + } + + for _, f := range []*fileOut{outFile, errFile} { + if f == nil || f.buf.Len() == 0 { + continue + } + if err := writeFile(c, f.path, f.buf.String(), f.app); err != nil { + errOut("bash: " + f.path + ": " + errText(err) + "\n") + status = 1 } } - return strings.Join(parts, " ") + return piped.String(), status } -// complete expands the last word of the input against the vfs. +// complete expands the last word of the input: a command name in command +// position (line start, after | ; &&), otherwise a path in the vfs. func (s *shellState) complete(root *vfs.Node) { if s.input == "" { return } - idx := strings.LastIndexFunc(s.input, unicode.IsSpace) - if idx < 0 { - // first word: complete command names + idx := strings.LastIndexFunc(s.input, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune(";|&<>", r) + }) + word := s.input[idx+1:] + before := strings.TrimRightFunc(s.input[:idx+1], unicode.IsSpace) + if before == "" || strings.ContainsAny(before[len(before)-1:], ";|&") { matches := []string{} for _, c := range shellCommands { - if strings.HasPrefix(c, s.input) { + if strings.HasPrefix(c, word) { matches = append(matches, c) } } if len(matches) == 1 { - s.input = matches[0] + " " - } else if p := commonPrefix(matches); len(p) > len(s.input) { - s.input = p + s.input = s.input[:idx+1] + matches[0] + " " + } else if p := commonPrefix(matches); len(p) > len(word) { + s.input = s.input[:idx+1] + p } return } - word := s.input[idx+1:] dirPart, base := "", word if i := strings.LastIndex(word, "/"); i >= 0 { dirPart, base = word[:i+1], word[i+1:] @@ -209,6 +380,9 @@ func (s *shellState) complete(root *vfs.Node) { var matches []string nodes := map[string]*vfs.Node{} for _, c := range dir.SortedChildren() { + if strings.HasPrefix(c.Name, ".") && !strings.HasPrefix(base, ".") { + continue + } if strings.HasPrefix(c.Name, base) { matches = append(matches, c.Name) nodes[c.Name] = c @@ -228,8 +402,6 @@ func (s *shellState) complete(root *vfs.Node) { s.input = s.input[:idx+1] + dirPart + completed } -var shellCommands = []string{"cat", "cd", "clear", "echo", "exit", "help", "ls", "pwd", "whoami"} - func commonPrefix(words []string) string { if len(words) == 0 { return "" @@ -302,7 +474,7 @@ func (m *portfolioModel) handleShellKey(msg tea.KeyMsg) (tea.Cmd, bool) { if s.input == "" { return nil, false } - s.complete(vfs.Build(m.vfsSource())) + s.complete(m.shellFS().Root()) case tea.KeyEnter: return m.runShellLine(), true default: @@ -317,23 +489,50 @@ func (m *portfolioModel) runShellLine() tea.Cmd { line := s.input s.input = "" cwd := s.cwd - res := s.exec(vfs.Build(m.vfsSource()), line) + res := s.exec(m.shellEnv(), line) if res.quit { return tea.Quit } + entry := shellEntry{cwd: cwd, input: line, output: res.output, err: res.err} if res.clear { s.history = nil s.cleared = true - } else { - s.history = append(s.history, shellEntry{cwd: cwd, input: line, output: res.output, err: res.err}) - if len(s.history) > shellMaxHistory { - s.history = s.history[len(s.history)-shellMaxHistory:] - } + entry.bare = true + } + if !entry.bare || entry.output != "" { + s.history = append(s.history, entry) + } + if len(s.history) > shellMaxHistory { + s.history = s.history[len(s.history)-shellMaxHistory:] } m.syncShellViewport() return m.prefetchAllProjectReadmesCmd() } +// shellEnv is what a shell line runs against: a freshly generated base tree +// (the session's overlay goes on top of a private clone of it). +func (m portfolioModel) shellEnv() shellEnv { + now := m.now + if now.IsZero() { + now = time.Now() + } + return shellEnv{ + root: vfs.Build(m.vfsSource()), + now: now, + fetch: func(tty bool) string { + if tty { + return m.aboutFetch() + } + return ansi.Strip(m.aboutFetchLines()) + }, + } +} + +// shellFS is the session's merged view, for completion. +func (m portfolioModel) shellFS() *vfs.FS { + return vfs.NewFS(vfs.Build(m.vfsSource()), m.shell.home, m.shell.overlay) +} + // prefetchAllProjectReadmesCmd fills the projects/*/README.md files. func (m *portfolioModel) prefetchAllProjectReadmesCmd() tea.Cmd { cmds := []tea.Cmd{} @@ -364,7 +563,9 @@ func (m portfolioModel) shellView(width int) string { wrap := lipgloss.NewStyle().Width(max(width, 10)) parts := make([]string, 0, len(m.shell.history)*2+1) for _, e := range m.shell.history { - parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, e.cwd, e.input)) + if !e.bare { + parts = append(parts, renderShellPrompt(m.shell.user, m.shell.home, e.cwd, e.input)) + } if e.output == "" { continue } diff --git a/internal/tui/shell_cmds.go b/internal/tui/shell_cmds.go @@ -0,0 +1,1199 @@ +package tui + +import ( + "fmt" + "math/rand" + "path" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + + "git.prime8.dev/moncheeta/prime8dev/internal/vfs" +) + +// Commands for the about-page shell. Behaviour follows the web shell +// (internal/web/scripts/modules/shell-cmds.js); writes land in the session's +// in-memory overlay (vfs.FS) and are gone when the SSH session ends. + +// shellCtx is what a command sees: its argv, stdin, where its output goes, and +// whether that is the terminal (styled) or a pipe/file (plain text). +type shellCtx struct { + s *shellState + fs *vfs.FS + env shellEnv + argv []string + stdin string + tty bool + out func(string) + err func(string) + res *shellResult +} + +func (c *shellCtx) resolve(p string) string { return vfs.Resolve(c.s.home, c.s.cwd, p) } + +func (c *shellCtx) display(abs string) string { return vfs.DisplayPath(c.s.home, abs) } + +type shellCmd func(c *shellCtx) int + +var shellUsage = map[string]string{ + "cat": "cat [-n] [file...] print files (or stdin)", + "cd": "cd [dir|-] change directory (~ is home, - is the last one)", + "clear": "clear clear the screen", + "cp": "cp [-r] src... dest copy files and directories", + "date": "date print the date and time", + "echo": "echo [-n] [-e] [text...] print text", + "env": "env print environment variables", + "exit": "exit close the session", + "export": "export [NAME=value...] set environment variables", + "false": "false do nothing, unsuccessfully", + "fetch": "fetch system info, the same card as the top of the page", + "grep": "grep [-invcrlwFq] pattern [file...] search for a regex", + "head": "head [-n N] [file...] first N lines (default 10)", + "help": "help list commands", + "history": "history [-c] command history (-c clears it)", + "hostname": "hostname print the host name", + "id": "id print user and group ids", + "ls": "ls [-a1d] [path...] list directory contents", + "man": "man command show usage for a command", + "mkdir": "mkdir [-p] dir... make directories", + "mv": "mv src... dest move or rename", + "pwd": "pwd print working directory", + "restore": "restore drop your changes, back to the original files", + "rm": "rm [-rf] path... remove files and directories", + "rmdir": "rmdir dir... remove empty directories", + "sort": "sort [-rnu] [file...] sort lines", + "tail": "tail [-n N] [file...] last N lines (default 10)", + "tee": "tee [-a] file... copy stdin to files and stdout", + "touch": "touch file... create files", + "tree": "tree [-a] [path] draw a directory tree", + "true": "true do nothing, successfully", + "type": "type name... say what a command is", + "uname": "uname [-a] print system information", + "uniq": "uniq [-c] [file...] collapse repeated lines", + "unset": "unset NAME... remove environment variables", + "uptime": "uptime how long this session has been up", + "wc": "wc [-lwc] [file...] count lines, words and bytes", + "which": "which command... locate a command", + "whoami": "whoami print the user name", +} + +const shellHelpText = `prime8 shell. what you create here lasts until you disconnect. + + navigate ls cd pwd tree + read cat head tail wc grep sort uniq + write echo touch mkdir cp mv rm rmdir tee + system fetch whoami id hostname uname date uptime env export + session clear history man restore exit + +pipes |, redirects > >> < 2>, ; && ||, globs * ? [ ], $VARS and ~ all work. +man <cmd> for usage. try: cat about, tree projects, echo hi > notes && cat notes` + +var shellBuiltins = map[string]bool{ + "cd": true, "echo": true, "exit": true, "logout": true, "export": true, "history": true, + "pwd": true, "type": true, "unset": true, "help": true, "true": true, "false": true, +} + +var shellCommandTable map[string]shellCmd + +// shellCommands is every command name, sorted, for completion. +var shellCommands []string + +func init() { + shellCommandTable = map[string]shellCmd{ + "cat": cmdCat, "cd": cmdCd, "clear": cmdClear, "cp": cmdCp, "date": cmdDate, + "echo": cmdEcho, "env": cmdEnv, "exit": cmdExit, "logout": cmdExit, "export": cmdExport, + "false": func(*shellCtx) int { return 1 }, "fetch": cmdFetch, "grep": cmdGrep, + "head": func(c *shellCtx) int { return headTail(c, false) }, "help": cmdHelp, + "history": cmdHistory, "hostname": cmdHostname, "id": cmdID, "ls": cmdLs, "man": cmdMan, + "mkdir": cmdMkdir, "mv": cmdMv, "pwd": cmdPwd, "restore": cmdRestore, "rm": cmdRm, + "rmdir": cmdRmdir, "sort": cmdSort, "sudo": cmdSudo, + "tail": func(c *shellCtx) int { return headTail(c, true) }, "tee": cmdTee, + "touch": cmdTouch, "tree": cmdTree, "true": func(*shellCtx) int { return 0 }, + "type": cmdType, "uname": cmdUname, "uniq": cmdUniq, "unset": cmdUnset, + "uptime": cmdUptime, "wc": cmdWc, "which": cmdWhich, "whoami": cmdWhoami, + } + for name := range shellCommandTable { + shellCommands = append(shellCommands, name) + } + sort.Strings(shellCommands) +} + +// --- helpers --- + +// shellFlags splits leading -abc options from operands. Letters in withValue +// take the next argument (or the rest of the cluster) as their value. +func shellFlags(args []string, withValue string) (map[byte]string, []string) { + set := map[byte]string{} + var rest []string + i := 0 + for ; i < len(args); i++ { + a := args[i] + if a == "--" { + i++ + break + } + if !strings.HasPrefix(a, "-") || a == "-" || (len(a) > 1 && a[1] >= '0' && a[1] <= '9') { + rest = append(rest, a) + continue + } + for j := 1; j < len(a); j++ { + f := a[j] + if strings.IndexByte(withValue, f) >= 0 { + v := a[j+1:] + if v == "" && i+1 < len(args) { + i++ + v = args[i] + } + set[f] = v + break + } + set[f] = "1" + } + } + rest = append(rest, args[i:]...) + return set, rest +} + +func has(set map[byte]string, f byte) bool { _, ok := set[f]; return ok } + +func textLines(text string) []string { + if text == "" { + return nil + } + ls := strings.Split(text, "\n") + if ls[len(ls)-1] == "" { + ls = ls[:len(ls)-1] + } + return ls +} + +func joinLines(ls []string) string { + if len(ls) == 0 { + return "" + } + return strings.Join(ls, "\n") + "\n" +} + +// fileText is a file's content as a text stream: the generated files carry +// no trailing newline, so add one like a real text file would have. +func fileText(n *vfs.Node) string { + if n.Content == "" || strings.HasSuffix(n.Content, "\n") { + return n.Content + } + return n.Content + "\n" +} + +type shellInput struct{ name, text string } + +// readInputs yields each file operand's text, or stdin when there are none. +func readInputs(c *shellCtx, files []string) []shellInput { + if len(files) == 0 { + return []shellInput{{"", c.stdin}} + } + var out []shellInput + for _, f := range files { + if f == "-" { + out = append(out, shellInput{"-", c.stdin}) + continue + } + if c.resolve(f) == "/dev/null" { + out = append(out, shellInput{f, ""}) + continue + } + n := c.fs.Get(c.resolve(f)) + switch { + case n == nil: + c.err(c.argv[0] + ": " + f + ": No such file or directory\n") + case n.Dir: + c.err(c.argv[0] + ": " + f + ": Is a directory\n") + default: + out = append(out, shellInput{f, fileText(n)}) + } + } + return out +} + +func inputsStatus(inputs []shellInput, files []string) int { + if len(inputs) == max(len(files), 1) { + return 0 + } + return 1 +} + +func (c *shellCtx) name(n *vfs.Node, label string) string { + if c.tty && n != nil && n.Dir { + return shellDirStyle.Render(label) + } + return label +} + +func errText(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// writeFile creates, replaces or appends to a file through the overlay. +func writeFile(c *shellCtx, p, content string, appendTo bool) error { + abs := c.resolve(p) + if abs == "/dev/null" { + return nil + } + n := c.fs.Get(abs) + if n != nil && n.Dir { + return vfs.ErrIsDir + } + if err := c.fs.CheckCreate(abs); err != nil { + return err + } + if appendTo && n != nil { + content = n.Content + content + } + return c.fs.Write(abs, content) +} + +func pad2(n int) string { return fmt.Sprintf("%02d", n) } + +// --- commands --- + +func cmdHelp(c *shellCtx) int { + c.out(shellHelpText + "\n") + return 0 +} + +func cmdMan(c *shellCtx) int { + if len(c.argv) < 2 { + c.err("What manual page do you want?\nFor example, try 'man man'.\n") + return 1 + } + name := c.argv[1] + u, ok := shellUsage[name] + if name == "man" { + u, ok = shellUsage["man"], true + } + if !ok { + c.err("No manual entry for " + name + "\n") + return 16 + } + c.out(strings.ToUpper(name) + "(1)\n\n " + u + "\n") + return 0 +} + +func cmdPwd(c *shellCtx) int { c.out(c.s.cwd + "\n"); return 0 } +func cmdWhoami(c *shellCtx) int { c.out(c.s.user + "\n"); return 0 } +func cmdHostname(c *shellCtx) int { c.out("prime8\n"); return 0 } + +func cmdID(c *shellCtx) int { + u := c.s.user + c.out(fmt.Sprintf("uid=1000(%s) gid=1000(%s) groups=1000(%s)\n", u, u, u)) + return 0 +} + +func cmdUname(c *shellCtx) int { + set, _ := shellFlags(c.argv[1:], "") + if has(set, 'a') { + c.out("Linux prime8 6.12.0-prime8 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux\n") + } else { + c.out("Linux\n") + } + return 0 +} + +func cmdFetch(c *shellCtx) int { + if c.env.fetch == nil { + c.err("fetch: no system info\n") + return 1 + } + card := c.env.fetch(c.tty) + if strings.TrimSpace(card) == "" { + c.err("fetch: no system info\n") + return 1 + } + c.out(card + "\n") + return 0 +} + +func shellNow(c *shellCtx) time.Time { + if c.env.now.IsZero() { + return time.Now() + } + return c.env.now +} + +func cmdDate(c *shellCtx) int { + c.out(shellNow(c).Format("Mon Jan _2 15:04:05 MST 2006") + "\n") + return 0 +} + +func cmdUptime(c *shellCtx) int { + now := shellNow(c) + mins := 0 + if !c.s.started.IsZero() { + mins = int(now.Sub(c.s.started).Minutes()) + } + up := fmt.Sprintf("%d min", mins) + if mins >= 60 { + up = fmt.Sprintf("%d:%s", mins/60, pad2(mins%60)) + } + load := func() string { return fmt.Sprintf("%.2f", rand.Float64()*0.2) } + c.out(fmt.Sprintf(" %s up %s, 1 user, load average: %s, %s, %s\n", now.Format("15:04:05"), up, load(), load(), load())) + return 0 +} + +func cmdEcho(c *shellCtx) int { + args := c.argv[1:] + newline, escapes := true, false + for len(args) > 0 && echoFlagRE.MatchString(args[0]) { + if strings.Contains(args[0], "n") { + newline = false + } + if strings.Contains(args[0], "e") { + escapes = true + } + if strings.Contains(args[0], "E") { + escapes = false + } + args = args[1:] + } + s := strings.Join(args, " ") + if escapes { + s = strings.NewReplacer(`\n`, "\n", `\t`, "\t", `\r`, "\r", `\\`, `\`).Replace(s) + } + if newline { + s += "\n" + } + c.out(s) + return 0 +} + +func cmdEnv(c *shellCtx) int { + env := c.s.envMap() + keys := make([]string, 0, len(env)) + for k := range env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + c.out(k + "=" + env[k] + "\n") + } + return 0 +} + +var shellAssignRE = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)=(.*)$`) + +func cmdExport(c *shellCtx) int { + if len(c.argv) == 1 { + return cmdEnv(c) + } + status := 0 + for _, a := range c.argv[1:] { + if m := shellAssignRE.FindStringSubmatch(a); m != nil { + c.s.setVar(m[1], m[2]) + } else if !shellAssignRE.MatchString(a + "=") { + c.err("export: `" + a + "': not a valid identifier\n") + status = 1 + } + } + return status +} + +func cmdUnset(c *shellCtx) int { + for _, a := range c.argv[1:] { + delete(c.s.vars, a) + } + return 0 +} + +func cmdHistory(c *shellCtx) int { + set, _ := shellFlags(c.argv[1:], "") + if has(set, 'c') { + c.s.cmdHistory = nil + return 0 + } + for i, l := range c.s.cmdHistory { + c.out(fmt.Sprintf("%5d %s\n", i+1, l)) + } + return 0 +} + +func cmdClear(c *shellCtx) int { + if c.tty { + c.res.clear = true + } + return 0 +} + +func cmdExit(c *shellCtx) int { + c.res.quit = true + return 0 +} + +func cmdSudo(c *shellCtx) int { + c.err(c.s.user + " is not in the sudoers file. This incident will be reported.\n") + return 1 +} + +func cmdRestore(c *shellCtx) int { + n := c.s.overlay.Changes() + c.s.overlay.Reset() + c.out(fmt.Sprintf("restored the original files (%d change%s dropped)\n", n, plural(n, "", "s"))) + return 0 +} + +func plural(n int, one, many string) string { + if n == 1 { + return one + } + return many +} + +func cmdWhich(c *shellCtx) int { + status := 0 + for _, a := range c.argv[1:] { + switch { + case shellBuiltins[a]: + c.out(a + ": shell built-in command\n") + case shellCommandTable[a] != nil: + c.out("/usr/bin/" + a + "\n") + default: + status = 1 + } + } + return status +} + +func cmdType(c *shellCtx) int { + status := 0 + for _, a := range c.argv[1:] { + switch { + case shellBuiltins[a]: + c.out(a + " is a shell builtin\n") + case shellCommandTable[a] != nil: + c.out(a + " is /usr/bin/" + a + "\n") + default: + c.err("type: " + a + ": not found\n") + status = 1 + } + } + return status +} + +func cmdCd(c *shellCtx) int { + args := c.argv[1:] + if len(args) > 1 { + c.err("cd: too many arguments\n") + return 1 + } + target := "" + if len(args) == 1 { + target = args[0] + } + if target == "-" { + if c.s.oldpwd == "" { + c.err("cd: OLDPWD not set\n") + return 1 + } + target = c.s.oldpwd + c.out(target + "\n") + } + abs := c.resolve(target) + n := c.fs.Get(abs) + switch { + case n == nil: + c.err("cd: " + target + ": No such file or directory\n") + return 1 + case !n.Dir: + c.err("cd: " + target + ": Not a directory\n") + return 1 + } + c.s.oldpwd, c.s.cwd = c.s.cwd, abs + return 0 +} + +func cmdLs(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + all := has(set, 'a') || has(set, 'A') + perLine := has(set, '1') || !c.tty + if len(rest) == 0 { + rest = []string{"."} + } + status := 0 + show := func(names []string, nodes []*vfs.Node) { + if len(names) == 0 { + return + } + parts := make([]string, len(names)) + for i := range names { + label := names[i] + if c.tty && nodes[i].Dir { + label += "/" + } + parts[i] = c.name(nodes[i], label) + } + if perLine { + c.out(strings.Join(parts, "\n") + "\n") + } else { + c.out(strings.Join(parts, " ") + "\n") + } + } + var fileNames []string + var fileNodes []*vfs.Node + type dirArg struct { + p string + n *vfs.Node + } + var dirs []dirArg + for _, p := range rest { + n := c.fs.Get(c.resolve(p)) + switch { + case n == nil: + c.err("ls: cannot access '" + p + "': No such file or directory\n") + status = 2 + case n.Dir && !has(set, 'd'): + dirs = append(dirs, dirArg{p, n}) + default: + fileNames = append(fileNames, p) + fileNodes = append(fileNodes, n) + } + } + show(fileNames, fileNodes) + headers := len(dirs)+len(fileNames) > 1 + for i, d := range dirs { + if i > 0 || len(fileNames) > 0 { + c.out("\n") + } + if headers { + c.out(d.p + ":\n") + } + var names []string + var nodes []*vfs.Node + for _, k := range d.n.SortedChildren() { + if !all && strings.HasPrefix(k.Name, ".") { + continue + } + names = append(names, k.Name) + nodes = append(nodes, k) + } + show(names, nodes) + } + return status +} + +func cmdCat(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + inputs := readInputs(c, rest) + var b strings.Builder + for _, in := range inputs { + b.WriteString(in.text) + } + text := b.String() + if has(set, 'n') { + ls := textLines(text) + for i, l := range ls { + ls[i] = fmt.Sprintf("%6d\t%s", i+1, l) + } + text = joinLines(ls) + } + c.out(text) + return inputsStatus(inputs, rest) +} + +func cmdTree(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + p := "." + if len(rest) > 0 { + p = rest[0] + } + root := c.fs.Get(c.resolve(p)) + if root == nil { + c.err(p + " [error opening dir]\n\n0 directories, 0 files\n") + return 2 + } + dirs, files := 0, 0 + var b strings.Builder + b.WriteString(c.name(root, p) + "\n") + var walk func(n *vfs.Node, prefix string) + walk = func(n *vfs.Node, prefix string) { + var kids []*vfs.Node + for _, k := range n.SortedChildren() { + if has(set, 'a') || !strings.HasPrefix(k.Name, ".") { + kids = append(kids, k) + } + } + for i, k := range kids { + last := i == len(kids)-1 + branch, next := "├── ", "│ " + if last { + branch, next = "└── ", " " + } + b.WriteString(prefix + branch + c.name(k, k.Name) + "\n") + if k.Dir { + dirs++ + walk(k, prefix+next) + } else { + files++ + } + } + } + if root.Dir { + walk(root, "") + } + fmt.Fprintf(&b, "\n%d director%s, %d file%s\n", dirs, plural(dirs, "y", "ies"), files, plural(files, "", "s")) + c.out(b.String()) + return 0 +} + +func headTail(c *shellCtx, tail bool) int { + args := make([]string, len(c.argv)-1) + for i, a := range c.argv[1:] { + if headCountRE.MatchString(a) { + a = "-n" + a[1:] + } + args[i] = a + } + set, rest := shellFlags(args, "n") + n := 10 + if v, ok := set['n']; ok { + x, err := strconv.Atoi(v) + if err != nil || x < 0 { + c.err(c.argv[0] + ": invalid number of lines: '" + v + "'\n") + return 1 + } + n = x + } + inputs := readInputs(c, rest) + for i, in := range inputs { + if len(inputs) > 1 { + if i > 0 { + c.out("\n") + } + c.out("==> " + in.name + " <==\n") + } + ls := textLines(in.text) + if tail { + ls = ls[max(len(ls)-n, 0):] + } else { + ls = ls[:min(n, len(ls))] + } + c.out(joinLines(ls)) + } + return inputsStatus(inputs, rest) +} + +func cmdWc(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + pickL, pickW, pickC := has(set, 'l'), has(set, 'w'), has(set, 'c') + if !pickL && !pickW && !pickC { + pickL, pickW, pickC = true, true, true + } + inputs := readInputs(c, rest) + type row struct { + l, w, b int + name string + } + var rows []row + for _, in := range inputs { + rows = append(rows, row{strings.Count(in.text, "\n"), len(strings.Fields(in.text)), len(in.text), in.name}) + } + if len(rows) > 1 { + t := row{name: "total"} + for _, r := range rows { + t.l, t.w, t.b = t.l+r.l, t.w+r.w, t.b+r.b + } + rows = append(rows, t) + } + cols := 0 + for _, p := range []bool{pickL, pickW, pickC} { + if p { + cols++ + } + } + width := 1 + if len(rest) == 0 && cols > 1 { + width = 7 + } + for _, r := range rows { + for _, v := range []int{r.l, r.w, r.b} { + width = max(width, len(strconv.Itoa(v))) + } + } + for _, r := range rows { + var parts []string + if pickL { + parts = append(parts, fmt.Sprintf("%*d", width, r.l)) + } + if pickW { + parts = append(parts, fmt.Sprintf("%*d", width, r.w)) + } + if pickC { + parts = append(parts, fmt.Sprintf("%*d", width, r.b)) + } + line := strings.Join(parts, " ") + if r.name != "" && r.name != "-" { + line += " " + r.name + } + c.out(line + "\n") + } + return inputsStatus(inputs, rest) +} + +var shellMatchStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + +func cmdGrep(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "e") + pattern, ok := set['e'] + if !ok { + if len(rest) == 0 { + c.err("Usage: grep [OPTION]... PATTERNS [FILE]...\n") + return 2 + } + pattern, rest = rest[0], rest[1:] + } + src := pattern + if has(set, 'F') { + src = regexp.QuoteMeta(pattern) + } + if has(set, 'w') { + src = `\b(?:` + src + `)\b` + } + if has(set, 'i') { + src = "(?i)" + src + } + re, err := regexp.Compile(src) + if err != nil { + lit := regexp.QuoteMeta(pattern) + if has(set, 'i') { + lit = "(?i)" + lit + } + re = regexp.MustCompile(lit) + } + recursive := has(set, 'r') || has(set, 'R') + var inputs []shellInput + errs := false + if len(rest) == 0 { + if recursive { + rest = []string{"."} + } else { + inputs = append(inputs, shellInput{"", c.stdin}) + } + } + var collect func(label string, n *vfs.Node) + collect = func(label string, n *vfs.Node) { + if !n.Dir { + inputs = append(inputs, shellInput{label, fileText(n)}) + return + } + for _, k := range n.SortedChildren() { + collect(label+"/"+k.Name, k) + } + } + for _, f := range rest { + n := c.fs.Get(c.resolve(f)) + switch { + case n == nil: + if !has(set, 's') { + c.err("grep: " + f + ": No such file or directory\n") + } + errs = true + case n.Dir && !recursive: + c.err("grep: " + f + ": Is a directory\n") + errs = true + default: + label := strings.TrimRight(f, "/") + if label == "" { + label = f + } + collect(label, n) + } + } + status := 1 + prefix := len(inputs) > 1 || recursive + invert := has(set, 'v') + for _, in := range inputs { + count := 0 + for i, l := range textLines(in.text) { + if re.MatchString(l) == invert { + continue + } + count++ + status = 0 + if has(set, 'l') || has(set, 'c') || has(set, 'q') { + continue + } + head := "" + if prefix { + head += in.name + ":" + } + if has(set, 'n') { + head += strconv.Itoa(i+1) + ":" + } + if c.tty && head != "" { + head = promptPathStyle.Render(head) + } + if has(set, 'o') && !invert { + for _, m := range re.FindAllString(l, -1) { + if m == "" { + continue + } + if c.tty { + m = shellMatchStyle.Render(m) + } + c.out(head + m + "\n") + } + continue + } + if c.tty && !invert { + l = re.ReplaceAllStringFunc(l, func(m string) string { + if m == "" { + return m + } + return shellMatchStyle.Render(m) + }) + } + c.out(head + l + "\n") + } + if has(set, 'c') { + label := "" + if prefix { + label = in.name + ":" + } + c.out(label + strconv.Itoa(count) + "\n") + } else if has(set, 'l') && count > 0 { + c.out(in.name + "\n") + } + } + if errs && status != 0 { + return 2 + } + return status +} + +func cmdSort(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + inputs := readInputs(c, rest) + var ls []string + for _, in := range inputs { + ls = append(ls, textLines(in.text)...) + } + if has(set, 'n') { + num := func(s string) float64 { + f, _ := strconv.ParseFloat(strings.Fields(s + " x")[0], 64) + return f + } + sort.SliceStable(ls, func(i, j int) bool { return num(ls[i]) < num(ls[j]) }) + } else { + sort.Strings(ls) + } + if has(set, 'r') { + for i, j := 0, len(ls)-1; i < j; i, j = i+1, j-1 { + ls[i], ls[j] = ls[j], ls[i] + } + } + if has(set, 'u') { + var u []string + for i, l := range ls { + if i == 0 || l != ls[i-1] { + u = append(u, l) + } + } + ls = u + } + c.out(joinLines(ls)) + return inputsStatus(inputs, rest) +} + +func cmdUniq(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + inputs := readInputs(c, rest) + var ls []string + for _, in := range inputs { + ls = append(ls, textLines(in.text)...) + } + var out []string + for i := 0; i < len(ls); { + j := i + for j < len(ls) && ls[j] == ls[i] { + j++ + } + if has(set, 'c') { + out = append(out, fmt.Sprintf("%7d %s", j-i, ls[i])) + } else { + out = append(out, ls[i]) + } + i = j + } + c.out(joinLines(out)) + return inputsStatus(inputs, rest) +} + +func cmdTee(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + status := 0 + for _, f := range rest { + if err := writeFile(c, f, c.stdin, has(set, 'a')); err != nil { + c.err("tee: " + f + ": " + errText(err) + "\n") + status = 1 + } + } + c.out(c.stdin) + return status +} + +func cmdTouch(c *shellCtx) int { + _, rest := shellFlags(c.argv[1:], "") + if len(rest) == 0 { + c.err("touch: missing file operand\n") + return 1 + } + status := 0 + for _, f := range rest { + abs := c.resolve(f) + if abs == "/dev/null" { + continue + } + if n := c.fs.Get(abs); n != nil { + if !c.fs.Writable(abs) { + c.err("touch: cannot touch '" + f + "': Permission denied\n") + status = 1 + } + continue + } + err := c.fs.CheckCreate(abs) + if err == nil { + err = c.fs.Write(abs, "") + } + if err != nil { + c.err("touch: cannot touch '" + f + "': " + errText(err) + "\n") + status = 1 + } + } + return status +} + +func cmdMkdir(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + if len(rest) == 0 { + c.err("mkdir: missing operand\n") + return 1 + } + status := 0 + for _, d := range rest { + abs := c.resolve(d) + fail := func(err error) { + c.err("mkdir: cannot create directory '" + d + "': " + errText(err) + "\n") + status = 1 + } + if has(set, 'p') { + cur := "" + for _, part := range strings.Split(strings.Trim(abs, "/"), "/") { + if part == "" { + continue + } + cur += "/" + part + n := c.fs.Get(cur) + if n != nil && n.Dir { + continue + } + var err error + if n != nil { + err = vfs.ErrExist + } else if err = c.fs.CheckCreate(cur); err == nil { + err = c.fs.Mkdir(cur) + } + if err != nil { + fail(err) + break + } + } + continue + } + var err error + if c.fs.Get(abs) != nil { + err = vfs.ErrExist + } else if err = c.fs.CheckCreate(abs); err == nil { + err = c.fs.Mkdir(abs) + } + if err != nil { + fail(err) + } + } + return status +} + +func cmdRmdir(c *shellCtx) int { + _, rest := shellFlags(c.argv[1:], "") + if len(rest) == 0 { + c.err("rmdir: missing operand\n") + return 1 + } + status := 0 + for _, d := range rest { + abs := c.resolve(d) + n := c.fs.Get(abs) + var err error + switch { + case n == nil: + err = vfs.ErrNotExist + case !n.Dir: + err = vfs.ErrNotDir + case len(n.Children) > 0: + err = vfs.ErrNotEmpty + default: + if err = c.fs.CheckCreate(abs); err == nil { + err = c.fs.Remove(abs) + } + } + if err != nil { + c.err("rmdir: failed to remove '" + d + "': " + errText(err) + "\n") + status = 1 + } + } + return status +} + +var ( + echoFlagRE = regexp.MustCompile(`^-[neE]+$`) + headCountRE = regexp.MustCompile(`^-\d+$`) +) + +var dotPathRE = regexp.MustCompile(`(^|/)\.\.?/*$`) + +func cmdRm(c *shellCtx) int { + set, rest := shellFlags(c.argv[1:], "") + recursive := has(set, 'r') || has(set, 'R') + force := has(set, 'f') + if len(rest) == 0 { + if force { + return 0 + } + c.err("rm: missing operand\n") + return 1 + } + status := 0 + for _, p := range rest { + abs := c.resolve(p) + if dotPathRE.MatchString(p) { + c.err("rm: refusing to remove '.' or '..' directory: skipping '" + p + "'\n") + status = 1 + continue + } + if abs == "/" && recursive { + c.err("rm: it is dangerous to operate recursively on '/'\nrm: use --no-preserve-root to override this failsafe\n") + status = 1 + continue + } + n := c.fs.Get(abs) + if n == nil { + if !force { + c.err("rm: cannot remove '" + p + "': No such file or directory\n") + status = 1 + } + continue + } + if n.Dir && !recursive { + c.err("rm: cannot remove '" + p + "': Is a directory\n") + status = 1 + continue + } + err := c.fs.CheckCreate(abs) + if err == nil { + err = c.fs.Remove(abs) + } + if err != nil { + c.err("rm: cannot remove '" + p + "': " + errText(err) + "\n") + status = 1 + } + } + return status +} + +func cmdCp(c *shellCtx) int { return transfer(c, false) } +func cmdMv(c *shellCtx) int { return transfer(c, true) } + +func transfer(c *shellCtx, move bool) int { + name := c.argv[0] + set, rest := shellFlags(c.argv[1:], "") + recursive := has(set, 'r') || has(set, 'R') || has(set, 'a') + if len(rest) < 2 { + if len(rest) == 1 { + c.err(name + ": missing destination file operand after '" + rest[0] + "'\n") + } else { + c.err(name + ": missing file operand\n") + } + return 1 + } + destArg := rest[len(rest)-1] + srcs := rest[:len(rest)-1] + destAbs := c.resolve(destArg) + destNode := c.fs.Get(destAbs) + destIsDir := destNode != nil && destNode.Dir + if len(srcs) > 1 && !destIsDir { + c.err(name + ": target '" + destArg + "': Not a directory\n") + return 1 + } + verb := "create" + if move { + verb = "move" + } + status := 0 + for _, src := range srcs { + srcAbs := c.resolve(src) + node := c.fs.Get(srcAbs) + fail := func(msg string) { + c.err(name + ": " + msg + "\n") + status = 1 + } + if node == nil { + fail("cannot stat '" + src + "': No such file or directory") + continue + } + if node.Dir && !move && !recursive { + fail("-r not specified; omitting directory '" + src + "'") + continue + } + target := destAbs + if destIsDir { + target = path.Join(destAbs, path.Base(srcAbs)) + } + if target == srcAbs { + fail("'" + src + "' and '" + destArg + "' are the same file") + continue + } + if node.Dir && strings.HasPrefix(target+"/", srcAbs+"/") { + op := "copy" + if move { + op = "move" + } + fail("cannot " + op + " '" + src + "' to a subdirectory of itself, '" + c.display(target) + "'") + continue + } + existing := c.fs.Get(target) + if existing != nil && existing.Dir && !node.Dir { + fail("cannot overwrite directory '" + c.display(target) + "' with non-directory") + continue + } + if existing != nil && !existing.Dir && node.Dir { + fail("cannot overwrite non-directory '" + c.display(target) + "' with directory '" + src + "'") + continue + } + err := c.fs.CheckCreate(target) + if err == nil && move { + err = c.fs.CheckCreate(srcAbs) + } + if err == nil { + err = c.fs.Copy(node, target) + } + if err == nil && move { + err = c.fs.Remove(srcAbs) + } + if err != nil { + fail("cannot " + verb + " '" + src + "': " + errText(err)) + } + } + return status +} diff --git a/internal/tui/shell_cmds_test.go b/internal/tui/shell_cmds_test.go @@ -0,0 +1,217 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +// run executes lines in a fresh session on the test model and returns the +// plain output of the last one. +func runShell(t *testing.T, m *portfolioModel, lines ...string) string { + t.Helper() + var res shellResult + for _, l := range lines { + res = m.shell.exec(m.shellEnv(), l) + } + return ansi.Strip(res.output) +} + +func TestShellWritesStayInSessionOverlay(t *testing.T) { + m := testModel(t, 120, 60) + out := runShell(t, &m, + "mkdir -p notes/deep/er", + "echo hello > notes/a", + "echo world >> notes/a", + "cat notes/a", + ) + if out != "hello\nworld" { + t.Fatalf("cat after redirects = %q", out) + } + // a second session (another SSH user) sees none of it + other := testModel(t, 120, 60) + if got := runShell(t, &other, "ls notes"); !strings.Contains(got, "No such file or directory") { + t.Fatalf("overlay leaked into another session: %q", got) + } + // and the generated base tree is untouched + if m.shellEnv().root.Lookup(m.shell.home+"/notes") != nil { + t.Fatal("base vfs was mutated") + } +} + +func TestShellFileCommands(t *testing.T) { + m := testModel(t, 120, 60) + cases := []struct{ line, want string }{ + {"touch f && ls -1 | grep '^f$'", "f"}, + {"cp bio bio2 && wc -l bio2 | grep -c bio2", "1"}, + {"mv bio2 bio3; ls bio2", "ls: cannot access 'bio2': No such file or directory"}, + {"cat bio3 | head -n 1 | wc -l", "1"}, + {"rm bio3 && ls bio3 2>&1", "ls: cannot access 'bio3': No such file or directory"}, + {"mkdir d && rmdir d && echo ok", "ok"}, + {"mkdir d2 && touch d2/x && rmdir d2", "rmdir: failed to remove 'd2': Directory not empty"}, + {"rm d2", "rm: cannot remove 'd2': Is a directory"}, + {"rm -rf d2 && echo gone", "gone"}, + {"rm projects/janitor/info 2>/dev/null; ls projects/janitor", "README.md"}, + {"rm -r /etc", "rm: cannot remove '/etc': No such file or directory"}, + {"touch /root", "touch: cannot touch '/root': Permission denied"}, + {"echo x > /home/x", "bash: /home/x: Permission denied"}, + {"echo hi > /tmp/t; cat /tmp/t", "hi"}, + {"printf x", "printf: command not found"}, + {"false || echo fallback", "fallback"}, + {"false && echo no; echo $?", "1"}, + {"FOO=bar; echo \"$FOO baz\" '$FOO'", "bar baz $FOO"}, + {"echo -n a; echo b", "ab"}, + {"cp -r projects p2 && ls p2 | wc -l", "4"}, + {"mv p2 p2/inner", "mv: cannot move 'p2' to a subdirectory of itself, '~/p2/inner'"}, + } + for _, c := range cases { + got := runShell(t, &m, c.line) + if !strings.Contains(got, c.want) { + t.Errorf("%s\n got: %q\nwant: %q", c.line, got, c.want) + } + } + // rm on base files is a whiteout in the overlay, not a real delete + if got := runShell(t, &m, "ls projects/janitor"); strings.Contains(got, "info") { + t.Errorf("whiteout not applied: %q", got) + } + runShell(t, &m, "restore") + if got := runShell(t, &m, "ls projects/janitor"); !strings.Contains(got, "info") { + t.Errorf("restore did not bring back base files: %q", got) + } +} + +func TestShellTextCommands(t *testing.T) { + m := testModel(t, 120, 60) + runShell(t, &m, `echo -e "b\na\nb\nc" > l`) + cases := []struct{ line, want string }{ + {"sort l | uniq", "a\nb\nc"}, + {"sort -r l | head -2", "c\nb"}, + {"tail -n 1 l", "c"}, + {"wc l", "4 4 8 l"}, + {"wc -l < l", "4"}, + {"grep -n b l", "1:b\n3:b"}, + {"grep -c b l", "2"}, + {"grep -v b l | tr", "tr: command not found"}, + {"sort l | uniq -c | head -1", " 1 a"}, + {"cat -n l | head -1", " 1\tb"}, + {"grep -ri janitor projects | head -1", "projects/janitor/"}, + } + for _, c := range cases { + if got := runShell(t, &m, c.line); got != c.want && !strings.Contains(got, c.want) { + t.Errorf("%s\n got: %q\nwant: %q", c.line, got, c.want) + } + } +} + +func TestShellSystemCommands(t *testing.T) { + m := testModel(t, 120, 60) + cases := []struct{ line, want string }{ + {"hostname", "prime8"}, + {"uname", "Linux"}, + {"uname -a", "Linux prime8 6.12.0-prime8 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux"}, + {"date", "Wed Oct 21 12:00:00 UTC 2026"}, + {"uptime", "up "}, + {"whoami", "visitor"}, + {"id", "uid=1000(visitor)"}, + {"man tree", "TREE(1)\n\n tree [-a] [path]"}, + {"man nope", "No manual entry for nope"}, + {"help", "fetch whoami id hostname uname date uptime"}, + {"fetch | head -2", "@prime8\n---"}, + {"which ls cd", "/usr/bin/ls\ncd: shell built-in command"}, + {"echo $HOME", "/home/visitor"}, + {"cd projects; cd -", "/home/visitor"}, + {"echo 'unterminated", "unexpected EOF while looking for matching"}, + {"ls |", "syntax error: unexpected end of file"}, + } + for _, c := range cases { + if got := runShell(t, &m, c.line); !strings.Contains(got, c.want) { + t.Errorf("%s\n got: %q\nwant: %q", c.line, got, c.want) + } + } + got := runShell(t, &m, "history") + if !strings.Contains(got, " 1 hostname") || !strings.Contains(got, "history") { + t.Errorf("history = %q", got) + } + runShell(t, &m, "history -c") + if got := runShell(t, &m, "history"); got != " 1 history" { + t.Errorf("history after -c = %q", got) + } +} + +func TestShellTree(t *testing.T) { + m := testModel(t, 120, 60) + runShell(t, &m, "mkdir -p t/a/b && touch t/a/b/c t/z t/.hidden") + got := runShell(t, &m, "tree t") + want := "t\n├── a\n│ └── b\n│ └── c\n└── z\n\n2 directories, 2 files" + if got != want { + t.Fatalf("tree:\n%s\nwant:\n%s", got, want) + } + if got := runShell(t, &m, "tree -a t"); !strings.Contains(got, "├── .hidden") || !strings.Contains(got, "2 directories, 3 files") { + t.Fatalf("tree -a:\n%s", got) + } +} + +func TestShellGlobs(t *testing.T) { + m := testModel(t, 120, 60) + if got := runShell(t, &m, "echo projects/j*"); got != "projects/janitor" { + t.Errorf("glob = %q", got) + } + if got := runShell(t, &m, "echo 'projects/j*'"); got != "projects/j*" { + t.Errorf("quoted glob = %q", got) + } + if got := runShell(t, &m, "echo nomatch*"); got != "nomatch*" { + t.Errorf("unmatched glob = %q", got) + } +} + +func TestShellOverlayQuota(t *testing.T) { + m := testModel(t, 120, 60) + runShell(t, &m, "cat bio bio bio bio bio bio bio bio > big") + var got string + for i := 0; i < 40; i++ { + got = runShell(t, &m, "cat big big > big2 && mv big2 big") + if strings.Contains(got, "No space left on device") { + break + } + } + if !strings.Contains(got, "No space left on device") { + t.Fatalf("quota never hit: %q (used %d)", got, m.shell.overlay.Used()) + } +} + +func TestShellClearThenOutput(t *testing.T) { + m := testModel(t, 120, 60) + typeString(&m, "ls\n") + typeString(&m, "clear; echo fresh\n") + out := plainView(m) + if strings.Contains(out, "experience/") || strings.Contains(out, "$ clear") { + t.Fatalf("clear left output behind:\n%s", out) + } + if !strings.Contains(out, "fresh") { + t.Fatalf("output after clear missing:\n%s", out) + } +} + +func TestShellCommandCompletion(t *testing.T) { + m := testModel(t, 120, 60) + for in, want := range map[string]string{ + "tre": "tree ", + "ls | gr": "ls | grep ", + "ls; una": "ls; uname ", + "echo x > bi": "echo x > bio ", + "cat bio && h": "cat bio && h", // head/help/history/hostname: ambiguous + } { + m.shell.input = in + m.shell.complete(m.shellFS().Root()) + if m.shell.input != want { + t.Errorf("complete(%q) = %q, want %q", in, m.shell.input, want) + } + } + runShell(t, &m, "mkdir mynotes") + m.shell.input = "cd myn" + m.shell.complete(m.shellFS().Root()) + if m.shell.input != "cd mynotes/" { + t.Errorf("overlay dir completion = %q", m.shell.input) + } +} diff --git a/internal/tui/shell_lang.go b/internal/tui/shell_lang.go @@ -0,0 +1,368 @@ +package tui + +import ( + "path" + "strings" + + "git.prime8.dev/moncheeta/prime8dev/internal/vfs" +) + +// A small POSIX-ish command language for the about-page shell, the Go twin of +// internal/web/scripts/modules/shell-lang.js: quoting, escapes, $VAR / ${VAR} +// / $? expansion, ~, globs, pipes, redirects and ; && ||. + +var shellOps = []string{"2>&1", "2>>", "&&", "||", ">>", "2>", "|", ";", ">", "<", "&"} + +const varMark = "\x00" + +// shellWord is one argument. v is the literal value; pat is the same with +// quoted glob characters backslash-escaped (path.Match syntax). +type shellWord struct { + v, pat string + glob bool + quoted bool +} + +type shellToken struct { + op string + w *shellWord +} + +type shellRedir struct { + op string + target shellWord +} + +type shellSimple struct { + words []shellWord + redirs []shellRedir +} + +type shellListItem struct { + conn string // operator joining this item to the previous one + pipeline []shellSimple +} + +func isShellBreak(c byte) bool { + return c == ' ' || c == '\t' || c == ';' || c == '|' || c == '&' || c == '<' || c == '>' +} + +func escGlob(s string) string { + var b strings.Builder + for _, r := range s { + if r == '\\' || r == '*' || r == '?' || r == '[' || r == ']' { + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() +} + +func isVarStart(c byte) bool { + return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +func isVarChar(c byte) bool { return isVarStart(c) || (c >= '0' && c <= '9') } + +// readVar leaves a \0name\0 marker; variables expand per command so +// `false; echo $?` sees the right status. +func readVar(line string, i int) (string, int) { + if i+1 >= len(line) { + return "$", i + 1 + } + nx := line[i+1] + switch { + case nx == '?' || nx == '$': + return varMark + string(nx) + varMark, i + 2 + case nx == '{': + j := strings.IndexByte(line[i+2:], '}') + if j < 0 { + return "$", i + 1 + } + return varMark + line[i+2:i+2+j] + varMark, i + 3 + j + case isVarStart(nx): + j := i + 1 + for j < len(line) && isVarChar(line[j]) { + j++ + } + return varMark + line[i+1:j] + varMark, j + } + return "$", i + 1 +} + +// shellTokenize splits a line into words and operators. +func shellTokenize(line, home string) ([]shellToken, string) { + line = strings.ReplaceAll(line, varMark, "") + var toks []shellToken + n := len(line) + i := 0 + for i < n { + c := line[i] + if c == ' ' || c == '\t' { + i++ + continue + } + if c == '#' { + break + } + matched := "" + for _, op := range shellOps { + if strings.HasPrefix(line[i:], op) { + matched = op + break + } + } + if matched != "" { + toks = append(toks, shellToken{op: matched}) + i += len(matched) + continue + } + var v, pat strings.Builder + glob, quoted := false, false + if c == '~' && (i+1 == n || line[i+1] == '/' || isShellBreak(line[i+1])) { + v.WriteString(home) + pat.WriteString(escGlob(home)) + i++ + } + for i < n { + ch := line[i] + if isShellBreak(ch) { + break + } + switch ch { + case '\\': + if i+1 < n { + v.WriteByte(line[i+1]) + pat.WriteString(escGlob(line[i+1 : i+2])) + } + quoted = true + i += 2 + continue + case '\'': + j := strings.IndexByte(line[i+1:], '\'') + if j < 0 { + return nil, "unexpected EOF while looking for matching `''" + } + s := line[i+1 : i+1+j] + v.WriteString(s) + pat.WriteString(escGlob(s)) + quoted = true + i += j + 2 + continue + case '"': + i++ + var s strings.Builder + for i < n && line[i] != '"' { + if line[i] == '\\' && i+1 < n && strings.IndexByte("$\"\\`", line[i+1]) >= 0 { + s.WriteByte(line[i+1]) + i += 2 + continue + } + if line[i] == '$' { + r, j := readVar(line, i) + s.WriteString(r) + i = j + continue + } + s.WriteByte(line[i]) + i++ + } + if i >= n { + return nil, "unexpected EOF while looking for matching `\"'" + } + i++ + v.WriteString(s.String()) + pat.WriteString(escGlob(s.String())) + quoted = true + continue + case '$': + r, j := readVar(line, i) + v.WriteString(r) + pat.WriteString(escGlob(r)) + i = j + continue + case '*', '?', '[': + glob = true + } + v.WriteByte(ch) + pat.WriteByte(ch) + i++ + } + toks = append(toks, shellToken{w: &shellWord{v: v.String(), pat: pat.String(), glob: glob, quoted: quoted}}) + } + return toks, "" +} + +var shellRedirOps = map[string]bool{">": true, ">>": true, "<": true, "2>": true, "2>>": true} + +// shellParse groups tokens into ; && || separated pipelines. +func shellParse(toks []shellToken) ([]shellListItem, string) { + var list []shellListItem + var pipeline []shellSimple + cmd := shellSimple{} + conn := "" + dangling := false + empty := func() bool { return len(cmd.words) == 0 && len(cmd.redirs) == 0 } + fail := func(tok string) string { return "syntax error near unexpected token `" + tok + "'" } + + for k := 0; k < len(toks); k++ { + t := toks[k] + if t.w != nil { + cmd.words = append(cmd.words, *t.w) + dangling = false + continue + } + op := t.op + switch { + case shellRedirOps[op]: + if k+1 >= len(toks) { + return nil, fail("newline") + } + if toks[k+1].w == nil { + return nil, fail(toks[k+1].op) + } + cmd.redirs = append(cmd.redirs, shellRedir{op: op, target: *toks[k+1].w}) + k++ + dangling = false + case op == "2>&1": + cmd.redirs = append(cmd.redirs, shellRedir{op: op}) + case op == "|": + if empty() { + return nil, fail("|") + } + pipeline = append(pipeline, cmd) + cmd = shellSimple{} + dangling = true + default: // ; & && || + if empty() { + return nil, fail(op) + } + pipeline = append(pipeline, cmd) + list = append(list, shellListItem{conn: conn, pipeline: pipeline}) + pipeline = nil + cmd = shellSimple{} + conn = op + if op == "&" { + conn = ";" + } + dangling = op == "&&" || op == "||" + } + } + if !empty() { + pipeline = append(pipeline, cmd) + } + if dangling { + return nil, "syntax error: unexpected end of file" + } + if len(pipeline) > 0 { + list = append(list, shellListItem{conn: conn, pipeline: pipeline}) + } + return list, "" +} + +// resolveVars substitutes the \0name\0 markers. +func resolveVars(w shellWord, env func(string) string) shellWord { + if !strings.Contains(w.v, varMark) { + return w + } + sub := func(s string, esc bool) string { + parts := strings.Split(s, varMark) + var b strings.Builder + for i, p := range parts { + if i%2 == 0 { + b.WriteString(p) + continue + } + val := env(strings.ReplaceAll(p, "\\", "")) + if esc { + val = escGlob(val) + } + b.WriteString(val) + } + return b.String() + } + return shellWord{v: sub(w.v, false), pat: sub(w.pat, true), glob: w.glob, quoted: w.quoted} +} + +func hasGlobMeta(seg string) bool { + for i := 0; i < len(seg); i++ { + switch seg[i] { + case '\\': + i++ + case '*', '?', '[': + return true + } + } + return false +} + +func unescapeGlob(seg string) string { + var b strings.Builder + for i := 0; i < len(seg); i++ { + if seg[i] == '\\' && i+1 < len(seg) { + i++ + } + b.WriteByte(seg[i]) + } + return b.String() +} + +// expandGlob matches a word against the fs; no match keeps the word as is. +func expandGlob(w shellWord, root *vfs.Node, home, cwd string) []string { + if !w.glob { + return []string{w.v} + } + absolute := strings.HasPrefix(w.pat, "/") + bases := []string{""} + if absolute { + bases = []string{"/"} + } + join := func(b, name string) string { + switch b { + case "": + return name + case "/": + return "/" + name + } + return b + "/" + name + } + for _, seg := range strings.Split(w.pat, "/") { + if seg == "" { + continue + } + var next []string + for _, b := range bases { + if !hasGlobMeta(seg) { + next = append(next, join(b, unescapeGlob(seg))) + continue + } + lookup := b + if lookup == "" { + lookup = "." + } + dir := root.Lookup(vfs.Resolve(home, cwd, lookup)) + if dir == nil || !dir.Dir { + continue + } + for _, c := range dir.SortedChildren() { + if strings.HasPrefix(c.Name, ".") && !strings.HasPrefix(seg, ".") { + continue + } + if ok, err := path.Match(seg, c.Name); err == nil && ok { + next = append(next, join(b, c.Name)) + } + } + } + bases = next + } + var found []string + for _, p := range bases { + if root.Lookup(vfs.Resolve(home, cwd, p)) != nil { + found = append(found, p) + } + } + if len(found) == 0 { + return []string{w.v} + } + return found +} diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go @@ -2,6 +2,7 @@ package tui import ( "math/rand" + "regexp" "strings" "testing" "time" @@ -22,26 +23,25 @@ func testModel(t *testing.T, w, h int) portfolioModel { exp := appconfig.GetExperience() projects := appconfig.GetProjects() m := portfolioModel{ - user: "visitor", - about: appconfig.GetAbout(), - currentJob: appconfig.CurrentJob(exp), - aboutASCIICache: map[int]string{}, - aboutASCIILoads: map[int]bool{}, - projects: projects, - experience: exp, - now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC), - width: w, - height: h, - bootRand: rand.New(rand.NewSource(1)), - keys: defaultKeyMap(), - help: help.New(), - viewport: viewport.New(1, 1), - shell: newShellState("visitor"), - projectReadmes: map[string]string{projects[0].ID: "# janitor\n\nsorts audio."}, - projectReadmeErrors: map[string]string{}, - projectReadmeLoads: map[string]bool{}, - projectReadmeRender: map[string]map[int]string{}, - projectMarkdownLoads: map[string]bool{}, + user: "visitor", + about: appconfig.GetAbout(), + currentJob: appconfig.CurrentJob(exp), + aboutASCIICache: map[int]string{}, + aboutASCIILoads: map[int]bool{}, + projects: projects, + experience: exp, + now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC), + width: w, + height: h, + bootRand: rand.New(rand.NewSource(1)), + keys: defaultKeyMap(), + help: help.New(), + viewport: viewport.New(1, 1), + shell: newShellState("visitor"), + projectReadmes: map[string]string{projects[0].ID: "# janitor\n\nsorts audio."}, + projectReadmeErrors: map[string]string{}, + projectReadmeLoads: map[string]bool{}, + projectReadmeRender: map[string]map[int]string{}, } m.syncViewport() return m @@ -138,7 +138,7 @@ func TestShellCommands(t *testing.T) { "sorts audio.", "cat: nope: No such file or directory", "cd: ../bio: Not a directory", - "rm: command not found", + "rm: it is dangerous to operate recursively on '/'", "cat: ../../../../etc/passwd: No such file or directory", "visitor@prime8:~/projects$ █", } { @@ -239,7 +239,7 @@ func TestWindowTitleIsShort(t *testing.T) { } func TestProjectsSharedBorderIsAccent(t *testing.T) { - accent := "\x1b[38;5;179m" + accent := "\x1b[33m" for _, w := range []int{120, 90} { m := testModel(t, w, 30) m.activateTab(tabProjects) @@ -272,3 +272,42 @@ func TestProjectsSharedBorderIsAccent(t *testing.T) { } } } + +// The chrome must follow the terminal theme: no fixed 256/truecolor +// colors and no painted backgrounds outside tags and the logo. +func TestChromeUsesTerminalPalette(t *testing.T) { + sgr := regexp.MustCompile(`\x1b\[([0-9;]*)m`) + for _, profile := range []termenv.Profile{termenv.TrueColor, termenv.ANSI256} { + m := testModel(t, 120, 40) + lipgloss.SetColorProfile(profile) // as the SSH handler does + m.wakaHours = "1,234 hrs" + // tags carry fixed brand fg/bg pairs on purpose; leave them out + m.about.Interests, m.about.Hobbies, m.about.Languages = nil, nil, nil + for i := range m.experience { + m.experience[i].Tech = nil + } + for _, tab := range []int{tabAbout, tabExperience} { + m.activateTab(tab) + view := m.View() + reverse := false + for _, match := range sgr.FindAllStringSubmatch(view, -1) { + params := strings.Split(match[1], ";") + for k := 0; k < len(params); k++ { + p := params[k] + switch { + case p == "38" || p == "48": + t.Errorf("profile %v tab %d: extended color %q", profile, tab, match[0]) + k = len(params) + case len(p) == 2 && p[0] == '4' && p != "49", len(p) == 3 && p[:2] == "10": + t.Errorf("profile %v tab %d: background %q", profile, tab, match[0]) + case p == "7": + reverse = true + } + } + } + if !reverse { + t.Errorf("profile %v tab %d: active tab not in reverse video", profile, tab) + } + } + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go @@ -2,15 +2,22 @@ package tui import "github.com/charmbracelet/lipgloss" +// The chrome follows the user's terminal theme: text uses the terminal's +// default foreground and background (nothing paints a background), accents +// are the 16 ANSI palette slots, and highlighted bars use reverse video, so +// the same styles read right in light and dark themes. Only content with its +// own fixed colors keeps them: the truecolor logo, tech tags (each a fixed +// fg/bg pair) and language colors. var ( - colorFg = lipgloss.Color("250") - colorDim = lipgloss.Color("242") - colorBorder = lipgloss.Color("239") - colorAccent = lipgloss.Color("179") - colorBlue = lipgloss.Color("74") - colorCyan = lipgloss.Color("73") - colorMagenta = lipgloss.Color("133") - colorFooterBg = lipgloss.Color("235") + colorFg lipgloss.TerminalColor = lipgloss.NoColor{} // terminal default + colorDim lipgloss.TerminalColor = lipgloss.Color("8") // bright black + colorBorder lipgloss.TerminalColor = lipgloss.Color("8") + colorAccent lipgloss.TerminalColor = lipgloss.Color("3") // yellow + colorGreen lipgloss.TerminalColor = lipgloss.Color("2") + colorRed lipgloss.TerminalColor = lipgloss.Color("1") + colorBlue lipgloss.TerminalColor = lipgloss.Color("4") + colorCyan lipgloss.TerminalColor = lipgloss.Color("6") + colorMagenta lipgloss.TerminalColor = lipgloss.Color("5") headerStyle = lipgloss.NewStyle(). BorderBottom(true). @@ -21,7 +28,6 @@ var ( footerStyle = lipgloss.NewStyle(). BorderTop(true). BorderForeground(colorBorder). - Background(colorFooterBg). Foreground(colorDim). Padding(0, 1) @@ -39,21 +45,22 @@ var ( Foreground(colorDim) promptStyle = lipgloss.NewStyle().Foreground(colorDim) - promptUserStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("71")) + promptUserStyle = lipgloss.NewStyle().Foreground(colorGreen) promptHostStyle = lipgloss.NewStyle().Foreground(colorAccent) promptPathStyle = lipgloss.NewStyle().Foreground(colorCyan) - promptDollarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("71")) + promptDollarStyle = lipgloss.NewStyle().Foreground(colorGreen) promptCmdStyle = lipgloss.NewStyle().Foreground(colorFg) selectedRowStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) - tabActiveStyle = lipgloss.NewStyle().Background(colorAccent).Foreground(lipgloss.Color("0")).Bold(true) - rowStyle = lipgloss.NewStyle().Foreground(colorFg) - dimStyle = lipgloss.NewStyle().Foreground(colorDim) - titleStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) - fetchKeyStyle = lipgloss.NewStyle().Foreground(colorAccent) - linkStyle = lipgloss.NewStyle().Foreground(colorBlue) - infoStyle = lipgloss.NewStyle().Foreground(colorCyan) - footerTimeStyle = lipgloss.NewStyle().Foreground(colorCyan).Background(colorFooterBg) - footerWakaStyle = lipgloss.NewStyle().Foreground(colorMagenta).Background(colorFooterBg) - footerDimStyle = lipgloss.NewStyle().Foreground(colorDim).Background(colorFooterBg) + // reverse video: the accent becomes the bar, the terminal background the text + tabActiveStyle = lipgloss.NewStyle().Foreground(colorAccent).Reverse(true).Bold(true) + rowStyle = lipgloss.NewStyle().Foreground(colorFg) + dimStyle = lipgloss.NewStyle().Foreground(colorDim) + titleStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + fetchKeyStyle = lipgloss.NewStyle().Foreground(colorAccent) + linkStyle = lipgloss.NewStyle().Foreground(colorBlue) + infoStyle = lipgloss.NewStyle().Foreground(colorCyan) + footerTimeStyle = lipgloss.NewStyle().Foreground(colorCyan) + footerWakaStyle = lipgloss.NewStyle().Foreground(colorMagenta) + footerDimStyle = lipgloss.NewStyle().Foreground(colorDim) ) diff --git a/internal/tui/tags.go b/internal/tui/tags.go @@ -7,7 +7,8 @@ import ( "github.com/charmbracelet/lipgloss" ) -var tagDefaultStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("233")).Background(colorDim).Padding(0, 1) +// untagged labels: reverse video in the dim color, readable in any theme +var tagDefaultStyle = lipgloss.NewStyle().Foreground(colorDim).Reverse(true).Padding(0, 1) func renderTag(label string) string { style := tagDefaultStyle diff --git a/internal/tui/types.go b/internal/tui/types.go @@ -68,12 +68,11 @@ type portfolioModel struct { viewport viewport.Model gitService *external.Git - projectReadmes map[string]string - projectReadmeErrors map[string]string - projectReadmeLoads map[string]bool - projectReadmeRender map[string]map[int]string - projectMarkdownLoads map[string]bool - projectPrefetchDone bool + projectReadmes map[string]string + projectReadmeErrors map[string]string + projectReadmeLoads map[string]bool + projectReadmeRender map[string]map[int]string + projectPrefetchDone bool wakaHours string wakaLoaded bool @@ -119,11 +118,10 @@ func newPortfolioModel(user string) portfolioModel { viewport: vp, shell: newShellState(user), - projectReadmes: make(map[string]string), - projectReadmeErrors: make(map[string]string), - projectReadmeLoads: make(map[string]bool), - projectReadmeRender: make(map[string]map[int]string), - projectMarkdownLoads: make(map[string]bool), + projectReadmes: make(map[string]string), + projectReadmeErrors: make(map[string]string), + projectReadmeLoads: make(map[string]bool), + projectReadmeRender: make(map[string]map[int]string), wakaTime: external.NewWakaTime(cache.New(5*time.Minute, 10*time.Minute)), } diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -66,24 +66,15 @@ func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.maybeLoadWakaFooterCmd() case projectReadmeMsg: m.projectReadmeLoads[msg.projectID] = false - cmd := tea.Cmd(nil) if msg.err != nil { m.projectReadmeErrors[msg.projectID] = msg.err.Error() } else { + if m.projectReadmes[msg.projectID] != msg.readme { + delete(m.projectReadmeRender, msg.projectID) // re-render on the next sync + } m.projectReadmes[msg.projectID] = msg.readme delete(m.projectReadmeErrors, msg.projectID) - delete(m.projectReadmeRender, msg.projectID) - cmd = m.maybeRenderSelectedProjectMarkdownCmd() - } - if m.activeTab == tabProjects { - if selected := m.selectedProject(); selected != nil && selected.ID == msg.projectID { - m.syncViewport() - } } - return m, cmd - case projectMarkdownRenderedMsg: - delete(m.projectMarkdownLoads, projectMarkdownLoadKey(msg.projectID, msg.width)) - m.setCachedProjectMarkdown(msg.projectID, msg.width, msg.rendered) if m.activeTab == tabProjects { if selected := m.selectedProject(); selected != nil && selected.ID == msg.projectID { m.syncViewport() @@ -119,7 +110,6 @@ func (m *portfolioModel) refreshDataCmd() tea.Cmd { return tea.Batch( m.maybeLoadProjectReadmeCmd(), m.maybePrefetchProjectReadmesCmd(), - m.maybeRenderSelectedProjectMarkdownCmd(), m.maybeLoadWakaFooterCmd(), m.maybeRenderAboutASCIICmd(), ) diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -116,11 +116,11 @@ func (m portfolioModel) renderBoot(width int, height int) string { titleStyle := bootTitleStyle subtitleStyle := bootSubtitleStyle if m.bootFadeProgress >= 0.66 { - titleStyle = titleStyle.Foreground(lipgloss.Color("242")) - subtitleStyle = subtitleStyle.Foreground(lipgloss.Color("238")) + titleStyle = titleStyle.Foreground(colorDim).Faint(true) + subtitleStyle = subtitleStyle.Foreground(colorDim).Faint(true) } else if m.bootFadeProgress >= 0.33 { - titleStyle = titleStyle.Foreground(lipgloss.Color("247")) - subtitleStyle = subtitleStyle.Foreground(lipgloss.Color("240")) + titleStyle = titleStyle.Foreground(colorFg) + subtitleStyle = subtitleStyle.Foreground(colorDim).Faint(true) } contentWidth := max(width-4, 1) @@ -220,15 +220,15 @@ func (m portfolioModel) renderFooter(width int) string { } contentW := max(width-2, 1) - leftCellStyle := lipgloss.NewStyle().Width(contentW).Background(colorFooterBg) + leftCellStyle := lipgloss.NewStyle().Width(contentW) if right == "" { return footerStyle.Width(width).Render(leftCellStyle.Render(left)) } leftW := max(width-lipgloss.Width(right)-4, 10) rightW := max(width-leftW-2, 0) - leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW).Background(colorFooterBg) - rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW).Align(lipgloss.Right).Background(colorFooterBg) + leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW) + rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW).Align(lipgloss.Right) line := lipgloss.JoinHorizontal( lipgloss.Top, leftStyle.Render(left), diff --git a/internal/vfs/overlay.go b/internal/vfs/overlay.go @@ -0,0 +1,274 @@ +package vfs + +import ( + "errors" + "path" + "sort" + "strings" +) + +// Writable layer for the shell. The generated tree (Build) stays read-only and +// shared; each shell session keeps an Overlay of what it created, changed or +// deleted, and FS replays it over a private clone of the base on every +// command. The overlay lives in memory only (an SSH session loses it on +// disconnect) and is capped so a public session cannot grow without bound. + +const ( + // OverlayQuota caps the bytes (paths + contents) one session may write. + OverlayQuota = 1 << 20 + // OverlayMaxEntries caps how many paths one session may touch. + OverlayMaxEntries = 4096 +) + +// Errors carry the text a shell prints after "cmd: 'path': ". +var ( + ErrNotExist = errors.New("No such file or directory") + ErrNotDir = errors.New("Not a directory") + ErrIsDir = errors.New("Is a directory") + ErrExist = errors.New("File exists") + ErrNotEmpty = errors.New("Directory not empty") + ErrPermission = errors.New("Permission denied") + ErrNoSpace = errors.New("No space left on device") +) + +// overlayEntry is one changed path. A nil *overlayEntry in the map is a +// whiteout (deleted base node). fresh marks a directory that replaces the +// base directory at the same path instead of merging with it. +type overlayEntry struct { + dir bool + fresh bool + content string +} + +func (e *overlayEntry) size(p string) int { + if e == nil { + return len(p) + } + return len(p) + len(e.content) +} + +// Overlay is a session's set of changes, keyed by absolute path. +type Overlay struct { + entries map[string]*overlayEntry + used int +} + +// NewOverlay returns an empty overlay. +func NewOverlay() *Overlay { + return &Overlay{entries: map[string]*overlayEntry{}} +} + +// Changes is the number of paths the overlay touches. +func (o *Overlay) Changes() int { return len(o.entries) } + +// Used is the bytes counted against OverlayQuota. +func (o *Overlay) Used() int { return o.used } + +// Reset drops every change. +func (o *Overlay) Reset() { + o.entries = map[string]*overlayEntry{} + o.used = 0 +} + +func (o *Overlay) set(p string, e *overlayEntry) error { + old, had := o.entries[p] + next := o.used + e.size(p) + if had { + next -= old.size(p) + } + if next > OverlayQuota || (!had && len(o.entries) >= OverlayMaxEntries) { + return ErrNoSpace + } + o.entries[p] = e + o.used = next + return nil +} + +func (o *Overlay) drop(p string) { + if e, ok := o.entries[p]; ok { + o.used -= e.size(p) + delete(o.entries, p) + } +} + +// Clone deep-copies a node. +func (n *Node) Clone() *Node { + c := &Node{Name: n.Name, Dir: n.Dir, Content: n.Content} + if n.Dir { + c.Children = make(map[string]*Node, len(n.Children)) + for k, v := range n.Children { + c.Children[k] = v.Clone() + } + } + return c +} + +// FS is a writable view: a private clone of base with the overlay applied. +// Mutations update both the view and the overlay; base is never touched. +type FS struct { + base *Node + root *Node + home string + ov *Overlay +} + +// NewFS clones base and replays ov over it. home (and /tmp) are writable. +func NewFS(base *Node, home string, ov *Overlay) *FS { + if ov == nil { + ov = NewOverlay() + } + f := &FS{base: base, root: base.Clone(), home: home, ov: ov} + f.apply() + return f +} + +func depth(p string) int { return strings.Count(p, "/") } + +func (f *FS) apply() { + keys := make([]string, 0, len(f.ov.entries)) + for k := range f.ov.entries { + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if di, dj := depth(keys[i]), depth(keys[j]); di != dj { + return di < dj + } + return keys[i] < keys[j] + }) + for _, k := range keys { + parent := f.root.Lookup(path.Dir(k)) + if parent == nil || !parent.Dir { + continue + } + name := path.Base(k) + e := f.ov.entries[k] + switch { + case e == nil: + delete(parent.Children, name) + case e.dir: + if cur, ok := parent.Children[name]; e.fresh || !ok || !cur.Dir { + parent.Children[name] = NewDir(name) + } + default: + parent.AddFile(name, e.content) + } + } +} + +// Root is the merged tree. Treat it as read-only; mutate through FS. +func (f *FS) Root() *Node { return f.root } + +// Get looks up an absolute path in the merged tree. +func (f *FS) Get(abs string) *Node { return f.root.Lookup(abs) } + +func inside(abs, base string) bool { + return abs == base || strings.HasPrefix(abs, strings.TrimSuffix(base, "/")+"/") +} + +// Writable: the session owns its home and /tmp; the rest belongs to root. +func (f *FS) Writable(abs string) bool { + return inside(abs, f.home) || inside(abs, "/tmp") +} + +// CheckCreate reports why abs cannot be created, replaced or removed. +func (f *FS) CheckCreate(abs string) error { + if abs == "/" { + return ErrPermission + } + parent := f.Get(path.Dir(abs)) + switch { + case parent == nil: + return ErrNotExist + case !parent.Dir: + return ErrNotDir + case !f.Writable(path.Dir(abs)) || !f.Writable(abs): + return ErrPermission + } + return nil +} + +func (f *FS) inBase(abs string) bool { return f.base.Lookup(abs) != nil } + +// Mkdir creates (or replaces) a directory at abs. Call CheckCreate first. +func (f *FS) Mkdir(abs string) error { + parent := f.Get(path.Dir(abs)) + if parent == nil || !parent.Dir { + return ErrNotExist + } + if err := f.ov.set(abs, &overlayEntry{dir: true, fresh: f.inBase(abs)}); err != nil { + return err + } + name := path.Base(abs) + parent.Children[name] = NewDir(name) + return nil +} + +// Write creates or replaces the file at abs. Call CheckCreate first. +func (f *FS) Write(abs, content string) error { + parent := f.Get(path.Dir(abs)) + if parent == nil || !parent.Dir { + return ErrNotExist + } + if err := f.ov.set(abs, &overlayEntry{content: content}); err != nil { + return err + } + parent.AddFile(path.Base(abs), content) + return nil +} + +// Remove deletes abs and everything under it. Call CheckCreate first. +func (f *FS) Remove(abs string) error { + parent := f.Get(path.Dir(abs)) + if parent == nil || !parent.Dir { + return ErrNotExist + } + prefix := abs + "/" + for k := range f.ov.entries { + if strings.HasPrefix(k, prefix) { + f.ov.drop(k) + } + } + if f.inBase(abs) { + if err := f.ov.set(abs, nil); err != nil { + return err + } + } else { + f.ov.drop(abs) + } + delete(parent.Children, path.Base(abs)) + return nil +} + +// Copy materializes n (a whole subtree for a directory) at abs. +func (f *FS) Copy(n *Node, abs string) error { + n = n.Clone() // n may live under abs's old subtree + if f.Get(abs) != nil { + if err := f.Remove(abs); err != nil { + return err + } + } + if !n.Dir { + return f.Write(abs, n.Content) + } + if err := f.Mkdir(abs); err != nil { + return err + } + for _, c := range n.SortedChildren() { + if err := f.Copy(c, path.Join(abs, c.Name)); err != nil { + return err + } + } + return nil +} + +// Count is the number of nodes in n's subtree, n included. +func Count(n *Node) int { + if !n.Dir { + return 1 + } + total := 1 + for _, c := range n.Children { + total += Count(c) + } + return total +} diff --git a/internal/vfs/overlay_test.go b/internal/vfs/overlay_test.go @@ -0,0 +1,73 @@ +package vfs + +import ( + "errors" + "strings" + "testing" +) + +func testBase() *Node { + root := NewDir("/") + home := root.AddDir("home").AddDir("u") + home.AddFile("bio", "hi") + home.AddDir("projects").AddFile("info", "x") + root.AddDir("tmp") + return root +} + +func TestOverlayNeverTouchesBase(t *testing.T) { + base := testBase() + ov := NewOverlay() + fs := NewFS(base, "/home/u", ov) + if err := fs.Write("/home/u/bio", "changed"); err != nil { + t.Fatal(err) + } + if err := fs.Remove("/home/u/projects"); err != nil { + t.Fatal(err) + } + if err := fs.Mkdir("/home/u/new"); err != nil { + t.Fatal(err) + } + if base.Lookup("/home/u/bio").Content != "hi" || base.Lookup("/home/u/projects") == nil || base.Lookup("/home/u/new") != nil { + t.Fatal("base mutated") + } + // a fresh view replays the overlay + again := NewFS(base, "/home/u", ov) + if again.Get("/home/u/bio").Content != "changed" || again.Get("/home/u/projects") != nil || again.Get("/home/u/new") == nil { + t.Fatal("overlay not replayed") + } + // recreating a deleted base dir starts empty + if err := again.Mkdir("/home/u/projects"); err != nil { + t.Fatal(err) + } + if n := NewFS(base, "/home/u", ov).Get("/home/u/projects"); n == nil || len(n.Children) != 0 { + t.Fatal("fresh dir merged with base") + } + ov.Reset() + if NewFS(base, "/home/u", ov).Get("/home/u/bio").Content != "hi" { + t.Fatal("reset") + } +} + +func TestOverlayPermissionsAndQuota(t *testing.T) { + fs := NewFS(testBase(), "/home/u", nil) + for p, want := range map[string]error{ + "/": ErrPermission, + "/home/x": ErrPermission, + "/etc/passwd": ErrNotExist, + "/home/u/bio/x": ErrNotDir, + "/tmp/ok": nil, + "/home/u/ok": nil, + } { + if got := fs.CheckCreate(p); !errors.Is(got, want) && got != want { + t.Errorf("CheckCreate(%s) = %v, want %v", p, got, want) + } + } + big := strings.Repeat("x", OverlayQuota) + if err := fs.Write("/tmp/big", big); !errors.Is(err, ErrNoSpace) { + t.Fatalf("quota: %v", err) + } + if fs.Get("/tmp/big") != nil { + t.Fatal("over-quota write applied") + } +}