comparison hg-web/src/components/app.tsx @ 226:3fa4bf481f42

[merge] Merge hg-web into default
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 14:42:01 -0700
parents 70de0c80d093
children
comparison
equal deleted inserted replaced
220:eb8b4230fdb9 226:3fa4bf481f42
3 import { DirectoryBrowser } from "hg-web/src/components/directory-browser"; 3 import { DirectoryBrowser } from "hg-web/src/components/directory-browser";
4 import { Header } from "hg-web/src/components/header"; 4 import { Header } from "hg-web/src/components/header";
5 import { Footer } from "hg-web/src/components/footer"; 5 import { Footer } from "hg-web/src/components/footer";
6 import { ThemeProvider, useTheme } from "hg-web/src/components/theme"; 6 import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
7 7
8 type Page = 'landing' | 'graph' | 'directory'; 8 type Page = 'landing' | 'graph' | 'directory' | 'changeset';
9 9
10 type RouteState = { 10 type RouteState = {
11 page: Page; 11 page: Page;
12 graphCommit?: string; 12 graphCommit?: string;
13 graphTip?: string; 13 graphTip?: string;
14 dirPath?: string; 14 dirPath?: string;
15 changesetId?: string;
16 returnDepth?: number;
17 }
18
19 type ChangesetDetail = {
20 node: string;
21 date: [number, number];
22 desc: string;
23 branch: string;
24 bookmarks: string[];
25 tags: string[];
26 user: string;
27 parents: string[];
28 files: Array<{
29 file: string;
30 status: string;
31 }>;
32 diff: Array<{
33 blockno: number;
34 lines: Array<{ t: string; n: number; l: string }>;
35 }>;
36 };
37
38 type DiffLine = ChangesetDetail['diff'][number]['lines'][number];
39
40 type DiffCell = {
41 lineNumber: number | null;
42 text: string;
43 kind: 'context' | 'add' | 'remove' | 'meta';
44 };
45
46 type SideBySideRow = {
47 left?: DiffCell;
48 right?: DiffCell;
49 range?: string;
50 };
51
52 function trimDiffLine(line: string): string {
53 return line.endsWith('\n') ? line.slice(0, -1) : line;
54 }
55
56 function contentDiffLine(line: DiffLine): string {
57 const text = trimDiffLine(line.l);
58 if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') &&
59 text.startsWith(line.t || ' ')) {
60 return text.slice(1);
61 }
62 return text;
63 }
64
65 function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] {
66 const rows: SideBySideRow[] = [];
67 let removals: DiffCell[] = [];
68 let additions: DiffCell[] = [];
69 let oldLine: number | null = null;
70 let newLine: number | null = null;
71 let inHunk = false;
72
73 const flushChanges = () => {
74 const count = Math.max(removals.length, additions.length);
75 for (let index = 0; index < count; index++) {
76 rows.push({ left: removals[index], right: additions[index] });
77 }
78 removals = [];
79 additions = [];
80 };
81
82 for (const line of lines) {
83 if (line.t === '@') {
84 flushChanges();
85 const range = trimDiffLine(line.l);
86 const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
87 oldLine = match ? Number(match[1]) : null;
88 newLine = match ? Number(match[2]) : null;
89 inHunk = true;
90 rows.push({ range });
91 continue;
92 }
93
94 if (!inHunk && (line.t === '-' || line.t === '+')) {
95 const cell: DiffCell = {
96 lineNumber: null,
97 text: trimDiffLine(line.l),
98 kind: 'meta',
99 };
100 if (line.t === '-') removals.push(cell);
101 else additions.push(cell);
102 continue;
103 }
104
105 if (line.t === '-') {
106 removals.push({
107 lineNumber: oldLine,
108 text: contentDiffLine(line),
109 kind: 'remove',
110 });
111 if (oldLine !== null) oldLine++;
112 continue;
113 }
114
115 if (line.t === '+') {
116 additions.push({
117 lineNumber: newLine,
118 text: contentDiffLine(line),
119 kind: 'add',
120 });
121 if (newLine !== null) newLine++;
122 continue;
123 }
124
125 flushChanges();
126 const text = contentDiffLine(line);
127 rows.push({
128 left: { lineNumber: oldLine, text, kind: 'context' },
129 right: { lineNumber: newLine, text, kind: 'context' },
130 });
131 if (oldLine !== null) oldLine++;
132 if (newLine !== null) newLine++;
133 }
134
135 flushChanges();
136 return rows;
137 }
138
139 function diffBlockFilename(
140 block: ChangesetDetail['diff'][number],
141 fallback?: string,
142 ): string {
143 if (fallback) return fallback;
144 const newFileHeader = block.lines.find(
145 line => line.t === '+' && line.l.startsWith('+++ '),
146 );
147 if (!newFileHeader) return `Diff block ${block.blockno}`;
148 return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0];
149 }
150
151 function SideBySideDiff({
152 block,
153 filename,
154 }: {
155 block: ChangesetDetail['diff'][number];
156 filename: string;
157 }) {
158 const rows = buildSideBySideRows(block.lines);
159 return (
160 <div className="side-by-side-diff">
161 <div className="diff-file-header">{filename}</div>
162 <div className="diff-column-headings">
163 <span>Before</span>
164 <span>After</span>
165 </div>
166 <div className="diff-grid">
167 {rows.map((row, index) => (
168 row.range ? (
169 <div className="diff-range-row" key={`range-${index}`}>{row.range}</div>
170 ) : (
171 <React.Fragment key={`row-${index}`}>
172 <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}>
173 {row.left?.lineNumber ?? ''}
174 </span>
175 <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}>
176 {row.left?.text ?? ''}
177 </code>
178 <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}>
179 {row.right?.lineNumber ?? ''}
180 </span>
181 <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}>
182 {row.right?.text ?? ''}
183 </code>
184 </React.Fragment>
185 )
186 ))}
187 </div>
188 </div>
189 );
15 } 190 }
16 191
17 // Icons 192 // Icons
18 const ICONS = { 193 const ICONS = {
19 folder: "/icons/folder.png", 194 folder: "/icons/folder.png",
38 const API_BASE = '/api/repo'; 213 const API_BASE = '/api/repo';
39 214
40 function parseRoute(): RouteState { 215 function parseRoute(): RouteState {
41 const params = new URLSearchParams(window.location.search); 216 const params = new URLSearchParams(window.location.search);
42 const pathname = window.location.pathname; 217 const pathname = window.location.pathname;
218 const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/);
219
220 if (changesetMatch) {
221 try {
222 const changesetId = decodeURIComponent(changesetMatch[1]);
223 if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) {
224 return { page: 'changeset', changesetId };
225 }
226 } catch {
227 return { page: 'landing' };
228 }
229 }
43 230
44 if (pathname.startsWith('/graph') || params.has('graph')) { 231 if (pathname.startsWith('/graph') || params.has('graph')) {
45 return { 232 return {
46 page: 'graph', 233 page: 'graph',
47 graphCommit: params.get('commit') || undefined, 234 graphCommit: params.get('commit') || undefined,
68 if (state.graphTip) params.set('tip', state.graphTip); 255 if (state.graphTip) params.set('tip', state.graphTip);
69 return `/graph${params.toString() ? '?' + params.toString() : ''}`; 256 return `/graph${params.toString() ? '?' + params.toString() : ''}`;
70 case 'directory': 257 case 'directory':
71 if (state.dirPath) params.set('path', state.dirPath); 258 if (state.dirPath) params.set('path', state.dirPath);
72 return `/directory${params.toString() ? '?' + params.toString() : ''}`; 259 return `/directory${params.toString() ? '?' + params.toString() : ''}`;
260 case 'changeset':
261 return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph';
73 default: 262 default:
74 return '/'; 263 return '/';
75 } 264 }
265 }
266
267 function isRouteState(value: unknown): value is RouteState {
268 if (!value || typeof value !== 'object' || !('page' in value)) return false;
269 return ['landing', 'graph', 'directory', 'changeset'].includes(
270 String((value as { page: unknown }).page),
271 );
76 } 272 }
77 273
78 // Landing Page Component 274 // Landing Page Component
79 function LandingPage({ 275 function LandingPage({
80 onNavigateToGraph, 276 onNavigateToGraph,
81 onNavigateToDirectory, 277 onNavigateToDirectory,
278 onNavigateToChangeset,
82 }: { 279 }: {
83 onNavigateToGraph: () => void; 280 onNavigateToGraph: () => void;
84 onNavigateToDirectory: (path?: string) => void; 281 onNavigateToDirectory: (path?: string) => void;
282 onNavigateToChangeset: (node: string) => void;
85 }) { 283 }) {
86 const [directories, setDirectories] = useState<any[]>([]); 284 const [directories, setDirectories] = useState<any[]>([]);
87 const [files, setFiles] = useState<any[]>([]); 285 const [files, setFiles] = useState<any[]>([]);
88 const [dirLoading, setDirLoading] = useState(true); 286 const [dirLoading, setDirLoading] = useState(true);
89 287
126 <div className="loading-state">Loading commits...</div> 324 <div className="loading-state">Loading commits...</div>
127 ) : graphData ? ( 325 ) : graphData ? (
128 <Graph 326 <Graph
129 data={graphData} 327 data={graphData}
130 maxRows={8} 328 maxRows={8}
131 onCommitClick={(node) => { 329 onCommitClick={onNavigateToChangeset}
132 console.log('Clicked commit:', node);
133 }}
134 /> 330 />
135 ) : ( 331 ) : (
136 <div className="empty-state">Failed to load commits</div> 332 <div className="empty-state">Failed to load commits</div>
137 )} 333 )}
138 </div> 334 </div>
184 // Graph Page Component 380 // Graph Page Component
185 function GraphPage({ 381 function GraphPage({
186 onBack, 382 onBack,
187 initialCommit, 383 initialCommit,
188 initialTip, 384 initialTip,
385 onOpenChangeset,
189 }: { 386 }: {
190 onBack: () => void; 387 onBack: () => void;
191 initialCommit?: string; 388 initialCommit?: string;
192 initialTip?: string; 389 initialTip?: string;
390 onOpenChangeset: (node: string) => void;
193 }) { 391 }) {
194 const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({ 392 const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({
195 initialCommit: initialCommit || null, 393 initialCommit: initialCommit || null,
196 graphTop: initialTip || null, 394 graphTop: initialTip || null,
197 }); 395 });
207 }, [currentCommit, tip]); 405 }, [currentCommit, tip]);
208 406
209 return ( 407 return (
210 <div> 408 <div>
211 <div className="page-header"> 409 <div className="page-header">
212 <button className="back-button" onClick={onBack}> 410 <button className="back-button" onClick={onBack} aria-label="Back">
213 &larr; Back 411 &larr; Back
214 </button> 412 </button>
215 <span className="page-title">Commit Graph</span> 413 <span className="page-title">Commit Graph</span>
216 </div> 414 </div>
217 415
237 <Graph 435 <Graph
238 data={data} 436 data={data}
239 loading={loading} 437 loading={loading}
240 hasMore={hasMore} 438 hasMore={hasMore}
241 onLoadMore={loadMore} 439 onLoadMore={loadMore}
242 onCommitClick={(node) => { 440 onCommitClick={onOpenChangeset}
243 console.log('Clicked commit:', node);
244 }}
245 /> 441 />
442 </div>
443 );
444 }
445
446 function ChangesetPage({
447 changesetId,
448 onBack,
449 onOpenChangeset,
450 }: {
451 changesetId: string;
452 onBack: () => void;
453 onOpenChangeset: (node: string) => void;
454 }) {
455 const [changeset, setChangeset] = useState<ChangesetDetail | null>(null);
456 const [loading, setLoading] = useState(true);
457 const [error, setError] = useState<string | null>(null);
458
459 useEffect(() => {
460 const controller = new AbortController();
461
462 setLoading(true);
463 setError(null);
464 setChangeset(null);
465
466 fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal })
467 .then(async response => {
468 if (!response.ok) {
469 const message = await response.text();
470 throw new Error(message || `Unable to load changeset (${response.status})`);
471 }
472 return response.json() as Promise<ChangesetDetail>;
473 })
474 .then(setChangeset)
475 .catch(err => {
476 if (err.name !== 'AbortError') setError(err.message);
477 })
478 .finally(() => {
479 if (!controller.signal.aborted) setLoading(false);
480 });
481
482 return () => controller.abort();
483 }, [changesetId]);
484
485 return (
486 <div>
487 <div className="page-header">
488 <button className="back-button" onClick={onBack} aria-label="Back">
489 &larr; Back
490 </button>
491 <span className="page-title">Changeset</span>
492 </div>
493
494 {loading && <div className="loading-state">Loading changeset...</div>}
495 {error && <div className="error-message">Error: {error}</div>}
496
497 {changeset && (
498 <article className="changeset-paper">
499 <div className="changeset-heading">
500 <code>{changeset.node}</code>
501 <span className="changeset-branch">{changeset.branch}</span>
502 </div>
503 <h2>{changeset.desc}</h2>
504 <div className="changeset-meta">
505 <span>{changeset.user}</span>
506 <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}>
507 {new Date(changeset.date[0] * 1000).toLocaleString()}
508 </time>
509 </div>
510
511 {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && (
512 <div className="changeset-labels">
513 {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)}
514 {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)}
515 </div>
516 )}
517
518 {changeset.parents.length > 0 && (
519 <div className="changeset-parents">
520 <strong>Parents</strong>
521 {changeset.parents.map(parent => (
522 <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}>
523 {parent.substring(0, 12)}
524 </button>
525 ))}
526 </div>
527 )}
528
529 {changeset.files.length > 0 && (
530 <div className="changeset-files">
531 <strong>Files</strong>
532 {changeset.files.map(file => (
533 <code key={file.file}>
534 <span className={`changeset-file-status status-${file.status}`}>
535 {file.status}
536 </span>
537 {file.file}
538 </code>
539 ))}
540 </div>
541 )}
542
543 <section className="changeset-diff" aria-label="Changeset diff">
544 <h3>Diff</h3>
545 {changeset.diff.length === 0 ? (
546 <div className="empty-state">No textual changes in this changeset.</div>
547 ) : changeset.diff.map((block, index) => (
548 <SideBySideDiff
549 key={block.blockno}
550 block={block}
551 filename={diffBlockFilename(block, changeset.files[index]?.file)}
552 />
553 ))}
554 </section>
555 </article>
556 )}
246 </div> 557 </div>
247 ); 558 );
248 } 559 }
249 560
250 // Directory Page Component 561 // Directory Page Component
258 onPathChange: (path: string) => void; 569 onPathChange: (path: string) => void;
259 }) { 570 }) {
260 return ( 571 return (
261 <div> 572 <div>
262 <div className="page-header"> 573 <div className="page-header">
263 <button className="back-button" onClick={onBack}> 574 <button className="back-button" onClick={onBack} aria-label="Back">
264 &larr; Back 575 &larr; Back
265 </button> 576 </button>
266 <span className="page-title">Repository Files</span> 577 <span className="page-title">Repository Files</span>
267 </div> 578 </div>
268 579
279 const [route, setRoute] = useState<RouteState>(parseRoute); 590 const [route, setRoute] = useState<RouteState>(parseRoute);
280 const { isDark, toggleTheme } = useTheme(); 591 const { isDark, toggleTheme } = useTheme();
281 592
282 // Handle browser back/forward 593 // Handle browser back/forward
283 useEffect(() => { 594 useEffect(() => {
284 const handlePopState = () => { 595 const handlePopState = (event: PopStateEvent) => {
285 setRoute(parseRoute()); 596 setRoute(isRouteState(event.state) ? event.state : parseRoute());
286 }; 597 };
287 window.addEventListener('popstate', handlePopState); 598 window.addEventListener('popstate', handlePopState);
288 return () => window.removeEventListener('popstate', handlePopState); 599 return () => window.removeEventListener('popstate', handlePopState);
289 }, []); 600 }, []);
290 601
303 }, [navigate]); 614 }, [navigate]);
304 615
305 const navigateToDirectory = useCallback((path?: string) => { 616 const navigateToDirectory = useCallback((path?: string) => {
306 navigate({ page: 'directory', dirPath: path || '' }); 617 navigate({ page: 'directory', dirPath: path || '' });
307 }, [navigate]); 618 }, [navigate]);
619
620 const navigateToChangeset = useCallback((changesetId: string) => {
621 const returnDepth = route.page === 'changeset'
622 ? (route.returnDepth ?? 0) + 1
623 : 1;
624 navigate({ page: 'changeset', changesetId, returnDepth });
625 }, [navigate, route.page, route.returnDepth]);
626
627 const navigateBackFromChangeset = useCallback(() => {
628 if (route.returnDepth !== undefined && route.returnDepth > 0) {
629 window.history.go(-route.returnDepth);
630 return;
631 }
632 navigateToGraph();
633 }, [navigateToGraph, route.returnDepth]);
308 634
309 const handleDirectoryPathChange = useCallback((path: string) => { 635 const handleDirectoryPathChange = useCallback((path: string) => {
310 // Update URL without full navigation 636 // Update URL without full navigation
311 const params = new URLSearchParams(); 637 const params = new URLSearchParams();
312 if (path) params.set('path', path); 638 if (path) params.set('path', path);
331 onClick={navigateToLanding} 657 onClick={navigateToLanding}
332 > 658 >
333 Home 659 Home
334 </button> 660 </button>
335 <button 661 <button
336 className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`} 662 className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`}
337 onClick={() => navigateToGraph()} 663 onClick={() => navigateToGraph()}
338 > 664 >
339 <GraphIcon /> 665 <GraphIcon />
340 Graph 666 Graph
341 </button> 667 </button>
351 {/* Page Content */} 677 {/* Page Content */}
352 {route.page === 'landing' && ( 678 {route.page === 'landing' && (
353 <LandingPage 679 <LandingPage
354 onNavigateToGraph={() => navigateToGraph()} 680 onNavigateToGraph={() => navigateToGraph()}
355 onNavigateToDirectory={navigateToDirectory} 681 onNavigateToDirectory={navigateToDirectory}
682 onNavigateToChangeset={navigateToChangeset}
356 /> 683 />
357 )} 684 )}
358 685
359 {route.page === 'graph' && ( 686 {route.page === 'graph' && (
360 <GraphPage 687 <GraphPage
361 onBack={navigateToLanding} 688 onBack={navigateToLanding}
362 initialCommit={route.graphCommit} 689 initialCommit={route.graphCommit}
363 initialTip={route.graphTip} 690 initialTip={route.graphTip}
691 onOpenChangeset={navigateToChangeset}
364 /> 692 />
365 )} 693 )}
366 694
367 {route.page === 'directory' && ( 695 {route.page === 'directory' && (
368 <DirectoryPage 696 <DirectoryPage
370 initialPath={route.dirPath} 698 initialPath={route.dirPath}
371 onPathChange={handleDirectoryPathChange} 699 onPathChange={handleDirectoryPathChange}
372 /> 700 />
373 )} 701 )}
374 702
703 {route.page === 'changeset' && route.changesetId && (
704 <ChangesetPage
705 changesetId={route.changesetId}
706 onBack={navigateBackFromChangeset}
707 onOpenChangeset={navigateToChangeset}
708 />
709 )}
710
375 <Footer /> 711 <Footer />
376 </div> 712 </div>
377 ); 713 );
378 } 714 }
379 715