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.
897 lines
35 KiB
TypeScript
897 lines
35 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* test/projects-panel.test.ts — unit tests for pure helpers in public/projects.ts.
|
|
*
|
|
* Covers filterProjects, sortProjects, toggleFav, getFavs/saveFavs.
|
|
* localStorage is provided by the jsdom environment.
|
|
* mountProjects (DOM wiring) is thin glue and not unit-tested here.
|
|
*
|
|
* @xterm/xterm is mocked because projects.ts imports preview-grid.ts which
|
|
* uses Terminal. The mock must be declared before the dynamic import below.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { ProjectInfo } from '../src/types.js'
|
|
|
|
// ── Stub @xterm/xterm (transitively required via preview-grid.ts) ─────────────
|
|
class FakeTerminal {
|
|
open = vi.fn()
|
|
dispose = vi.fn()
|
|
}
|
|
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
|
|
|
|
// Stub the PR-status chip (W3) so renderProjectDetail's header mount doesn't hit
|
|
// the network; the chip's own logic is covered in test/gh-chip.test.ts.
|
|
vi.mock('../public/gh-chip.js', () => ({ mountPrChip: vi.fn(() => ({ destroy: vi.fn() })) }))
|
|
|
|
// Dynamic import AFTER mock declaration so the factory is in place.
|
|
const {
|
|
filterProjects,
|
|
sortProjects,
|
|
getFavs,
|
|
saveFavs,
|
|
toggleFav,
|
|
normalizeProject,
|
|
makeProjectCard,
|
|
makeSyncChip,
|
|
makeSyncBand,
|
|
makeWorktreeRow,
|
|
countSessionsByWorktree,
|
|
renderProjectDetail,
|
|
groupProjects,
|
|
displayLabel,
|
|
ACTIVE_GROUP_KEY,
|
|
OTHER_GROUP_KEY,
|
|
} = await import('../public/projects.js')
|
|
|
|
/* ── Helpers ───────────────────────────────────────────────────────────────── */
|
|
|
|
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
|
|
return {
|
|
name: 'test-repo',
|
|
path: '/home/user/test-repo',
|
|
isGit: true,
|
|
sessions: [],
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/* ── filterProjects ────────────────────────────────────────────────────────── */
|
|
|
|
describe('filterProjects', () => {
|
|
const projects: ProjectInfo[] = [
|
|
makeProject({ name: 'web-terminal', path: '/home/user/web-terminal' }),
|
|
makeProject({ name: 'api-service', path: '/home/user/projects/api-service' }),
|
|
makeProject({ name: 'utils', path: '/home/user/utils' }),
|
|
]
|
|
|
|
it('returns all projects when query is empty string', () => {
|
|
expect(filterProjects(projects, '')).toHaveLength(3)
|
|
})
|
|
|
|
it('returns all projects when query is only whitespace', () => {
|
|
expect(filterProjects(projects, ' ')).toHaveLength(3)
|
|
})
|
|
|
|
it('filters by name substring (case-insensitive)', () => {
|
|
const result = filterProjects(projects, 'WEB')
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]!.name).toBe('web-terminal')
|
|
})
|
|
|
|
it('filters by path substring', () => {
|
|
const result = filterProjects(projects, 'projects')
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]!.name).toBe('api-service')
|
|
})
|
|
|
|
it('is case-insensitive on path', () => {
|
|
const result = filterProjects(projects, 'UTILS')
|
|
expect(result).toHaveLength(1)
|
|
expect(result[0]!.name).toBe('utils')
|
|
})
|
|
|
|
it('returns empty array when no project matches', () => {
|
|
expect(filterProjects(projects, 'zzz-no-match-at-all')).toHaveLength(0)
|
|
})
|
|
|
|
it('does not mutate the input array', () => {
|
|
const original = [...projects]
|
|
filterProjects(projects, 'web')
|
|
expect(projects).toEqual(original)
|
|
})
|
|
|
|
it('returns a new array each time', () => {
|
|
const r1 = filterProjects(projects, '')
|
|
const r2 = filterProjects(projects, '')
|
|
expect(r1).not.toBe(r2) // different references
|
|
})
|
|
})
|
|
|
|
/* ── sortProjects ──────────────────────────────────────────────────────────── */
|
|
|
|
describe('sortProjects', () => {
|
|
const a = makeProject({ name: 'a', path: '/a', lastActiveMs: 1000 })
|
|
const b = makeProject({ name: 'b', path: '/b', lastActiveMs: 3000 })
|
|
const c = makeProject({ name: 'c', path: '/c', lastActiveMs: 2000 })
|
|
|
|
it('places favourites before non-favourites', () => {
|
|
const favs = new Set(['/c'])
|
|
const sorted = sortProjects([a, b, c], favs)
|
|
expect(sorted[0]!.path).toBe('/c')
|
|
})
|
|
|
|
it('among non-favourites sorts by lastActiveMs descending', () => {
|
|
const sorted = sortProjects([a, b, c], new Set())
|
|
expect(sorted[0]!.path).toBe('/b') // 3000 ms
|
|
expect(sorted[1]!.path).toBe('/c') // 2000 ms
|
|
expect(sorted[2]!.path).toBe('/a') // 1000 ms
|
|
})
|
|
|
|
it('among favourites also sorts by lastActiveMs descending', () => {
|
|
const favs = new Set(['/a', '/b'])
|
|
const sorted = sortProjects([a, b, c], favs)
|
|
expect(sorted[0]!.path).toBe('/b') // fav, 3000 ms
|
|
expect(sorted[1]!.path).toBe('/a') // fav, 1000 ms
|
|
expect(sorted[2]!.path).toBe('/c') // non-fav, 2000 ms
|
|
})
|
|
|
|
it('handles missing lastActiveMs (treats as 0)', () => {
|
|
const noMs = makeProject({ path: '/x' })
|
|
const withMs = makeProject({ path: '/y', lastActiveMs: 500 })
|
|
const sorted = sortProjects([noMs, withMs], new Set())
|
|
expect(sorted[0]!.path).toBe('/y')
|
|
expect(sorted[1]!.path).toBe('/x')
|
|
})
|
|
|
|
it('does not mutate the input array', () => {
|
|
const arr = [a, b, c]
|
|
const origFirst = arr[0]!.path
|
|
sortProjects(arr, new Set())
|
|
expect(arr[0]!.path).toBe(origFirst)
|
|
})
|
|
|
|
it('returns a new array', () => {
|
|
const arr = [a, b, c]
|
|
const sorted = sortProjects(arr, new Set())
|
|
expect(sorted).not.toBe(arr)
|
|
})
|
|
|
|
it('returns empty array for empty input', () => {
|
|
expect(sortProjects([], new Set())).toEqual([])
|
|
})
|
|
})
|
|
|
|
/* ── toggleFav ─────────────────────────────────────────────────────────────── */
|
|
|
|
describe('toggleFav', () => {
|
|
it('adds a path that is not present', () => {
|
|
const original = new Set(['/a'])
|
|
const next = toggleFav(original, '/b')
|
|
expect(next.has('/b')).toBe(true)
|
|
expect(next.has('/a')).toBe(true)
|
|
})
|
|
|
|
it('removes a path that is already present', () => {
|
|
const original = new Set(['/a', '/b'])
|
|
const next = toggleFav(original, '/a')
|
|
expect(next.has('/a')).toBe(false)
|
|
expect(next.has('/b')).toBe(true)
|
|
})
|
|
|
|
it('does not mutate the original set', () => {
|
|
const original = new Set(['/a'])
|
|
toggleFav(original, '/a')
|
|
expect(original.has('/a')).toBe(true) // unchanged
|
|
})
|
|
|
|
it('returns a new Set instance', () => {
|
|
const original = new Set(['/a'])
|
|
const next = toggleFav(original, '/b')
|
|
expect(next).not.toBe(original)
|
|
})
|
|
|
|
it('works on an empty set (add case)', () => {
|
|
const next = toggleFav(new Set(), '/x')
|
|
expect(next.has('/x')).toBe(true)
|
|
expect(next.size).toBe(1)
|
|
})
|
|
})
|
|
|
|
/* ── getFavs / saveFavs (localStorage persistence) ─────────────────────────── */
|
|
|
|
describe('getFavs / saveFavs', () => {
|
|
beforeEach(() => {
|
|
localStorage.clear()
|
|
})
|
|
|
|
it('getFavs returns empty set when no data is stored', () => {
|
|
expect(getFavs().size).toBe(0)
|
|
})
|
|
|
|
it('saveFavs + getFavs round-trips a set of paths', () => {
|
|
const paths = new Set(['/home/user/proj-a', '/home/user/proj-b'])
|
|
saveFavs(paths)
|
|
const loaded = getFavs()
|
|
expect(loaded.has('/home/user/proj-a')).toBe(true)
|
|
expect(loaded.has('/home/user/proj-b')).toBe(true)
|
|
expect(loaded.size).toBe(2)
|
|
})
|
|
|
|
it('getFavs returns empty set on malformed JSON', () => {
|
|
localStorage.setItem('web-terminal:proj-favs', 'NOT_JSON{{{')
|
|
expect(getFavs().size).toBe(0)
|
|
})
|
|
|
|
it('getFavs returns empty set when stored value is not an array', () => {
|
|
localStorage.setItem('web-terminal:proj-favs', JSON.stringify({ not: 'an-array' }))
|
|
expect(getFavs().size).toBe(0)
|
|
})
|
|
|
|
it('getFavs returns empty set when stored null', () => {
|
|
localStorage.setItem('web-terminal:proj-favs', 'null')
|
|
expect(getFavs().size).toBe(0)
|
|
})
|
|
|
|
it('saveFavs persists an empty set (removes all favs)', () => {
|
|
saveFavs(new Set(['/a']))
|
|
saveFavs(new Set())
|
|
expect(getFavs().size).toBe(0)
|
|
})
|
|
|
|
it('getFavs drops non-string entries from a corrupt/tampered array', () => {
|
|
localStorage.setItem('web-terminal:proj-favs', JSON.stringify(['/a', 42, null, '/b', { x: 1 }]))
|
|
const favs = getFavs()
|
|
expect([...favs].sort()).toEqual(['/a', '/b'])
|
|
})
|
|
})
|
|
|
|
/* ── normalizeProject (untrusted /projects element coercion) ────────────────── */
|
|
|
|
describe('normalizeProject', () => {
|
|
it('passes a well-formed project through, defaulting sessions to []', () => {
|
|
const p = normalizeProject({ name: 'web', path: '/home/u/web', isGit: true })
|
|
expect(p).toEqual({ name: 'web', path: '/home/u/web', isGit: true, sessions: [] })
|
|
})
|
|
|
|
it('preserves a sessions array and optional fields', () => {
|
|
const sess = { id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: false }
|
|
const p = normalizeProject({
|
|
name: 'web', path: '/p', isGit: true, branch: 'main', dirty: true, lastActiveMs: 99, sessions: [sess],
|
|
})
|
|
expect(p?.branch).toBe('main')
|
|
expect(p?.dirty).toBe(true)
|
|
expect(p?.lastActiveMs).toBe(99)
|
|
expect(p?.sessions).toHaveLength(1)
|
|
})
|
|
|
|
it('coerces a missing sessions field to [] (prevents card-render TypeError)', () => {
|
|
const p = normalizeProject({ name: 'web', path: '/p', isGit: true, sessions: undefined })
|
|
expect(p?.sessions).toEqual([])
|
|
})
|
|
|
|
it('returns null for a non-object', () => {
|
|
expect(normalizeProject(null)).toBeNull()
|
|
expect(normalizeProject('x')).toBeNull()
|
|
expect(normalizeProject(42)).toBeNull()
|
|
})
|
|
|
|
it('returns null when name or path is missing/non-string', () => {
|
|
expect(normalizeProject({ path: '/p' })).toBeNull()
|
|
expect(normalizeProject({ name: 'web' })).toBeNull()
|
|
expect(normalizeProject({ name: 1, path: '/p' })).toBeNull()
|
|
})
|
|
|
|
it('drops optional fields of the wrong type', () => {
|
|
const p = normalizeProject({ name: 'web', path: '/p', branch: 5, dirty: 'yes', lastActiveMs: 'x' })
|
|
expect(p?.branch).toBeUndefined()
|
|
expect(p?.dirty).toBeUndefined()
|
|
expect(p?.lastActiveMs).toBeUndefined()
|
|
expect(p?.isGit).toBe(false)
|
|
})
|
|
|
|
it('passes through numeric sync fields (W3 a)', () => {
|
|
const p = normalizeProject({ name: 'web', path: '/p', ahead: 2, behind: 1, lastCommitMs: 123456 })
|
|
expect(p?.ahead).toBe(2)
|
|
expect(p?.behind).toBe(1)
|
|
expect(p?.lastCommitMs).toBe(123456)
|
|
})
|
|
|
|
it('drops non-numeric sync fields (W3 a)', () => {
|
|
const p = normalizeProject({ name: 'web', path: '/p', ahead: '2', behind: null, lastCommitMs: 'x' })
|
|
expect(p?.ahead).toBeUndefined()
|
|
expect(p?.behind).toBeUndefined()
|
|
expect(p?.lastCommitMs).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
/* ── makeSyncChip / sync chip on the card (W3 a) ─────────────────────────────── */
|
|
|
|
describe('makeSyncChip', () => {
|
|
it('renders ↑ahead ↓behind when there is drift', () => {
|
|
const chip = makeSyncChip(makeProject({ ahead: 2, behind: 1 }))
|
|
expect(chip).not.toBeNull()
|
|
expect(chip?.className).toContain('proj-sync')
|
|
expect(chip?.textContent).toBe('↑2 ↓1')
|
|
})
|
|
|
|
it('shows only the ahead arrow when behind is 0', () => {
|
|
const chip = makeSyncChip(makeProject({ ahead: 3, behind: 0 }))
|
|
expect(chip?.textContent).toBe('↑3')
|
|
})
|
|
|
|
it('returns null when in sync (ahead=behind=0)', () => {
|
|
expect(makeSyncChip(makeProject({ ahead: 0, behind: 0 }))).toBeNull()
|
|
})
|
|
|
|
it('returns null when ahead/behind are undefined', () => {
|
|
expect(makeSyncChip(makeProject())).toBeNull()
|
|
})
|
|
|
|
it('includes the last-commit time in the tooltip when present', () => {
|
|
const chip = makeSyncChip(makeProject({ ahead: 1, lastCommitMs: Date.now() - 3600_000 }))
|
|
expect(chip?.title).toContain('last commit')
|
|
})
|
|
})
|
|
|
|
describe('makeProjectCard — sync chip', () => {
|
|
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
|
|
|
|
it('renders the sync chip when the project has drift', () => {
|
|
const card = makeProjectCard(makeProject({ ahead: 2, behind: 1 }), new Set(), noopHooks(), () => {})
|
|
expect(card.querySelector('.proj-sync')?.textContent).toBe('↑2 ↓1')
|
|
})
|
|
|
|
it('omits the sync chip when in sync / undefined', () => {
|
|
const inSync = makeProjectCard(makeProject({ ahead: 0, behind: 0 }), new Set(), noopHooks(), () => {})
|
|
expect(inSync.querySelector('.proj-sync')).toBeNull()
|
|
const noData = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
|
|
expect(noData.querySelector('.proj-sync')).toBeNull()
|
|
})
|
|
})
|
|
|
|
/* ── makeProjectCard launcher row (Claude · Codex · VS Code) ────────────────── */
|
|
|
|
describe('makeProjectCard — launcher row', () => {
|
|
const noopHooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
|
|
|
|
it('renders exactly three brand launcher buttons', () => {
|
|
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
|
|
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
|
|
expect(card.querySelector('.proj-launch-claude')).not.toBeNull()
|
|
expect(card.querySelector('.proj-launch-codex')).not.toBeNull()
|
|
expect(card.querySelector('.proj-launch-vscode')).not.toBeNull()
|
|
// The old single-button action is gone.
|
|
expect(card.querySelector('.proj-new')).toBeNull()
|
|
})
|
|
|
|
it('each launcher embeds an inline SVG logo + caption', () => {
|
|
const card = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
|
|
const claude = card.querySelector('.proj-launch-claude')!
|
|
expect(claude.querySelector('svg')).not.toBeNull()
|
|
expect(claude.querySelector('.proj-launch-cap')?.textContent).toBe('Claude')
|
|
})
|
|
|
|
it('Claude launcher opens the project with the claude command (with CR)', () => {
|
|
const hooks = noopHooks()
|
|
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
|
|
;(card.querySelector('.proj-launch-claude') as HTMLButtonElement).click()
|
|
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'claude\r')
|
|
})
|
|
|
|
it('Codex launcher opens the project with the codex command (with CR)', () => {
|
|
const hooks = noopHooks()
|
|
const card = makeProjectCard(makeProject({ name: 'repo', path: '/p/repo' }), new Set(), hooks, () => {})
|
|
;(card.querySelector('.proj-launch-codex') as HTMLButtonElement).click()
|
|
expect(hooks.onOpenProject).toHaveBeenCalledWith('/p/repo', 'repo', 'codex\r')
|
|
})
|
|
|
|
it('still lists running sessions above the launcher row', () => {
|
|
const p = makeProject({
|
|
sessions: [{ id: 's1', status: 'idle', clientCount: 1, createdAt: 1, exited: false }],
|
|
})
|
|
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
|
expect(card.querySelector('.proj-sessions-list')).not.toBeNull()
|
|
expect(card.querySelectorAll('.proj-launch')).toHaveLength(3)
|
|
})
|
|
|
|
it('shows a Kill (✕) on each session row only when onKillSession is given', () => {
|
|
const p = makeProject({
|
|
sessions: [{ id: 's1', status: 'idle', clientCount: 1, createdAt: 1, exited: false }],
|
|
})
|
|
// Without the callback: no kill button (enter-only).
|
|
const plain = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
|
expect(plain.querySelector('.proj-session-kill')).toBeNull()
|
|
|
|
// With the callback: clicking ✕ kills that session and does not enter it.
|
|
const hooks = noopHooks()
|
|
const onKill = vi.fn()
|
|
const card = makeProjectCard(p, new Set(), hooks, () => {}, undefined, onKill)
|
|
const kill = card.querySelector('.proj-session-kill') as HTMLButtonElement
|
|
expect(kill).not.toBeNull()
|
|
kill.click()
|
|
expect(onKill).toHaveBeenCalledWith('s1')
|
|
expect(hooks.onEnterSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('highlights the Claude launcher when a Claude session (known status) is live', () => {
|
|
const p = makeProject({
|
|
sessions: [{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false }],
|
|
})
|
|
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
|
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(true)
|
|
// Codex / VS Code are not highlighted by a Claude session.
|
|
expect(card.querySelector('.proj-launch-codex')!.classList.contains('proj-launch-active')).toBe(false)
|
|
})
|
|
|
|
it('does NOT highlight Claude for an unknown-status session (plain shell / codex)', () => {
|
|
const p = makeProject({
|
|
sessions: [{ id: 's1', status: 'unknown', clientCount: 0, createdAt: 1, exited: false }],
|
|
})
|
|
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
|
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
|
|
})
|
|
|
|
it('does NOT highlight Claude for an exited session', () => {
|
|
const p = makeProject({
|
|
sessions: [{ id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: true }],
|
|
})
|
|
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
|
expect(card.querySelector('.proj-launch-claude')!.classList.contains('proj-launch-active')).toBe(false)
|
|
})
|
|
|
|
it('shows a count badge when more than one Claude session is live', () => {
|
|
const p = makeProject({
|
|
sessions: [
|
|
{ id: 's1', status: 'working', clientCount: 0, createdAt: 1, exited: false },
|
|
{ id: 's2', status: 'idle', clientCount: 0, createdAt: 2, exited: false },
|
|
],
|
|
})
|
|
const card = makeProjectCard(p, new Set(), noopHooks(), () => {})
|
|
expect(card.querySelector('.proj-launch-claude .proj-launch-badge')?.textContent).toBe('2')
|
|
})
|
|
|
|
it('makes the name a detail link only when onOpenDetail is given', () => {
|
|
const onOpenDetail = vi.fn()
|
|
const card = makeProjectCard(makeProject({ path: '/p/r' }), new Set(), noopHooks(), () => {}, onOpenDetail)
|
|
const name = card.querySelector('.proj-name') as HTMLElement
|
|
expect(name.classList.contains('proj-name-link')).toBe(true)
|
|
name.click()
|
|
expect(onOpenDetail).toHaveBeenCalledWith('/p/r')
|
|
|
|
const plain = makeProjectCard(makeProject(), new Set(), noopHooks(), () => {})
|
|
expect(plain.querySelector('.proj-name')!.classList.contains('proj-name-link')).toBe(false)
|
|
})
|
|
})
|
|
|
|
/* ── renderProjectDetail (branch/worktrees + active sessions) ───────────────── */
|
|
|
|
describe('renderProjectDetail', () => {
|
|
const hooks = () => ({ onOpenProject: vi.fn(), onEnterSession: vi.fn() })
|
|
const cbs = () => ({ onBack: vi.fn(), onKill: vi.fn() })
|
|
|
|
function detail(over: Partial<import('../src/types.js').ProjectDetail> = {}) {
|
|
return {
|
|
name: 'repo',
|
|
path: '/p/repo',
|
|
isGit: true,
|
|
branch: 'main',
|
|
worktrees: [],
|
|
sessions: [],
|
|
...over,
|
|
}
|
|
}
|
|
|
|
it('renders "Project not found" + working Back for a null detail', () => {
|
|
const cb = cbs()
|
|
const root = renderProjectDetail(null, hooks(), cb)
|
|
expect(root.textContent).toContain('Project not found')
|
|
;(root.querySelector('.proj-back') as HTMLButtonElement).click()
|
|
expect(cb.onBack).toHaveBeenCalled()
|
|
})
|
|
|
|
it('shows the header name, path, and branch', () => {
|
|
const root = renderProjectDetail(detail(), hooks(), cbs())
|
|
expect(root.querySelector('.proj-detail-name')?.textContent).toBe('repo')
|
|
expect(root.querySelector('.proj-detail-path')?.textContent).toBe('/p/repo')
|
|
expect(root.querySelector('.proj-branch')?.textContent).toBe('main')
|
|
})
|
|
|
|
it('lists worktrees with branch, tags, and path', () => {
|
|
const root = renderProjectDetail(
|
|
detail({
|
|
worktrees: [
|
|
{ path: '/p/repo', branch: 'main', head: 'abc12345', isMain: true, isCurrent: true },
|
|
{ path: '/p/wt/feat', branch: 'feature/x', head: 'def67890', isMain: false, isCurrent: false },
|
|
],
|
|
}),
|
|
hooks(),
|
|
cbs(),
|
|
)
|
|
const rows = root.querySelectorAll('.proj-wt-row')
|
|
expect(rows).toHaveLength(2)
|
|
expect(rows[0]?.querySelector('.proj-wt-branch')?.textContent).toBe('main')
|
|
expect(rows[0]?.querySelector('.proj-wt-tag-current')).not.toBeNull()
|
|
expect(rows[1]?.querySelector('.proj-wt-branch')?.textContent).toBe('feature/x')
|
|
})
|
|
|
|
it('renders detached worktrees as "detached @ head"', () => {
|
|
const root = renderProjectDetail(
|
|
detail({ worktrees: [{ path: '/p/repo', head: 'deadbeef', isMain: true, isCurrent: true }] }),
|
|
hooks(),
|
|
cbs(),
|
|
)
|
|
expect(root.querySelector('.proj-wt-branch')?.textContent).toBe('detached @ deadbeef')
|
|
})
|
|
|
|
it('lists active sessions with Open + Kill wired, ignoring exited ones', () => {
|
|
const h = hooks()
|
|
const cb = cbs()
|
|
const root = renderProjectDetail(
|
|
detail({
|
|
sessions: [
|
|
{ id: 's1', title: 'claude', status: 'working', clientCount: 2, createdAt: 1, exited: false },
|
|
{ id: 'gone', status: 'idle', clientCount: 0, createdAt: 1, exited: true },
|
|
],
|
|
}),
|
|
h,
|
|
cb,
|
|
)
|
|
const rows = root.querySelectorAll('.proj-dsession')
|
|
expect(rows).toHaveLength(1)
|
|
;(rows[0]!.querySelector('.proj-dsession-open') as HTMLButtonElement).click()
|
|
expect(h.onEnterSession).toHaveBeenCalledWith('s1')
|
|
;(rows[0]!.querySelector('.proj-dsession-kill') as HTMLButtonElement).click()
|
|
expect(cb.onKill).toHaveBeenCalledWith('s1')
|
|
})
|
|
|
|
it('shows an empty-state when no sessions are running, plus the launcher row', () => {
|
|
const root = renderProjectDetail(detail(), hooks(), cbs())
|
|
expect(root.textContent).toContain('No running sessions')
|
|
expect(root.querySelectorAll('.proj-launch')).toHaveLength(3)
|
|
})
|
|
|
|
it('flags a non-git directory', () => {
|
|
const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs())
|
|
expect(root.textContent).toContain('Not a git repository')
|
|
})
|
|
|
|
it('mounts a PR-status chip host in the header for a git repo (W3)', () => {
|
|
const root = renderProjectDetail(detail(), hooks(), cbs())
|
|
expect(root.querySelector('.proj-pr-host')).not.toBeNull()
|
|
})
|
|
|
|
it('omits the PR-status chip host for a non-git directory (W3)', () => {
|
|
const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs())
|
|
expect(root.querySelector('.proj-pr-host')).toBeNull()
|
|
})
|
|
|
|
it('shows CLAUDE.md content + an Update button when present', () => {
|
|
const root = renderProjectDetail(detail({ hasClaudeMd: true, claudeMd: '# Rules\nbe nice' }), hooks(), cbs())
|
|
expect(root.querySelector('.proj-claudemd')?.textContent).toContain('be nice')
|
|
expect(root.querySelector('.proj-claudemd-btn')?.textContent).toContain('Update')
|
|
})
|
|
|
|
it('shows a Generate button + empty state when CLAUDE.md is absent', () => {
|
|
const root = renderProjectDetail(detail({ hasClaudeMd: false }), hooks(), cbs())
|
|
expect(root.querySelector('.proj-claudemd')).toBeNull()
|
|
expect(root.textContent).toContain('No CLAUDE.md yet')
|
|
expect(root.querySelector('.proj-claudemd-btn')?.textContent).toContain('Generate')
|
|
})
|
|
|
|
it('the CLAUDE.md button opens a Claude /init session in the repo', () => {
|
|
const h = hooks()
|
|
const root = renderProjectDetail(
|
|
detail({ name: 'r', path: '/p/r', hasClaudeMd: true, claudeMd: 'x' }),
|
|
h,
|
|
cbs(),
|
|
)
|
|
;(root.querySelector('.proj-claudemd-btn') as HTMLButtonElement).click()
|
|
expect(h.onOpenProject).toHaveBeenCalledWith('/p/r', 'r', 'claude "/init"\r')
|
|
})
|
|
})
|
|
|
|
/* ── groupProjects / displayLabel (namespace grouping, v0.6) ─────────────────── */
|
|
|
|
const runningSession = { id: 's', status: 'working' as const, clientCount: 1, createdAt: 1, exited: false }
|
|
|
|
function proj(name: string, over: Partial<ProjectInfo> = {}): ProjectInfo {
|
|
return makeProject({ name, path: `/repos/${name}`, ...over })
|
|
}
|
|
|
|
describe('groupProjects', () => {
|
|
const noFavs = new Set<string>()
|
|
|
|
it('groups repos sharing a two-segment namespace (≥2 members)', () => {
|
|
const groups = groupProjects(
|
|
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document')],
|
|
noFavs,
|
|
)
|
|
expect(groups).toHaveLength(1)
|
|
expect(groups[0].kind).toBe('namespace')
|
|
expect(groups[0].label).toBe('Billo.Platform')
|
|
expect(groups[0].projects.map((p) => p.name)).toEqual([
|
|
'Billo.Platform.Payment',
|
|
'Billo.Platform.Document',
|
|
])
|
|
})
|
|
|
|
it('drops single-member namespaces and no-dot repos into "Other"', () => {
|
|
const groups = groupProjects(
|
|
[
|
|
proj('Billo.Platform.Payment'),
|
|
proj('Billo.Platform.Document'),
|
|
proj('Billo.Customer'), // lone "Billo.Customer" namespace
|
|
proj('web-terminal'), // no dot
|
|
],
|
|
noFavs,
|
|
)
|
|
const other = groups.find((g) => g.kind === 'other')
|
|
expect(other).toBeDefined()
|
|
expect(other!.projects.map((p) => p.name).sort()).toEqual(['Billo.Customer', 'web-terminal'])
|
|
})
|
|
|
|
it('falls back to a single flat group when no namespace earns a section', () => {
|
|
const groups = groupProjects([proj('web-terminal'), proj('graphrag'), proj('piano')], noFavs)
|
|
expect(groups).toHaveLength(1)
|
|
expect(groups[0].kind).toBe('flat')
|
|
expect(groups[0].projects).toHaveLength(3)
|
|
})
|
|
|
|
it('pins an "Active now" section first with projects that have a running session', () => {
|
|
const groups = groupProjects(
|
|
[
|
|
proj('Billo.Platform.Payment', { sessions: [runningSession] }),
|
|
proj('Billo.Platform.Document'),
|
|
],
|
|
noFavs,
|
|
)
|
|
expect(groups[0].kind).toBe('active')
|
|
expect(groups[0].label).toBe('Active now')
|
|
expect(groups[0].projects.map((p) => p.name)).toEqual(['Billo.Platform.Payment'])
|
|
// still also present inside its namespace group (deliberate duplication)
|
|
const ns = groups.find((g) => g.kind === 'namespace')!
|
|
expect(ns.projects.map((p) => p.name)).toContain('Billo.Platform.Payment')
|
|
expect(ns.activeCount).toBe(1)
|
|
})
|
|
|
|
it('orders namespace sections by their most-recently-active member', () => {
|
|
const groups = groupProjects(
|
|
[
|
|
proj('Old.Alpha.a', { lastActiveMs: 10 }),
|
|
proj('Old.Alpha.b', { lastActiveMs: 20 }),
|
|
proj('New.Beta.a', { lastActiveMs: 900 }),
|
|
proj('New.Beta.b', { lastActiveMs: 100 }),
|
|
],
|
|
noFavs,
|
|
)
|
|
const ns = groups.filter((g) => g.kind === 'namespace').map((g) => g.label)
|
|
expect(ns).toEqual(['New.Beta', 'Old.Alpha'])
|
|
})
|
|
|
|
it('keeps "Other" last', () => {
|
|
const groups = groupProjects(
|
|
[proj('Billo.Platform.Payment'), proj('Billo.Platform.Document'), proj('loose')],
|
|
noFavs,
|
|
)
|
|
expect(groups[groups.length - 1].kind).toBe('other')
|
|
})
|
|
|
|
it('merges namespaces case-insensitively', () => {
|
|
const groups = groupProjects([proj('Billo.Platform.a'), proj('billo.platform.b')], noFavs)
|
|
expect(groups).toHaveLength(1)
|
|
expect(groups[0].projects).toHaveLength(2)
|
|
})
|
|
|
|
it('does not mutate the input array', () => {
|
|
const input = [proj('A.b.c'), proj('A.b.d')]
|
|
const copy = [...input]
|
|
groupProjects(input, noFavs)
|
|
expect(input).toEqual(copy)
|
|
})
|
|
})
|
|
|
|
describe('displayLabel', () => {
|
|
it('strips the namespace prefix inside a namespace group', () => {
|
|
expect(displayLabel('Billo.Platform.Payment', 'Billo.Platform')).toBe('Payment')
|
|
expect(displayLabel('Billo.Platform.Shared.DomainEvents', 'Billo.Platform')).toBe(
|
|
'Shared.DomainEvents',
|
|
)
|
|
})
|
|
|
|
it('shows the full name in the active and other sections', () => {
|
|
expect(displayLabel('Billo.Platform.Payment', ACTIVE_GROUP_KEY)).toBe('Billo.Platform.Payment')
|
|
expect(displayLabel('web-terminal', OTHER_GROUP_KEY)).toBe('web-terminal')
|
|
})
|
|
|
|
it('is case-insensitive on the prefix but preserves the tail casing', () => {
|
|
expect(displayLabel('Billo.Platform.Payment', 'billo.platform')).toBe('Payment')
|
|
})
|
|
})
|
|
|
|
/* ── w6/G3 makeSyncBand — the header's ambient git state ────────────────────── */
|
|
// The band's contract is mostly about what it must REFUSE to say: a stale or
|
|
// unknowable comparison must never be dressed up as "in sync".
|
|
|
|
describe('makeSyncBand (w6 G3)', () => {
|
|
const NOW = 1_700_000_000_000
|
|
const FRESH = NOW - 60_000 // a minute ago
|
|
const STALE = NOW - 26 * 60 * 60 * 1000 // 26 hours ago
|
|
|
|
function band(
|
|
sync: import('../src/types.js').SyncState | undefined,
|
|
opts: { dirtyCount?: number } = {},
|
|
): HTMLElement | null {
|
|
return makeSyncBand(sync, { nowMs: NOW, ...opts })
|
|
}
|
|
|
|
it('renders nothing for a non-git project', () => {
|
|
expect(band(undefined)).toBeNull()
|
|
})
|
|
|
|
it('shows the single green in-sync state only when even with a fresh fetch', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH })!
|
|
expect(b.querySelector('.proj-sync-ok')).not.toBeNull()
|
|
expect(b.querySelector('.proj-sync-stale')).toBeNull()
|
|
})
|
|
|
|
it('refuses to call a stale comparison in sync', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: STALE })!
|
|
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
|
|
})
|
|
|
|
it('treats a never-fetched repo as stale, not as in sync', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0 })!
|
|
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-stale')).not.toBeNull()
|
|
})
|
|
|
|
it('never flags ahead as stale — it does not depend on a fetch', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 9, behind: 0, lastFetchMs: STALE })!
|
|
const ahead = b.querySelector('.proj-sync-ahead')!
|
|
expect(ahead.textContent).toContain('9')
|
|
expect(ahead.classList.contains('proj-sync-stale')).toBe(false)
|
|
expect(b.querySelector('.proj-sync-behind')!.classList.contains('proj-sync-stale')).toBe(true)
|
|
})
|
|
|
|
it('says "no upstream" instead of inventing counts, and is not green', () => {
|
|
const b = band({ ahead: undefined, behind: undefined, lastFetchMs: FRESH })!
|
|
expect(b.querySelector('.proj-sync-noupstream')).not.toBeNull()
|
|
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-behind')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
|
})
|
|
|
|
it('flags a detached HEAD, drops the counts and disables fetching', () => {
|
|
const b = band({ detached: true })!
|
|
expect(b.querySelector('.proj-sync-detached')).not.toBeNull()
|
|
expect(b.querySelector('.proj-sync-ahead')).toBeNull()
|
|
expect(b.querySelector('.proj-sync-ok')).toBeNull()
|
|
const btn = b.querySelector('.proj-sync-fetch') as HTMLButtonElement
|
|
expect(btn.disabled).toBe(true)
|
|
})
|
|
|
|
it('names the upstream so the arrows are not floating numbers', () => {
|
|
const b = band({ upstream: 'origin/develop', ahead: 9, behind: 0, lastFetchMs: FRESH })!
|
|
expect(b.textContent).toContain('origin/develop')
|
|
})
|
|
|
|
it('counts dirty files instead of showing a bare dot', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
|
|
dirtyCount: 3,
|
|
})!
|
|
expect(b.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
|
|
})
|
|
|
|
it('omits the dirty chip entirely for a clean tree', () => {
|
|
const b = band({ upstream: 'origin/main', ahead: 0, behind: 0, lastFetchMs: FRESH }, {
|
|
dirtyCount: 0,
|
|
})!
|
|
expect(b.querySelector('.proj-sync-dirty')).toBeNull()
|
|
})
|
|
})
|
|
|
|
/* ── w6/G5 worktree panel ───────────────────────────────────────────────────── */
|
|
|
|
describe('makeWorktreeRow — per-worktree state (w6 G5)', () => {
|
|
const wt = (over: Partial<import('../src/types.js').WorktreeInfo> = {}) => ({
|
|
path: '/p/repo',
|
|
isMain: true,
|
|
isCurrent: true,
|
|
branch: 'develop',
|
|
...over,
|
|
})
|
|
|
|
it('shows nothing extra for a worktree whose state was not probed', () => {
|
|
const row = makeWorktreeRow(wt({ isCurrent: false, isMain: false }))
|
|
expect(row.querySelector('.proj-sync-chip')).toBeNull()
|
|
})
|
|
|
|
it('says "no upstream" for a fresh worktree branch instead of looking settled', () => {
|
|
const row = makeWorktreeRow(wt(), undefined, { sync: { ahead: undefined } })
|
|
expect(row.querySelector('.proj-sync-noupstream')).not.toBeNull()
|
|
expect(row.querySelector('.proj-sync-ahead')).toBeNull()
|
|
})
|
|
|
|
it('shows the unpushed count for a tracking worktree', () => {
|
|
const row = makeWorktreeRow(wt(), undefined, {
|
|
sync: { upstream: 'origin/develop', ahead: 9, behind: 0 },
|
|
dirtyCount: 3,
|
|
})
|
|
expect(row.querySelector('.proj-sync-ahead')!.textContent).toContain('9')
|
|
expect(row.querySelector('.proj-sync-dirty')!.textContent).toContain('3')
|
|
})
|
|
|
|
it('omits the dirty chip on a clean worktree', () => {
|
|
const row = makeWorktreeRow(wt(), undefined, {
|
|
sync: { upstream: 'origin/develop', ahead: 0, behind: 0 },
|
|
dirtyCount: 0,
|
|
})
|
|
expect(row.querySelector('.proj-sync-dirty')).toBeNull()
|
|
})
|
|
})
|
|
|
|
/* ── w6/G7 session attribution across worktrees ─────────────────────────────── */
|
|
// `.claude/worktrees/<name>` lives INSIDE the main checkout, so this is exactly
|
|
// the case naive prefix matching double-counts.
|
|
|
|
describe('countSessionsByWorktree (w6 G7)', () => {
|
|
const MAIN = '/Users/x/repo'
|
|
const WT = '/Users/x/repo/.claude/worktrees/feature'
|
|
|
|
const wts = [
|
|
{ path: MAIN, isMain: true, isCurrent: true, branch: 'develop' },
|
|
{ path: WT, isMain: false, isCurrent: false, branch: 'worktree-feature' },
|
|
]
|
|
|
|
function sess(id: string, cwd: string | undefined, exited = false) {
|
|
return { id, status: 'idle' as const, clientCount: 1, createdAt: 0, exited, ...(cwd !== undefined ? { cwd } : {}) }
|
|
}
|
|
|
|
it('attributes a nested worktree session to the worktree, not to the parent', () => {
|
|
const counts = countSessionsByWorktree([sess('a', `${WT}/src`)], wts)
|
|
expect(counts.get(WT)).toBe(1)
|
|
expect(counts.get(MAIN)).toBe(0)
|
|
})
|
|
|
|
it('counts a session in the main checkout against the main worktree', () => {
|
|
const counts = countSessionsByWorktree([sess('a', `${MAIN}/src`)], wts)
|
|
expect(counts.get(MAIN)).toBe(1)
|
|
expect(counts.get(WT)).toBe(0)
|
|
})
|
|
|
|
it('ignores exited sessions and sessions with no cwd', () => {
|
|
const counts = countSessionsByWorktree(
|
|
[sess('a', `${MAIN}/src`, true), sess('b', undefined)],
|
|
wts,
|
|
)
|
|
expect(counts.get(MAIN)).toBe(0)
|
|
})
|
|
|
|
it('does not treat a sibling with a shared prefix as inside', () => {
|
|
const counts = countSessionsByWorktree([sess('a', '/Users/x/repo-old/src')], wts)
|
|
expect(counts.get(MAIN)).toBe(0)
|
|
})
|
|
|
|
it('gives every worktree an entry so a row can render 0 without a lookup miss', () => {
|
|
const counts = countSessionsByWorktree([], wts)
|
|
expect(counts.get(MAIN)).toBe(0)
|
|
expect(counts.get(WT)).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('makeWorktreeRow — session chip (w6 G7)', () => {
|
|
const wt = { path: '/p/repo', isMain: true, isCurrent: true, branch: 'develop' }
|
|
|
|
it('shows the running-session count', () => {
|
|
const row = makeWorktreeRow(wt, undefined, { sessionCount: 2 })
|
|
expect(row.querySelector('.proj-wt-sessions')!.textContent).toContain('2')
|
|
})
|
|
|
|
it('omits the chip when nothing runs there', () => {
|
|
const row = makeWorktreeRow(wt, undefined, { sessionCount: 0 })
|
|
expect(row.querySelector('.proj-wt-sessions')).toBeNull()
|
|
})
|
|
})
|