10 files changed,
+380,
-81
+6,
-1
1@@ -67,9 +67,14 @@ PORT=5679 lcr /path/to/other # Second project on port 5679
2 - **Request Changes** — sends your review to Claude Code for another iteration
3 - **Approve & Commit** — opens commit dialog with approved/all file options
4
5+### Diff scope
6+
7+- **Uncommitted** (default) — shows uncommitted changes vs HEAD
8+- **Worktree** — shows the full branch diff (all commits since the branch diverged from main/master/development, plus uncommitted changes)
9+
10 ### Diff modes
11
12-- **Full Diff** — shows all uncommitted changes (staged + unstaged)
13+- **Full Diff** — shows all changes in the current scope (staged + unstaged, or full branch)
14 - **Needs Review** — shows only unstaged changes, so you can focus on what hasn't been approved yet
15
16 ### Export options
+13,
-6
1@@ -1,12 +1,19 @@
2 <script lang="ts">
3 import { showCommitDialog } from '$lib/stores/ui.ts';
4- import { approvedCount } from '$lib/stores/files.ts';
5+ import { uncommittedCount, loadUncommittedFiles } from '$lib/stores/files.ts';
6
7 let message = $state('');
8 let commitMode = $state<'approved' | 'all'>('approved');
9 let committing = $state(false);
10 let result = $state<{ success: boolean; hash?: string; error?: string } | null>(null);
11
12+ // Refresh uncommitted file counts when dialog opens
13+ $effect(() => {
14+ if ($showCommitDialog) {
15+ loadUncommittedFiles();
16+ }
17+ });
18+
19 async function handleCommit() {
20 if (!message.trim()) return;
21 committing = true;
22@@ -79,19 +86,19 @@
23 onclick={() => (commitMode = 'approved')}
24 >
25 <div class="font-medium">Approved files only</div>
26- <div class="text-xs mt-0.5 opacity-70">{$approvedCount.approved} of {$approvedCount.total} files</div>
27+ <div class="text-xs mt-0.5 opacity-70">{$uncommittedCount.approved} of {$uncommittedCount.total} files</div>
28 </button>
29 <button
30 class="flex-1 text-left px-3 py-2 rounded-lg border text-sm transition-colors {commitMode === 'all' ? 'border-blue-600 bg-accent-blue/10 text-accent-blue' : 'border-border-strong text-tertiary hover:border-border-strong'}"
31 onclick={() => (commitMode = 'all')}
32 >
33 <div class="font-medium">All changes</div>
34- <div class="text-xs mt-0.5 opacity-70">{$approvedCount.total} files</div>
35+ <div class="text-xs mt-0.5 opacity-70">{$uncommittedCount.total} files</div>
36 </button>
37 </div>
38 </div>
39
40- {#if commitMode === 'approved' && $approvedCount.approved === 0}
41+ {#if commitMode === 'approved' && $uncommittedCount.approved === 0}
42 <div class="text-sm rounded-lg px-3 py-2 bg-accent-yellow/10 text-accent-yellow">
43 No files approved yet. Approve files in the sidebar first, or commit all changes.
44 </div>
45@@ -128,10 +135,10 @@
46 </button>
47 <button
48 class="text-sm px-4 py-2 rounded bg-green-600 text-white hover:bg-green-500 transition-colors disabled:opacity-50"
49- disabled={!message.trim() || committing || (commitMode === 'approved' && $approvedCount.approved === 0)}
50+ disabled={!message.trim() || committing || (commitMode === 'approved' && $uncommittedCount.approved === 0)}
51 onclick={handleCommit}
52 >
53- {committing ? 'Committing...' : commitMode === 'approved' ? `Commit ${$approvedCount.approved} files` : 'Commit all'}
54+ {committing ? 'Committing...' : commitMode === 'approved' ? `Commit ${$uncommittedCount.approved} files` : 'Commit all'}
55 </button>
56 </div>
57 </div>
+29,
-3
1@@ -1,10 +1,15 @@
2 <script lang="ts">
3 import { viewMode, showCommitDialog, showExportDialog, theme } from '$lib/stores/ui.ts';
4 import { stats, reviewStatus, submitReview } from '$lib/stores/review.ts';
5- import { diffMode, approvedCount, diffStats, loadAllDiffs } from '$lib/stores/files.ts';
6+ import { diffMode, diffScope, baseBranch, approvedCount, uncommittedCount, diffStats, loadAllDiffs, setDiffScope, loadUncommittedFiles } from '$lib/stores/files.ts';
7
8 let showReviewMenu = $state(false);
9
10+ function openReviewMenu() {
11+ showReviewMenu = !showReviewMenu;
12+ if (showReviewMenu) loadUncommittedFiles();
13+ }
14+
15 async function handleApprove() {
16 await submitReview('approved');
17 $showCommitDialog = true;
18@@ -40,6 +45,23 @@
19 </button>
20 </div>
21
22+ <!-- Diff scope toggle -->
23+ <div class="flex bg-surface rounded overflow-hidden border border-border">
24+ <button
25+ class="text-xs px-3 py-1 transition-colors {$diffScope === 'uncommitted' ? 'bg-active text-white' : 'text-tertiary hover:text-primary'}"
26+ onclick={() => setDiffScope('uncommitted')}
27+ >
28+ Uncommitted
29+ </button>
30+ <button
31+ class="text-xs px-3 py-1 transition-colors {$diffScope === 'worktree' ? 'bg-active text-white' : 'text-tertiary hover:text-primary'}"
32+ onclick={() => setDiffScope('worktree')}
33+ title={$baseBranch ? `Comparing against ${$baseBranch}` : 'Show all branch changes'}
34+ >
35+ Worktree
36+ </button>
37+ </div>
38+
39 <!-- Diff mode toggle -->
40 <div class="flex bg-surface rounded overflow-hidden border border-border">
41 <button
42@@ -114,7 +136,7 @@
43 <div class="relative">
44 <button
45 class="text-xs px-3 py-1.5 rounded bg-green-600 text-white hover:bg-green-500 transition-colors"
46- onclick={() => (showReviewMenu = !showReviewMenu)}
47+ onclick={openReviewMenu}
48 >
49 Review ▾
50 </button>
51@@ -134,10 +156,14 @@
52 <span class="text-accent-yellow mr-2">↻</span> Request Changes (send to AI)
53 </button>
54 <button
55- class="w-full text-left px-3 py-2 text-sm text-secondary hover:bg-hover transition-colors"
56+ class="w-full text-left px-3 py-2 text-sm transition-colors {$uncommittedCount.total === 0 ? 'text-muted cursor-not-allowed' : 'text-secondary hover:bg-hover'}"
57+ disabled={$uncommittedCount.total === 0}
58 onclick={() => { handleApprove(); showReviewMenu = false; }}
59 >
60 <span class="text-accent-green mr-2">✓</span> Approve & Commit
61+ {#if $uncommittedCount.total === 0}
62+ <span class="text-xs text-muted ml-1">(no uncommitted changes)</span>
63+ {/if}
64 </button>
65 </div>
66 {/if}
+181,
-4
1@@ -1,5 +1,6 @@
2 import simpleGit, { type SimpleGit } from 'simple-git';
3-import type { FileChange, DiffFile, DiffHunk, DiffLine } from '$lib/types/index.ts';
4+import type { FileChange, DiffFile, DiffHunk, DiffLine, DiffScope } from '$lib/types/index.ts';
5+import { getWorktreeApprovals } from './review-store.ts';
6
7 let git: SimpleGit;
8
9@@ -315,10 +316,10 @@ function getLanguage(filePath: string): string {
10 return map[ext] || filenameMap[filename] || 'text';
11 }
12
13-export async function getFileContent(filePath: string): Promise<string> {
14+export async function getFileContent(filePath: string, ref: string = 'HEAD'): Promise<string> {
15 const g = getGit();
16 try {
17- return await g.show(['HEAD:' + filePath]);
18+ return await g.show([`${ref}:${filePath}`]);
19 } catch {
20 return '';
21 }
22@@ -410,10 +411,13 @@ export async function commit(message: string): Promise<string> {
23
24 export type DiffMode = 'full' | 'unstaged';
25
26-export async function getDiffByMode(mode: DiffMode, filePath?: string): Promise<string> {
27+export async function getDiffByMode(mode: DiffMode, filePath?: string, scope?: DiffScope): Promise<string> {
28 if (mode === 'unstaged') {
29 return getUnstagedDiff(filePath);
30 }
31+ if (scope === 'worktree') {
32+ return getWorktreeDiff(filePath);
33+ }
34 return getDiff(filePath);
35 }
36
37@@ -451,6 +455,179 @@ async function getUnstagedDiff(filePath?: string): Promise<string> {
38 }
39 }
40
41+// --- Worktree (branch) diff support ---
42+
43+let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
44+
45+export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> {
46+ if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
47+
48+ const g = getGit();
49+ const currentBranch = (await g.revparse(['--abbrev-ref', 'HEAD'])).trim();
50+
51+ // Detached HEAD — can't determine base branch
52+ if (currentBranch === 'HEAD') {
53+ cachedBaseBranchInfo = { baseBranch: null, mergeBase: null };
54+ return cachedBaseBranchInfo;
55+ }
56+
57+ const candidates = ['main', 'master', 'development', 'develop'];
58+ let bestBranch: string | null = null;
59+ let bestMergeBase: string | null = null;
60+ let bestTimestamp = 0;
61+
62+ for (const branch of candidates) {
63+ try {
64+ // Check if branch exists locally
65+ await g.raw(['rev-parse', '--verify', branch]);
66+
67+ // Skip if we're on this branch
68+ if (branch === currentBranch) continue;
69+
70+ const mergeBase = (await g.raw(['merge-base', 'HEAD', branch])).trim();
71+ // Get the timestamp of the merge-base to pick the most recent one
72+ const timestamp = parseInt(
73+ (await g.raw(['log', '-1', '--format=%ct', mergeBase])).trim(),
74+ 10
75+ );
76+
77+ if (timestamp > bestTimestamp) {
78+ bestTimestamp = timestamp;
79+ bestBranch = branch;
80+ bestMergeBase = mergeBase;
81+ }
82+ } catch {
83+ // Branch doesn't exist or merge-base failed — skip
84+ }
85+ }
86+
87+ // Fallback: try upstream tracking branch
88+ if (!bestBranch) {
89+ try {
90+ const upstream = (await g.raw(['rev-parse', '--abbrev-ref', '@{upstream}'])).trim();
91+ // upstream is e.g. "origin/main" — extract the local branch name
92+ const localName = upstream.replace(/^[^/]+\//, '');
93+ if (localName !== currentBranch) {
94+ const mergeBase = (await g.raw(['merge-base', 'HEAD', upstream])).trim();
95+ bestBranch = localName;
96+ bestMergeBase = mergeBase;
97+ }
98+ } catch {
99+ // No upstream configured — skip
100+ }
101+ }
102+
103+ cachedBaseBranchInfo = { baseBranch: bestBranch, mergeBase: bestMergeBase };
104+ return cachedBaseBranchInfo;
105+}
106+
107+async function getWorktreeDiff(filePath?: string): Promise<string> {
108+ const g = getGit();
109+ const { mergeBase } = await getBaseBranchInfo();
110+ if (!mergeBase) return getDiff(filePath); // Fallback to regular diff
111+
112+ const args = [mergeBase];
113+ if (filePath) args.push('--', filePath);
114+ let result = '';
115+ try {
116+ result = await g.diff(args);
117+ } catch {
118+ result = '';
119+ }
120+
121+ // Also include untracked files (same as getDiff)
122+ const status = await g.status();
123+ const untrackedFiles = filePath
124+ ? status.not_added.filter((f) => f === filePath)
125+ : status.not_added;
126+
127+ for (const f of untrackedFiles) {
128+ try {
129+ const { readFile } = await import('fs/promises');
130+ const { join } = await import('path');
131+ const content = await readFile(join(getRepoDir(), f), 'utf-8');
132+ const lines = content.split('\n');
133+ const header = `diff --git a/${f} b/${f}\nnew file mode 100644\n--- /dev/null\n+++ b/${f}\n@@ -0,0 +1,${lines.length} @@\n`;
134+ const body = lines.map((l) => '+' + l).join('\n');
135+ result += '\n' + header + body + '\n';
136+ } catch {
137+ // Skip files that can't be read
138+ }
139+ }
140+
141+ return result;
142+}
143+
144+export async function getWorktreeStatus(): Promise<FileChange[]> {
145+ const g = getGit();
146+ const { mergeBase } = await getBaseBranchInfo();
147+ if (!mergeBase) return getStatus(); // Fallback
148+
149+ // Run all independent git calls in parallel
150+ const [nameStatus, gitStatus, diffStat] = await Promise.all([
151+ g.diff(['--name-status', mergeBase]),
152+ g.status(),
153+ g.diff([mergeBase, '--numstat']).catch(() => '')
154+ ]);
155+
156+ const files: FileChange[] = [];
157+ const seen = new Set<string>();
158+
159+ // Parse files changed between merge-base and working tree
160+ for (const line of nameStatus.split('\n')) {
161+ if (!line.trim()) continue;
162+ const match = line.match(/^([AMDRC])\d*\t(.+?)(?:\t(.+))?$/);
163+ if (!match) continue;
164+ const [, statusChar, filePath, newPath] = match;
165+ const path = newPath || filePath;
166+ if (seen.has(path)) continue;
167+ seen.add(path);
168+
169+ let status: FileChange['status'] = 'modified';
170+ let oldPath: string | undefined;
171+ if (statusChar === 'A') status = 'added';
172+ else if (statusChar === 'D') status = 'deleted';
173+ else if (statusChar === 'R' || statusChar === 'C') {
174+ status = 'renamed';
175+ oldPath = filePath;
176+ }
177+
178+ files.push({ path, oldPath, status, staged: false, approved: false, additions: 0, deletions: 0 });
179+ }
180+
181+ // Include untracked files
182+ for (const f of gitStatus.not_added) {
183+ if (seen.has(f)) continue;
184+ seen.add(f);
185+ files.push({ path: f, status: 'added', staged: false, approved: false, additions: 0, deletions: 0 });
186+ }
187+
188+ // Merge staging info from git status + worktree approvals
189+ const stagedSet = new Set([...gitStatus.created, ...gitStatus.staged]);
190+ const approvals = getWorktreeApprovals();
191+ for (const file of files) {
192+ if (stagedSet.has(file.path) || approvals.has(file.path)) {
193+ file.staged = true;
194+ file.approved = true;
195+ }
196+ }
197+
198+ // Apply numstat additions/deletions
199+ for (const line of diffStat.split('\n')) {
200+ const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
201+ if (match) {
202+ const [, adds, dels, path] = match;
203+ const file = files.find((f) => f.path === path);
204+ if (file) {
205+ file.additions = adds === '-' ? 0 : parseInt(adds, 10);
206+ file.deletions = dels === '-' ? 0 : parseInt(dels, 10);
207+ }
208+ }
209+ }
210+
211+ return files;
212+}
213+
214 export async function getRepoInfo(): Promise<{ branch: string; repoName: string }> {
215 const g = getGit();
216 const branch = await g.revparse(['--abbrev-ref', 'HEAD']);
+14,
-0
1@@ -5,6 +5,7 @@ const threads = new Map<string, ReviewThread>();
2 let reviewStatus: 'pending' | 'approved' | 'changes_requested' = 'pending';
3 let reviewSummary: string | undefined;
4 const contextFiles = new Set<string>();
5+const worktreeApprovals = new Set<string>();
6
7 export function addContextFile(filePath: string): void {
8 contextFiles.add(filePath);
9@@ -110,11 +111,24 @@ export function setReviewSummary(summary: string): void {
10 reviewSummary = summary;
11 }
12
13+export function approveWorktreeFile(filePath: string): void {
14+ worktreeApprovals.add(filePath);
15+}
16+
17+export function unapproveWorktreeFile(filePath: string): void {
18+ worktreeApprovals.delete(filePath);
19+}
20+
21+export function getWorktreeApprovals(): Set<string> {
22+ return worktreeApprovals;
23+}
24+
25 export function clearAll(): void {
26 threads.clear();
27 reviewStatus = 'pending';
28 reviewSummary = undefined;
29 contextFiles.clear();
30+ worktreeApprovals.clear();
31 }
32
33 export function markOutdated(threadId: string, outdated: boolean): boolean {
+75,
-52
1@@ -1,14 +1,17 @@
2 import { writable, derived } from 'svelte/store';
3-import type { FileChange, DiffFile } from '$lib/types/index.ts';
4+import type { FileChange, DiffFile, DiffScope } from '$lib/types/index.ts';
5 import { checkOutdated } from './review.ts';
6
7 export type DiffMode = 'full' | 'unstaged';
8
9 export const files = writable<FileChange[]>([]);
10+export const uncommittedFiles = writable<FileChange[]>([]);
11 export const selectedFile = writable<string | null>(null);
12 export const diffFiles = writable<DiffFile[]>([]);
13 export const loading = writable(false);
14 export const diffMode = writable<DiffMode>('full');
15+export const diffScope = writable<DiffScope>('uncommitted');
16+export const baseBranch = writable<string | null>(null);
17
18 export const selectedDiff = derived([diffFiles, selectedFile], ([$diffFiles, $selectedFile]) => {
19 if (!$selectedFile) return null;
20@@ -42,6 +45,11 @@ export const approvedCount = derived(files, ($files) => {
21 return { approved, total: $files.length };
22 });
23
24+export const uncommittedCount = derived(uncommittedFiles, ($uncommittedFiles) => {
25+ const approved = $uncommittedFiles.filter((f) => f.approved).length;
26+ return { approved, total: $uncommittedFiles.length };
27+});
28+
29 export const diffStats = derived(files, ($files) => {
30 let additions = 0;
31 let deletions = 0;
32@@ -63,32 +71,56 @@ export const selectedFileData = derived([files, selectedFile], ([$files, $select
33 return $files.find((f) => f.path === $selectedFile) || null;
34 });
35
36+function getStoreValue<T>(store: { subscribe: (fn: (v: T) => void) => () => void }): T {
37+ let value: T;
38+ store.subscribe((v) => (value = v))();
39+ return value!;
40+}
41+
42 export async function loadFiles() {
43 loading.set(true);
44 try {
45- const res = await fetch('/api/files');
46- const data = await res.json();
47- files.set(data);
48+ const scope = getStoreValue(diffScope);
49+
50+ if (scope === 'worktree') {
51+ const res = await fetch('/api/files?scope=worktree');
52+ const data = await res.json();
53+ files.set(data.files);
54+ baseBranch.set(data.baseBranch);
55+ } else {
56+ const res = await fetch('/api/files');
57+ const data = await res.json();
58+ const fileList = Array.isArray(data) ? data : data.files || [];
59+ files.set(fileList);
60+ uncommittedFiles.set(fileList);
61+ baseBranch.set(null);
62+ }
63
64 // Auto-select first file
65- if (data.length > 0) {
66- const current = await new Promise<string | null>((resolve) => {
67- selectedFile.subscribe((v) => resolve(v))();
68- });
69- if (!current) {
70- selectedFile.set(data[0].path);
71+ const fileList = getStoreValue(files);
72+ if (fileList.length > 0) {
73+ const current = getStoreValue(selectedFile);
74+ if (!current || !fileList.find((f) => f.path === current)) {
75+ selectedFile.set(fileList[0].path);
76 }
77+ } else {
78+ selectedFile.set(null);
79 }
80 } finally {
81 loading.set(false);
82 }
83 }
84
85+export async function loadUncommittedFiles() {
86+ const res = await fetch('/api/files');
87+ const data = await res.json();
88+ uncommittedFiles.set(Array.isArray(data) ? data : []);
89+}
90+
91 export async function loadAllDiffs(mode?: DiffMode) {
92- const currentMode = mode || await new Promise<DiffMode>((resolve) => {
93- diffMode.subscribe((v) => resolve(v))();
94- });
95- const res = await fetch(`/api/diff?mode=${currentMode}`);
96+ const currentMode = mode || getStoreValue(diffMode);
97+ const currentScope = getStoreValue(diffScope);
98+ const res = await fetch(`/api/diff?mode=${currentMode}&scope=${currentScope}`);
99 const data = await res.json();
100 if (data.files) {
101 diffFiles.set(data.files);
102@@ -97,72 +129,63 @@ export async function loadAllDiffs(mode?: DiffMode) {
103 checkOutdated().catch(() => {});
104 }
105
106+export async function setDiffScope(scope: DiffScope) {
107+ diffScope.set(scope);
108+ // Reset to full diff mode when switching scope
109+ diffMode.set('full');
110+ await loadFiles();
111+ await loadAllDiffs('full');
112+}
113+
114 export async function approveFile(path: string) {
115- const res = await fetch('/api/files', {
116+ const scope = getStoreValue(diffScope);
117+ await fetch('/api/files', {
118 method: 'POST',
119 headers: { 'Content-Type': 'application/json' },
120- body: JSON.stringify({ path, action: 'approve' })
121+ body: JSON.stringify({ path, action: 'approve', scope })
122 });
123- const data = await res.json();
124- if (Array.isArray(data)) {
125- files.set(data);
126- // Reload diffs since staging changed
127- await loadAllDiffs();
128- }
129+ await loadFiles();
130+ await loadAllDiffs();
131 }
132
133 export async function unapproveFile(path: string) {
134- const res = await fetch('/api/files', {
135+ const scope = getStoreValue(diffScope);
136+ await fetch('/api/files', {
137 method: 'POST',
138 headers: { 'Content-Type': 'application/json' },
139- body: JSON.stringify({ path, action: 'unapprove' })
140+ body: JSON.stringify({ path, action: 'unapprove', scope })
141 });
142- const data = await res.json();
143- if (Array.isArray(data)) {
144- files.set(data);
145- await loadAllDiffs();
146- }
147+ await loadFiles();
148+ await loadAllDiffs();
149 }
150
151 export async function resetFile(path: string) {
152- const res = await fetch('/api/files', {
153+ await fetch('/api/files', {
154 method: 'POST',
155 headers: { 'Content-Type': 'application/json' },
156 body: JSON.stringify({ path, action: 'reset' })
157 });
158- const data = await res.json();
159- if (Array.isArray(data)) {
160- files.set(data);
161- await loadAllDiffs();
162- // If the reset file was selected and is now gone, select another
163- const currentSelected = await new Promise<string | null>((resolve) => {
164- selectedFile.subscribe((v) => resolve(v))();
165- });
166- if (currentSelected === path) {
167- const remaining = data as FileChange[];
168- selectedFile.set(remaining.length > 0 ? remaining[0].path : null);
169- }
170+ await loadFiles();
171+ await loadAllDiffs();
172+ // If the reset file was selected and is now gone, select another
173+ if (getStoreValue(selectedFile) === path) {
174+ const remaining = getStoreValue(files);
175+ selectedFile.set(remaining.length > 0 ? remaining[0].path : null);
176 }
177 }
178
179 export async function resetHunk(path: string, hunkHeader: string) {
180- const res = await fetch('/api/files', {
181+ await fetch('/api/files', {
182 method: 'POST',
183 headers: { 'Content-Type': 'application/json' },
184 body: JSON.stringify({ path, action: 'reset-hunk', hunkHeader })
185 });
186- const data = await res.json();
187- if (Array.isArray(data)) {
188- files.set(data);
189- await loadAllDiffs();
190- }
191+ await loadFiles();
192+ await loadAllDiffs();
193 }
194
195 export async function toggleApproval(path: string) {
196- const currentFiles = await new Promise<FileChange[]>((resolve) => {
197- files.subscribe((v) => resolve(v))();
198- });
199- const file = currentFiles.find((f) => f.path === path);
200+ const file = getStoreValue(files).find((f) => f.path === path);
201 if (file?.approved) {
202 await unapproveFile(path);
203 } else {
+2,
-0
1@@ -33,3 +33,5 @@ export interface DiffLine {
2 oldNumber?: number;
3 newNumber?: number;
4 }
5+
6+export type DiffScope = 'uncommitted' | 'worktree';
+19,
-3
1@@ -6,7 +6,7 @@
2 import CommitDialog from '$lib/components/CommitDialog.svelte';
3 import ExportDialog from '$lib/components/ExportDialog.svelte';
4 import TerminalPanel from '$lib/components/TerminalPanel.svelte';
5- import { files, selectedFile, selectedDiff, selectedFileData, loadFiles, loadAllDiffs, toggleApproval } from '$lib/stores/files.ts';
6+ import { files, selectedFile, selectedDiff, selectedFileData, loadFiles, loadAllDiffs, toggleApproval, diffScope, diffMode } from '$lib/stores/files.ts';
7 import { loadComments } from '$lib/stores/review.ts';
8 import { sidebarOpen } from '$lib/stores/ui.ts';
9 import { toggleTerminal } from '$lib/stores/terminal.ts';
10@@ -113,8 +113,13 @@
11 <div class="flex items-center justify-center h-full text-muted">
12 <div class="text-center">
13 <div class="text-4xl mb-3">✨</div>
14- <p class="font-medium text-secondary">No uncommitted changes</p>
15- <p class="text-sm mt-1">Make some changes and come back to review them</p>
16+ {#if $diffScope === 'worktree'}
17+ <p class="font-medium text-secondary">No changes on this branch</p>
18+ <p class="text-sm mt-1">Commits and uncommitted changes relative to the base branch will appear here</p>
19+ {:else}
20+ <p class="font-medium text-secondary">No uncommitted changes</p>
21+ <p class="text-sm mt-1">Make some changes and come back to review them</p>
22+ {/if}
23 </div>
24 </div>
25 {:else if $selectedDiff}
26@@ -146,6 +151,17 @@
27 {/if}
28 </div>
29 <DiffView file={$selectedDiff} />
30+ {:else if $selectedFile}
31+ <div class="flex items-center justify-center h-full text-muted">
32+ <div class="text-center">
33+ <p class="font-mono text-sm text-secondary mb-2">{$selectedFile}</p>
34+ {#if $diffMode === 'unstaged'}
35+ <p class="text-sm">No unstaged changes in this file</p>
36+ {:else}
37+ <p class="text-sm">No diff available for this file</p>
38+ {/if}
39+ </div>
40+ </div>
41 {:else}
42 <div class="flex items-center justify-center h-full text-muted">
43 <p class="text-sm">Select a file from the sidebar</p>
+15,
-7
1@@ -1,14 +1,14 @@
2 import { json } from '@sveltejs/kit';
3-import { getDiffByMode, parseDiff, getFileContent } from '$lib/server/git.ts';
4+import { getDiffByMode, parseDiff, getFileContent, getBaseBranchInfo } from '$lib/server/git.ts';
5 import type { DiffMode } from '$lib/server/git.ts';
6-import type { DiffFile } from '$lib/types/index.ts';
7+import type { DiffFile, DiffScope } from '$lib/types/index.ts';
8 import { highlightLines } from '$lib/server/highlighter.ts';
9 import { readFile } from 'fs/promises';
10 import { join } from 'path';
11 import { getRepoDir } from '$lib/server/git.ts';
12 import type { RequestHandler } from './$types.ts';
13
14-async function applyHighlighting(files: DiffFile[]): Promise<void> {
15+async function applyHighlighting(files: DiffFile[], oldRef: string = 'HEAD'): Promise<void> {
16 for (const file of files) {
17 // Get the full new file content for accurate highlighting
18 let newContent = '';
19@@ -22,8 +22,8 @@ async function applyHighlighting(files: DiffFile[]): Promise<void> {
20 }
21
22 try {
23- // Get the HEAD version (old content)
24- oldContent = await getFileContent(file.path);
25+ // Get the old version (HEAD or merge-base for worktree scope)
26+ oldContent = await getFileContent(file.path, oldRef);
27 } catch {
28 // File might be new
29 }
30@@ -82,8 +82,9 @@ export const GET: RequestHandler = async ({ url }) => {
31 try {
32 const filePath = url.searchParams.get('file') || undefined;
33 const mode = (url.searchParams.get('mode') || 'full') as DiffMode;
34+ const scope = (url.searchParams.get('scope') || 'uncommitted') as DiffScope;
35 const skipTruncation = url.searchParams.get('full') === '1';
36- const raw = await getDiffByMode(mode, filePath);
37+ const raw = await getDiffByMode(mode, filePath, scope);
38 const parsed = parseDiff(raw);
39
40 // Truncate large files before highlighting (unless full load requested)
41@@ -98,9 +99,16 @@ export const GET: RequestHandler = async ({ url }) => {
42 }
43 }
44
45+ // Determine the old ref for syntax highlighting
46+ let oldRef = 'HEAD';
47+ if (scope === 'worktree') {
48+ const { mergeBase } = await getBaseBranchInfo();
49+ if (mergeBase) oldRef = mergeBase;
50+ }
51+
52 // Skip highlighting for very large files
53 const filesToHighlight = parsed.filter((f) => (f.totalLines || 0) <= MAX_LINES_FOR_HIGHLIGHTING);
54- await applyHighlighting(filesToHighlight);
55+ await applyHighlighting(filesToHighlight, oldRef);
56
57 return json({ files: parsed, raw });
58 } catch (err) {
+26,
-5
1@@ -1,9 +1,20 @@
2 import { json } from '@sveltejs/kit';
3-import { getStatus, stageFile, unstageFile, resetFile, resetHunk } from '$lib/server/git.ts';
4+import { getStatus, getWorktreeStatus, getBaseBranchInfo, stageFile, unstageFile, resetFile, resetHunk } from '$lib/server/git.ts';
5+import { approveWorktreeFile, unapproveWorktreeFile } from '$lib/server/review-store.ts';
6+import type { DiffScope } from '$lib/types/index.ts';
7 import type { RequestHandler } from './$types.ts';
8
9-export const GET: RequestHandler = async () => {
10+export const GET: RequestHandler = async ({ url }) => {
11 try {
12+ const scope = (url.searchParams.get('scope') || 'uncommitted') as DiffScope;
13+
14+ if (scope === 'worktree') {
15+ // getWorktreeStatus internally calls getBaseBranchInfo and populates the cache
16+ const files = await getWorktreeStatus();
17+ const branchInfo = await getBaseBranchInfo();
18+ return json({ files, baseBranch: branchInfo.baseBranch, mergeBase: branchInfo.mergeBase });
19+ }
20+
21 const files = await getStatus();
22 return json(files);
23 } catch (err) {
24@@ -14,16 +25,26 @@ export const GET: RequestHandler = async () => {
25 export const POST: RequestHandler = async ({ request }) => {
26 try {
27 const body = await request.json();
28- const { path, action } = body;
29+ const { path, action, scope } = body;
30
31 if (!path || !action) {
32 return json({ error: 'Missing path or action' }, { status: 400 });
33 }
34
35+ const isWorktree = scope === 'worktree';
36+
37 if (action === 'approve') {
38- await stageFile(path);
39+ if (isWorktree) {
40+ approveWorktreeFile(path);
41+ } else {
42+ await stageFile(path);
43+ }
44 } else if (action === 'unapprove') {
45- await unstageFile(path);
46+ if (isWorktree) {
47+ unapproveWorktreeFile(path);
48+ } else {
49+ await unstageFile(path);
50+ }
51 } else if (action === 'reset') {
52 await resetFile(path);
53 } else if (action === 'reset-hunk') {