Merge branch 'main' of github.com:AlexIzh/local-code-review
# Conflicts: # src/routes/api/diff/+server.ts
10 files changed,  +380, -81
M README.md
+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
M src/lib/components/CommitDialog.svelte
+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>
M src/lib/components/Toolbar.svelte
+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}
M src/lib/server/git.ts
+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']);
M src/lib/server/review-store.ts
+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 {
M src/lib/stores/files.ts
+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 {
M src/lib/types/diff.ts
+2, -0
1@@ -33,3 +33,5 @@ export interface DiffLine {
2 	oldNumber?: number;
3 	newNumber?: number;
4 }
5+
6+export type DiffScope = 'uncommitted' | 'worktree';
M src/routes/+page.svelte
+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>
M src/routes/api/diff/+server.ts
+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) {
M src/routes/api/files/+server.ts
+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') {