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:
318
public/projects.ts
Normal file
318
public/projects.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* public/projects.ts — Projects panel for the home screen (v0.6).
|
||||
*
|
||||
* Renders a grid of discovered git repositories fetched from GET /projects.
|
||||
* Each card shows: repo name, branch chip, dirty indicator, last-active time,
|
||||
* and running sessions (1:N). Supports live search and per-project favourites
|
||||
* persisted to localStorage.
|
||||
*
|
||||
* Pure helpers (filterProjects, sortProjects, getFavs, saveFavs, toggleFav)
|
||||
* are exported for unit testing without DOM. mountProjects is the thin wiring.
|
||||
*
|
||||
* Timer lifecycle mirrors launcher.ts: auto-refresh every 5 s while visible,
|
||||
* stopped + cleaned up on hide.
|
||||
*/
|
||||
|
||||
import type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js'
|
||||
import { el, relTime } from './preview-grid.js'
|
||||
|
||||
/* ── Constants ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const FAV_KEY = 'web-terminal:proj-favs'
|
||||
const REFRESH_MS = 5000
|
||||
|
||||
/* ── Public contract (consumed by P6 — tabs.ts wiring) ──────────────────── */
|
||||
|
||||
export interface ProjectsHooks {
|
||||
/** Spawn a new claude session in the given repo. */
|
||||
onOpenProject: (repoPath: string, repoName: string) => void
|
||||
/** Enter an already-running session by id. */
|
||||
onEnterSession: (id: string) => void
|
||||
}
|
||||
|
||||
export interface ProjectsPanel {
|
||||
setVisible(v: boolean): void
|
||||
refresh(): void
|
||||
}
|
||||
|
||||
/* ── Pure helpers (exported for unit tests) ──────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Filter projects by name or path substring (case-insensitive).
|
||||
* Returns a new array; never mutates the input.
|
||||
*/
|
||||
export function filterProjects(projects: readonly ProjectInfo[], query: string): ProjectInfo[] {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return [...projects]
|
||||
const lower = trimmed.toLowerCase()
|
||||
return projects.filter(
|
||||
(p) => p.name.toLowerCase().includes(lower) || p.path.toLowerCase().includes(lower),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort projects: favourites first, then by lastActiveMs descending.
|
||||
* Returns a new array; never mutates the input.
|
||||
*/
|
||||
export function sortProjects(
|
||||
projects: readonly ProjectInfo[],
|
||||
favs: ReadonlySet<string>,
|
||||
): ProjectInfo[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
const aFav = favs.has(a.path) ? 0 : 1
|
||||
const bFav = favs.has(b.path) ? 0 : 1
|
||||
if (aFav !== bFav) return aFav - bFav
|
||||
return (b.lastActiveMs ?? 0) - (a.lastActiveMs ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
/** Load persisted favourite project paths from localStorage. */
|
||||
export function getFavs(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAV_KEY)
|
||||
if (!raw) return new Set()
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
// Never trust persisted data: keep only string entries (corrupt/tampered
|
||||
// localStorage could otherwise poison the Set<string> used for path matching).
|
||||
if (!Array.isArray(parsed)) return new Set()
|
||||
return new Set(parsed.filter((x): x is string => typeof x === 'string'))
|
||||
} catch {
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a set of favourite project paths to localStorage. */
|
||||
export function saveFavs(favs: ReadonlySet<string>): void {
|
||||
try {
|
||||
localStorage.setItem(FAV_KEY, JSON.stringify([...favs]))
|
||||
} catch {
|
||||
// localStorage unavailable — run without persistence
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable toggle: add if absent, remove if present.
|
||||
* Returns a new Set; never mutates the original.
|
||||
*/
|
||||
export function toggleFav(favs: ReadonlySet<string>, path: string): Set<string> {
|
||||
const next = new Set(favs)
|
||||
if (next.has(path)) {
|
||||
next.delete(path)
|
||||
} else {
|
||||
next.add(path)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/* ── Fetch ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** Coerce one untrusted /projects element into a safe ProjectInfo, or null.
|
||||
* Guarantees `sessions` is always an array so card rendering never throws on a
|
||||
* malformed response (never trust external data — API responses included). */
|
||||
export function normalizeProject(p: unknown): ProjectInfo | null {
|
||||
if (p === null || typeof p !== 'object') return null
|
||||
const o = p as Record<string, unknown>
|
||||
if (typeof o['name'] !== 'string' || typeof o['path'] !== 'string') return null
|
||||
const sessions = Array.isArray(o['sessions']) ? (o['sessions'] as ProjectSessionRef[]) : []
|
||||
return {
|
||||
name: o['name'],
|
||||
path: o['path'],
|
||||
isGit: o['isGit'] === true,
|
||||
...(typeof o['branch'] === 'string' ? { branch: o['branch'] } : {}),
|
||||
...(typeof o['dirty'] === 'boolean' ? { dirty: o['dirty'] } : {}),
|
||||
...(typeof o['lastActiveMs'] === 'number' ? { lastActiveMs: o['lastActiveMs'] } : {}),
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<ProjectInfo[]> {
|
||||
try {
|
||||
const res = await fetch('/projects')
|
||||
const data: unknown = await res.json()
|
||||
if (!Array.isArray(data)) return []
|
||||
return data.map(normalizeProject).filter((x): x is ProjectInfo => x !== null)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Status helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
function sessionDotClass(status: ClaudeStatus): string {
|
||||
return `proj-session-dot proj-dot-${status}`
|
||||
}
|
||||
|
||||
/* ── Card sub-elements ───────────────────────────────────────────────────── */
|
||||
|
||||
function makeSessionRow(
|
||||
sess: { id: string; title?: string; status: ClaudeStatus; clientCount: number },
|
||||
onEnterSession: (id: string) => void,
|
||||
): HTMLElement {
|
||||
const row = el('div', 'proj-session-row')
|
||||
row.setAttribute('role', 'button')
|
||||
row.tabIndex = 0
|
||||
|
||||
const dot = el('span', sessionDotClass(sess.status))
|
||||
const title = el('span', 'proj-session-title', sess.title ?? 'claude')
|
||||
const badge = el('span', 'proj-session-badge', `👁 ${sess.clientCount}`)
|
||||
|
||||
row.append(dot, title, badge)
|
||||
|
||||
row.addEventListener('click', () => onEnterSession(sess.id))
|
||||
row.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onEnterSession(sess.id)
|
||||
}
|
||||
})
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
function makeProjectCard(
|
||||
project: ProjectInfo,
|
||||
favs: ReadonlySet<string>,
|
||||
hooks: ProjectsHooks,
|
||||
onToggleFav: (path: string) => void,
|
||||
): HTMLElement {
|
||||
const card = el('div', 'proj-card')
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
const header = el('div', 'proj-card-head')
|
||||
|
||||
const isFav = favs.has(project.path)
|
||||
const favBtn = el('button', isFav ? 'proj-fav on' : 'proj-fav')
|
||||
favBtn.textContent = '★'
|
||||
favBtn.title = isFav ? 'Remove from favourites' : 'Add to favourites'
|
||||
favBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
onToggleFav(project.path)
|
||||
})
|
||||
|
||||
const name = el('span', 'proj-name', project.name)
|
||||
|
||||
header.append(favBtn, name)
|
||||
|
||||
if (project.branch) {
|
||||
const branch = el('span', 'proj-branch', project.branch)
|
||||
header.append(branch)
|
||||
}
|
||||
|
||||
if (project.dirty === true) {
|
||||
const dirty = el('span', 'proj-dirty', '●')
|
||||
dirty.title = 'Uncommitted changes'
|
||||
header.append(dirty)
|
||||
}
|
||||
|
||||
card.append(header)
|
||||
|
||||
// ── Meta line ─────────────────────────────────────────────────────────────
|
||||
if (project.lastActiveMs !== undefined) {
|
||||
const meta = el('div', 'proj-meta', `active ${relTime(project.lastActiveMs)} ago`)
|
||||
card.append(meta)
|
||||
}
|
||||
|
||||
// ── Sessions area (1:N) ───────────────────────────────────────────────────
|
||||
const runningSessions = project.sessions.filter((s) => !s.exited)
|
||||
|
||||
if (runningSessions.length === 0) {
|
||||
// 0 running sessions: single primary "start claude here" action
|
||||
const newBtn = el('button', 'proj-new proj-new-primary', '+ start claude here')
|
||||
newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name))
|
||||
card.append(newBtn)
|
||||
} else {
|
||||
// 1+ running sessions: list session rows + always-present "+ New" button
|
||||
const sessionsList = el('div', 'proj-sessions-list')
|
||||
for (const sess of runningSessions) {
|
||||
sessionsList.append(makeSessionRow(sess, hooks.onEnterSession))
|
||||
}
|
||||
card.append(sessionsList)
|
||||
|
||||
const newBtn = el('button', 'proj-new', '+ New')
|
||||
newBtn.addEventListener('click', () => hooks.onOpenProject(project.path, project.name))
|
||||
card.append(newBtn)
|
||||
}
|
||||
|
||||
return card
|
||||
}
|
||||
|
||||
/* ── Mount ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): ProjectsPanel {
|
||||
const root = el('div', 'proj-panel')
|
||||
root.style.display = 'none'
|
||||
host.appendChild(root)
|
||||
|
||||
// Search box
|
||||
const searchWrap = el('div', 'proj-search-wrap')
|
||||
const searchInput = document.createElement('input')
|
||||
searchInput.className = 'proj-search'
|
||||
searchInput.type = 'search'
|
||||
searchInput.placeholder = 'Filter projects…'
|
||||
searchInput.setAttribute('aria-label', 'Filter projects')
|
||||
searchWrap.append(searchInput)
|
||||
root.append(searchWrap)
|
||||
|
||||
const grid = el('div', 'proj-grid')
|
||||
root.append(grid)
|
||||
|
||||
let allProjects: ProjectInfo[] = []
|
||||
let favs: Set<string> = getFavs()
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function renderGrid(): void {
|
||||
const filtered = filterProjects(allProjects, searchInput.value)
|
||||
const sorted = sortProjects(filtered, favs)
|
||||
|
||||
grid.replaceChildren()
|
||||
|
||||
if (sorted.length === 0) {
|
||||
const msg =
|
||||
allProjects.length === 0
|
||||
? 'No projects found. Check PROJECT_ROOTS configuration.'
|
||||
: 'No projects match your search.'
|
||||
grid.append(el('div', 'proj-empty', msg))
|
||||
return
|
||||
}
|
||||
|
||||
for (const p of sorted) {
|
||||
grid.append(
|
||||
makeProjectCard(p, favs, hooks, (path) => {
|
||||
favs = toggleFav(favs, path)
|
||||
saveFavs(favs)
|
||||
renderGrid()
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
allProjects = await fetchProjects()
|
||||
favs = getFavs()
|
||||
renderGrid()
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', () => renderGrid())
|
||||
|
||||
return {
|
||||
setVisible(v: boolean): void {
|
||||
root.style.display = v ? 'block' : 'none'
|
||||
if (v) {
|
||||
void refresh()
|
||||
if (timer === null) {
|
||||
timer = setInterval(() => {
|
||||
if (root.style.display !== 'none') void refresh()
|
||||
}, REFRESH_MS)
|
||||
}
|
||||
} else {
|
||||
if (timer !== null) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
},
|
||||
refresh(): void {
|
||||
void refresh()
|
||||
},
|
||||
}
|
||||
}
|
||||
271
public/style.css
271
public/style.css
@@ -664,7 +664,7 @@ body {
|
||||
/* ── Launcher (home session chooser, shown when no tab is open) ──── */
|
||||
.launcher {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
inset: 44px 0 0 0; /* top offset reserves space for .home-seg (height ~32px + gap) */
|
||||
overflow-y: auto;
|
||||
padding: 22px 18px 40px;
|
||||
box-sizing: border-box;
|
||||
@@ -872,3 +872,272 @@ body {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── v0.6 Projects panel ─────────────────────────────────────────────────── */
|
||||
|
||||
/* Panel wrapper (same host as .launcher; toggled via display) */
|
||||
.proj-panel {
|
||||
position: absolute;
|
||||
inset: 44px 0 0 0; /* top offset reserves space for .home-seg (height ~32px + gap) */
|
||||
overflow-y: auto;
|
||||
padding: 22px 18px 40px;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Search box */
|
||||
.proj-search-wrap {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.proj-search {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 8px 12px;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.proj-search:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Grid — mirrors .mg-grid layout */
|
||||
.proj-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Project card */
|
||||
.proj-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
.proj-card:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Card header row */
|
||||
.proj-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Repo name */
|
||||
.proj-name {
|
||||
flex: 1 1 auto;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Branch chip */
|
||||
.proj-branch {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
white-space: nowrap;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Dirty indicator (● dot) */
|
||||
.proj-dirty {
|
||||
font-size: 10px;
|
||||
color: var(--amber);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Meta line (last-active time) */
|
||||
.proj-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Favourite star toggle */
|
||||
.proj-fav {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.proj-fav:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.proj-fav.on {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Sessions list (1:N) */
|
||||
.proj-sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* One running-session row */
|
||||
.proj-session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-3);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
.proj-session-row:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Status dot on a session row — colours from THEME.md v0.6 note */
|
||||
.proj-session-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
background: var(--text-faint);
|
||||
}
|
||||
.proj-dot-working {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 5px var(--accent-soft);
|
||||
}
|
||||
.proj-dot-waiting {
|
||||
background: var(--amber);
|
||||
}
|
||||
.proj-dot-idle {
|
||||
background: #8b8a86;
|
||||
}
|
||||
.proj-dot-unknown {
|
||||
background: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Session title */
|
||||
.proj-session-title {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Client-count badge (👁 N) */
|
||||
.proj-session-badge {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
background: var(--surface-2);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* "+ New" / "+ start claude here" action button */
|
||||
.proj-new {
|
||||
background: var(--surface-3);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
transition: background 0.1s ease, color 0.1s ease, border-color 0.1s ease;
|
||||
}
|
||||
.proj-new:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-color: rgba(227, 166, 74, 0.3);
|
||||
}
|
||||
|
||||
/* Primary action — no sessions yet; full Amber accent button */
|
||||
.proj-new.proj-new-primary {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.proj-new.proj-new-primary:hover {
|
||||
background: var(--accent-2);
|
||||
border-color: var(--accent-2);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.proj-empty {
|
||||
color: var(--text-dim);
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Home segmented control (P6 builds the DOM; styles defined here per spec) */
|
||||
|
||||
/* Horizontal pill container, centered above the home grid */
|
||||
.home-seg {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Inactive segment */
|
||||
.home-seg-btn {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 20px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.home-seg-btn:first-child {
|
||||
border-radius: 20px 0 0 20px;
|
||||
}
|
||||
.home-seg-btn:last-child {
|
||||
border-radius: 0 20px 20px 0;
|
||||
border-left: none;
|
||||
}
|
||||
.home-seg-btn:only-child {
|
||||
border-radius: 20px;
|
||||
}
|
||||
.home-seg-btn:not(.active):hover {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Active segment — Amber accent; text uses --on-accent for contrast */
|
||||
.home-seg-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.home-seg-btn.active:hover {
|
||||
background: var(--accent-2);
|
||||
border-color: var(--accent-2);
|
||||
}
|
||||
|
||||
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