Two layout defects the screenshots caught that the DOM tests could not. 1. `.proj-wt-row` was declared twice — my two-line-card rule earlier in the file, the original flex rule later. Same specificity, so the later copy won and the Open button sat against the text instead of at the card's right edge. Folded the grid into the ORIGINAL rule and left a note where the duplicate was. One rule per selector; a second declaration of a layout property is a silent override waiting to happen. 2. The unpushed count rendered as its own line under "Recent commits". The design has it ON the heading, because it qualifies that heading rather than making a separate statement. renderGitLog now takes the heading element and writes the badge there, clearing any previous badge first so a re-render cannot stack duplicates. Both were invisible to the existing assertions: they checked that the elements exist and carry the right text, which was true in each case. Added a test for the no-duplicate-badge path; the right-edge alignment is a pure CSS outcome and is verified by screenshot, not by the suite.
1773 lines
65 KiB
TypeScript
1773 lines
65 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 = ' |