prime8dev

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

commit 35964d1108a5661f75dc062d8e33b49169833a7d
parent e39fd98ee2632ece169c40b1b85c960866329fbf
Author: Damian Myrda <damian@prime8.dev>
Date:   Wed, 21 Oct 2026 01:57:04 +0000

feat: window title shows the current path

The browser tab and the terminal window title read like a shell
prompt, e.g. moncheeta@prime8:~/projects/janitor. The web title is
rendered server-side from the request path and kept in sync on htmx
navigation; the TUI sets it with tea.SetWindowTitle when it changes.

Diffstat:
Mcmd/server/handlers/render.go | 1+
Minternal/tui/types.go | 1+
Minternal/tui/update.go | 27+++++++++++++++++++++++++++
Minternal/web/renderer.go | 7+++++++
Minternal/web/scripts/modules/header.js | 65+++++------------------------------------------------------------
Minternal/web/scripts/modules/projects.js | 8++------
Minternal/web/templates/layout.gohtml | 4++--
7 files changed, 45 insertions(+), 68 deletions(-)

diff --git a/cmd/server/handlers/render.go b/cmd/server/handlers/render.go @@ -15,6 +15,7 @@ func (h *Handler) renderTemplate(w http.ResponseWriter, r *http.Request, name st maxAgeSeconds = 0 } + data.Path = r.URL.Path body, err := h.renderer.Render(name, data) if err != nil { http.Error(w, "Failed to render content", http.StatusInternalServerError) diff --git a/internal/tui/types.go b/internal/tui/types.go @@ -36,6 +36,7 @@ type portfolioModel struct { experience []appconfig.Experience activeTab int + windowTitleSet string projectIndex int projectReadmeScroll bool experienceIndex int diff --git a/internal/tui/update.go b/internal/tui/update.go @@ -15,6 +15,33 @@ func tickCmd() tea.Cmd { } func (m portfolioModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + next, cmd := m.update(msg) + pm, ok := next.(portfolioModel) + if !ok { + return next, cmd + } + if title := pm.windowTitle(); title != pm.windowTitleSet { + pm.windowTitleSet = title + cmd = tea.Batch(cmd, tea.SetWindowTitle(title)) + } + return pm, cmd +} + +func (m portfolioModel) windowTitle() string { + path := "~" + switch m.activeTab { + case tabExperience: + path += "/experience" + case tabProjects: + path += "/projects" + if p := m.selectedProject(); p != nil { + path += "/" + p.ID + } + } + return m.about.Handle + "@prime8:" + path +} + +func (m portfolioModel) update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width diff --git a/internal/web/renderer.go b/internal/web/renderer.go @@ -19,6 +19,7 @@ var templatesFS embed.FS type Data struct { Title string + Path string Description string ActiveTab string ContentTemplate string @@ -57,6 +58,7 @@ func New() (*Renderer, error) { "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, @@ -141,3 +143,8 @@ func loadInlineStyles() (map[string]template.CSS, error) { return styles, nil } + +// ShellPath renders a URL path the way a shell prompt shows cwd: "/" is "~". +func ShellPath(path string) string { + return "~" + strings.TrimRight(path, "/") +} diff --git a/internal/web/scripts/modules/header.js b/internal/web/scripts/modules/header.js @@ -1,6 +1,5 @@ import { isEditableTarget } from "./dom.js"; -const detailTitles = {}; const headerHandlers = { keydown: null, afterSwap: null, @@ -116,53 +115,10 @@ export function initHeader() { setActiveTabUI(currentFromPath(order, fallback)); } -export function updateAppTitle(activeTab = "") { - const headerEl = document.getElementById("tabs"); - if (!headerEl) return; - - const order = (headerEl.getAttribute("data-tabs") || "about").split(",").filter(Boolean); - const fallback = order[0] || "about"; - const tab = activeTab || currentFromPath(order, fallback); - - const tabEl = document.querySelector(`.tab[data-tab="${tab}"]`); - const tabLabel = tabEl?.getAttribute("data-label") || tab; - const siteName = document.getElementById("app")?.dataset.bootTitle || "prime8.dev"; - - let detailLabel = ""; - const path = window.location.pathname; - - switch (tab) { - case "projects": - if (/^\/projects\/[^/]+$/.test(path)) { - const projectID = path.match(/^\/projects\/([^/]+)$/)?.[1] || ""; - detailLabel = - detailTitles.projects || - document.querySelector("#project-detail h2")?.textContent?.trim() || - selectedListTitle(".list-item[data-id]", ".list-title", projectID); - } - break; - default: - break; - } - - if (detailLabel) { - document.title = `${detailLabel} | ${tabLabel} | ${siteName}`; - return; - } - - document.title = `${tabLabel} | ${siteName}`; -} - -export function setAppTitleDetail(tab, value) { - if (value) { - detailTitles[tab] = value; - } else { - delete detailTitles[tab]; - } -} - -export function clearAppTitleDetail(tab) { - delete detailTitles[tab]; +export function updateAppTitle() { + const prefix = document.getElementById("app")?.dataset.titlePrefix || "prime8"; + const path = window.location.pathname.replace(/\/+$/, ""); + document.title = `${prefix}:~${path}`; } function setActiveTabUI(tab) { @@ -176,7 +132,7 @@ function setActiveTabUI(tab) { } }); - updateAppTitle(tab); + updateAppTitle(); } function currentFromPath(order, fallback) { @@ -185,14 +141,3 @@ function currentFromPath(order, fallback) { const tab = path.replace(/^\//, "").split("/")[0]; return order.includes(tab) ? tab : fallback; } - -function selectedListTitle(selector, titleSelector, id) { - if (!id) return ""; - - const normalized = decodeURIComponent(id); - const items = Array.from(document.querySelectorAll(selector)); - const item = items.find((el) => (el.dataset.id || "") === normalized); - if (!item) return ""; - - return item.querySelector(titleSelector)?.textContent?.trim() || ""; -} diff --git a/internal/web/scripts/modules/projects.js b/internal/web/scripts/modules/projects.js @@ -1,5 +1,5 @@ import { isEditableTarget } from "./dom.js"; -import { setAppTitleDetail, updateAppTitle } from "./header.js"; +import { updateAppTitle } from "./header.js"; const projectsHandlers = { keydown: null, @@ -37,10 +37,6 @@ export function initProjects() { if (selectedIndex < 0) selectedIndex = 0; selectedItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); - - const detail = selectedItem.querySelector(".list-title")?.textContent?.trim() || ""; - setAppTitleDetail("projects", detail); - updateAppTitle("projects"); } function updateProjectURL(projectID) { @@ -48,7 +44,7 @@ export function initProjects() { const path = `/projects/${encodeURIComponent(projectID)}`; if (window.location.pathname !== path) { history.replaceState({ tab: "projects" }, "", path); - updateAppTitle("projects"); + updateAppTitle(); } } diff --git a/internal/web/templates/layout.gohtml b/internal/web/templates/layout.gohtml @@ -4,7 +4,7 @@ <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{{.Title}} | {{.About.Name}}</title> + <title>{{.About.Handle}}@prime8:{{shellPath .Path}}</title> <meta name="description" content="{{.Description}}"> <link rel="canonical" href="{{tabURL .ActiveTab}}"> <link rel="alternate" hreflang="en" href="{{tabURL .ActiveTab}}"> @@ -39,7 +39,7 @@ {{tagCSS}}</style> </head> <body> - <div id="app" data-boot-title="{{.About.Name}}" data-boot-subtitle="www.prime8.dev" hx-ext="preload"> + <div id="app" data-title-prefix="{{.About.Handle}}@prime8" data-boot-title="{{.About.Name}}" data-boot-subtitle="www.prime8.dev" hx-ext="preload"> {{template "header" .}} <main id="content"> {{if eq .ContentTemplate "about"}}