files.ts
1import { writable, derived } from 'svelte/store';
2import type { FileChange, DiffFile, DiffScope } from '$lib/types/index';
3import { checkOutdated } from './review.ts';
4
5export type DiffMode = 'full' | 'unstaged';
6
7export const files = writable<FileChange[]>([]);
8export const uncommittedFiles = writable<FileChange[]>([]);
9export const selectedFile = writable<string | null>(null);
10export const diffFiles = writable<DiffFile[]>([]);
11export const loading = writable(false);
12export const diffMode = writable<DiffMode>('full');
13export const diffScope = writable<DiffScope>('uncommitted');
14export const baseBranch = writable<string | null>(null);
15export const vcs = writable<'git' | 'jj'>('git');
16
17export async function loadRepoInfo() {
18 try {
19 const res = await fetch('/api/repo');
20 const data = await res.json();
21 if (data.vcs === 'jj' || data.vcs === 'git') vcs.set(data.vcs);
22 } catch {
23 // Keep the git default
24 }
25}
26
27export const selectedDiff = derived([diffFiles, selectedFile], ([$diffFiles, $selectedFile]) => {
28 if (!$selectedFile) return null;
29 return $diffFiles.find((f) => f.path === $selectedFile) || null;
30});
31
32export const fileCommentCounts = writable<Record<string, number>>({});
33export const contextFiles = writable<string[]>([]);
34
35export async function loadContextFiles() {
36 const res = await fetch('/api/context-files');
37 const data = await res.json();
38 if (data.files) contextFiles.set(data.files);
39}
40
41export async function toggleContextFile(path: string) {
42 let current: string[] = [];
43 contextFiles.subscribe((v) => (current = v))();
44 const isContext = current.includes(path);
45 const res = await fetch('/api/context-files', {
46 method: 'POST',
47 headers: { 'Content-Type': 'application/json' },
48 body: JSON.stringify({ path, action: isContext ? 'remove' : 'add' })
49 });
50 const data = await res.json();
51 if (data.files) contextFiles.set(data.files);
52}
53
54export const approvedCount = derived(files, ($files) => {
55 const approved = $files.filter((f) => f.approved).length;
56 return { approved, total: $files.length };
57});
58
59export const uncommittedCount = derived(uncommittedFiles, ($uncommittedFiles) => {
60 const approved = $uncommittedFiles.filter((f) => f.approved).length;
61 return { approved, total: $uncommittedFiles.length };
62});
63
64export const diffStats = derived(files, ($files) => {
65 let additions = 0;
66 let deletions = 0;
67 let unapprovedAdditions = 0;
68 let unapprovedDeletions = 0;
69 for (const f of $files) {
70 additions += f.additions;
71 deletions += f.deletions;
72 if (!f.approved) {
73 unapprovedAdditions += f.additions;
74 unapprovedDeletions += f.deletions;
75 }
76 }
77 return { additions, deletions, unapprovedAdditions, unapprovedDeletions };
78});
79
80export const selectedFileData = derived([files, selectedFile], ([$files, $selectedFile]) => {
81 if (!$selectedFile) return null;
82 return $files.find((f) => f.path === $selectedFile) || null;
83});
84
85function getStoreValue<T>(store: { subscribe: (fn: (v: T) => void) => () => void }): T {
86 let value: T;
87 store.subscribe((v) => (value = v))();
88 return value!;
89}
90
91export async function loadFiles() {
92 loading.set(true);
93 try {
94 const scope = getStoreValue(diffScope);
95
96 if (scope === 'worktree') {
97 const res = await fetch('/api/files?scope=worktree');
98 const data = await res.json();
99 files.set(data.files);
100 baseBranch.set(data.baseBranch);
101 } else {
102 const res = await fetch('/api/files');
103 const data = await res.json();
104 const fileList = Array.isArray(data) ? data : data.files || [];
105 files.set(fileList);
106 uncommittedFiles.set(fileList);
107 baseBranch.set(null);
108 }
109
110 // Auto-select first file
111 const fileList = getStoreValue(files);
112 if (fileList.length > 0) {
113 const current = getStoreValue(selectedFile);
114 if (!current || !fileList.find((f) => f.path === current)) {
115 selectedFile.set(fileList[0].path);
116 }
117 } else {
118 selectedFile.set(null);
119 }
120 } finally {
121 loading.set(false);
122 }
123}
124
125export async function loadUncommittedFiles() {
126 const res = await fetch('/api/files');
127 const data = await res.json();
128 uncommittedFiles.set(Array.isArray(data) ? data : []);
129}
130
131export async function loadAllDiffs(mode?: DiffMode) {
132 const currentMode = mode || getStoreValue(diffMode);
133 const currentScope = getStoreValue(diffScope);
134 const res = await fetch(`/api/diff?mode=${currentMode}&scope=${currentScope}`);
135 const data = await res.json();
136 if (data.files) {
137 diffFiles.set(data.files);
138 }
139 // Check if any comment threads are outdated after refreshing diff
140 checkOutdated().catch(() => {});
141}
142
143export async function setDiffScope(scope: DiffScope) {
144 diffScope.set(scope);
145 // Reset to full diff mode when switching scope
146 diffMode.set('full');
147 await loadFiles();
148 await loadAllDiffs('full');
149}
150
151export async function approveFile(path: string) {
152 const scope = getStoreValue(diffScope);
153 await fetch('/api/files', {
154 method: 'POST',
155 headers: { 'Content-Type': 'application/json' },
156 body: JSON.stringify({ path, action: 'approve', scope })
157 });
158 await loadFiles();
159 await loadAllDiffs();
160}
161
162export async function unapproveFile(path: string) {
163 const scope = getStoreValue(diffScope);
164 await fetch('/api/files', {
165 method: 'POST',
166 headers: { 'Content-Type': 'application/json' },
167 body: JSON.stringify({ path, action: 'unapprove', scope })
168 });
169 await loadFiles();
170 await loadAllDiffs();
171}
172
173export async function resetFile(path: string) {
174 await fetch('/api/files', {
175 method: 'POST',
176 headers: { 'Content-Type': 'application/json' },
177 body: JSON.stringify({ path, action: 'reset' })
178 });
179 await loadFiles();
180 await loadAllDiffs();
181 // If the reset file was selected and is now gone, select another
182 if (getStoreValue(selectedFile) === path) {
183 const remaining = getStoreValue(files);
184 selectedFile.set(remaining.length > 0 ? remaining[0].path : null);
185 }
186}
187
188export async function resetHunk(path: string, hunkHeader: string) {
189 await fetch('/api/files', {
190 method: 'POST',
191 headers: { 'Content-Type': 'application/json' },
192 body: JSON.stringify({ path, action: 'reset-hunk', hunkHeader })
193 });
194 await loadFiles();
195 await loadAllDiffs();
196}
197
198export async function toggleApproval(path: string) {
199 const file = getStoreValue(files).find((f) => f.path === path);
200 if (file?.approved) {
201 await unapproveFile(path);
202 } else {
203 await approveFile(path);
204 }
205}