prime8dev

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

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:
Mgo.mod | 2+-
Minternal/tui/content.go | 6+++++-
Minternal/tui/layout.go | 4++++
Ainternal/tui/shell.go | 380+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/shell_test.go | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/types.go | 3+++
Minternal/tui/update.go | 6++++++
Ainternal/tui/vfs.go | 239+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/view.go | 1+
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)