feat(v0.6): per-project detail view — branch/worktrees + active sessions
Clicking a project's name opens an in-app detail view (back button returns to the grid) with: - header: name, path, branch, dirty - Branch / Worktrees: each `git worktree list` entry (branch or detached@head, main/current/locked/prunable tags, path) — a single-worktree repo shows its branch; a multi-worktree repo shows them all - Active sessions (N): detailed rows (status, devices watching, started-ago) with Open + Kill - the Claude/Codex/VS Code launcher row Backend: src/http/worktrees.ts (parseWorktrees pure + listWorktrees via execFile, no shell, best-effort); buildProjectDetail(cfg, path, liveSessions) in projects.ts (validates absolute existing dir, reuses branch/dirty/session helpers, uncached — detail is on-demand and wants fresh state); read-only GET /projects/detail?path= (no Origin guard, like /projects; 400 missing / 404 invalid). Types: WorktreeInfo, ProjectDetail. Frontend: detail view + navigation in projects.ts (grid<->detail), project name becomes a link, Kill via DELETE /live-sessions/:id. Tests +21 (452 total): worktree parser, buildProjectDetail (validation, non-git, session merge, real git-init worktree), renderProjectDetail. worktrees.ts 100% / projects.ts 93.8% cov. Verified in-browser: opened a Claude session, then the detail showed its branch+worktree and the live session with Open/Kill.
This commit is contained in:
@@ -19,8 +19,15 @@ import path from 'node:path'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { Dirent } from 'node:fs'
|
||||
import type { Config, LiveSessionInfo, ProjectInfo, ProjectSessionRef } from '../types.js'
|
||||
import type {
|
||||
Config,
|
||||
LiveSessionInfo,
|
||||
ProjectInfo,
|
||||
ProjectSessionRef,
|
||||
ProjectDetail,
|
||||
} from '../types.js'
|
||||
import { listSessions } from './history.js'
|
||||
import { listWorktrees } from './worktrees.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -353,3 +360,42 @@ export async function buildProjects(
|
||||
}))
|
||||
return sortProjects(withSessions).slice(0, MAX_PROJECTS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail for one project: branch + dirty + worktrees + its running sessions.
|
||||
* `projectPath` must be an absolute, existing directory (else null — 404). Not
|
||||
* cached: the detail view is opened on demand and wants fresh worktree/session
|
||||
* state. Reads only (git branch/status/worktree-list); never throws.
|
||||
*/
|
||||
export async function buildProjectDetail(
|
||||
cfg: Config,
|
||||
projectPath: string,
|
||||
liveSessions: readonly LiveSessionInfo[],
|
||||
): Promise<ProjectDetail | null> {
|
||||
if (typeof projectPath !== 'string' || !path.isAbsolute(projectPath)) return null
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.stat(projectPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!stat.isDirectory()) return null
|
||||
|
||||
const isGit = await hasGitEntry(projectPath)
|
||||
const [branch, dirty, worktrees] = await Promise.all([
|
||||
isGit ? readBranch(projectPath) : Promise.resolve(undefined),
|
||||
isGit && cfg.projectDirtyCheck ? readDirty(projectPath) : Promise.resolve(undefined),
|
||||
isGit ? listWorktrees(projectPath) : Promise.resolve([]),
|
||||
])
|
||||
|
||||
return {
|
||||
name: path.basename(projectPath),
|
||||
path: projectPath,
|
||||
isGit,
|
||||
branch,
|
||||
dirty,
|
||||
worktrees,
|
||||
sessions: matchSessions(projectPath, liveSessions),
|
||||
}
|
||||
}
|
||||
|
||||
73
src/http/worktrees.ts
Normal file
73
src/http/worktrees.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* src/http/worktrees.ts (v0.6 project detail) — list a repo's git worktrees.
|
||||
*
|
||||
* `git worktree list --porcelain` emits blank-line-separated records; the first
|
||||
* record is the main worktree. Best-effort: a non-repo / git error yields [].
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import type { WorktreeInfo } from '../types.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
const WORKTREE_TIMEOUT_MS = 2000
|
||||
const WORKTREE_MAX_BUFFER = 1024 * 1024
|
||||
|
||||
/**
|
||||
* Parse `git worktree list --porcelain`. `currentPath` flags which worktree is
|
||||
* the requested project (isCurrent). The first record is the main worktree.
|
||||
* Pure + unit-tested.
|
||||
*/
|
||||
export function parseWorktrees(porcelain: string, currentPath: string): WorktreeInfo[] {
|
||||
const out: WorktreeInfo[] = []
|
||||
let cur: { path?: string; branch?: string; head?: string; locked?: boolean; prunable?: boolean } = {}
|
||||
|
||||
const flush = (): void => {
|
||||
if (cur.path === undefined) return
|
||||
out.push({
|
||||
path: cur.path,
|
||||
branch: cur.branch,
|
||||
head: cur.head,
|
||||
isMain: out.length === 0, // git lists the main worktree first
|
||||
isCurrent: cur.path === currentPath,
|
||||
locked: cur.locked,
|
||||
prunable: cur.prunable,
|
||||
})
|
||||
cur = {}
|
||||
}
|
||||
|
||||
for (const raw of porcelain.split('\n')) {
|
||||
const line = raw.trimEnd()
|
||||
if (line === '') {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
const sep = line.indexOf(' ')
|
||||
const key = sep === -1 ? line : line.slice(0, sep)
|
||||
const val = sep === -1 ? '' : line.slice(sep + 1)
|
||||
if (key === 'worktree') cur.path = val
|
||||
else if (key === 'HEAD') cur.head = val.slice(0, 8)
|
||||
else if (key === 'branch') cur.branch = val.replace(/^refs\/heads\//, '')
|
||||
else if (key === 'detached') cur.branch = undefined
|
||||
else if (key === 'locked') cur.locked = true
|
||||
else if (key === 'prunable') cur.prunable = true
|
||||
}
|
||||
flush() // final record has no trailing blank line
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** List worktrees for a repo (execFile, no shell); [] on any error/non-repo. */
|
||||
export async function listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain'], {
|
||||
cwd: repoPath,
|
||||
timeout: WORKTREE_TIMEOUT_MS,
|
||||
maxBuffer: WORKTREE_MAX_BUFFER,
|
||||
})
|
||||
return parseWorktrees(stdout, repoPath)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ import { parseClientMessage, serialize } from './protocol.js'
|
||||
import { isOriginAllowed } from './http/origin.js'
|
||||
import { parseHookEvent } from './http/hook.js'
|
||||
import { listSessions } from './http/history.js'
|
||||
import { buildProjects } from './http/projects.js'
|
||||
import { buildProjects, buildProjectDetail } from './http/projects.js'
|
||||
import { openInEditor } from './http/editor.js'
|
||||
import { createSessionManager } from './session/manager.js'
|
||||
import { detachWs, writeInput, setClientDims } from './session/session.js'
|
||||
@@ -170,6 +170,28 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
||||
}
|
||||
})
|
||||
|
||||
// Project detail (v0.6) — branch/worktrees + running sessions for one repo.
|
||||
// Read-only (git branch/status/worktree-list); same threat model as /projects.
|
||||
// ?path must be an absolute existing directory (validated in buildProjectDetail).
|
||||
app.get('/projects/detail', async (req, res) => {
|
||||
const target = req.query['path']
|
||||
if (typeof target !== 'string' || target === '') {
|
||||
res.status(400).json({ error: 'path query parameter is required' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const detail = await buildProjectDetail(cfg, target, manager.list())
|
||||
if (detail === null) {
|
||||
res.status(404).json({ error: 'project not found' })
|
||||
return
|
||||
}
|
||||
res.json(detail)
|
||||
} catch (err) {
|
||||
console.error('[server] /projects/detail failed:', err instanceof Error ? err.message : String(err))
|
||||
res.status(500).json({ error: 'failed to read project detail' })
|
||||
}
|
||||
})
|
||||
|
||||
// Preview (v0.4 manage page) — the tail of a session's scrollback so the grid
|
||||
// can render a live read-only thumbnail of its current screen. No client/attach.
|
||||
app.get('/live-sessions/:id/preview', (req, res) => {
|
||||
|
||||
23
src/types.ts
23
src/types.ts
@@ -223,6 +223,29 @@ export interface ProjectInfo {
|
||||
sessions: ProjectSessionRef[]; // running sessions in this project (1:N; may be empty)
|
||||
}
|
||||
|
||||
/** One entry from `git worktree list --porcelain` (project detail view). */
|
||||
export interface WorktreeInfo {
|
||||
path: string; // worktree absolute path
|
||||
branch?: string; // branch name (refs/heads/x → x); undefined when detached
|
||||
head?: string; // short HEAD sha
|
||||
isMain: boolean; // the repo's primary worktree (listed first)
|
||||
isCurrent: boolean; // this worktree is the requested project path
|
||||
locked?: boolean; // `git worktree lock`ed
|
||||
prunable?: boolean; // git considers it prunable (gone working tree)
|
||||
}
|
||||
|
||||
/** Detailed view of one project: branch/worktrees + its running sessions.
|
||||
* impl: src/http/projects.ts buildProjectDetail(cfg, path, liveSessions). */
|
||||
export interface ProjectDetail {
|
||||
name: string;
|
||||
path: string;
|
||||
isGit: boolean;
|
||||
branch?: string;
|
||||
dirty?: boolean;
|
||||
worktrees: WorktreeInfo[]; // empty for a non-git dir
|
||||
sessions: ProjectSessionRef[]; // running sessions under this path (fresh)
|
||||
}
|
||||
|
||||
export interface SessionManager {
|
||||
handleAttach(
|
||||
ws: WebSocketLike,
|
||||
|
||||
Reference in New Issue
Block a user