+page.svelte

  1<script lang="ts">
  2	import { onMount } from 'svelte';
  3	import { get } from 'svelte/store';
  4	import FileTree from '$lib/components/FileTree.svelte';
  5	import ChangeStack from '$lib/components/ChangeStack.svelte';
  6	import DiffView from '$lib/components/DiffView.svelte';
  7	import Toolbar from '$lib/components/Toolbar.svelte';
  8	import CommitDialog from '$lib/components/CommitDialog.svelte';
  9	import ExportDialog from '$lib/components/ExportDialog.svelte';
 10	import TerminalPanel from '$lib/components/TerminalPanel.svelte';
 11	import { files, selectedFile, selectedDiff, selectedFileData, loadFiles, loadAllDiffs, loadRepoInfo, toggleApproval, diffScope, diffMode, vcs } from '$lib/stores/files';
 12	import { loadComments } from '$lib/stores/review';
 13	import { loadChanges, lastStackSignature } from '$lib/stores/changes';
 14	import { sidebarOpen, commentingLine } from '$lib/stores/ui';
 15	import { toggleTerminal } from '$lib/stores/terminal';
 16
 17	let loading = $state(true);
 18	let error = $state<string | null>(null);
 19	let sidebarWidth = $state(288); // 18rem default
 20	let isResizingSidebar = $state(false);
 21
 22	function startSidebarResize(e: MouseEvent) {
 23		e.preventDefault();
 24		isResizingSidebar = true;
 25		const startX = e.clientX;
 26		const startWidth = sidebarWidth;
 27
 28		function onMouseMove(e: MouseEvent) {
 29			const newWidth = startWidth + (e.clientX - startX);
 30			sidebarWidth = Math.max(200, Math.min(600, newWidth));
 31		}
 32
 33		function onMouseUp() {
 34			isResizingSidebar = false;
 35			window.removeEventListener('mousemove', onMouseMove);
 36			window.removeEventListener('mouseup', onMouseUp);
 37		}
 38
 39		window.addEventListener('mousemove', onMouseMove);
 40		window.addEventListener('mouseup', onMouseUp);
 41	}
 42
 43	onMount(() => {
 44		(async () => {
 45			try {
 46				await loadRepoInfo();
 47				await loadChanges();
 48				await loadFiles();
 49				await loadAllDiffs();
 50				await loadComments();
 51			} catch (err) {
 52				error = String(err);
 53			} finally {
 54				loading = false;
 55			}
 56		})();
 57
 58		// Track along with the session: poll for new changes and reload when the
 59		// stack or a commit actually changes. Skip while a comment is being edited.
 60		const refresh = async () => {
 61			if (get(vcs) !== 'jj') return;
 62			if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
 63			if (get(commentingLine)) return;
 64
 65			const before = get(lastStackSignature);
 66			await loadChanges();
 67			if (get(lastStackSignature) === before) return;
 68			await loadFiles();
 69			await loadAllDiffs();
 70		};
 71
 72		const interval = setInterval(refresh, 5000);
 73		const onFocus = () => refresh();
 74		const onVisibility = () => {
 75			if (document.visibilityState === 'visible') refresh();
 76		};
 77		window.addEventListener('focus', onFocus);
 78		document.addEventListener('visibilitychange', onVisibility);
 79
 80		return () => {
 81			clearInterval(interval);
 82			window.removeEventListener('focus', onFocus);
 83			document.removeEventListener('visibilitychange', onVisibility);
 84		};
 85	});
 86
 87	function handleKeydown(e: KeyboardEvent) {
 88		if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return;
 89
 90		const fileList = $files;
 91		const currentIdx = fileList.findIndex((f) => f.path === $selectedFile);
 92
 93		if (e.key === 'j' && currentIdx < fileList.length - 1) {
 94			e.preventDefault();
 95			$selectedFile = fileList[currentIdx + 1].path;
 96		}
 97		if (e.key === 'k' && currentIdx > 0) {
 98			e.preventDefault();
 99			$selectedFile = fileList[currentIdx - 1].path;
100		}
101		if (e.key === 'b') {
102			e.preventDefault();
103			$sidebarOpen = !$sidebarOpen;
104		}
105		if (e.key === '`') {
106			e.preventDefault();
107			toggleTerminal();
108		}
109	}
110</script>
111
112<svelte:window onkeydown={handleKeydown} />
113
114<div class="h-full flex flex-col">
115	<Toolbar />
116
117	<div class="flex-1 flex overflow-hidden">
118		<!-- Sidebar -->
119		{#if $sidebarOpen}
120			<aside class="shrink-0 bg-panel/50 border-r border-border overflow-hidden flex flex-col relative" style="width: {sidebarWidth}px">
121				<div class="flex-1 min-h-0 overflow-hidden">
122					<FileTree />
123				</div>
124				<ChangeStack />
125				<!-- Resize handle -->
126				<!-- svelte-ignore a11y_no_static_element_interactions -->
127				<div
128					class="absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-accent-blue/50 transition-colors {isResizingSidebar ? 'bg-accent-blue/50' : ''}"
129					onmousedown={startSidebarResize}
130				></div>
131			</aside>
132		{/if}
133
134		<!-- Main diff area -->
135		<main class="flex-1 overflow-y-auto">
136			{#if loading}
137				<div class="flex items-center justify-center h-full text-muted">
138					<div class="text-center">
139						<div class="text-2xl mb-2 animate-pulse">⏳</div>
140						<p class="text-sm">Loading changes...</p>
141					</div>
142				</div>
143			{:else if error}
144				<div class="flex items-center justify-center h-full text-accent-red">
145					<div class="text-center max-w-md">
146						<div class="text-2xl mb-2">⚠️</div>
147						<p class="text-sm">{error}</p>
148					</div>
149				</div>
150			{:else if $files.length === 0}
151				<div class="flex items-center justify-center h-full text-muted">
152					<div class="text-center">
153						<div class="text-4xl mb-3">✨</div>
154						{#if $diffScope === 'worktree'}
155							<p class="font-medium text-secondary">No changes on this branch</p>
156							<p class="text-sm mt-1">Commits and uncommitted changes relative to the base branch will appear here</p>
157						{:else}
158							<p class="font-medium text-secondary">No uncommitted changes</p>
159							<p class="text-sm mt-1">Make some changes and come back to review them</p>
160						{/if}
161					</div>
162				</div>
163			{:else if $selectedDiff}
164				<!-- File header -->
165				<div class="sticky top-0 z-10 bg-surface/95 backdrop-blur border-b border-border px-4 py-2 flex items-center gap-2">
166					<button
167						class="shrink-0 w-5 h-5 rounded border flex items-center justify-center transition-colors {$selectedFileData?.approved ? 'bg-green-600 border-green-600 text-white' : 'border-border-strong hover:border-accent-green text-transparent hover:text-accent-green'}"
168						onclick={() => toggleApproval($selectedDiff!.path)}
169						title={$selectedFileData?.approved ? 'Unapprove file' : 'Approve file'}
170					>
171						<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
172							<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
173						</svg>
174					</button>
175					<span class="font-mono text-sm text-primary">{$selectedDiff.path}</span>
176					<span class="text-xs text-muted">({$selectedDiff.language})</span>
177					{#if $selectedFileData}
178						<span class="text-xs tabular-nums">
179							{#if $selectedFileData.additions > 0}
180								<span class="text-accent-green">+{$selectedFileData.additions}</span>
181							{/if}
182							{#if $selectedFileData.deletions > 0}
183								<span class="text-accent-red ml-1">-{$selectedFileData.deletions}</span>
184							{/if}
185						</span>
186					{/if}
187					{#if $selectedFileData?.approved}
188						<span class="text-xs px-2 py-0.5 rounded-full bg-accent-green/20 text-accent-green">Approved</span>
189					{/if}
190				</div>
191				<DiffView file={$selectedDiff} />
192			{:else if $selectedFile}
193				<div class="flex items-center justify-center h-full text-muted">
194					<div class="text-center">
195						<p class="font-mono text-sm text-secondary mb-2">{$selectedFile}</p>
196						{#if $diffMode === 'unstaged'}
197							<p class="text-sm">No unstaged changes in this file</p>
198						{:else}
199							<p class="text-sm">No diff available for this file</p>
200						{/if}
201					</div>
202				</div>
203			{:else}
204				<div class="flex items-center justify-center h-full text-muted">
205					<p class="text-sm">Select a file from the sidebar</p>
206				</div>
207			{/if}
208		</main>
209	</div>
210
211	<TerminalPanel />
212</div>
213
214<CommitDialog />
215<ExportDialog />