+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 />