Add Uncommitted/Worktree scope toggle for full branch diff review
Introduces a DiffScope concept ('uncommitted' | 'worktree') orthogonal to the existing DiffMode ('full' | 'unstaged'). The Worktree scope shows all changes since the branch diverged from the base branch (auto-detected from main/master/development/develop via merge-base), including both committed and uncommitted work.
7 files changed,  +262, -30
M src/lib/components/Toolbar.svelte
+18, -1
 1@@ -1,7 +1,7 @@
 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, diffStats, loadAllDiffs, setDiffScope } from '$lib/stores/files.ts';
 7 
 8 	let showReviewMenu = $state(false);
 9 
10@@ -40,6 +40,23 @@
11 		</button>
12 	</div>
13 
14+	<!-- Diff scope toggle -->
15+	<div class="flex bg-surface rounded overflow-hidden border border-border">
16+		<button
17+			class="text-xs px-3 py-1 transition-colors {$diffScope === 'uncommitted' ? 'bg-active text-white' : 'text-tertiary hover:text-primary'}"
18+			onclick={() => setDiffScope('uncommitted')}
19+		>
20+			Uncommitted
21+		</button>
22+		<button
23+			class="text-xs px-3 py-1 transition-colors {$diffScope === 'worktree' ? 'bg-active text-white' : 'text-tertiary hover:text-primary'}"
24+			onclick={() => setDiffScope('worktree')}
25+			title={$baseBranch ? `Comparing against ${$baseBranch}` : 'Show all branch changes'}
26+		>
27+			Worktree
28+		</button>
29+	</div>
30+
31 	<!-- Diff mode toggle -->
32 	<div class="flex bg-surface rounded overflow-hidden border border-border">
33 		<button
M src/lib/server/git.ts
+167, -4
  1@@ -1,5 +1,5 @@
  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 
  6 let git: SimpleGit;
  7 
  8@@ -315,10 +315,10 @@ function getLanguage(filePath: string): string {
  9 	return map[ext] || filenameMap[filename] || 'text';
 10 }
 11 
 12-export async function getFileContent(filePath: string): Promise<string> {
 13+export async function getFileContent(filePath: string, ref: string = 'HEAD'): Promise<string> {
 14 	const g = getGit();
 15 	try {
 16-		return await g.show(['HEAD:' + filePath]);
 17+		return await g.show([`${ref}:${filePath}`]);
 18 	} catch {
 19 		return '';
 20 	}
 21@@ -410,10 +410,13 @@ export async function commit(message: string): Promise<string> {
 22 
 23 export type DiffMode = 'full' | 'unstaged';
 24 
 25-export async function getDiffByMode(mode: DiffMode, filePath?: string): Promise<string> {
 26+export async function getDiffByMode(mode: DiffMode, filePath?: string, scope?: DiffScope): Promise<string> {
 27 	if (mode === 'unstaged') {
 28 		return getUnstagedDiff(filePath);
 29 	}
 30+	if (scope === 'worktree') {
 31+		return getWorktreeDiff(filePath);
 32+	}
 33 	return getDiff(filePath);
 34 }
 35 
 36@@ -451,6 +454,166 @@ async function getUnstagedDiff(filePath?: string): Promise<string> {
 37 	}
 38 }
 39 
 40+// --- Worktree (branch) diff support ---
 41+
 42+let cachedBaseBranchInfo: { baseBranch: string | null; mergeBase: string | null } | null = null;
 43+
 44+export function clearBaseBranchCache(): void {
 45+	cachedBaseBranchInfo = null;
 46+}
 47+
 48+export async function getBaseBranchInfo(): Promise<{ baseBranch: string | null; mergeBase: string | null }> {
 49+	if (cachedBaseBranchInfo) return cachedBaseBranchInfo;
 50+
 51+	const g = getGit();
 52+	const currentBranch = (await g.revparse(['--abbrev-ref', 'HEAD'])).trim();
 53+
 54+	// Detached HEAD — can't determine base branch
 55+	if (currentBranch === 'HEAD') {
 56+		cachedBaseBranchInfo = { baseBranch: null, mergeBase: null };
 57+		return cachedBaseBranchInfo;
 58+	}
 59+
 60+	const candidates = ['main', 'master', 'development', 'develop'];
 61+	let bestBranch: string | null = null;
 62+	let bestMergeBase: string | null = null;
 63+	let bestTimestamp = 0;
 64+
 65+	for (const branch of candidates) {
 66+		try {
 67+			// Check if branch exists locally
 68+			await g.raw(['rev-parse', '--verify', branch]);
 69+
 70+			// Skip if we're on this branch
 71+			if (branch === currentBranch) continue;
 72+
 73+			const mergeBase = (await g.raw(['merge-base', 'HEAD', branch])).trim();
 74+			// Get the timestamp of the merge-base to pick the most recent one
 75+			const timestamp = parseInt(
 76+				(await g.raw(['log', '-1', '--format=%ct', mergeBase])).trim(),
 77+				10
 78+			);
 79+
 80+			if (timestamp > bestTimestamp) {
 81+				bestTimestamp = timestamp;
 82+				bestBranch = branch;
 83+				bestMergeBase = mergeBase;
 84+			}
 85+		} catch {
 86+			// Branch doesn't exist or merge-base failed — skip
 87+		}
 88+	}
 89+
 90+	cachedBaseBranchInfo = { baseBranch: bestBranch, mergeBase: bestMergeBase };
 91+	return cachedBaseBranchInfo;
 92+}
 93+
 94+async function getWorktreeDiff(filePath?: string): Promise<string> {
 95+	const g = getGit();
 96+	const { mergeBase } = await getBaseBranchInfo();
 97+	if (!mergeBase) return getDiff(filePath); // Fallback to regular diff
 98+
 99+	const args = [mergeBase];
100+	if (filePath) args.push('--', filePath);
101+	let result = '';
102+	try {
103+		result = await g.diff(args);
104+	} catch {
105+		result = '';
106+	}
107+
108+	// Also include untracked files (same as getDiff)
109+	const status = await g.status();
110+	const untrackedFiles = filePath
111+		? status.not_added.filter((f) => f === filePath)
112+		: status.not_added;
113+
114+	for (const f of untrackedFiles) {
115+		try {
116+			const { readFile } = await import('fs/promises');
117+			const { join } = await import('path');
118+			const content = await readFile(join(getRepoDir(), f), 'utf-8');
119+			const lines = content.split('\n');
120+			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`;
121+			const body = lines.map((l) => '+' + l).join('\n');
122+			result += '\n' + header + body + '\n';
123+		} catch {
124+			// Skip files that can't be read
125+		}
126+	}
127+
128+	return result;
129+}
130+
131+export async function getWorktreeStatus(): Promise<FileChange[]> {
132+	const g = getGit();
133+	const { mergeBase } = await getBaseBranchInfo();
134+	if (!mergeBase) return getStatus(); // Fallback
135+
136+	const files: FileChange[] = [];
137+	const seen = new Set<string>();
138+
139+	// Get all files changed between merge-base and working tree
140+	const nameStatus = await g.diff(['--name-status', mergeBase]);
141+	for (const line of nameStatus.split('\n')) {
142+		if (!line.trim()) continue;
143+		const match = line.match(/^([AMDRC])\d*\t(.+?)(?:\t(.+))?$/);
144+		if (!match) continue;
145+		const [, statusChar, filePath, newPath] = match;
146+		const path = newPath || filePath;
147+		if (seen.has(path)) continue;
148+		seen.add(path);
149+
150+		let status: FileChange['status'] = 'modified';
151+		let oldPath: string | undefined;
152+		if (statusChar === 'A') status = 'added';
153+		else if (statusChar === 'D') status = 'deleted';
154+		else if (statusChar === 'R' || statusChar === 'C') {
155+			status = 'renamed';
156+			oldPath = filePath;
157+		}
158+
159+		files.push({ path, oldPath, status, staged: false, approved: false, additions: 0, deletions: 0 });
160+	}
161+
162+	// Include untracked files
163+	const gitStatus = await g.status();
164+	for (const f of gitStatus.not_added) {
165+		if (seen.has(f)) continue;
166+		seen.add(f);
167+		files.push({ path: f, status: 'added', staged: false, approved: false, additions: 0, deletions: 0 });
168+	}
169+
170+	// Merge staging info from git status
171+	const stagedSet = new Set([...gitStatus.created, ...gitStatus.staged]);
172+	for (const file of files) {
173+		if (stagedSet.has(file.path)) {
174+			file.staged = true;
175+			file.approved = true;
176+		}
177+	}
178+
179+	// Get numstat for additions/deletions
180+	try {
181+		const diffStat = await g.diff([mergeBase, '--numstat']);
182+		for (const line of diffStat.split('\n')) {
183+			const match = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
184+			if (match) {
185+				const [, adds, dels, path] = match;
186+				const file = files.find((f) => f.path === path);
187+				if (file) {
188+					file.additions = adds === '-' ? 0 : parseInt(adds, 10);
189+					file.deletions = dels === '-' ? 0 : parseInt(dels, 10);
190+				}
191+			}
192+		}
193+	} catch {
194+		// numstat may fail
195+	}
196+
197+	return files;
198+}
199+
200 export async function getRepoInfo(): Promise<{ branch: string; repoName: string }> {
201 	const g = getGit();
202 	const branch = await g.revparse(['--abbrev-ref', 'HEAD']);
M src/lib/stores/files.ts
+38, -13
 1@@ -1,5 +1,5 @@
 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,6 +9,8 @@ export const selectedFile = writable<string | null>(null);
 9 export const diffFiles = writable<DiffFile[]>([]);
10 export const loading = writable(false);
11 export const diffMode = writable<DiffMode>('full');
12+export const diffScope = writable<DiffScope>('uncommitted');
13+export const baseBranch = writable<string | null>(null);
14 
15 export const selectedDiff = derived([diffFiles, selectedFile], ([$diffFiles, $selectedFile]) => {
16 	if (!$selectedFile) return null;
17@@ -63,21 +65,37 @@ export const selectedFileData = derived([files, selectedFile], ([$files, $select
18 	return $files.find((f) => f.path === $selectedFile) || null;
19 });
20 
21+function getStoreValue<T>(store: { subscribe: (fn: (v: T) => void) => () => void }): T {
22+	let value: T;
23+	store.subscribe((v) => (value = v))();
24+	return value!;
25+}
26+
27 export async function loadFiles() {
28 	loading.set(true);
29 	try {
30-		const res = await fetch('/api/files');
31+		const scope = getStoreValue(diffScope);
32+		const url = scope === 'worktree' ? '/api/files?scope=worktree' : '/api/files';
33+		const res = await fetch(url);
34 		const data = await res.json();
35-		files.set(data);
36+
37+		if (scope === 'worktree') {
38+			files.set(data.files);
39+			baseBranch.set(data.baseBranch);
40+		} else {
41+			files.set(Array.isArray(data) ? data : data.files || []);
42+			baseBranch.set(null);
43+		}
44 
45 		// Auto-select first file
46-		if (data.length > 0) {
47-			const current = await new Promise<string | null>((resolve) => {
48-				selectedFile.subscribe((v) => resolve(v))();
49-			});
50-			if (!current) {
51-				selectedFile.set(data[0].path);
52+		const fileList = getStoreValue(files);
53+		if (fileList.length > 0) {
54+			const current = getStoreValue(selectedFile);
55+			if (!current || !fileList.find((f) => f.path === current)) {
56+				selectedFile.set(fileList[0].path);
57 			}
58+		} else {
59+			selectedFile.set(null);
60 		}
61 	} finally {
62 		loading.set(false);
63@@ -85,10 +103,9 @@ export async function loadFiles() {
64 }
65 
66 export async function loadAllDiffs(mode?: DiffMode) {
67-	const currentMode = mode || await new Promise<DiffMode>((resolve) => {
68-		diffMode.subscribe((v) => resolve(v))();
69-	});
70-	const res = await fetch(`/api/diff?mode=${currentMode}`);
71+	const currentMode = mode || getStoreValue(diffMode);
72+	const currentScope = getStoreValue(diffScope);
73+	const res = await fetch(`/api/diff?mode=${currentMode}&scope=${currentScope}`);
74 	const data = await res.json();
75 	if (data.files) {
76 		diffFiles.set(data.files);
77@@ -97,6 +114,14 @@ export async function loadAllDiffs(mode?: DiffMode) {
78 	checkOutdated().catch(() => {});
79 }
80 
81+export async function setDiffScope(scope: DiffScope) {
82+	diffScope.set(scope);
83+	// Reset to full diff mode when switching scope
84+	diffMode.set('full');
85+	await loadFiles();
86+	await loadAllDiffs('full');
87+}
88+
89 export async function approveFile(path: string) {
90 	const res = await fetch('/api/files', {
91 		method: 'POST',
M src/lib/types/diff.ts
+2, -0
1@@ -31,3 +31,5 @@ export interface DiffLine {
2 	oldNumber?: number;
3 	newNumber?: number;
4 }
5+
6+export type DiffScope = 'uncommitted' | 'worktree';
M src/routes/+page.svelte
+8, -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 } 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}
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@@ -53,11 +53,19 @@ 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 raw = await getDiffByMode(mode, filePath);
35+		const scope = (url.searchParams.get('scope') || 'uncommitted') as DiffScope;
36+		const raw = await getDiffByMode(mode, filePath, scope);
37 		const parsed = parseDiff(raw);
38 
39+		// Determine the old ref for syntax highlighting
40+		let oldRef = 'HEAD';
41+		if (scope === 'worktree') {
42+			const { mergeBase } = await getBaseBranchInfo();
43+			if (mergeBase) oldRef = mergeBase;
44+		}
45+
46 		// Apply syntax highlighting
47-		await applyHighlighting(parsed);
48+		await applyHighlighting(parsed, oldRef);
49 
50 		return json({ files: parsed, raw });
51 	} catch (err) {
M src/routes/api/files/+server.ts
+14, -2
 1@@ -1,9 +1,21 @@
 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, clearBaseBranchCache } from '$lib/server/git.ts';
 5+import type { DiffScope } from '$lib/types/index.ts';
 6 import type { RequestHandler } from './$types.ts';
 7 
 8-export const GET: RequestHandler = async () => {
 9+export const GET: RequestHandler = async ({ url }) => {
10 	try {
11+		const scope = (url.searchParams.get('scope') || 'uncommitted') as DiffScope;
12+
13+		if (scope === 'worktree') {
14+			clearBaseBranchCache();
15+			const [files, branchInfo] = await Promise.all([
16+				getWorktreeStatus(),
17+				getBaseBranchInfo()
18+			]);
19+			return json({ files, baseBranch: branchInfo.baseBranch, mergeBase: branchInfo.mergeBase });
20+		}
21+
22 		const files = await getStatus();
23 		return json(files);
24 	} catch (err) {