An audit of the project-detail panel against docs/mockups/project-detail-git.html turned up 59 differences. The ones that changed what you see: Controls that had class names but no rules, so they rendered as raw OS widgets: - .proj-wt-form had no layout at all — the branch input and Create Worktree button sat flush together, and the button jumped to its own line the moment a validation error appeared between them. - .proj-wt-error / .proj-wt-actions-error had no rule, so a validation failure rendered as 16px near-white body text, indistinguishable from content. - .proj-wt-remove (the worktree ✕) and .proj-wt-prune had no rule either. - .proj-fanout-mode never reset `appearance`, so WebKit kept its own light dropdown — the one control in the panel that stayed a native grey widget. - --danger was never defined, so every validation message fell back to an off-palette red. Cascade bugs — a later rule at equal specificity was silently winning: - .proj-wt-row-current's amber wash and border were completely dead. "You are here" rendered identically to every other row. - .proj-wt-branch inherited the 16px user-agent default in the UI sans, making a branch name the largest text on the page. Now 13.5px mono. Layout: - .proj-syncband dropped flex-wrap. Wrapping produced ragged half-empty rows between 721px and 1000px — at 900px the Fetch button dropped alone onto a second row. One cell now absorbs the shrink so the band stays a single row down to the 720px column break. - Commit rows are a 4-column grid, not flex, and the ↑ mark cell is rendered on every row instead of only unpushed ones. Previously no two lines agreed on where the sha started, so the unpushed group did not read as a group. - Pushed commit subjects step down to --text-dim, which is what makes the upstream boundary mean anything. Honesty of the sync band — the design's two hard rules: - Green "✓ in sync" was reachable when ahead/behind were UNKNOWN, because `?? 0` folded undefined into zero. Unknown counts now render "↑ —" / "↓ —" and are marked unverified. Green requires real zeros and a fresh fetch. - A stale ↓ painted the digit warning-red. Since a missing FETCH_HEAD is the default state, the largest glyph in the band was almost always red and the panel read as "this repo is broken". The doubt now sits on the `unverified` chip and the footnote, never on the number. Also: semantic tokens (--sunk/--warn/--ok/--dirty/--mono-font) replacing hex literals, one chip language across the panel, section headings as tracked caps with the count as a separate lighter element, and a ≤720px rule that drops the age column — placed after the base rule, since a media query adds no specificity. Verified in a real browser at 1280/1000/900/780/721/600/420px: the sync band holds one row down to 721px and stacks below it, commit columns align on every row, and nothing overflows or scrolls the page horizontally.
1806 lines
67 KiB
TypeScript
1806 lines
67 KiB
TypeScript
/**
|
||
* 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,
|
||
ProjectDetail,
|
||
WorktreeInfo,
|
||
CreateWorktreeResult,
|
||
FanoutLaunchOpts,
|
||
PermissionMode,
|
||
SyncState,
|
||
WorktreeState,
|
||
} from '../src/types.js'
|
||
import { el, relTime, statusText } from './preview-grid.js'
|
||
import { slugify, FANOUT_MAX_LANES } from './fanout.js'
|
||
import { loadPrefs, savePrefs } from './prefs.js'
|
||
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
|
||
import { mountDiffViewer, type DiffViewerHandle } from './diff.js'
|
||
import { mountPrChip, type PrChipHandle } from './gh-chip.js'
|
||
import { mountGitLog, type GitLogHandle } from './git-log.js'
|
||
import { mountTimeline, type TimelineHandle } from './timeline.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 terminal session in the repo running `cmd` (default: claude). */
|
||
onOpenProject: (repoPath: string, repoName: string, cmd?: string) => void
|
||
/** Enter an already-running session by id. */
|
||
onEnterSession: (id: string) => void
|
||
/** W5: fan ONE prompt across N branch/agent lanes of this repo (N worktrees, N
|
||
* Claude sessions on the same prompt, watched in the split-grid board). */
|
||
onFanout: (repoPath: string, repoName: string, opts: FanoutLaunchOpts) => 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)
|
||
})
|
||
}
|
||
|
||
/* ── Namespace grouping (v0.6) — turn the flat grid into collapsible sections ── */
|
||
|
||
/** Sentinel group keys (can't collide with a real `First.Second` namespace). */
|
||
export const ACTIVE_GROUP_KEY = ' |