feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring
Home screen gains a Sessions↔Projects segmented control. The Projects view discovers the host's git repos and, on click, opens a tab named after the repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project). Backend (P2/P4): - src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions): depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops descending at a repo), .git/HEAD branch parse, rate-limited `git status` dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session merge (fresh each call), TTL discovery cache with in-flight dedup. - src/server.ts — read-only GET /projects (no Origin guard, []-fallback). Frontend (P5/P6): - public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot, session rows, "+ start claude here"), live search, localStorage favourites; pure helpers extracted for unit tests. - public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'), countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView). - public/style.css — Projects panel + .home-seg control styles. Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already added; this commit adds fail-fast rejection of relative PROJECT_ROOTS. Review hardening (4 confirmed HIGH + boundary validation): - carriage-return fix so claude auto-executes (A3); seg-control z-order; parallelised history merge; concurrent-cache-miss dedup. - normalizeProject guards the /projects response; getFavs filters non-strings. Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%. Verified: both typechecks clean, full suite green, build:web OK, coverage ≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
This commit is contained in:
144
public/tabs.ts
144
public/tabs.ts
@@ -14,14 +14,23 @@
|
||||
*
|
||||
* Closing a tab only disconnects its WS (a *detach*); the server PTY keeps
|
||||
* running and is reclaimed by IDLE_TTL.
|
||||
*
|
||||
* v0.6 (P6): the home screen has a segmented control ("Sessions" / "Projects")
|
||||
* that switches between the session launcher and the projects panel. State is
|
||||
* persisted to localStorage. openProject() spawns a new claude session in a
|
||||
* repo directory, labelled with the repo name (deduped with #N suffixes).
|
||||
*/
|
||||
|
||||
import { TerminalSession } from './terminal-session.js'
|
||||
import { THEMES, DEFAULT_SETTINGS, type Settings } from './settings.js'
|
||||
import { mountLauncher, type Launcher } from './launcher.js'
|
||||
import { mountProjects, type ProjectsPanel } from './projects.js'
|
||||
|
||||
const TABS_KEY = 'web-terminal:tabs'
|
||||
const ACTIVE_KEY = 'web-terminal:active'
|
||||
const HOME_VIEW_KEY = 'web-terminal:home-view'
|
||||
|
||||
type HomeView = 'sessions' | 'projects'
|
||||
|
||||
interface TabEntry {
|
||||
session: TerminalSession
|
||||
@@ -47,7 +56,9 @@ export class TabApp {
|
||||
private readonly tabBar: HTMLElement
|
||||
private readonly approvalBar: HTMLDivElement
|
||||
private readonly launcher: Launcher
|
||||
private launcherVisible = false
|
||||
private readonly projects: ProjectsPanel
|
||||
private homeView: HomeView = 'sessions'
|
||||
private readonly segControl: HTMLElement
|
||||
|
||||
constructor(paneHost: HTMLElement, tabBar: HTMLElement) {
|
||||
this.paneHost = paneHost
|
||||
@@ -56,25 +67,100 @@ export class TabApp {
|
||||
this.approvalBar.id = 'approvalbar'
|
||||
this.approvalBar.style.display = 'none'
|
||||
document.body.appendChild(this.approvalBar)
|
||||
|
||||
this.launcher = mountLauncher(this.paneHost, {
|
||||
onOpen: (id) => this.openSession(id),
|
||||
onNew: () => this.newTab(),
|
||||
})
|
||||
// v0.5: do NOT auto-create or auto-restore tabs. Land on the launcher (the
|
||||
// session chooser); the user picks which session to open. Sessions persist
|
||||
// server-side, so opening one replays its full scrollback.
|
||||
|
||||
// Load persisted home view choice.
|
||||
try {
|
||||
const stored = localStorage.getItem(HOME_VIEW_KEY)
|
||||
if (stored === 'sessions' || stored === 'projects') this.homeView = stored
|
||||
} catch {
|
||||
// localStorage unavailable — use default
|
||||
}
|
||||
|
||||
this.projects = mountProjects(this.paneHost, {
|
||||
onOpenProject: (repoPath, repoName) => this.openProject(repoPath, repoName),
|
||||
onEnterSession: (id) => this.openSession(id),
|
||||
})
|
||||
|
||||
this.segControl = this.buildSegControl()
|
||||
this.paneHost.appendChild(this.segControl)
|
||||
|
||||
// v0.5: do NOT auto-create or auto-restore tabs. Land on the home screen;
|
||||
// the user picks which session to open. Sessions persist server-side, so
|
||||
// opening one replays its full scrollback.
|
||||
this.rebuild()
|
||||
this.updateLauncher()
|
||||
this.updateHomeView()
|
||||
}
|
||||
|
||||
/** Show the launcher (session chooser) iff no tab is open. */
|
||||
private updateLauncher(): void {
|
||||
/* ── Home-view visibility ────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Controls visibility of the segmented control and both home sub-panels.
|
||||
* When tabs are open: hide everything (the terminal panes own the screen).
|
||||
* When no tab is open: show the seg control and the selected sub-panel.
|
||||
*/
|
||||
private updateHomeView(): void {
|
||||
const empty = this.tabs.length === 0
|
||||
if (empty === this.launcherVisible) return
|
||||
this.launcherVisible = empty
|
||||
this.launcher.setVisible(empty)
|
||||
|
||||
// Sync active-class on segmented control buttons.
|
||||
const btns = this.segControl.querySelectorAll<HTMLButtonElement>('.home-seg-btn')
|
||||
btns.forEach((btn, i) => {
|
||||
const view: HomeView = i === 0 ? 'sessions' : 'projects'
|
||||
btn.classList.toggle('active', view === this.homeView)
|
||||
})
|
||||
|
||||
if (empty) {
|
||||
this.segControl.style.display = 'flex'
|
||||
this.launcher.setVisible(this.homeView === 'sessions')
|
||||
this.projects.setVisible(this.homeView === 'projects')
|
||||
} else {
|
||||
this.segControl.style.display = 'none'
|
||||
this.launcher.setVisible(false)
|
||||
this.projects.setVisible(false)
|
||||
}
|
||||
}
|
||||
|
||||
private saveHomeView(): void {
|
||||
try {
|
||||
localStorage.setItem(HOME_VIEW_KEY, this.homeView)
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
private buildSegControl(): HTMLElement {
|
||||
const seg = document.createElement('div')
|
||||
seg.className = 'home-seg'
|
||||
seg.style.display = 'none' // updateHomeView() will show it when needed
|
||||
|
||||
const sessBtn = document.createElement('button')
|
||||
sessBtn.className = 'home-seg-btn'
|
||||
sessBtn.textContent = 'Sessions'
|
||||
sessBtn.addEventListener('click', () => {
|
||||
this.homeView = 'sessions'
|
||||
this.saveHomeView()
|
||||
this.updateHomeView()
|
||||
})
|
||||
|
||||
const projBtn = document.createElement('button')
|
||||
projBtn.className = 'home-seg-btn'
|
||||
projBtn.textContent = 'Projects'
|
||||
projBtn.addEventListener('click', () => {
|
||||
this.homeView = 'projects'
|
||||
this.saveHomeView()
|
||||
this.updateHomeView()
|
||||
})
|
||||
|
||||
seg.append(sessBtn, projBtn)
|
||||
return seg
|
||||
}
|
||||
|
||||
/* ── Approval bar ───────────────────────────────────────────────── */
|
||||
|
||||
/** Show/hide the approve/reject banner for the active tab's held request (H3). */
|
||||
private updateApprovalBar(): void {
|
||||
const session = this.tabs[this.activeIndex]?.session
|
||||
@@ -108,7 +194,7 @@ export class TabApp {
|
||||
return entry.customTitle ?? entry.autoTitle ?? `Term ${idx + 1}`
|
||||
}
|
||||
|
||||
/* ── persistence ─────────────────────────────────────────────── */
|
||||
/* ── persistence ─────────────────────────────────────────────────── */
|
||||
|
||||
private persist(): void {
|
||||
try {
|
||||
@@ -138,7 +224,7 @@ export class TabApp {
|
||||
this.activate(this.tabs.length - 1)
|
||||
}
|
||||
|
||||
/* ── tab lifecycle ───────────────────────────────────────────── */
|
||||
/* ── tab lifecycle ───────────────────────────────────────────────── */
|
||||
|
||||
private addEntry(
|
||||
sessionId: string | null,
|
||||
@@ -193,12 +279,34 @@ export class TabApp {
|
||||
|
||||
/** O2: open a new tab in `cwd` and run `claude --resume <id>`. */
|
||||
newTabForResume(cwd: string, sessionId: string): void {
|
||||
this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}`)
|
||||
this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}\r`)
|
||||
this.persist()
|
||||
this.rebuild()
|
||||
this.activate(this.tabs.length - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6 (P6): spawn a new claude session in `repoPath`, labelled `repoName`.
|
||||
* If tabs with the same base name already exist, appends a #N suffix to
|
||||
* distinguish them (e.g. "web-terminal #2"). Mirrors newTabForResume in
|
||||
* structure: addEntry → persist → rebuild → activate.
|
||||
*/
|
||||
openProject(repoPath: string, repoName: string, cmd = 'claude\r'): void {
|
||||
const n = this.countOpenWithTitlePrefix(repoName)
|
||||
const label = n === 0 ? repoName : `${repoName} #${n + 1}`
|
||||
this.addEntry(null, label, repoPath || undefined, cmd)
|
||||
this.persist()
|
||||
this.rebuild()
|
||||
this.activate(this.tabs.length - 1)
|
||||
}
|
||||
|
||||
/** Count tabs whose customTitle is exactly `name` or starts with `name + ' #'`. */
|
||||
private countOpenWithTitlePrefix(name: string): number {
|
||||
return this.tabs.filter(
|
||||
(t) => t.customTitle === name || t.customTitle?.startsWith(`${name} #`) === true,
|
||||
).length
|
||||
}
|
||||
|
||||
activate(i: number): void {
|
||||
if (i < 0 || i >= this.tabs.length) return
|
||||
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
||||
@@ -217,10 +325,10 @@ export class TabApp {
|
||||
entry?.session.dispose()
|
||||
if (this.editingIndex === i) this.editingIndex = -1
|
||||
if (this.tabs.length === 0) {
|
||||
// v0.5: closing the last tab returns to the launcher — no auto-blank tab.
|
||||
// v0.5: closing the last tab returns to the home screen — no auto-blank tab.
|
||||
this.activeIndex = -1
|
||||
this.persist()
|
||||
this.rebuild() // updateLauncher() (in rebuild) shows the chooser
|
||||
this.rebuild() // updateHomeView() (in rebuild) shows the chooser
|
||||
this.updateApprovalBar()
|
||||
return
|
||||
}
|
||||
@@ -323,7 +431,7 @@ export class TabApp {
|
||||
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
|
||||
}
|
||||
|
||||
/* ── rendering ───────────────────────────────────────────────── */
|
||||
/* ── rendering ───────────────────────────────────────────────────── */
|
||||
|
||||
/** In-place update of one tab's classes/label/dot — never destroys the DOM. */
|
||||
private refreshTab(entry: TabEntry): void {
|
||||
@@ -453,7 +561,7 @@ export class TabApp {
|
||||
add.addEventListener('click', () => this.newTab())
|
||||
this.tabBar.appendChild(add)
|
||||
|
||||
// Show/hide the launcher based on whether any tab is open.
|
||||
this.updateLauncher()
|
||||
// Show/hide the home screen based on whether any tab is open.
|
||||
this.updateHomeView()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user