7 files changed,
+262,
-30
+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
+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']);
+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',
+2,
-0
1@@ -31,3 +31,5 @@ export interface DiffLine {
2 oldNumber?: number;
3 newNumber?: number;
4 }
5+
6+export type DiffScope = 'uncommitted' | 'worktree';
+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}
+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) {
+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) {