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}