commit 07c87fa6a514712c209f8e54d3678105a0c06e30
parent 0a9b317b4156e89ff147f2775b6514f608e4c015
Author: Damian Myrda <damian@prime8.dev>
Date: Wed, 21 Oct 2026 08:00:24 +0000
feat(tui): interactive shell on the about page
The prompt at the bottom of the about page now takes input. It runs
against a read-only in-memory fs generated from the site content:
~/{about,bio,contact}
~/{interests,hobbies,languages}/
~/experience/<id>
~/projects/<id>/{README.md,info}
Supports ls, cd, pwd, cat, clear, help, exit (plus whoami, echo), tab
completion, ctrl+u/ctrl+w/ctrl+l/ctrl+d. Letters always go to the
prompt; with an empty prompt, 1-3, tab, ? and the arrows keep their
usual bindings.
Diffstat:
9 files changed, 862 insertions(+), 2 deletions(-)
diff --git a/go.mod b/go.mod
@@ -16,6 +16,7 @@ require (
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/ssh v0.0.0-20250128164007-98fd5ae11894
github.com/charmbracelet/wish v1.4.7
+ github.com/charmbracelet/x/ansi v0.10.1
github.com/evanw/esbuild v0.27.4
github.com/joho/godotenv v1.5.1
github.com/muesli/termenv v0.16.0
@@ -31,7 +32,6 @@ require (
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/keygen v0.5.3 // indirect
github.com/charmbracelet/log v0.4.1 // indirect
- github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/conpty v0.1.0 // indirect
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 // indirect
diff --git a/internal/tui/content.go b/internal/tui/content.go
@@ -12,6 +12,10 @@ import (
)
func (m portfolioModel) aboutDetail() string {
+ shellW, _ := m.detailSize()
+ if m.shell.cleared {
+ return m.shellView(shellW)
+ }
parts := []string{
m.aboutFetch(),
"",
@@ -24,7 +28,7 @@ func (m portfolioModel) aboutDetail() string {
renderPrompt(m.about.Handle, "ls hobbies/"),
renderTagList(m.about.Hobbies),
"",
- renderPrompt(m.about.Handle, "") + rowStyle.Render("█"),
+ m.shellView(shellW),
}
if appconfig.WakaTimeEnabled {
parts = append(parts, "", renderPrompt(m.about.Handle, "wakatime --week"), m.aboutWeeklyStats())
diff --git a/internal/tui/layout.go b/internal/tui/layout.go
@@ -7,6 +7,10 @@ func (m *portfolioModel) syncViewport() {
w, h := m.detailSize()
m.viewport.Width = max(w, 1)
m.viewport.Height = max(h, 1)
+ if m.activeTab == tabAbout && (m.shell.cleared || len(m.shell.history) > 0 || m.shell.input != "") {
+ m.viewport.GotoBottom()
+ return
+ }
m.viewport.GotoTop()
}
diff --git a/internal/tui/shell.go b/internal/tui/shell.go
@@ -0,0 +1,380 @@
+package tui
+
+import (
+ "sort"
+ "strings"
+ "unicode"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+const shellMaxHistory = 200
+
+var (
+ shellDirStyle = lipgloss.NewStyle().Foreground(colorBlue).Bold(true)
+ shellErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("167"))
+)
+
+type shellEntry struct {
+ cwd string
+ input string
+ output string
+ err bool
+}
+
+// shellState is the interactive prompt at the bottom of the about page.
+type shellState struct {
+ cwd string
+ input string
+ history []shellEntry
+ // cleared hides the about-page intro, like `clear` in a real terminal.
+ cleared bool
+}
+
+func newShellState() shellState {
+ return shellState{cwd: shellHome}
+}
+
+type shellResult struct {
+ output string
+ err bool
+ clear bool
+ quit bool
+}
+
+func (s *shellState) exec(root *vfsNode, line string) shellResult {
+ fields := strings.Fields(line)
+ if len(fields) == 0 {
+ return shellResult{}
+ }
+ 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: shellUser}
+ 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 := resolvePath(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
+ return shellResult{}
+ case "ls":
+ paths := make([]string, 0, len(args))
+ for _, a := range args {
+ if !strings.HasPrefix(a, "-") {
+ paths = append(paths, a)
+ }
+ }
+ if len(paths) == 0 {
+ paths = []string{"."}
+ }
+ var out []string
+ failed := false
+ for _, p := range paths {
+ n := root.lookup(resolvePath(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
+ }
+ 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(resolvePath(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))
+ }
+ }
+ return shellResult{output: strings.Join(out, "\n"), err: failed && len(args) == 1}
+ case "sudo":
+ return errf(shellUser + " is not in the sudoers file. This incident will be reported.")
+ default:
+ return errf(cmd + ": command not found")
+ }
+}
+
+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`
+
+func renderLs(n *vfsNode) string {
+ if !n.dir {
+ return rowStyle.Render(n.name)
+ }
+ children := n.sortedChildren()
+ if len(children) == 0 {
+ return ""
+ }
+ parts := make([]string, 0, len(children))
+ for _, c := range children {
+ if c.dir {
+ parts = append(parts, shellDirStyle.Render(c.name+"/"))
+ } else {
+ parts = append(parts, rowStyle.Render(c.name))
+ }
+ }
+ return strings.Join(parts, " ")
+}
+
+// complete expands the last word of the input against the vfs.
+func (s *shellState) complete(root *vfsNode) {
+ if s.input == "" {
+ return
+ }
+ idx := strings.LastIndexFunc(s.input, unicode.IsSpace)
+ if idx < 0 {
+ // first word: complete command names
+ matches := []string{}
+ for _, c := range shellCommands {
+ if strings.HasPrefix(c, s.input) {
+ 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
+ }
+ return
+ }
+
+ word := s.input[idx+1:]
+ dirPart, base := "", word
+ if i := strings.LastIndex(word, "/"); i >= 0 {
+ dirPart, base = word[:i+1], word[i+1:]
+ }
+ dir := root.lookup(resolvePath(s.cwd, dirPart))
+ if dir == nil || !dir.dir {
+ return
+ }
+ var matches []string
+ nodes := map[string]*vfsNode{}
+ for _, c := range dir.sortedChildren() {
+ if strings.HasPrefix(c.name, base) {
+ matches = append(matches, c.name)
+ nodes[c.name] = c
+ }
+ }
+ if len(matches) == 0 {
+ return
+ }
+ completed := commonPrefix(matches)
+ if len(matches) == 1 {
+ if nodes[completed].dir {
+ completed += "/"
+ } else {
+ completed += " "
+ }
+ }
+ 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 ""
+ }
+ sorted := append([]string(nil), words...)
+ sort.Strings(sorted)
+ first, last := sorted[0], sorted[len(sorted)-1]
+ i := 0
+ for i < len(first) && i < len(last) && first[i] == last[i] {
+ i++
+ }
+ return first[:i]
+}
+
+// handleShellKey routes a key on the about page to the prompt. It reports
+// whether the key was consumed; unconsumed keys fall through to the global
+// bindings (tab switching, scrolling, quit, ...).
+func (m *portfolioModel) handleShellKey(msg tea.KeyMsg) (tea.Cmd, bool) {
+ s := &m.shell
+ switch msg.Type {
+ case tea.KeyRunes, tea.KeySpace:
+ r := string(msg.Runes)
+ if msg.Type == tea.KeySpace {
+ r = " "
+ }
+ // with an empty prompt, keep the digit tab shortcuts and ? help
+ if s.input == "" && !msg.Paste {
+ if _, ok := tabIndexFromKey(r); ok {
+ return nil, false
+ }
+ if r == "?" || r == " " {
+ return nil, false
+ }
+ }
+ s.input += strings.Map(func(r rune) rune {
+ if unicode.IsControl(r) {
+ return -1
+ }
+ return r
+ }, r)
+ case tea.KeyBackspace, tea.KeyCtrlH:
+ if s.input == "" {
+ return nil, true
+ }
+ rs := []rune(s.input)
+ s.input = string(rs[:len(rs)-1])
+ case tea.KeyCtrlU:
+ s.input = ""
+ case tea.KeyCtrlW:
+ trimmed := strings.TrimRightFunc(s.input, unicode.IsSpace)
+ if i := strings.LastIndexFunc(trimmed, unicode.IsSpace); i >= 0 {
+ s.input = trimmed[:i+1]
+ } else {
+ s.input = ""
+ }
+ case tea.KeyCtrlL:
+ s.history = nil
+ s.cleared = true
+ case tea.KeyCtrlD:
+ if s.input == "" {
+ return tea.Quit, true
+ }
+ return nil, true
+ case tea.KeyEsc:
+ if s.input == "" {
+ return nil, false
+ }
+ s.input = ""
+ case tea.KeyTab:
+ if s.input == "" {
+ return nil, false
+ }
+ s.complete(buildVFS(m.vfsSource()))
+ case tea.KeyEnter:
+ return m.runShellLine(), true
+ default:
+ return nil, false
+ }
+ m.syncShellViewport()
+ return nil, true
+}
+
+func (m *portfolioModel) runShellLine() tea.Cmd {
+ s := &m.shell
+ line := s.input
+ s.input = ""
+ cwd := s.cwd
+ res := s.exec(buildVFS(m.vfsSource()), line)
+ if res.quit {
+ return tea.Quit
+ }
+ 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:]
+ }
+ }
+ m.syncShellViewport()
+ return m.prefetchAllProjectReadmesCmd()
+}
+
+// prefetchAllProjectReadmesCmd fills the projects/*/README.md files.
+func (m *portfolioModel) prefetchAllProjectReadmesCmd() tea.Cmd {
+ cmds := []tea.Cmd{}
+ for _, p := range m.projects {
+ if _, ok := m.projectReadmes[p.ID]; ok || m.projectReadmeLoads[p.ID] || m.gitService == nil {
+ continue
+ }
+ m.projectReadmeLoads[p.ID] = true
+ cmds = append(cmds, fetchProjectReadmeCmd(m.gitService, p))
+ }
+ if len(cmds) == 0 {
+ return nil
+ }
+ return tea.Batch(cmds...)
+}
+
+// syncShellViewport re-renders the about page and keeps the prompt in view.
+func (m *portfolioModel) syncShellViewport() {
+ if m.activeTab != tabAbout {
+ return
+ }
+ m.viewport.SetContent(m.detailContent())
+ m.viewport.GotoBottom()
+}
+
+// shellView renders past commands, their output, and the live prompt line.
+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(e.cwd, e.input))
+ if e.output == "" {
+ continue
+ }
+ out := e.output
+ if e.err {
+ out = shellErrorStyle.Render(out)
+ }
+ parts = append(parts, wrap.Render(out))
+ }
+ parts = append(parts, renderShellPrompt(m.shell.cwd, m.shell.input)+rowStyle.Render("█"))
+ return strings.Join(parts, "\n")
+}
+
+func renderShellPrompt(cwd, input string) string {
+ return promptUserStyle.Render(shellUser) +
+ promptStyle.Render("@") +
+ promptHostStyle.Render("prime8") +
+ promptStyle.Render(":") +
+ promptPathStyle.Render(displayPath(cwd)) +
+ promptDollarStyle.Render("$") +
+ promptStyle.Render(" ") +
+ promptCmdStyle.Render(input)
+}
diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go
@@ -0,0 +1,223 @@
+package tui
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+ "time"
+
+ appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "github.com/charmbracelet/bubbles/help"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/muesli/termenv"
+)
+
+// testModel builds a model without network or git access.
+func testModel(t *testing.T, w, h int) portfolioModel {
+ t.Helper()
+ lipgloss.SetColorProfile(termenv.ANSI256)
+ 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(),
+ 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{},
+ }
+ m.syncViewport()
+ return m
+}
+
+func typeString(m *portfolioModel, s string) tea.Cmd {
+ var cmd tea.Cmd
+ for _, r := range s {
+ if r == '\n' {
+ cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter})
+ continue
+ }
+ if r == ' ' {
+ cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}})
+ continue
+ }
+ cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ return cmd
+}
+
+func isQuit(cmd tea.Cmd) bool {
+ if cmd == nil {
+ return false
+ }
+ _, ok := cmd().(tea.QuitMsg)
+ return ok
+}
+
+func plainView(m portfolioModel) string { return ansi.Strip(m.View()) }
+
+func TestShellPromptRendersOnAboutPage(t *testing.T) {
+ m := testModel(t, 120, 60)
+ if !strings.Contains(plainView(m), "guest@prime8:~$ █") {
+ t.Fatalf("prompt not rendered:\n%s", plainView(m))
+ }
+}
+
+func TestShellTypingDoesNotTriggerShortcuts(t *testing.T) {
+ m := testModel(t, 120, 60)
+ // q, f, b, h are bindings elsewhere; on the about page they are text.
+ if cmd := typeString(&m, "qfbh"); isQuit(cmd) {
+ t.Fatal("q quit the session while typing")
+ }
+ if m.shell.input != "qfbh" {
+ t.Fatalf("input = %q", m.shell.input)
+ }
+ if !strings.Contains(plainView(m), "guest@prime8:~$ qfbh█") {
+ t.Fatalf("typed text not shown:\n%s", plainView(m))
+ }
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyBackspace})
+ if m.shell.input != "qfb" {
+ t.Fatalf("backspace: input = %q", m.shell.input)
+ }
+}
+
+func TestShellTabSwitchingStillWorks(t *testing.T) {
+ m := testModel(t, 120, 60)
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}})
+ if m.activeTab != tabProjects {
+ t.Fatalf("digit shortcut with empty prompt: tab = %d", m.activeTab)
+ }
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}})
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
+ if m.activeTab != tabExperience {
+ t.Fatalf("tab key with empty prompt: tab = %d", m.activeTab)
+ }
+}
+
+func TestShellExitQuits(t *testing.T) {
+ m := testModel(t, 120, 60)
+ if cmd := typeString(&m, "exit\n"); !isQuit(cmd) {
+ t.Fatal("exit did not quit")
+ }
+}
+
+func TestShellCommands(t *testing.T) {
+ m := testModel(t, 120, 80)
+ typeString(&m, "ls\n")
+ typeString(&m, "cd projects\n")
+ typeString(&m, "pwd\n")
+ typeString(&m, "ls\n")
+ typeString(&m, "cat janitor/README.md\n")
+ typeString(&m, "cat nope\n")
+ typeString(&m, "cd ../bio\n")
+ typeString(&m, "rm -rf /\n")
+ typeString(&m, "cat ../../../../etc/passwd\n")
+ out := plainView(m)
+ for _, want := range []string{
+ "about bio contact experience/ hobbies/ interests/ languages/ projects/",
+ "guest@prime8:~/projects$ pwd",
+ "/home/guest/projects",
+ "computer-science-club/ finger/ janitor/ summarizer/",
+ "sorts audio.",
+ "cat: nope: No such file or directory",
+ "cd: ../bio: Not a directory",
+ "rm: command not found",
+ "cat: ../../../../etc/passwd: No such file or directory",
+ "guest@prime8:~/projects$ █",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("missing %q in:\n%s", want, out)
+ }
+ }
+}
+
+func TestShellClear(t *testing.T) {
+ m := testModel(t, 120, 60)
+ typeString(&m, "ls\nclear\n")
+ out := plainView(m)
+ if strings.Contains(out, "cat bio") || strings.Contains(out, "experience/") {
+ t.Fatalf("clear left output behind:\n%s", out)
+ }
+ if !strings.Contains(out, "guest@prime8:~$ █") {
+ t.Fatalf("prompt missing after clear:\n%s", out)
+ }
+}
+
+func TestShellCompletion(t *testing.T) {
+ m := testModel(t, 120, 60)
+ typeString(&m, "cat proj")
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
+ if m.shell.input != "cat projects/" {
+ t.Fatalf("completion: %q", m.shell.input)
+ }
+ typeString(&m, "ja")
+ m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab})
+ if m.shell.input != "cat projects/janitor/" {
+ t.Fatalf("completion: %q", m.shell.input)
+ }
+}
+
+func TestVFSNeverEscapesRoot(t *testing.T) {
+ root := buildVFS(testModel(t, 120, 60).vfsSource())
+ for _, p := range []string{"/etc/passwd", "/proc/self/environ", "../../..", "/srv"} {
+ n := root.lookup(resolvePath(shellHome, p))
+ if n != nil && n != root {
+ t.Errorf("%s resolved to %q", p, n.name)
+ }
+ }
+ if n := root.lookup(resolvePath(shellHome, "../../..")); n != root {
+ t.Error(".. above / should stay at /")
+ }
+}
+
+func TestProjectsSharedBorderIsAccent(t *testing.T) {
+ accent := "\x1b[38;5;179m"
+ for _, w := range []int{120, 90} {
+ m := testModel(t, w, 30)
+ m.activateTab(tabProjects)
+ for _, readme := range []bool{false, true} {
+ m.projectReadmeScroll = readme
+ view := m.View()
+ lines := strings.Split(view, "\n")
+ // every line holding a divider glyph must draw it in the accent color
+ found := false
+ for _, line := range lines {
+ for _, glyph := range []string{"┬", "┴", "├", "┤"} {
+ i := strings.Index(line, glyph)
+ if i < 0 {
+ continue
+ }
+ found = true
+ if j := strings.LastIndex(line[:i], "\x1b["); j < 0 || !strings.HasPrefix(line[j:], accent) {
+ t.Errorf("w=%d readme=%v: %s not accent-colored", w, readme, glyph)
+ }
+ }
+ }
+ if !found {
+ t.Errorf("w=%d readme=%v: no shared border junction found", w, readme)
+ }
+ for _, l := range strings.Split(ansi.Strip(view), "\n") {
+ if lipgloss.Width(l) > w {
+ t.Errorf("w=%d readme=%v: line overflows (%d)", w, readme, lipgloss.Width(l))
+ }
+ }
+ }
+ }
+}
diff --git a/internal/tui/types.go b/internal/tui/types.go
@@ -41,6 +41,8 @@ type portfolioModel struct {
projectReadmeScroll bool
experienceIndex int
+ shell shellState
+
width int
height int
now time.Time
@@ -109,6 +111,7 @@ func newPortfolioModel(user string) portfolioModel {
keys: defaultKeyMap(),
help: h,
viewport: vp,
+ shell: newShellState(),
projectReadmes: make(map[string]string),
projectReadmeErrors: make(map[string]string),
diff --git a/internal/tui/update.go b/internal/tui/update.go
@@ -144,6 +144,12 @@ func (m *portfolioModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd {
}
}
+ if m.activeTab == tabAbout {
+ if cmd, ok := m.handleShellKey(msg); ok {
+ return cmd
+ }
+ }
+
if m.consumeProjectReadmeBack(msg) {
return nil
}
diff --git a/internal/tui/vfs.go b/internal/tui/vfs.go
@@ -0,0 +1,239 @@
+package tui
+
+import (
+ "fmt"
+ "path"
+ "sort"
+ "strings"
+ "time"
+
+ appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config"
+ "git.prime8.dev/moncheeta/prime8dev/internal/utils"
+)
+
+// The shell on the about page runs against a read-only, in-memory filesystem
+// built from the site's own content. Nothing here ever touches the host
+// filesystem: every node is generated from config/content values.
+
+const (
+ shellUser = "guest"
+ shellHome = "/home/" + shellUser
+)
+
+type vfsNode struct {
+ name string
+ dir bool
+ content string
+ children map[string]*vfsNode
+}
+
+func newVFSDir(name string) *vfsNode {
+ return &vfsNode{name: name, dir: true, children: map[string]*vfsNode{}}
+}
+
+func (n *vfsNode) addDir(name string) *vfsNode {
+ if c, ok := n.children[name]; ok && c.dir {
+ return c
+ }
+ c := newVFSDir(name)
+ n.children[name] = c
+ return c
+}
+
+func (n *vfsNode) addFile(name, content string) {
+ n.children[name] = &vfsNode{name: name, content: content}
+}
+
+func (n *vfsNode) sortedChildren() []*vfsNode {
+ out := make([]*vfsNode, 0, len(n.children))
+ for _, c := range n.children {
+ out = append(out, c)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
+ return out
+}
+
+// vfsSource is everything the filesystem is generated from.
+type vfsSource struct {
+ about appconfig.About
+ currentJob *appconfig.Experience
+ experience []appconfig.Experience
+ projects []appconfig.Project
+ readmes map[string]string
+ now time.Time
+}
+
+func (m portfolioModel) vfsSource() vfsSource {
+ return vfsSource{
+ about: m.about,
+ currentJob: m.currentJob,
+ experience: m.experience,
+ projects: m.projects,
+ readmes: m.projectReadmes,
+ now: m.now,
+ }
+}
+
+// buildVFS lays out:
+//
+// /home/guest/{about,bio,contact}
+// /home/guest/{interests,hobbies,languages}/<item>
+// /home/guest/experience/<id>
+// /home/guest/projects/<id>/{README.md,info}
+func buildVFS(src vfsSource) *vfsNode {
+ root := newVFSDir("/")
+ home := root.addDir("home").addDir(shellUser)
+ root.addDir("tmp")
+
+ a := src.about
+ home.addFile("bio", a.Bio)
+ home.addFile("about", vfsAboutFile(src))
+ home.addFile("contact", strings.Join([]string{
+ "email: damian@prime8.dev",
+ "web: " + appconfig.SiteURL,
+ "git: " + appconfig.SiteURL + "/projects",
+ "linkedin: https://linkedin.com/in/damian-myrda",
+ "ssh: ssh prime8.dev",
+ }, "\n"))
+
+ for dir, items := range map[string][]string{
+ "interests": a.Interests,
+ "hobbies": a.Hobbies,
+ "languages": a.Languages,
+ } {
+ d := home.addDir(dir)
+ for _, item := range items {
+ if name := vfsName(item); name != "" {
+ d.addFile(name, item)
+ }
+ }
+ }
+
+ expDir := home.addDir("experience")
+ for _, e := range src.experience {
+ if name := vfsName(e.ID); name != "" {
+ expDir.addFile(name, vfsExperienceFile(e))
+ }
+ }
+
+ projDir := home.addDir("projects")
+ for _, p := range src.projects {
+ name := vfsName(p.ID)
+ if name == "" {
+ continue
+ }
+ d := projDir.addDir(name)
+ readme := strings.TrimSpace(src.readmes[p.ID])
+ if readme == "" {
+ readme = strings.TrimSpace(p.Markdown)
+ }
+ if readme != "" {
+ d.addFile("README.md", readme)
+ }
+ info := []string{"name: " + p.Name, "about: " + p.Subtitle}
+ if len(p.Tech) > 0 {
+ info = append(info, "tech: "+strings.Join(p.Tech, ", "))
+ }
+ if p.Repo != "" {
+ info = append(info,
+ "clone: git clone "+appconfig.GitCloneURL(p.Repo),
+ " git clone "+appconfig.GitSSHCloneURL(p.Repo),
+ )
+ }
+ d.addFile("info", strings.Join(info, "\n"))
+ }
+
+ return root
+}
+
+func vfsAboutFile(src vfsSource) string {
+ a := src.about
+ role := a.Profession
+ if src.currentJob != nil {
+ role = src.currentJob.Role + " @ " + src.currentJob.Company
+ }
+ now := src.now
+ if now.IsZero() {
+ now = time.Now()
+ }
+ lines := []string{
+ "name: " + a.Name,
+ "role: " + role,
+ "location: " + a.Location,
+ "uptime: " + utils.Uptime(appconfig.Birthday, now),
+ }
+ for _, kv := range a.Setup {
+ lines = append(lines, fmt.Sprintf("%-9s %s", kv.Key+":", kv.Value))
+ }
+ lines = append(lines, "langs: "+strings.Join(a.Languages, ", "))
+ return strings.Join(lines, "\n")
+}
+
+func vfsExperienceFile(e appconfig.Experience) string {
+ lines := []string{
+ e.Role + " @ " + e.Company,
+ dateRangeLabel(e),
+ "",
+ }
+ lines = append(lines, e.Description...)
+ if len(e.Tech) > 0 {
+ lines = append(lines, "", "tech: "+strings.Join(e.Tech, ", "))
+ }
+ if e.URL != "" {
+ lines = append(lines, "url: "+e.URL)
+ }
+ return strings.Join(lines, "\n")
+}
+
+// vfsName turns a label into a single path component.
+func vfsName(s string) string {
+ s = strings.TrimSpace(strings.ToLower(s))
+ s = strings.NewReplacer("/", "-", " ", "-").Replace(s)
+ if s == "." || s == ".." {
+ return ""
+ }
+ return s
+}
+
+// resolvePath turns p (absolute, ~-relative, or relative to cwd) into a clean
+// absolute virtual path.
+func resolvePath(cwd, p string) string {
+ switch {
+ case p == "" || p == "~":
+ return shellHome
+ case strings.HasPrefix(p, "~/"):
+ p = shellHome + p[1:]
+ case !strings.HasPrefix(p, "/"):
+ p = cwd + "/" + p
+ }
+ return path.Clean(p)
+}
+
+func (root *vfsNode) lookup(abs string) *vfsNode {
+ n := root
+ for _, part := range strings.Split(strings.Trim(abs, "/"), "/") {
+ if part == "" {
+ continue
+ }
+ if !n.dir {
+ return nil
+ }
+ c, ok := n.children[part]
+ if !ok {
+ return nil
+ }
+ n = c
+ }
+ return n
+}
+
+// displayPath abbreviates the home directory to ~.
+func displayPath(abs string) string {
+ if abs == shellHome {
+ return "~"
+ }
+ if strings.HasPrefix(abs, shellHome+"/") {
+ return "~" + abs[len(shellHome):]
+ }
+ return abs
+}
diff --git a/internal/tui/view.go b/internal/tui/view.go
@@ -68,6 +68,7 @@ func (m portfolioModel) renderHelpBody(width int, height int) string {
{key: "1-3", desc: "switch tabs"},
{key: "enter", desc: "open item"},
{key: "?", desc: "toggle help"},
+ {key: "type", desc: "shell on the about page (try help, exit)"},
}
panelW := min(max(width-8, 42), 70)