The page carried one bare `●` for "dirty" and nothing else, so "do I have
commits I haven't pushed" and "which worktree am I in" still meant dropping
into a terminal. Design mock: docs/mockups/project-detail-git.html; plan and
task breakdown (G1-G7): docs/plans/w6-project-git-panel.md.
One rule drives the whole feature: ahead/behind compare against `@{u}`, a
LOCALLY CACHED remote ref that only a fetch moves. This repo was the live
example while building — `↑9` true, `↓0` false, because FETCH_HEAD had not
moved in 19 days. So:
- `ahead` needs only local refs and is never flagged.
- `behind` is flagged `stale` once FETCH_HEAD is older than an hour.
- Exactly ONE state may render green: ↑0 ↓0 AND a fresh fetch. Green means
"I checked, ignore this"; getting it wrong is lying to the user.
- No upstream (the normal state of a fresh worktree branch) leaves ahead and
behind undefined — it renders an explicit `no upstream`, never the green
path. That fall-through is the easiest bug to ship here.
What landed:
G1 SyncState (upstream/ahead/behind/lastFetchMs/detached) + ProjectDetail.sync
and .dirtyCount. All additive and optional — the Android and iOS clients
decode these shapes. The ahead/behind helper already existed for the list
view; buildProjectDetail had simply never called it.
Fixes a pre-existing bug on the way: readBranch read <repo>/.git/HEAD
directly, so it returned nothing inside a LINKED worktree, where .git is a
file. resolveGitDirs now resolves both the per-worktree gitdir (HEAD) and
the shared common dir (FETCH_HEAD).
G2 POST /projects/git/fetch. Same discipline as push: the remote is derived
server-side and no remote or refspec is ever read from the body, so a
client cannot aim it at an arbitrary URL. Touches refs/remotes only — no
working tree, no index, no merge; it is not a pull. Own rate-limit bucket
so refreshes cannot eat the budget a real push needs. On failure
lastFetchMs is left alone, so the UI keeps saying "stale" instead of
pretending it refreshed.
G3 makeSyncBand replaces the bare dot: upstream name, ↑n, ↓n, stale flag,
dirty count, Fetch button (disabled on a detached HEAD).
G4 The commit list marks unpushed commits and draws the upstream boundary
once, after the last of them. Marking is server-side from `rev-list`,
deliberately NOT "the first N rows": `git log` is date-ordered, so merging
an older branch interleaves unpushed commits BELOW pushed ones, and that
shortcut fails in the dangerous direction — calling an unpushed commit
pushed. A regression test builds exactly that backdated-merge shape.
G5 The worktree section is always "Worktrees (n)" (it used to rename itself
to "Branch" at n=1) and the current row carries its own state chips.
G6 Cost control. The plan called for a .git-mtime cache; that was dropped
during implementation because a fingerprint over HEAD/index/reflog does
NOT move when push updates a remote-tracking ref — the cached `ahead`
would still claim "9 to push" right after a successful push, which is the
exact lie the feature exists to prevent. Replaced with three measures that
cannot go stale: in-flight coalescing (N devices watching one repo cost
one probe, entry dropped as it settles, nothing cached across time),
skipping the re-render when the payload is byte-identical (this also stops
the 5 s re-mount of the commit log, two more git spawns per tick), and
pausing the timer while the document is hidden.
G7 Per-worktree state via GET /projects/worktree/state, kept narrower than
/projects/detail so N rows do not pay for worktree listing and CLAUDE.md
reads nothing renders. Needed an unplanned prerequisite: ProjectSessionRef
carried no cwd, so sessions could not be attributed to a worktree. Added
it, plus countSessionsByWorktree, which matches DEEPEST-first because
.claude/worktrees/<name> lives INSIDE the main checkout and prefix
matching would count every worktree session against the parent repo too.
Out of scope, unchanged: no reset, no checkout, no clean, no rebase, no
force-push. stage/commit/push stay exactly as they were.
Verified: tsc and build clean; 46 new tests.
1725 lines
63 KiB
TypeScript
1725 lines
63 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 = ' |