comparison hg-web/src/components/app.tsx @ 231:09a96dcb2b4c hg-web

[merge] Join existing hg-web branch head
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 16:50:48 -0700
parents 70de0c80d093
children
comparison
equal deleted inserted replaced
217:7ef4c9d2a72d 231:09a96dcb2b4c
1 import React, { useState, useEffect, useCallback } from 'react';
2 import { Graph, useGraphData } from "hg-web/src/components/graph";
3 import { DirectoryBrowser } from "hg-web/src/components/directory-browser";
4 import { Header } from "hg-web/src/components/header";
5 import { Footer } from "hg-web/src/components/footer";
6 import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
7
8 type Page = 'landing' | 'graph' | 'directory' | 'changeset';
9
10 type RouteState = {
11 page: Page;
12 graphCommit?: string;
13 graphTip?: 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 );
190 }
191
192 // Icons
193 const ICONS = {
194 folder: "/icons/folder.png",
195 };
196
197 const GraphIcon = () => (
198 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
199 <circle cx="6" cy="6" r="3"/>
200 <circle cx="6" cy="18" r="3"/>
201 <circle cx="18" cy="12" r="3"/>
202 <line x1="6" y1="9" x2="6" y2="15"/>
203 <path d="M8.5 7.5L15.5 11"/>
204 </svg>
205 );
206
207 const FolderIcon = () => (
208 <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
209 <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
210 </svg>
211 );
212
213 const API_BASE = '/api/repo';
214
215 function parseRoute(): RouteState {
216 const params = new URLSearchParams(window.location.search);
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 }
230
231 if (pathname.startsWith('/graph') || params.has('graph')) {
232 return {
233 page: 'graph',
234 graphCommit: params.get('commit') || undefined,
235 graphTip: params.get('tip') || undefined,
236 };
237 }
238
239 if (pathname.startsWith('/directory') || params.has('path')) {
240 return {
241 page: 'directory',
242 dirPath: params.get('path') || '',
243 };
244 }
245
246 return { page: 'landing' };
247 }
248
249 function buildUrl(state: RouteState): string {
250 const params = new URLSearchParams();
251
252 switch (state.page) {
253 case 'graph':
254 if (state.graphCommit) params.set('commit', state.graphCommit);
255 if (state.graphTip) params.set('tip', state.graphTip);
256 return `/graph${params.toString() ? '?' + params.toString() : ''}`;
257 case 'directory':
258 if (state.dirPath) params.set('path', state.dirPath);
259 return `/directory${params.toString() ? '?' + params.toString() : ''}`;
260 case 'changeset':
261 return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph';
262 default:
263 return '/';
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 );
272 }
273
274 // Landing Page Component
275 function LandingPage({
276 onNavigateToGraph,
277 onNavigateToDirectory,
278 onNavigateToChangeset,
279 }: {
280 onNavigateToGraph: () => void;
281 onNavigateToDirectory: (path?: string) => void;
282 onNavigateToChangeset: (node: string) => void;
283 }) {
284 const [directories, setDirectories] = useState<any[]>([]);
285 const [files, setFiles] = useState<any[]>([]);
286 const [dirLoading, setDirLoading] = useState(true);
287
288 const { data: graphData, loading: graphLoading } = useGraphData();
289
290 useEffect(() => {
291 fetch(`${API_BASE}/list`)
292 .then(r => r.json())
293 .then(data => {
294 setDirectories(data.directories || []);
295 setFiles(data.files || []);
296 setDirLoading(false);
297 })
298 .catch(() => setDirLoading(false));
299 }, []);
300
301 const previewItems = [
302 ...directories.slice(0, 6),
303 ...files.slice(0, Math.max(0, 6 - directories.length))
304 ].slice(0, 6);
305
306 return (
307 <div className="landing-grid">
308 {/* Graph Preview */}
309 <div className="landing-section">
310 <div className="landing-section-header">
311 <span className="landing-section-title">
312 <GraphIcon />
313 Recent Commits
314 </span>
315 <a href="/graph" className="landing-section-link" onClick={(e) => {
316 e.preventDefault();
317 onNavigateToGraph();
318 }}>
319 View all
320 </a>
321 </div>
322 <div className="landing-section-content">
323 {graphLoading ? (
324 <div className="loading-state">Loading commits...</div>
325 ) : graphData ? (
326 <Graph
327 data={graphData}
328 maxRows={8}
329 onCommitClick={onNavigateToChangeset}
330 />
331 ) : (
332 <div className="empty-state">Failed to load commits</div>
333 )}
334 </div>
335 </div>
336
337 {/* Directory Preview */}
338 <div className="landing-section">
339 <div className="landing-section-header">
340 <span className="landing-section-title">
341 <FolderIcon />
342 Repository Files
343 </span>
344 <a href="/directory" className="landing-section-link" onClick={(e) => {
345 e.preventDefault();
346 onNavigateToDirectory();
347 }}>
348 Browse all
349 </a>
350 </div>
351 <div className="landing-section-content">
352 {dirLoading ? (
353 <div className="loading-state">Loading files...</div>
354 ) : previewItems.length > 0 ? (
355 previewItems.map((item) => (
356 <div
357 key={item.abspath}
358 className="dir-item"
359 onClick={() => onNavigateToDirectory(item.abspath)}
360 >
361 <span className="dir-item-icon">
362 <img
363 className="icon-invert"
364 src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"}
365 alt=""
366 />
367 </span>
368 <span className="dir-item-name">{item.basename}</span>
369 </div>
370 ))
371 ) : (
372 <div className="empty-state">No files found</div>
373 )}
374 </div>
375 </div>
376 </div>
377 );
378 }
379
380 // Graph Page Component
381 function GraphPage({
382 onBack,
383 initialCommit,
384 initialTip,
385 onOpenChangeset,
386 }: {
387 onBack: () => void;
388 initialCommit?: string;
389 initialTip?: string;
390 onOpenChangeset: (node: string) => void;
391 }) {
392 const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({
393 initialCommit: initialCommit || null,
394 graphTop: initialTip || null,
395 });
396
397 useEffect(() => {
398 if (tip && currentCommit) {
399 const params = new URLSearchParams();
400 params.set('commit', currentCommit);
401 params.set('tip', tip);
402 const newUrl = `/graph?${params.toString()}`;
403 window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl);
404 }
405 }, [currentCommit, tip]);
406
407 return (
408 <div>
409 <div className="page-header">
410 <button className="back-button" onClick={onBack} aria-label="Back">
411 &larr; Back
412 </button>
413 <span className="page-title">Commit Graph</span>
414 </div>
415
416 {tip && (
417 <div className="graph-params">
418 <span className="graph-param">
419 <span className="graph-param-label">Tip:</span>
420 <span className="graph-param-value">{tip.substring(0, 12)}</span>
421 </span>
422 {currentCommit && currentCommit !== tip && (
423 <span className="graph-param">
424 <span className="graph-param-label">Current:</span>
425 <span className="graph-param-value">{currentCommit.substring(0, 12)}</span>
426 </span>
427 )}
428 </div>
429 )}
430
431 {error && (
432 <div className="error-message">Error: {error}</div>
433 )}
434
435 <Graph
436 data={data}
437 loading={loading}
438 hasMore={hasMore}
439 onLoadMore={loadMore}
440 onCommitClick={onOpenChangeset}
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 )}
557 </div>
558 );
559 }
560
561 // Directory Page Component
562 function DirectoryPage({
563 onBack,
564 initialPath,
565 onPathChange,
566 }: {
567 onBack: () => void;
568 initialPath?: string;
569 onPathChange: (path: string) => void;
570 }) {
571 return (
572 <div>
573 <div className="page-header">
574 <button className="back-button" onClick={onBack} aria-label="Back">
575 &larr; Back
576 </button>
577 <span className="page-title">Repository Files</span>
578 </div>
579
580 <DirectoryBrowser
581 initialPath={initialPath}
582 onPathChange={onPathChange}
583 />
584 </div>
585 );
586 }
587
588 // Main App Content (uses theme context)
589 function AppContent() {
590 const [route, setRoute] = useState<RouteState>(parseRoute);
591 const { isDark, toggleTheme } = useTheme();
592
593 // Handle browser back/forward
594 useEffect(() => {
595 const handlePopState = (event: PopStateEvent) => {
596 setRoute(isRouteState(event.state) ? event.state : parseRoute());
597 };
598 window.addEventListener('popstate', handlePopState);
599 return () => window.removeEventListener('popstate', handlePopState);
600 }, []);
601
602 const navigate = useCallback((newRoute: RouteState) => {
603 const url = buildUrl(newRoute);
604 window.history.pushState(newRoute, '', url);
605 setRoute(newRoute);
606 }, []);
607
608 const navigateToLanding = useCallback(() => {
609 navigate({ page: 'landing' });
610 }, [navigate]);
611
612 const navigateToGraph = useCallback((commit?: string, tip?: string) => {
613 navigate({ page: 'graph', graphCommit: commit, graphTip: tip });
614 }, [navigate]);
615
616 const navigateToDirectory = useCallback((path?: string) => {
617 navigate({ page: 'directory', dirPath: path || '' });
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]);
634
635 const handleDirectoryPathChange = useCallback((path: string) => {
636 // Update URL without full navigation
637 const params = new URLSearchParams();
638 if (path) params.set('path', path);
639 const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`;
640 window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl);
641 setRoute(prev => ({ ...prev, dirPath: path }));
642 }, []);
643
644 return (
645 <div className="app-container">
646 <Header
647 title="Zenbu Repository"
648 showThemeToggle={true}
649 isDark={isDark}
650 onToggleTheme={toggleTheme}
651 />
652
653 {/* Navigation Tabs */}
654 <div className="nav-tabs">
655 <button
656 className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`}
657 onClick={navigateToLanding}
658 >
659 Home
660 </button>
661 <button
662 className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`}
663 onClick={() => navigateToGraph()}
664 >
665 <GraphIcon />
666 Graph
667 </button>
668 <button
669 className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`}
670 onClick={() => navigateToDirectory()}
671 >
672 <FolderIcon />
673 Files
674 </button>
675 </div>
676
677 {/* Page Content */}
678 {route.page === 'landing' && (
679 <LandingPage
680 onNavigateToGraph={() => navigateToGraph()}
681 onNavigateToDirectory={navigateToDirectory}
682 onNavigateToChangeset={navigateToChangeset}
683 />
684 )}
685
686 {route.page === 'graph' && (
687 <GraphPage
688 onBack={navigateToLanding}
689 initialCommit={route.graphCommit}
690 initialTip={route.graphTip}
691 onOpenChangeset={navigateToChangeset}
692 />
693 )}
694
695 {route.page === 'directory' && (
696 <DirectoryPage
697 onBack={navigateToLanding}
698 initialPath={route.dirPath}
699 onPathChange={handleDirectoryPathChange}
700 />
701 )}
702
703 {route.page === 'changeset' && route.changesetId && (
704 <ChangesetPage
705 changesetId={route.changesetId}
706 onBack={navigateBackFromChangeset}
707 onOpenChangeset={navigateToChangeset}
708 />
709 )}
710
711 <Footer />
712 </div>
713 );
714 }
715
716 // App wrapper with ThemeProvider
717 function App() {
718 return (
719 <ThemeProvider>
720 <AppContent />
721 </ThemeProvider>
722 );
723 }
724
725 export { App };