prime8dev

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

commit 66f6579dce2379d7e3805a7452f3a43d23d9b3fa
parent 4c83ff3b86587ff7c0f42378842ff127c2add16a
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.

The shell, prompts and window title use the ssh session user,
sanitized to a safe path segment ([a-z0-9._-], max 32 chars,
fallback "visitor"), for whoami, sudo and /home/<user>.

Diffstat:
Mcmd/server/handlers/curl.go | 7++++---
Acmd/server/handlers/curl_test.go | 36++++++++++++++++++++++++++++++++++++
Mcmd/server/handlers/wakatime.go | 8+++++++-
Mgo.mod | 2+-
Minternal/config/site.go | 9+++++++--
Minternal/external/wakatime.go | 33+++++++++++++++++++++++++++++++--
Ainternal/external/wakatime_test.go | 41+++++++++++++++++++++++++++++++++++++++++
Minternal/tui/content.go | 18+++++++++++-------
Ainternal/tui/hscroll.go | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/hscroll_test.go | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/keys.go | 12+++++++++++-
Minternal/tui/layout.go | 42+++++++++++++++++++++++++++++++++++++++++-
Minternal/tui/markdown.go | 5+++--
Ainternal/tui/markdown_wide.go | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/program.go | 10+++-------
Ainternal/tui/shell.go | 384+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/tui/shell_test.go | 278+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/types.go | 10++++++++++
Minternal/tui/update.go | 30++++++++++++++++++++++++++----
Ainternal/tui/vfs.go | 267+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/tui/view.go | 14+++++++++-----
Minternal/tui/waka_footer.go | 6+++++-
Minternal/web/renderer.go | 35++++++++++++++++++-----------------
Minternal/web/templates/partials/about.gohtml | 2+-
24 files changed, 1618 insertions(+), 55 deletions(-)

diff --git a/cmd/server/handlers/curl.go b/cmd/server/handlers/curl.go @@ -7,10 +7,10 @@ import ( "strings" "sync" "time" - "unicode/utf8" "git.prime8.dev/moncheeta/prime8dev/internal/config" "git.prime8.dev/moncheeta/prime8dev/internal/utils" + "github.com/charmbracelet/x/ansi" ) const ( @@ -22,7 +22,7 @@ const ( ansiBlue = "\x1b[38;5;110m" ) -var curlLogo = sync.OnceValue(func() string { return utils.LogoASCII(curlLogoWidth, false) }) +var curlLogo = sync.OnceValue(func() string { return utils.LogoASCII(curlLogoWidth, true) }) // isTerminalClient reports whether the request comes from a CLI http client. func isTerminalClient(r *http.Request) bool { @@ -84,7 +84,8 @@ func terminalFetch(about config.About, job *config.Experience) string { if i < len(logo) { l = logo[i] } - b.WriteString(l + strings.Repeat(" ", max(0, curlLogoWidth-utf8.RuneCountInString(l))) + " ") + // pad by visible width: the logo lines carry truecolor escapes + b.WriteString(l + ansiReset + strings.Repeat(" ", max(0, curlLogoWidth-ansi.StringWidth(l))) + " ") if i < len(lines) { b.WriteString(lines[i]) } diff --git a/cmd/server/handlers/curl_test.go b/cmd/server/handlers/curl_test.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "os" + "strings" + "testing" + + "git.prime8.dev/moncheeta/prime8dev/internal/config" + "github.com/charmbracelet/x/ansi" +) + +func TestTerminalFetchColoredLogoAligned(t *testing.T) { + if err := os.Chdir("../../.."); err != nil { + t.Fatal(err) + } + out := terminalFetch(config.GetAbout(), config.CurrentJob(config.GetExperience())) + // aic picks its color level from COLORTERM at init (the Docker image sets + // COLORTERM=truecolor); without it the logo is plain but must still align. + if strings.Contains(os.Getenv("COLORTERM"), "truecolor") && !strings.Contains(out, "\x1b[38;2;") { + t.Fatal("logo is not truecolor") + } + lines := strings.Split(strings.Trim(out, "\n"), "\n") + for i, l := range lines { + plain := ansi.Strip(l) + if ansi.StringWidth(plain) <= curlLogoWidth { + continue + } + // the info column starts right after the padded logo and 3 spaces + if got := []rune(plain)[curlLogoWidth : curlLogoWidth+3]; string(got) != " " { + t.Errorf("line %d misaligned: %q", i, plain) + } + } + if !strings.Contains(ansi.Strip(lines[0]), "moncheeta@prime8") { + t.Errorf("first line missing title: %q", ansi.Strip(lines[0])) + } +} diff --git a/cmd/server/handlers/wakatime.go b/cmd/server/handlers/wakatime.go @@ -3,10 +3,16 @@ package handlers import ( "encoding/json" "net/http" + + "git.prime8.dev/moncheeta/prime8dev/internal/config" ) func (h *Handler) WakaTimeHandler(w http.ResponseWriter, r *http.Request) { - stats, err := h.wakaTimeService.FetchStats() + fetch := h.wakaTimeService.FetchTotal + if config.WakaTimeWeeklyEnabled { + fetch = h.wakaTimeService.FetchStats + } + stats, err := fetch() if err != nil { http.Error(w, "Failed to fetch stats", http.StatusInternalServerError) return 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/config/site.go b/internal/config/site.go @@ -5,5 +5,10 @@ const ( SiteURL = "https://" + SiteDomain ) -// WakaTimeEnabled toggles the WakaTime stats on the web and SSH frontends. -const WakaTimeEnabled = false +// WakaTimeEnabled toggles the all-time WakaTime total ("N+ hrs coded") in the +// web status bar and the SSH TUI footer. +const WakaTimeEnabled = true + +// WakaTimeWeeklyEnabled toggles the daily breakdown (wakatime --week) on the +// about page. Off: only the all-time total is shown. +const WakaTimeWeeklyEnabled = false diff --git a/internal/external/wakatime.go b/internal/external/wakatime.go @@ -66,6 +66,36 @@ func (s *WakaTime) FetchStats() (*WakaTimeStats, error) { return stats, nil } +// FetchTotal returns only the all-time total ("N+ hrs coded"), skipping the +// daily/weekly endpoints. It returns nil when no API key is configured. +func (s *WakaTime) FetchTotal() (*WakaTimeStats, error) { + if s.apiKey == "" { + return nil, nil + } + + cacheKey := "wakatime:total" + if val, found := s.cache.Get(cacheKey); found { + return val.(*WakaTimeStats), nil + } + + resp, err := s.makeRequest("/users/current/all_time_since_today") + if err != nil { + return nil, err + } + var data allTimeResponse + if err := json.Unmarshal(resp, &data); err != nil { + return nil, err + } + + stats := &WakaTimeStats{AllTime: AllTimeStats{TotalText: formatTotalHours(data.Data.TotalSeconds)}} + s.cache.Set(cacheKey, stats, 5*time.Minute) + return stats, nil +} + +func formatTotalHours(totalSeconds float64) string { + return fmt.Sprintf("%d+ hrs coded", int(totalSeconds/3600)) +} + type allTimeResponse struct { Data struct { TotalSeconds float64 `json:"total_seconds"` @@ -140,8 +170,7 @@ func (s *WakaTime) fetchAllTimeStats() (*AllTimeStats, error) { if stats != nil { totalSeconds = stats.Data.TotalSeconds } - totalHours := int(totalSeconds / 3600) - totalText := fmt.Sprintf("%d+ hrs coded", totalHours) + totalText := formatTotalHours(totalSeconds) dailyAvgText := "0 mins" if langStats != nil && langStats.Data.DailyAverageIncludingOtherLanguage > 0 { diff --git a/internal/external/wakatime_test.go b/internal/external/wakatime_test.go @@ -0,0 +1,41 @@ +package external + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/patrickmn/go-cache" +) + +func TestFetchTotalOnlyHitsAllTime(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + _, _ = w.Write([]byte(`{"data":{"total_seconds":3603600}}`)) + })) + defer srv.Close() + + w := &WakaTime{httpClient: srv.Client(), baseURL: srv.URL, apiKey: "k", cache: cache.New(time.Minute, time.Minute)} + stats, err := w.FetchTotal() + if err != nil { + t.Fatal(err) + } + if stats.AllTime.TotalText != "1001+ hrs coded" { + t.Fatalf("total = %q", stats.AllTime.TotalText) + } + if stats.AllTime.DailyAverageText != "" || len(stats.Weekly.Days) != 0 { + t.Fatalf("daily stats leaked: %+v", stats) + } + if len(paths) != 1 || paths[0] != "/users/current/all_time_since_today" { + t.Fatalf("requests = %v", paths) + } +} + +func TestFetchTotalWithoutKey(t *testing.T) { + w := &WakaTime{apiKey: "", cache: cache.New(time.Minute, time.Minute)} + if stats, err := w.FetchTotal(); stats != nil || err != nil { + t.Fatalf("got %v, %v", stats, err) + } +} diff --git a/internal/tui/content.go b/internal/tui/content.go @@ -12,22 +12,26 @@ import ( ) func (m portfolioModel) aboutDetail() string { + shellW, _ := m.detailSize() + if m.shell.cleared { + return m.shellView(shellW) + } parts := []string{ m.aboutFetch(), "", - renderPrompt(m.about.Handle, "cat bio"), + renderPrompt(m.user, "cat bio"), m.about.Bio, "", - renderPrompt(m.about.Handle, "ls interests/"), + renderPrompt(m.user, "ls interests/"), renderTagList(m.about.Interests), "", - renderPrompt(m.about.Handle, "ls hobbies/"), + renderPrompt(m.user, "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()) + if appconfig.WakaTimeEnabled && appconfig.WakaTimeWeeklyEnabled { + parts = append(parts, "", renderPrompt(m.user, "wakatime --week"), m.aboutWeeklyStats()) } return strings.Join(parts, "\n") @@ -271,7 +275,7 @@ func (m portfolioModel) experiencePage() string { return "No experience available." } - rows := []string{renderPrompt(m.about.Handle, "git log --oneline --graph --decorate")} + rows := []string{renderPrompt(m.user, "git log --oneline --graph --decorate")} for i, e := range m.experience { decorator := "" if e.Current { diff --git a/internal/tui/hscroll.go b/internal/tui/hscroll.go @@ -0,0 +1,61 @@ +package tui + +import ( + "strings" + + "github.com/charmbracelet/x/ansi" +) + +const detailHorizontalStep = 8 + +var cutMarkerStyle = dimStyle + +func lineWidths(content string) []int { + lines := strings.Split(content, "\n") + widths := make([]int, len(lines)) + for i, l := range lines { + widths[i] = ansi.StringWidth(l) + } + return widths +} + +func (m portfolioModel) detailMaxXOffset() int { + longest := 0 + for _, w := range m.detailLineWidths { + longest = max(longest, w) + } + return max(longest-m.viewport.Width, 0) +} + +// scrollDetailX pans the detail viewport sideways; only wide preformatted +// blocks are wider than the pane, so prose never moves out of view for long. +func (m *portfolioModel) scrollDetailX(delta int) { + m.detailXOffset = min(max(m.detailXOffset+delta, 0), m.detailMaxXOffset()) + m.viewport.SetXOffset(m.detailXOffset) +} + +// detailViewWithCutMarkers renders the viewport and marks lines that continue +// past the right edge with › (and past the left edge with ‹ when panned). +func (m portfolioModel) detailViewWithCutMarkers() string { + view := m.viewport.View() + w := m.viewport.Width + if w < 4 || m.detailMaxXOffset() == 0 { + return view + } + lines := strings.Split(view, "\n") + for i, line := range lines { + idx := m.viewport.YOffset + i + if idx >= len(m.detailLineWidths) { + break + } + full := m.detailLineWidths[idx] + if full > m.detailXOffset+w { + line = ansi.Truncate(line, w-1, "") + cutMarkerStyle.Render("›") + } + if m.detailXOffset > 0 && full > m.detailXOffset { + line = cutMarkerStyle.Render("‹") + ansi.Cut(line, 1, w) + } + lines[i] = line + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/hscroll_test.go b/internal/tui/hscroll_test.go @@ -0,0 +1,207 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/x/ansi" +) + +const wideReadme = "# janitor\n\nsome prose that is long enough to wrap around the pane width for sure, yes indeed it is long. " + + "some prose that is long enough to wrap around the pane width for sure, yes indeed it is long.\n\n" + + "```\n" + + "+--------+ +-------------------------+ +--------------------+ +----------+ +-------+ END\n" + + "| client | ---> | server with a long name | ---> | queue | ---> | db |\n" + + "```\n\nnarrow:\n\n```\nok\n```\n" + +func TestWideBlocksDoNotWrap(t *testing.T) { + out := ansi.Strip(renderMarkdown(wideReadme, 40)) + if !strings.Contains(out, "┃ +--------+ +-------------------------+ +--------------------+ +----------+ +-------+ END") { + t.Fatalf("wide block wrapped:\n%s", out) + } + for _, l := range strings.Split(out, "\n") { + if strings.Contains(l, "prose") && ansi.StringWidth(l) > 40 { + t.Fatalf("prose did not wrap: %q", l) + } + } + if !strings.Contains(out, "┃ ok") { + t.Fatalf("narrow block lost:\n%s", out) + } + if strings.Contains(out, wideBlockToken) { + t.Fatalf("placeholder leaked:\n%s", out) + } +} + +func TestIndentedListContinuationIsNotCode(t *testing.T) { + md := "- item\n\n continuation of the list item that is quite long and keeps going past forty columns\n" + src, blocks := extractWideBlocks(md, 40) + if len(blocks) != 0 || src != md { + t.Fatalf("list continuation extracted: %q %v", src, blocks) + } +} + +func TestReadmeHorizontalScroll(t *testing.T) { + m := testModel(t, 100, 40) + 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()) + if !strings.Contains(view, "›") { + t.Fatalf("no right cut marker:\n%s", view) + } + if strings.Contains(view, "END") { + t.Fatal("END visible before scrolling") + } + for i := 0; i < 10; i++ { + m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + } + view = ansi.Strip(m.View()) + if !strings.Contains(view, "END") || !strings.Contains(view, "‹") { + t.Fatalf("scrolling right did not reveal END:\n%s", view) + } + for _, l := range strings.Split(view, "\n") { + if ansi.StringWidth(l) > 100 { + t.Fatalf("line overflows: %d", ansi.StringWidth(l)) + } + } + for i := 0; i < 20; i++ { + m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + } + if m.detailXOffset != 0 { + t.Fatalf("x offset = %d", m.detailXOffset) + } + // left still leaves readme focus + m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyLeft}) + if m.projectReadmeScroll { + t.Fatal("left no longer backs out of the readme") + } +} + +// readmeModel returns a model on the projects tab with the wide readme +// rendered and the readme pane focused. +func readmeModel(t *testing.T) portfolioModel { + t.Helper() + m := testModel(t, 100, 40) + 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") + } + if m.detailMaxXOffset() <= 0 { + t.Fatalf("detailMaxXOffset = %d for a wide code block", m.detailMaxXOffset()) + } + return m +} + +func send(t *testing.T, m portfolioModel, msg tea.Msg) portfolioModel { + t.Helper() + next, _ := m.Update(msg) + return next.(portfolioModel) +} + +func TestReadmeArrowKeysScrollSideways(t *testing.T) { + m := readmeModel(t) + maxX := m.detailMaxXOffset() + + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + if m.detailXOffset != detailHorizontalStep || m.activeTab != tabProjects { + t.Fatalf("right: x=%d tab=%d", m.detailXOffset, m.activeTab) + } + for i := 0; i < 50; i++ { + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + } + if m.detailXOffset != maxX || m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("right to the end: x=%d (max %d) tab=%d focus=%v", m.detailXOffset, maxX, m.activeTab, m.projectReadmeScroll) + } + if view := ansi.Strip(m.View()); !strings.Contains(view, "END") { + t.Fatalf("END not visible at max offset:\n%s", view) + } + + m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + if m.detailXOffset != maxX-detailHorizontalStep || m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("left: x=%d tab=%d focus=%v", m.detailXOffset, m.activeTab, m.projectReadmeScroll) + } + for m.detailXOffset > 0 { + m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + if m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("left while panned changed tab/focus: x=%d tab=%d", m.detailXOffset, m.activeTab) + } + } + // left at the left edge backs out of the readme, still without a tab change + m = send(t, m, tea.KeyMsg{Type: tea.KeyLeft}) + if m.projectReadmeScroll || m.activeTab != tabProjects { + t.Fatalf("left at x=0: focus=%v tab=%d", m.projectReadmeScroll, m.activeTab) + } + // with the readme unfocused, arrows switch tabs again + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + if m.activeTab != tabAbout { + t.Fatalf("right outside the readme: tab=%d", m.activeTab) + } +} + +func TestReadmeHLAndShiftArrowsScrollEndToEnd(t *testing.T) { + for _, keys := range []struct { + name string + right, left tea.KeyMsg + }{ + {"h/l", tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}}, + {"shift+arrows", tea.KeyMsg{Type: tea.KeyShiftRight}, tea.KeyMsg{Type: tea.KeyShiftLeft}}, + } { + m := readmeModel(t) + maxX := m.detailMaxXOffset() + for i := 0; i < 50; i++ { + m = send(t, m, keys.right) + } + if m.detailXOffset != maxX || m.activeTab != tabProjects { + t.Fatalf("%s right: x=%d (max %d) tab=%d", keys.name, m.detailXOffset, maxX, m.activeTab) + } + for i := 0; i < 50; i++ { + m = send(t, m, keys.left) + } + if m.detailXOffset != 0 || m.activeTab != tabProjects || !m.projectReadmeScroll { + t.Fatalf("%s left: x=%d tab=%d focus=%v", keys.name, m.detailXOffset, m.activeTab, m.projectReadmeScroll) + } + } +} + +func TestReadmeOffsetSurvivesAsyncRerender(t *testing.T) { + m := readmeModel(t) + for i := 0; i < 3; i++ { + m = send(t, m, tea.KeyMsg{Type: tea.KeyRight}) + } + m = send(t, m, tea.KeyMsg{Type: tea.KeyDown}) + x, y := m.detailXOffset, m.viewport.YOffset + if x == 0 { + t.Fatal("did not scroll") + } + + 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)) + if m.detailXOffset != x || m.viewport.YOffset != y { + t.Fatalf("re-render reset scroll: x=%d->%d y=%d->%d", x, m.detailXOffset, y, m.viewport.YOffset) + } + + // changing the selected project resets it + m = send(t, m, tea.KeyMsg{Type: tea.KeyEsc}) + m = send(t, m, tea.KeyMsg{Type: tea.KeyDown}) + if m.detailXOffset != 0 || m.viewport.YOffset != 0 { + t.Fatalf("new project kept scroll: x=%d y=%d", m.detailXOffset, m.viewport.YOffset) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go @@ -13,6 +13,8 @@ type keyMap struct { Quit key.Binding PageDown key.Binding PageUp key.Binding + Left key.Binding + Right key.Binding } func defaultKeyMap() keyMap { @@ -57,6 +59,14 @@ func defaultKeyMap() keyMap { key.WithKeys("pgup", "b", "shift+up"), key.WithHelp("pgup", "scroll up"), ), + Left: key.NewBinding( + key.WithKeys("h", "shift+left"), + key.WithHelp("←/→ h/l", "scroll readme sideways"), + ), + Right: key.NewBinding( + key.WithKeys("l", "shift+right"), + key.WithHelp("←/→ h/l", "scroll readme sideways"), + ), } } @@ -65,5 +75,5 @@ func (k keyMap) ShortHelp() []key.Binding { } func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown}, {k.Open, k.Back, k.Help, k.Quit}} + return [][]key.Binding{{k.TabPrev, k.TabNext, k.Up, k.Down, k.PageUp, k.PageDown, k.Right}, {k.Open, k.Back, k.Help, k.Quit}} } diff --git a/internal/tui/layout.go b/internal/tui/layout.go @@ -2,14 +2,54 @@ package tui import "github.com/charmbracelet/lipgloss" +// detailViewKey identifies what the detail pane is showing. Scroll offsets +// survive re-renders of the same view (async readme/markdown results, window +// resizes, ticks) and only reset when the view itself changes. +type detailViewKey struct { + tab, project, experience int +} + +func (m portfolioModel) currentDetailViewKey() detailViewKey { + return detailViewKey{tab: m.activeTab, project: m.projectIndex, experience: m.experienceIndex} +} + +// syncViewport re-renders the detail pane, keeping the scroll position when +// the same tab/item is still selected. func (m *portfolioModel) syncViewport() { - m.viewport.SetContent(m.detailContent()) + key := m.currentDetailViewKey() + same := m.detailViewSet && key == m.detailView + m.detailView, m.detailViewSet = key, true + + content := m.detailContent() + m.viewport.SetContent(content) + m.detailLineWidths = lineWidths(content) w, h := m.detailSize() m.viewport.Width = max(w, 1) m.viewport.Height = max(h, 1) + + if !same { + m.detailXOffset = 0 + } + m.detailXOffset = min(max(m.detailXOffset, 0), m.detailMaxXOffset()) + m.viewport.SetXOffset(m.detailXOffset) + + if m.activeTab == tabAbout && (m.shell.cleared || len(m.shell.history) > 0 || m.shell.input != "") { + m.viewport.GotoBottom() + return + } + if same { + m.viewport.SetYOffset(m.viewport.YOffset) // clamp to the new content + return + } m.viewport.GotoTop() } +// resetViewport re-renders the detail pane scrolled back to the top-left. +func (m *portfolioModel) resetViewport() { + m.detailViewSet = false + m.syncViewport() +} + func (m portfolioModel) detailSize() (int, int) { bodyW, bodyH := m.bodySize() diff --git a/internal/tui/markdown.go b/internal/tui/markdown.go @@ -26,12 +26,13 @@ func renderMarkdown(markdown string, width int) string { width = max(width, 20) renderWidth := markdownRenderWidth(markdown, width) - out := termmarkdown.Render(markdown, renderWidth, 0) + source, wide := extractWideBlocks(markdown, renderWidth) + out := termmarkdown.Render(source, renderWidth, 0) if len(out) == 0 { return markdown } - return styleRenderedMarkdown(strings.TrimRight(string(out), "\n")) + return restoreWideBlocks(styleRenderedMarkdown(strings.TrimRight(string(out), "\n")), wide) } func styleRenderedMarkdown(rendered string) string { diff --git a/internal/tui/markdown_wide.go b/internal/tui/markdown_wide.go @@ -0,0 +1,156 @@ +package tui + +import ( + "fmt" + "regexp" + "strings" + + "github.com/charmbracelet/x/ansi" +) + +// Preformatted blocks (code, ascii diagrams) wider than the pane are pulled +// out before markdown rendering and put back verbatim afterwards, so they +// overflow (and scroll horizontally) instead of wrapping. Narrow blocks keep +// the renderer's own styling and highlighting. + +const wideBlockToken = "PRIME8WIDEBLOCK" + +var ( + fenceOpenRE = regexp.MustCompile("^ {0,3}(`{3,}|~{3,})") + listItemRE = regexp.MustCompile(`^\s*([-*+]|\d+[.)])\s`) + wideTokenRE = regexp.MustCompile(`^\s*` + wideBlockToken + `(\d+)\s*$`) + codeGutter = "┃ " + codeGutterLen = ansi.StringWidth(codeGutter) +) + +// extractWideBlocks replaces every preformatted block whose longest line does +// not fit in width with a placeholder paragraph. +func extractWideBlocks(markdown string, width int) (string, [][]string) { + lines := strings.Split(markdown, "\n") + out := make([]string, 0, len(lines)) + var blocks [][]string + + emit := func(original []string, body []string) { + if blockWidth(body)+codeGutterLen <= width { + out = append(out, original...) + return + } + out = append(out, "", fmt.Sprintf("%s%d", wideBlockToken, len(blocks)), "") + blocks = append(blocks, body) + } + + for i := 0; i < len(lines); i++ { + line := lines[i] + + if m := fenceOpenRE.FindStringSubmatch(line); m != nil { + fence := m[1] + end := -1 + for j := i + 1; j < len(lines); j++ { + t := strings.TrimSpace(lines[j]) + if strings.HasPrefix(t, fence[:3]) && strings.Trim(t, fence[:1]) == "" && len(t) >= len(fence) { + end = j + break + } + } + if end < 0 { + out = append(out, lines[i:]...) + break + } + emit(lines[i:end+1], expandTabs(lines[i+1:end])) + i = end + continue + } + + if isIndentedCode(line) && precededByBlankParagraph(lines, i) { + j := i + for j < len(lines) && (isIndentedCode(lines[j]) || strings.TrimSpace(lines[j]) == "") { + j++ + } + for j > i && strings.TrimSpace(lines[j-1]) == "" { + j-- + } + body := make([]string, 0, j-i) + for _, l := range lines[i:j] { + l = expandTabs([]string{l})[0] + if len(l) >= 4 { + l = l[4:] + } else { + l = "" + } + body = append(body, l) + } + emit(lines[i:j], body) + i = j - 1 + continue + } + + out = append(out, line) + } + + return strings.Join(out, "\n"), blocks +} + +func isIndentedCode(line string) bool { + return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") +} + +// precededByBlankParagraph reports whether an indented line starts a code +// block rather than continuing a list item. +func precededByBlankParagraph(lines []string, i int) bool { + if i == 0 { + return true + } + if strings.TrimSpace(lines[i-1]) != "" { + return false + } + for j := i - 1; j >= 0; j-- { + if strings.TrimSpace(lines[j]) == "" { + continue + } + return !listItemRE.MatchString(lines[j]) && !isIndentedCode(lines[j]) + } + return true +} + +func expandTabs(lines []string) []string { + out := make([]string, len(lines)) + for i, l := range lines { + out[i] = strings.ReplaceAll(l, "\t", " ") + } + return out +} + +func blockWidth(lines []string) int { + w := 0 + for _, l := range lines { + w = max(w, ansi.StringWidth(l)) + } + return w +} + +// restoreWideBlocks swaps the placeholders back for the verbatim block lines. +func restoreWideBlocks(rendered string, blocks [][]string) string { + if len(blocks) == 0 { + return rendered + } + lines := strings.Split(rendered, "\n") + out := make([]string, 0, len(lines)) + for _, line := range lines { + plain := ansi.Strip(line) + m := wideTokenRE.FindStringSubmatch(plain) + if m == nil { + out = append(out, line) + continue + } + var idx int + if _, err := fmt.Sscanf(m[1], "%d", &idx); err != nil || idx >= len(blocks) { + out = append(out, line) + continue + } + indent := plain[:len(plain)-len(strings.TrimLeft(plain, " "))] + for _, l := range blocks[idx] { + out = append(out, indent+dimStyle.Render(codeGutter)+rowStyle.Render(l)) + } + } + return strings.Join(out, "\n") +} diff --git a/internal/tui/program.go b/internal/tui/program.go @@ -1,8 +1,6 @@ package tui import ( - "strings" - tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ssh "github.com/charmbracelet/ssh" @@ -26,10 +24,8 @@ func (m portfolioModel) Init() tea.Cmd { ) } +// defaultUser is the SSH session user as shown in prompts, the window title, +// whoami and the shell's home directory, sanitized to a safe path segment. func defaultUser(user string) string { - user = strings.TrimSpace(user) - if user == "" { - return "visitor" - } - return user + return sanitizeUser(user) } diff --git a/internal/tui/shell.go b/internal/tui/shell.go @@ -0,0 +1,384 @@ +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 { + user string + home string + cwd string + input string + history []shellEntry + // cleared hides the about-page intro, like `clear` in a real terminal. + cleared bool +} + +func newShellState(user string) shellState { + user = sanitizeUser(user) + home := homeDir(user) + return shellState{user: user, home: home, cwd: home} +} + +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: 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 := resolvePath(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 + 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.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 + } + 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.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)) + } + } + 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") + } +} + +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.home, 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(m.shell.user, m.shell.home, 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.user, m.shell.home, m.shell.cwd, m.shell.input)+rowStyle.Render("█")) + return strings.Join(parts, "\n") +} + +func renderShellPrompt(user, home, cwd, input string) string { + return promptUserStyle.Render(user) + + promptStyle.Render("@") + + promptHostStyle.Render("prime8") + + promptStyle.Render(":") + + promptPathStyle.Render(displayPath(home, 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,278 @@ +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("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{}, + } + 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), "visitor@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), "visitor@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/", + "visitor@prime8:~/projects$ pwd", + "/home/visitor/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", + "visitor@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, "visitor@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) { + m := testModel(t, 120, 60) + root := buildVFS(m.vfsSource()) + home := m.shell.home + for _, p := range []string{"/etc/passwd", "/proc/self/environ", "../../..", "/srv"} { + n := root.lookup(resolvePath(home, home, p)) + if n != nil && n != root { + t.Errorf("%s resolved to %q", p, n.name) + } + } + if n := root.lookup(resolvePath(home, home, "../../..")); n != root { + t.Error(".. above / should stay at /") + } +} + +func TestSanitizeUser(t *testing.T) { + for in, want := range map[string]string{ + "": "visitor", + " ": "visitor", + "Damian": "damian", + "moncheeta": "moncheeta", + "../../etc": "etc", + "..": "visitor", + "a/b c": "abc", + "j.doe_1-x": "j.doe_1-x", + "\x1b[31mred": "31mred", + "élan": "lan", + strings.Repeat("a", 50): strings.Repeat("a", 32), + } { + if got := sanitizeUser(in); got != want { + t.Errorf("sanitizeUser(%q) = %q, want %q", in, got, want) + } + } +} + +func TestShellUsesSessionUser(t *testing.T) { + m := testModel(t, 120, 80) + m.user = defaultUser("Dam/ian") + m.shell = newShellState(m.user) + m.syncViewport() + typeString(&m, "whoami\n") + typeString(&m, "pwd\n") + typeString(&m, "sudo ls\n") + typeString(&m, "cd ~/projects\n") + out := plainView(m) + for _, want := range []string{ + "damian@prime8:~$ whoami", + "/home/damian", + "damian is not in the sudoers file.", + "damian@prime8:~/projects$ █", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "guest") { + t.Errorf("guest leaked:\n%s", out) + } + if got := m.windowTitle(); got != "damian@prime8:~" { + t.Errorf("window title = %q", got) + } + m.shell = newShellState(m.user) + m.syncViewport() + if out := plainView(m); !strings.Contains(out, "damian@prime8:~$ cat bio") { + t.Errorf("intro prompt not using session user:\n%s", out) + } +} + +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,15 @@ type portfolioModel struct { projectReadmeScroll bool experienceIndex int + shell shellState + + // detailLineWidths holds the visible width of each viewport line, for + // horizontal scrolling and the cut-off markers. + detailLineWidths []int + detailXOffset int + detailView detailViewKey + detailViewSet bool + width int height int now time.Time @@ -109,6 +118,7 @@ func newPortfolioModel(user string) portfolioModel { keys: defaultKeyMap(), help: h, viewport: vp, + shell: newShellState(user), projectReadmes: make(map[string]string), projectReadmeErrors: make(map[string]string), diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -38,7 +38,7 @@ func (m portfolioModel) windowTitle() string { path += "/" + p.ID } } - return m.about.Handle + "@prime8:" + path + return m.user + "@prime8:" + path } func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -144,6 +144,28 @@ func (m *portfolioModel) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { } } + if m.activeTab == tabAbout { + if cmd, ok := m.handleShellKey(msg); ok { + return cmd + } + } + + if m.activeTab == tabProjects { + // With the readme focused, plain arrows pan sideways instead of + // switching tabs; left at the left edge falls through and backs out. + readme := m.projectReadmeScroll + switch { + case key.Matches(msg, m.keys.Left), + readme && msg.Type == tea.KeyLeft && m.detailXOffset > 0: + m.scrollDetailX(-detailHorizontalStep) + return nil + case key.Matches(msg, m.keys.Right), + readme && msg.Type == tea.KeyRight: + m.scrollDetailX(detailHorizontalStep) + return nil + } + } + if m.consumeProjectReadmeBack(msg) { return nil } @@ -192,7 +214,7 @@ func (m *portfolioModel) activateTab(tab int) tea.Cmd { m.activeTab = tab m.projectReadmeScroll = false - m.syncViewport() + m.resetViewport() return m.refreshDataCmd() } @@ -207,7 +229,7 @@ func (m *portfolioModel) consumeProjectReadmeBack(msg tea.KeyMsg) bool { } m.projectReadmeScroll = false - m.syncViewport() + m.resetViewport() return true } @@ -227,7 +249,7 @@ func (m *portfolioModel) handleOpenKey() tea.Cmd { func (m *portfolioModel) handleBackKey() tea.Cmd { if m.activeTab == tabProjects && m.projectReadmeScroll { m.projectReadmeScroll = false - m.syncViewport() + m.resetViewport() return nil } diff --git a/internal/tui/vfs.go b/internal/tui/vfs.go @@ -0,0 +1,267 @@ +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 ( + fallbackUser = "visitor" + maxShellUser = 32 + homeDirPrefix = "/home/" +) + +// sanitizeUser turns the SSH session user into a safe single path segment +// ([a-z0-9._-], at most 32 chars) so it can name the home directory and the +// prompt. Anything that sanitizes to nothing (or to . / ..) becomes visitor. +func sanitizeUser(user string) string { + var b strings.Builder + for _, r := range strings.ToLower(strings.TrimSpace(user)) { + if b.Len() >= maxShellUser { + break + } + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-': + b.WriteRune(r) + } + } + s := strings.Trim(b.String(), ".") + if s == "" { + return fallbackUser + } + return s +} + +func homeDir(user string) string { + return homeDirPrefix + user +} + +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 { + user string + 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{ + user: m.shell.user, + about: m.about, + currentJob: m.currentJob, + experience: m.experience, + projects: m.projects, + readmes: m.projectReadmes, + now: m.now, + } +} + +// buildVFS lays out: +// +// /home/<user>/{about,bio,contact} +// /home/<user>/{interests,hobbies,languages}/<item> +// /home/<user>/experience/<id> +// /home/<user>/projects/<id>/{README.md,info} +func buildVFS(src vfsSource) *vfsNode { + root := newVFSDir("/") + home := root.addDir("home").addDir(src.user) + 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(home, cwd, p string) string { + switch { + case p == "" || p == "~": + return home + case strings.HasPrefix(p, "~/"): + p = home + 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(home, abs string) string { + if abs == home { + return "~" + } + if strings.HasPrefix(abs, home+"/") { + return "~" + abs[len(home):] + } + return abs +} diff --git a/internal/tui/view.go b/internal/tui/view.go @@ -61,13 +61,16 @@ func (m portfolioModel) renderHelpBody(width int, height int) string { key string desc string }{ - {key: "◀ ▶", desc: "navigate tabs"}, + {key: "◀ ▶", desc: "navigate tabs (pan sideways in a readme)"}, {key: "▲ ▼", desc: "switch list items"}, {key: "shift + ▲ ▼", desc: "scroll item details"}, + {key: "◀ ▶ h l", desc: "scroll wide code sideways in a readme"}, + {key: "esc", desc: "leave the readme (or ◀ at the left edge)"}, {key: "tab", desc: "cycle tabs"}, {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) @@ -179,17 +182,18 @@ func (m portfolioModel) renderBody(width int, height int) string { func (m portfolioModel) renderProjectsBody(bodyW int, height int) string { listFocused := !m.projectReadmeScroll listContent := func(w, h int) string { return m.projectListContent(w, h) } + detail := m.detailViewWithCutMarkers() if bodyW < 72 { stackTop := max(height/2, 3) stackBottom := max(height-stackTop, 1) if listFocused { listPane := renderPanelWithStyle(panelStyleSharedBottom(panelActiveStyle), bodyW, stackTop+1, listContent(bodyW-4, stackTop-2)) - detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), m.viewport.View()) + detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), detail) return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane) } listPane := renderPanelWithStyle(panelStyleWithoutBottom(panelStyle), bodyW, stackTop, listContent(bodyW-4, stackTop-2)) - detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, m.viewport.View()) + detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, detail) return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane) } @@ -197,11 +201,11 @@ func (m portfolioModel) renderProjectsBody(bodyW int, height int) string { detailW := max(bodyW-listW, 12) if listFocused { listPane := renderPanelWithStyle(panelStyleSharedRight(panelActiveStyle), listW+1, height, listContent(listW-4, height-2)) - detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, m.viewport.View()) + detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, detail) return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane) } listPane := renderPanelWithStyle(panelStyleWithoutRight(panelStyle), listW, height, listContent(listW-4, height-2)) - detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, m.viewport.View()) + detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, detail) return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane) } diff --git a/internal/tui/waka_footer.go b/internal/tui/waka_footer.go @@ -31,7 +31,11 @@ func (m *portfolioModel) maybeLoadWakaFooterCmd() tea.Cmd { func fetchWakaFooterCmd(service *external.WakaTime) tea.Cmd { return func() tea.Msg { - stats, err := service.FetchStats() + fetch := service.FetchTotal + if config.WakaTimeWeeklyEnabled { + fetch = service.FetchStats + } + stats, err := fetch() if err != nil || stats == nil { return wakaFooterMsg{ok: false} } diff --git a/internal/web/renderer.go b/internal/web/renderer.go @@ -54,23 +54,24 @@ func New() (*Renderer, error) { } funcs := template.FuncMap{ - "tabPath": utils.TabPath, - "tabURL": utils.TabURL, - "tagClass": utils.TagClass, - "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) }, - "shellPath": ShellPath, - "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) }, - "inc": func(i int) int { return i + 1 }, - "techMeta": utils.TechMeta, - "formatMonthYear": utils.FormatMonthYear, - "formatEndDate": utils.FormatEndDate, - "formatDuration": utils.FormatDuration, - "durationHasYear": utils.DurationHasYear, - "timelineYear": utils.TimelineYear, - "toHTML": toHTML, - "gitCloneURL": config.GitCloneURL, - "gitSSHCloneURL": config.GitSSHCloneURL, - "wakaTimeEnabled": func() bool { return config.WakaTimeEnabled }, + "tabPath": utils.TabPath, + "tabURL": utils.TabURL, + "tagClass": utils.TagClass, + "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) }, + "shellPath": ShellPath, + "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) }, + "inc": func(i int) int { return i + 1 }, + "techMeta": utils.TechMeta, + "formatMonthYear": utils.FormatMonthYear, + "formatEndDate": utils.FormatEndDate, + "formatDuration": utils.FormatDuration, + "durationHasYear": utils.DurationHasYear, + "timelineYear": utils.TimelineYear, + "toHTML": toHTML, + "gitCloneURL": config.GitCloneURL, + "gitSSHCloneURL": config.GitSSHCloneURL, + "wakaTimeEnabled": func() bool { return config.WakaTimeEnabled }, + "wakaTimeWeeklyEnabled": func() bool { return config.WakaTimeEnabled && config.WakaTimeWeeklyEnabled }, "inlineCSS": func(name string) template.CSS { if css, ok := inlineStyles[name]; ok { return css diff --git a/internal/web/templates/partials/about.gohtml b/internal/web/templates/partials/about.gohtml @@ -46,7 +46,7 @@ <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cursor" aria-hidden="true">&nbsp;</span></div> </div> - {{if wakaTimeEnabled}} + {{if wakaTimeWeeklyEnabled}} <div class="section" id="activity-section"> <div class="shell-prompt"><span class="shell-user">{{.About.Handle}}</span>@<span class="shell-host">prime8</span>:<span class="shell-path">~</span><span class="shell-dollar">$</span> <span class="shell-cmd">wakatime --week</span></div> <div class="weekly-stats" id="activity-stats">