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:
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")
+ }
+}