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:
@@ -13,8 +13,14 @@
|
||||
* stopped + cleaned up on hide.
|
||||
*/
|
||||
|
||||
import type { ProjectInfo, ProjectSessionRef, ClaudeStatus } from '../src/types.js'
|
||||
import { el, relTime } from './preview-grid.js'
|
||||
import type {
|
||||
ProjectInfo,
|
||||
ProjectSessionRef,
|
||||
ClaudeStatus,
|
||||
ProjectDetail,
|
||||
WorktreeInfo,
|
||||
} from '../src/types.js'
|
||||
import { el, relTime, statusText } from './preview-grid.js'
|
||||
import { CLAUDE_LOGO, CODEX_LOGO, VSCODE_LOGO } from './icons.js'
|
||||
|
||||
/* ── Constants ───────────────────────────────────────────────────────────── */
|
||||
@@ -137,6 +143,26 @@ async function fetchProjects(): Promise<ProjectInfo[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch one project's detail (branch/worktrees + sessions). null on any error. */
|
||||
async function fetchProjectDetail(projectPath: string): Promise<ProjectDetail | null> {
|
||||
try {
|
||||
const res = await fetch(`/projects/detail?path=${encodeURIComponent(projectPath)}`)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as ProjectDetail
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill a running session (manage-page DELETE; same-origin → Origin guard passes). */
|
||||
async function killSession(id: string): Promise<void> {
|
||||
try {
|
||||
await fetch(`/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
} catch {
|
||||
// best-effort — the refresh that follows will reflect the real state
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Status helpers ──────────────────────────────────────────────────────── */
|
||||
|
||||
function sessionDotClass(status: ClaudeStatus): string {
|
||||
@@ -256,6 +282,7 @@ export function makeProjectCard(
|
||||
favs: ReadonlySet<string>,
|
||||
hooks: ProjectsHooks,
|
||||
onToggleFav: (path: string) => void,
|
||||
onOpenDetail?: (path: string) => void,
|
||||
): HTMLElement {
|
||||
const card = el('div', 'proj-card')
|
||||
|
||||
@@ -272,6 +299,19 @@ export function makeProjectCard(
|
||||
})
|
||||
|
||||
const name = el('span', 'proj-name', project.name)
|
||||
if (onOpenDetail) {
|
||||
name.classList.add('proj-name-link')
|
||||
name.setAttribute('role', 'button')
|
||||
name.tabIndex = 0
|
||||
name.title = `View ${project.name} details`
|
||||
name.addEventListener('click', () => onOpenDetail(project.path))
|
||||
name.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpenDetail(project.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
header.append(favBtn, name)
|
||||
|
||||
@@ -310,6 +350,112 @@ export function makeProjectCard(
|
||||
return card
|
||||
}
|
||||
|
||||
/* ── Detail view (branch / worktrees + active sessions) ─────────────────────── */
|
||||
|
||||
/** One worktree row: branch (or detached@head) + main/current/locked tags + path. */
|
||||
function makeWorktreeRow(wt: WorktreeInfo): HTMLElement {
|
||||
const row = el('div', 'proj-wt-row')
|
||||
const label = wt.branch ?? (wt.head ? `detached @ ${wt.head}` : 'detached')
|
||||
row.append(el('span', 'proj-wt-branch', label))
|
||||
if (wt.isMain) row.append(el('span', 'proj-wt-tag', 'main'))
|
||||
if (wt.isCurrent) row.append(el('span', 'proj-wt-tag proj-wt-tag-current', 'current'))
|
||||
if (wt.locked) row.append(el('span', 'proj-wt-tag', 'locked'))
|
||||
if (wt.prunable) row.append(el('span', 'proj-wt-tag', 'prunable'))
|
||||
row.append(el('span', 'proj-wt-path', wt.path))
|
||||
return row
|
||||
}
|
||||
|
||||
/** One detailed session row: status + title + metadata + Open / Kill actions. */
|
||||
function makeDetailSessionRow(
|
||||
s: ProjectSessionRef,
|
||||
onEnter: (id: string) => void,
|
||||
onKill: (id: string) => void,
|
||||
): HTMLElement {
|
||||
const row = el('div', 'proj-dsession')
|
||||
const dot = el('span', sessionDotClass(s.status))
|
||||
const main = el('div', 'proj-dsession-main')
|
||||
main.append(el('span', 'proj-dsession-title', s.title ?? 'session'))
|
||||
const parts: string[] = []
|
||||
if (s.status !== 'unknown') parts.push(statusText(s.status))
|
||||
parts.push(`👁 ${s.clientCount}`)
|
||||
parts.push(`started ${relTime(s.createdAt)} ago`)
|
||||
main.append(el('span', 'proj-dsession-meta', parts.join(' · ')))
|
||||
const open = el('button', 'proj-dsession-open', 'Open')
|
||||
open.addEventListener('click', () => onEnter(s.id))
|
||||
const kill = el('button', 'proj-dsession-kill', '✕')
|
||||
kill.title = 'Kill this session'
|
||||
kill.setAttribute('aria-label', 'Kill session')
|
||||
kill.addEventListener('click', () => onKill(s.id))
|
||||
row.append(dot, main, open, kill)
|
||||
return row
|
||||
}
|
||||
|
||||
interface DetailCallbacks {
|
||||
onBack: () => void
|
||||
onKill: (id: string) => void
|
||||
}
|
||||
|
||||
/** Render the project detail view (exported for unit tests). */
|
||||
export function renderProjectDetail(
|
||||
detail: ProjectDetail | null,
|
||||
hooks: ProjectsHooks,
|
||||
cb: DetailCallbacks,
|
||||
): HTMLElement {
|
||||
const root = el('div', 'proj-detail-inner')
|
||||
|
||||
const back = el('button', 'proj-back', '← All projects')
|
||||
back.addEventListener('click', () => cb.onBack())
|
||||
root.append(back)
|
||||
|
||||
if (detail === null) {
|
||||
root.append(el('div', 'proj-empty', 'Project not found.'))
|
||||
return root
|
||||
}
|
||||
|
||||
// Header: name + branch + dirty + path
|
||||
const head = el('div', 'proj-detail-head')
|
||||
head.append(el('h2', 'proj-detail-name', detail.name))
|
||||
if (detail.branch) head.append(el('span', 'proj-branch', detail.branch))
|
||||
if (detail.dirty === true) {
|
||||
const d = el('span', 'proj-dirty', '●')
|
||||
d.title = 'Uncommitted changes'
|
||||
head.append(d)
|
||||
}
|
||||
root.append(head)
|
||||
root.append(el('div', 'proj-detail-path', detail.path))
|
||||
|
||||
// Branch / worktrees
|
||||
root.append(el('div', 'proj-section-title', detail.worktrees.length > 1 ? 'Worktrees' : 'Branch'))
|
||||
if (!detail.isGit) {
|
||||
root.append(el('div', 'proj-empty', 'Not a git repository.'))
|
||||
} else if (detail.worktrees.length === 0) {
|
||||
root.append(
|
||||
el('div', 'proj-empty', detail.branch ? `On branch ${detail.branch}` : 'No worktree info.'),
|
||||
)
|
||||
} else {
|
||||
const list = el('div', 'proj-wt-list')
|
||||
for (const wt of detail.worktrees) list.append(makeWorktreeRow(wt))
|
||||
root.append(list)
|
||||
}
|
||||
|
||||
// Active sessions
|
||||
const running = detail.sessions.filter((s) => !s.exited)
|
||||
root.append(el('div', 'proj-section-title', `Active sessions (${running.length})`))
|
||||
if (running.length === 0) {
|
||||
root.append(el('div', 'proj-empty', 'No running sessions — start one below.'))
|
||||
} else {
|
||||
const list = el('div', 'proj-detail-sessions')
|
||||
for (const s of running) list.append(makeDetailSessionRow(s, hooks.onEnterSession, cb.onKill))
|
||||
root.append(list)
|
||||
}
|
||||
|
||||
// Launchers
|
||||
root.append(el('div', 'proj-section-title', 'Start'))
|
||||
root.append(makeLauncherRow(detail, hooks))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
/* ── Mount ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): ProjectsPanel {
|
||||
@@ -330,9 +476,36 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
|
||||
const grid = el('div', 'proj-grid')
|
||||
root.append(grid)
|
||||
|
||||
const detailEl = el('div', 'proj-detail')
|
||||
detailEl.style.display = 'none'
|
||||
root.append(detailEl)
|
||||
|
||||
let allProjects: ProjectInfo[] = []
|
||||
let favs: Set<string> = getFavs()
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let view: 'grid' | 'detail' = 'grid'
|
||||
let detailPath: string | null = null
|
||||
|
||||
function applyView(): void {
|
||||
const isGrid = view === 'grid'
|
||||
searchWrap.style.display = isGrid ? '' : 'none'
|
||||
grid.style.display = isGrid ? '' : 'none'
|
||||
detailEl.style.display = isGrid ? 'none' : 'block'
|
||||
}
|
||||
|
||||
function openDetail(p: string): void {
|
||||
view = 'detail'
|
||||
detailPath = p
|
||||
applyView()
|
||||
void refresh()
|
||||
}
|
||||
|
||||
function closeDetail(): void {
|
||||
view = 'grid'
|
||||
detailPath = null
|
||||
applyView()
|
||||
void refresh()
|
||||
}
|
||||
|
||||
function renderGrid(): void {
|
||||
const filtered = filterProjects(allProjects, searchInput.value)
|
||||
@@ -351,16 +524,39 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
|
||||
|
||||
for (const p of sorted) {
|
||||
grid.append(
|
||||
makeProjectCard(p, favs, hooks, (path) => {
|
||||
favs = toggleFav(favs, path)
|
||||
saveFavs(favs)
|
||||
renderGrid()
|
||||
}),
|
||||
makeProjectCard(
|
||||
p,
|
||||
favs,
|
||||
hooks,
|
||||
(path) => {
|
||||
favs = toggleFav(favs, path)
|
||||
saveFavs(favs)
|
||||
renderGrid()
|
||||
},
|
||||
openDetail,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetail(detail: ProjectDetail | null): void {
|
||||
detailEl.replaceChildren(
|
||||
renderProjectDetail(detail, hooks, {
|
||||
onBack: closeDetail,
|
||||
onKill: (id) => {
|
||||
void killSession(id).then(() => {
|
||||
if (view === 'detail') void refresh()
|
||||
})
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
if (view === 'detail' && detailPath !== null) {
|
||||
renderDetail(await fetchProjectDetail(detailPath))
|
||||
return
|
||||
}
|
||||
allProjects = await fetchProjects()
|
||||
favs = getFavs()
|
||||
renderGrid()
|
||||
@@ -372,6 +568,10 @@ export function mountProjects(host: HTMLElement, hooks: ProjectsHooks): Projects
|
||||
setVisible(v: boolean): void {
|
||||
root.style.display = v ? 'block' : 'none'
|
||||
if (v) {
|
||||
// Re-entering the chooser always lands on the grid (never a stale detail).
|
||||
view = 'grid'
|
||||
detailPath = null
|
||||
applyView()
|
||||
void refresh()
|
||||
if (timer === null) {
|
||||
timer = setInterval(() => {
|
||||
|
||||
154
public/style.css
154
public/style.css
@@ -1156,6 +1156,160 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Project detail view ─────────────────────────────────────────── */
|
||||
.proj-name-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.proj-name-link:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.proj-detail-inner {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.proj-back {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 8px;
|
||||
padding: 6px 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 16px;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.proj-back:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.proj-detail-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.proj-detail-name {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
.proj-detail-path {
|
||||
margin: 4px 0 8px;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
word-break: break-all;
|
||||
}
|
||||
.proj-section-title {
|
||||
margin: 22px 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
/* Worktrees */
|
||||
.proj-wt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.proj-wt-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.proj-wt-branch {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.proj-wt-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-3);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.proj-wt-tag-current {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
.proj-wt-path {
|
||||
margin-left: auto;
|
||||
font-family: Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-faint);
|
||||
word-break: break-all;
|
||||
}
|
||||
/* Detailed session rows */
|
||||
.proj-detail-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.proj-dsession {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.proj-dsession-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.proj-dsession-title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.proj-dsession-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.proj-dsession-open {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
padding: 6px 14px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.proj-dsession-open:hover {
|
||||
background: var(--accent-2);
|
||||
}
|
||||
.proj-dsession-kill {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
border-radius: 7px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.proj-dsession-kill:hover {
|
||||
background: var(--red, #e5484d);
|
||||
border-color: var(--red, #e5484d);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.proj-empty {
|
||||
color: var(--text-dim);
|
||||
|
||||
Reference in New Issue
Block a user