Skip to content

Commit c4269a2

Browse files
loganjclaude
andcommitted
feat(penpal): add git worktree awareness
Discover git worktrees for each project and expose them throughout the stack — backend discovery, cache, file scanning, comments, MCP tools, and frontend UI. Backend: - Discover worktrees via `git worktree list --porcelain` during project scan - Add worktree-scoped file scanning (ScanProjectSourcesForWorktree) that remaps both tree and files sources to worktree paths - Scope comments/threads to worktrees via optional worktree parameter - Handle worktree `.git` files (not directories) in git info resolution - Add `/api/ready` endpoint so Tauri waits for full server initialization before loading the webview, fixing a startup race condition - File watcher monitors worktree paths for changes Frontend: - Parse `@worktree` suffix in URLs for worktree-scoped navigation - Sidebar shows worktree sub-items with stacked name + branch layout - Project cards show worktree list with stacked presentation - File and project pages pass worktree param to all API calls - Comments panel scopes threads to active worktree Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 66c286b commit c4269a2

28 files changed

Lines changed: 1928 additions & 140 deletions

apps/penpal/frontend/src-tauri/src/lib.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,24 @@ pub fn run() {
5050
// Store the child so we can kill it on quit
5151
*app.state::<Sidecar>().0.lock().unwrap() = Some(child);
5252

53-
// Wait for server to be ready
53+
// Wait for server to be fully ready (projects discovered and files scanned).
54+
// The /api/ready endpoint blocks until initialization is complete.
5455
let addr = format!("127.0.0.1:{}", port);
55-
for _ in 0..50 {
56-
if std::net::TcpStream::connect(&addr).is_ok() {
57-
break;
56+
for _ in 0..300 {
57+
if let Ok(mut stream) = std::net::TcpStream::connect(&addr) {
58+
use std::io::{Read, Write};
59+
let req = format!("GET /api/ready HTTP/1.0\r\nHost: {}\r\n\r\n", addr);
60+
if stream.write_all(req.as_bytes()).is_ok() {
61+
// Set a generous timeout — initialization may take a while
62+
stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
63+
let mut buf = [0u8; 256];
64+
if let Ok(n) = stream.read(&mut buf) {
65+
let resp = String::from_utf8_lossy(&buf[..n]);
66+
if resp.contains("200") {
67+
break;
68+
}
69+
}
70+
}
5871
}
5972
std::thread::sleep(std::time::Duration::from_millis(100));
6073
}

apps/penpal/frontend/src/api.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ async function apiVoid(path: string, options?: RequestInit): Promise<void> {
3838
if (!res.ok) throw new Error(`API error: ${res.status}`);
3939
}
4040

41+
function wtParam(worktree?: string): string {
42+
return worktree ? `&worktree=${encodeURIComponent(worktree)}` : '';
43+
}
44+
4145
export const api = {
4246
// Projects
4347
listProjects: () => apiFetch<APIProject[]>('/api/projects'),
@@ -47,8 +51,8 @@ export const api = {
4751
apiVoid('/api/projects', { method: 'DELETE', body: JSON.stringify({ path }) }),
4852

4953
// Project files
50-
getProjectFiles: (qn: string) =>
51-
apiFetch<APIFileGroupView[]>(`/api/project/${qn}`),
54+
getProjectFiles: (qn: string, worktree?: string) =>
55+
apiFetch<APIFileGroupView[]>(`/api/project/${qn}${worktree ? '?worktree=' + encodeURIComponent(worktree) : ''}`),
5256
getProjectInfo: (name: string) =>
5357
apiFetch<ProjectInfo>(`/api/project-info?name=${encodeURIComponent(name)}`),
5458
deleteProject: (project: string) =>
@@ -61,30 +65,30 @@ export const api = {
6165
getInReview: () => apiFetch<ReviewGroup[]>('/api/in-review'),
6266

6367
// Threads
64-
getThreads: (project: string, file: string) =>
65-
apiFetch<ThreadResponse[]>(`/api/threads?project=${encodeURIComponent(project)}&path=${encodeURIComponent(file)}`),
68+
getThreads: (project: string, file: string, worktree?: string) =>
69+
apiFetch<ThreadResponse[]>(`/api/threads?project=${encodeURIComponent(project)}&path=${encodeURIComponent(file)}${wtParam(worktree)}`),
6670
getAllThreads: (project: string) =>
6771
apiFetch<ThreadWithFile[]>(`/api/threads?project=${encodeURIComponent(project)}`),
68-
createThread: (data: CreateThreadReq) =>
72+
createThread: (data: CreateThreadReq & { worktree?: string }) =>
6973
apiFetch<Thread>('/api/threads', { method: 'POST', body: JSON.stringify(data) }),
70-
replyToThread: (id: string, data: ReplyReq) =>
74+
replyToThread: (id: string, data: ReplyReq & { worktree?: string }) =>
7175
apiFetch<Thread>(`/api/threads/${encodeURIComponent(id)}/comments`, {
7276
method: 'POST',
7377
body: JSON.stringify(data),
7478
}),
75-
patchThread: (id: string, data: PatchThreadReq) =>
79+
patchThread: (id: string, data: PatchThreadReq & { worktree?: string }) =>
7680
apiFetch<{ ok: boolean }>(`/api/threads/${encodeURIComponent(id)}`, {
7781
method: 'PATCH',
7882
body: JSON.stringify(data),
7983
}),
8084

8185
// Reviews
82-
getReviews: (project: string) =>
83-
apiFetch<APIFileInReview[]>(`/api/reviews?project=${encodeURIComponent(project)}`),
86+
getReviews: (project: string, worktree?: string) =>
87+
apiFetch<APIFileInReview[]>(`/api/reviews?project=${encodeURIComponent(project)}${wtParam(worktree)}`),
8488

8589
// Raw file content
86-
getRawFile: (project: string, path: string) =>
87-
fetch(`${API_BASE}/api/raw?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}`, {
90+
getRawFile: (project: string, path: string, worktree?: string) =>
91+
fetch(`${API_BASE}/api/raw?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}${wtParam(worktree)}`, {
8892
cache: 'no-store',
8993
}).then(
9094
(r) => {

apps/penpal/frontend/src/components/CommentsPanel.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface CommentsPanelProps {
99
threads: ThreadResponse[];
1010
anchorLines: Record<string, number>;
1111
project: string;
12+
worktree?: string;
1213
filePath: string;
1314
onRefresh: () => void;
1415
onThreadFocus?: (threadId: string, line: number) => void;
@@ -45,6 +46,7 @@ export default function CommentsPanel({
4546
threads,
4647
anchorLines,
4748
project,
49+
worktree,
4850
filePath,
4951
onRefresh,
5052
onThreadFocus,
@@ -147,6 +149,7 @@ export default function CommentsPanel({
147149
anchor={pendingAnchor}
148150
selectedText={pendingText || ''}
149151
project={project}
152+
worktree={worktree}
150153
filePath={filePath}
151154
onSubmit={() => {
152155
onCancelNewThread?.();
@@ -170,6 +173,7 @@ export default function CommentsPanel({
170173
isHighlighted={highlightedThread === t.id}
171174
onClick={() => handleThreadClick(t.id)}
172175
project={project}
176+
worktree={worktree}
173177
filePath={filePath}
174178
onRefresh={onRefresh}
175179
replyOpen={replyFormThread === t.id}
@@ -188,6 +192,7 @@ export default function CommentsPanel({
188192
isHighlighted={highlightedThread === t.id}
189193
onClick={() => handleThreadClick(t.id)}
190194
project={project}
195+
worktree={worktree}
191196
filePath={filePath}
192197
onRefresh={onRefresh}
193198
replyOpen={replyFormThread === t.id}
@@ -227,6 +232,7 @@ interface NewThreadFormProps {
227232
anchor: Anchor;
228233
selectedText: string;
229234
project: string;
235+
worktree?: string;
230236
filePath: string;
231237
onSubmit: () => void;
232238
onCancel: () => void;
@@ -237,6 +243,7 @@ function NewThreadForm({
237243
anchor,
238244
selectedText,
239245
project,
246+
worktree,
240247
filePath,
241248
onSubmit,
242249
onCancel,
@@ -263,6 +270,7 @@ function NewThreadForm({
263270
author: author.trim(),
264271
role: 'human',
265272
body: body.trim(),
273+
worktree: worktree || undefined,
266274
});
267275
onSubmit();
268276
} catch (err) {
@@ -324,6 +332,7 @@ interface ThreadCardProps {
324332
isHighlighted: boolean;
325333
onClick: () => void;
326334
project: string;
335+
worktree?: string;
327336
filePath: string;
328337
onRefresh: () => void;
329338
replyOpen: boolean;
@@ -338,6 +347,7 @@ function ThreadCard({
338347
isHighlighted,
339348
onClick,
340349
project,
350+
worktree,
341351
filePath,
342352
onRefresh,
343353
replyOpen,
@@ -353,14 +363,14 @@ function ThreadCard({
353363
const handleResolve = (e: React.MouseEvent) => {
354364
e.stopPropagation();
355365
const author = getSavedAuthor() || 'anonymous';
356-
api.patchThread(thread.id, { project, path: filePath, status: 'resolved', resolvedBy: author })
366+
api.patchThread(thread.id, { project, path: filePath, status: 'resolved', resolvedBy: author, worktree: worktree || undefined })
357367
.then(onRefresh)
358368
.catch((err) => console.error('Failed to resolve:', err));
359369
};
360370

361371
const handleReopen = (e: React.MouseEvent) => {
362372
e.stopPropagation();
363-
api.patchThread(thread.id, { project, path: filePath, status: 'open' })
373+
api.patchThread(thread.id, { project, path: filePath, status: 'open', worktree: worktree || undefined })
364374
.then(onRefresh)
365375
.catch((err) => console.error('Failed to reopen:', err));
366376
};
@@ -371,7 +381,7 @@ function ThreadCard({
371381
onToggleReply();
372382
return;
373383
}
374-
api.replyToThread(thread.id, { project, path: filePath, author, role: 'human', body: text })
384+
api.replyToThread(thread.id, { project, path: filePath, author, role: 'human', body: text, worktree: worktree || undefined })
375385
.then(onRefresh)
376386
.catch((err) => console.error('Failed to submit suggested reply:', err));
377387
};
@@ -487,6 +497,7 @@ function ThreadCard({
487497
<ReplyForm
488498
threadId={thread.id}
489499
project={project}
500+
worktree={worktree}
490501
filePath={filePath}
491502
onSubmit={() => {
492503
onToggleReply();
@@ -504,12 +515,13 @@ function ThreadCard({
504515
interface ReplyFormProps {
505516
threadId: string;
506517
project: string;
518+
worktree?: string;
507519
filePath: string;
508520
onSubmit: () => void;
509521
onCancel: () => void;
510522
}
511523

512-
function ReplyForm({ threadId, project, filePath, onSubmit, onCancel }: ReplyFormProps) {
524+
function ReplyForm({ threadId, project, worktree, filePath, onSubmit, onCancel }: ReplyFormProps) {
513525
const [body, setBody] = useState('');
514526
const [author, setAuthor] = useState(getSavedAuthor());
515527
const [submitting, setSubmitting] = useState(false);
@@ -530,6 +542,7 @@ function ReplyForm({ threadId, project, filePath, onSubmit, onCancel }: ReplyFor
530542
author: author.trim(),
531543
role: 'human',
532544
body: body.trim(),
545+
worktree: worktree || undefined,
533546
});
534547
onSubmit();
535548
} catch (err) {

apps/penpal/frontend/src/components/Layout.tsx

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import FindBar from './FindBar';
1010
import InstallToolsModal from './InstallToolsModal';
1111
import type { Heading } from './TableOfContents';
1212
import type { APIProject, SSEEvent } from '../types';
13+
import { parseProjectWorktree } from '../utils/worktree';
1314

1415
export interface LayoutContext {
1516
setHeadings: (headings: Heading[]) => void;
@@ -252,12 +253,30 @@ export default function Layout() {
252253
return () => window.removeEventListener('keydown', handleKeyDown);
253254
}, [goBack, goForward, canGoBack, canGoForward]);
254255

255-
// Detect project-mode view: /project/:qn or /file/:qn/*
256+
// Detect project-mode view: /project/:qn[@worktree] or /file/:qn[@worktree]/*
256257
// QN may contain slashes (e.g. "Development/birdseye"), so match against known projects
257258
const pathAfterPrefix = location.pathname.match(/^\/(project|file)\/(.+)/)?.[2] || '';
258-
const activeProject = pathAfterPrefix
259-
? projects.find((p) => pathAfterPrefix === p.qualifiedName || pathAfterPrefix.startsWith(p.qualifiedName + '/'))
260-
: null;
259+
const { activeProject, activeWorktree } = (() => {
260+
if (!pathAfterPrefix) return { activeProject: null, activeWorktree: '' };
261+
// Strip @worktree suffix before matching against project QNs
262+
const sorted = [...projects].sort((a, b) => b.qualifiedName.length - a.qualifiedName.length);
263+
for (const p of sorted) {
264+
// Check for exact match or prefix match (with / or @ following)
265+
if (
266+
pathAfterPrefix === p.qualifiedName ||
267+
pathAfterPrefix.startsWith(p.qualifiedName + '/') ||
268+
pathAfterPrefix.startsWith(p.qualifiedName + '@')
269+
) {
270+
const rest = pathAfterPrefix.slice(p.qualifiedName.length);
271+
const { worktree } = parseProjectWorktree(p.qualifiedName + rest.split('/')[0]);
272+
return { activeProject: p, activeWorktree: worktree };
273+
}
274+
}
275+
// Fallback: try parsing with @
276+
const parsed = parseProjectWorktree(pathAfterPrefix.split('/').slice(0, 2).join('/'));
277+
const fallbackProject = projects.find((p) => p.qualifiedName === parsed.project) || null;
278+
return { activeProject: fallbackProject, activeWorktree: parsed.worktree };
279+
})();
261280
// Show project-mode sidebar as soon as URL matches, even before projects load
262281
const isProjectMode = !!pathAfterPrefix;
263282

@@ -468,30 +487,64 @@ export default function Layout() {
468487
.some((p) => p.agentConnected) && <span className="agent-dot" />}
469488
</NavLink>
470489
)}
471-
{activeProject && (
490+
{activeProject && activeProject.worktrees && activeProject.worktrees.length > 1 ? (
491+
<>
492+
{activeProject.worktrees.map((wt) => {
493+
const isActive = wt.isMain ? !activeWorktree : activeWorktree === wt.name;
494+
const url = wt.isMain
495+
? `/project/${activeProject.qualifiedName}`
496+
: `/project/${activeProject.qualifiedName}@${wt.name}`;
497+
return (
498+
<NavLink
499+
key={wt.name}
500+
to={url}
501+
className={`sidebar-item subitem worktree-item${isActive ? ' active' : ''}`}
502+
>
503+
<span className="worktree-name">
504+
{wt.isMain ? activeProject.name : wt.name}
505+
{wt.isMain && activeProject.badges.map((b) => (
506+
<span
507+
key={b.text}
508+
className="source-badge"
509+
style={{ '--badge-bg': b.bg, '--badge-color': b.color, '--badge-active-bg': b.activeBg || b.bg, '--badge-active-color': b.activeColor || b.color } as React.CSSProperties}
510+
>
511+
{b.text}
512+
</span>
513+
))}
514+
</span>
515+
{wt.branch && (
516+
<span className="branch-name">{wt.branch}</span>
517+
)}
518+
</NavLink>
519+
);
520+
})}
521+
</>
522+
) : activeProject ? (
472523
<NavLink
473524
to={`/project/${activeProject.qualifiedName}`}
474-
className="sidebar-item subitem active"
525+
className="sidebar-item subitem worktree-item active"
475526
>
476-
{activeProject.name}
477-
{activeProject.badges.map((b) => (
478-
<span
479-
key={b.text}
480-
className="source-badge"
481-
style={{ '--badge-bg': b.bg, '--badge-color': b.color, '--badge-active-bg': b.activeBg || b.bg, '--badge-active-color': b.activeColor || b.color } as React.CSSProperties}
482-
>
483-
{b.text}
484-
</span>
485-
))}
486-
{activeProject.agentConnected && <span className="agent-dot" />}
527+
<span className="worktree-name">
528+
{activeProject.name}
529+
{activeProject.badges.map((b) => (
530+
<span
531+
key={b.text}
532+
className="source-badge"
533+
style={{ '--badge-bg': b.bg, '--badge-color': b.color, '--badge-active-bg': b.activeBg || b.bg, '--badge-active-color': b.activeColor || b.color } as React.CSSProperties}
534+
>
535+
{b.text}
536+
</span>
537+
))}
538+
{activeProject.agentConnected && <span className="agent-dot" />}
539+
</span>
487540
{activeProject.branch && (
488-
<span className="branch-info">
541+
<span className="branch-name">
489542
{activeProject.branch}
490543
{activeProject.dirty && <span className="branch-dirty">*</span>}
491544
</span>
492545
)}
493546
</NavLink>
494-
)}
547+
) : null}
495548
</>
496549
) : (
497550
<>

apps/penpal/frontend/src/index.css

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,10 @@ a:hover { text-decoration: underline; }
323323
background: var(--badge-active-bg);
324324
color: var(--badge-active-color);
325325
}
326-
.sidebar-item .branch-info {
327-
font-size: 0.8em;
328-
color: var(--accent-success);
329-
flex-shrink: 0;
330-
margin-left: auto;
331-
}
332-
.sidebar-item .branch-dirty { color: var(--accent-danger); }
326+
.sidebar-item.worktree-item { flex-direction: column; align-items: flex-start; gap: 1px; }
327+
.sidebar-item.worktree-item .worktree-name { display: flex; align-items: center; gap: 6px; }
328+
.sidebar-item.worktree-item .branch-name { font-size: 0.85em; color: var(--accent-success); opacity: 0.8; }
329+
.sidebar-item.worktree-item .branch-dirty { color: var(--accent-danger); }
333330
.agent-dot {
334331
display: inline-block;
335332
width: 7px;
@@ -1179,9 +1176,15 @@ a:hover { text-decoration: underline; }
11791176
}
11801177
.project-card-name a { text-decoration: none; }
11811178
.project-card-name a:hover { text-decoration: none; }
1182-
.project-card-meta { font-size: 0.8em; color: var(--text-subtle); margin-bottom: 8px; }
1179+
.project-card-meta { font-size: 0.8em; color: var(--text-subtle); margin-bottom: 8px; display: flex; flex-wrap: wrap; gap: 4px 8px; }
11831180
.project-card-meta .branch { color: var(--accent-success); }
11841181
.project-card-meta .dirty { color: var(--accent-danger); }
1182+
.project-card-worktrees { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
1183+
.worktree-card-item { display: flex; flex-direction: column; text-decoration: none; padding: 4px 8px; border-radius: 4px; background: var(--bg-surface-tertiary); min-width: 0; }
1184+
.worktree-card-item:hover { background: var(--bg-surface-hover); }
1185+
.worktree-card-name { font-size: 0.85em; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1186+
.worktree-card-branch { font-size: 0.78em; color: var(--accent-success); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1187+
.worktree-card-branch .dirty { color: var(--accent-danger); }
11851188
.project-card .project-age { font-size: 0.8em; color: var(--text-faint); }
11861189
.project-card-name .agent-dot {
11871190
margin-left: 8px;

0 commit comments

Comments
 (0)