diff 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
line wrap: on
line diff
--- a/hg-web/src/components/app.tsx	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/src/components/app.tsx	Sun Aug 02 14:42:01 2026 -0700
@@ -5,13 +5,188 @@
 import { Footer } from "hg-web/src/components/footer";
 import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
 
-type Page = 'landing' | 'graph' | 'directory';
+type Page = 'landing' | 'graph' | 'directory' | 'changeset';
 
 type RouteState = {
   page: Page;
   graphCommit?: string;
   graphTip?: string;
   dirPath?: string;
+  changesetId?: string;
+  returnDepth?: number;
+}
+
+type ChangesetDetail = {
+  node: string;
+  date: [number, number];
+  desc: string;
+  branch: string;
+  bookmarks: string[];
+  tags: string[];
+  user: string;
+  parents: string[];
+  files: Array<{
+    file: string;
+    status: string;
+  }>;
+  diff: Array<{
+    blockno: number;
+    lines: Array<{ t: string; n: number; l: string }>;
+  }>;
+};
+
+type DiffLine = ChangesetDetail['diff'][number]['lines'][number];
+
+type DiffCell = {
+  lineNumber: number | null;
+  text: string;
+  kind: 'context' | 'add' | 'remove' | 'meta';
+};
+
+type SideBySideRow = {
+  left?: DiffCell;
+  right?: DiffCell;
+  range?: string;
+};
+
+function trimDiffLine(line: string): string {
+  return line.endsWith('\n') ? line.slice(0, -1) : line;
+}
+
+function contentDiffLine(line: DiffLine): string {
+  const text = trimDiffLine(line.l);
+  if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') &&
+      text.startsWith(line.t || ' ')) {
+    return text.slice(1);
+  }
+  return text;
+}
+
+function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] {
+  const rows: SideBySideRow[] = [];
+  let removals: DiffCell[] = [];
+  let additions: DiffCell[] = [];
+  let oldLine: number | null = null;
+  let newLine: number | null = null;
+  let inHunk = false;
+
+  const flushChanges = () => {
+    const count = Math.max(removals.length, additions.length);
+    for (let index = 0; index < count; index++) {
+      rows.push({ left: removals[index], right: additions[index] });
+    }
+    removals = [];
+    additions = [];
+  };
+
+  for (const line of lines) {
+    if (line.t === '@') {
+      flushChanges();
+      const range = trimDiffLine(line.l);
+      const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+      oldLine = match ? Number(match[1]) : null;
+      newLine = match ? Number(match[2]) : null;
+      inHunk = true;
+      rows.push({ range });
+      continue;
+    }
+
+    if (!inHunk && (line.t === '-' || line.t === '+')) {
+      const cell: DiffCell = {
+        lineNumber: null,
+        text: trimDiffLine(line.l),
+        kind: 'meta',
+      };
+      if (line.t === '-') removals.push(cell);
+      else additions.push(cell);
+      continue;
+    }
+
+    if (line.t === '-') {
+      removals.push({
+        lineNumber: oldLine,
+        text: contentDiffLine(line),
+        kind: 'remove',
+      });
+      if (oldLine !== null) oldLine++;
+      continue;
+    }
+
+    if (line.t === '+') {
+      additions.push({
+        lineNumber: newLine,
+        text: contentDiffLine(line),
+        kind: 'add',
+      });
+      if (newLine !== null) newLine++;
+      continue;
+    }
+
+    flushChanges();
+    const text = contentDiffLine(line);
+    rows.push({
+      left: { lineNumber: oldLine, text, kind: 'context' },
+      right: { lineNumber: newLine, text, kind: 'context' },
+    });
+    if (oldLine !== null) oldLine++;
+    if (newLine !== null) newLine++;
+  }
+
+  flushChanges();
+  return rows;
+}
+
+function diffBlockFilename(
+  block: ChangesetDetail['diff'][number],
+  fallback?: string,
+): string {
+  if (fallback) return fallback;
+  const newFileHeader = block.lines.find(
+    line => line.t === '+' && line.l.startsWith('+++ '),
+  );
+  if (!newFileHeader) return `Diff block ${block.blockno}`;
+  return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0];
+}
+
+function SideBySideDiff({
+  block,
+  filename,
+}: {
+  block: ChangesetDetail['diff'][number];
+  filename: string;
+}) {
+  const rows = buildSideBySideRows(block.lines);
+  return (
+    <div className="side-by-side-diff">
+      <div className="diff-file-header">{filename}</div>
+      <div className="diff-column-headings">
+        <span>Before</span>
+        <span>After</span>
+      </div>
+      <div className="diff-grid">
+        {rows.map((row, index) => (
+          row.range ? (
+            <div className="diff-range-row" key={`range-${index}`}>{row.range}</div>
+          ) : (
+            <React.Fragment key={`row-${index}`}>
+              <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}>
+                {row.left?.lineNumber ?? ''}
+              </span>
+              <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}>
+                {row.left?.text ?? ''}
+              </code>
+              <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}>
+                {row.right?.lineNumber ?? ''}
+              </span>
+              <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}>
+                {row.right?.text ?? ''}
+              </code>
+            </React.Fragment>
+          )
+        ))}
+      </div>
+    </div>
+  );
 }
 
 // Icons
@@ -40,6 +215,18 @@
 function parseRoute(): RouteState {
   const params = new URLSearchParams(window.location.search);
   const pathname = window.location.pathname;
+  const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/);
+
+  if (changesetMatch) {
+    try {
+      const changesetId = decodeURIComponent(changesetMatch[1]);
+      if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) {
+        return { page: 'changeset', changesetId };
+      }
+    } catch {
+      return { page: 'landing' };
+    }
+  }
 
   if (pathname.startsWith('/graph') || params.has('graph')) {
     return {
@@ -70,18 +257,29 @@
     case 'directory':
       if (state.dirPath) params.set('path', state.dirPath);
       return `/directory${params.toString() ? '?' + params.toString() : ''}`;
+    case 'changeset':
+      return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph';
     default:
       return '/';
   }
 }
 
+function isRouteState(value: unknown): value is RouteState {
+  if (!value || typeof value !== 'object' || !('page' in value)) return false;
+  return ['landing', 'graph', 'directory', 'changeset'].includes(
+    String((value as { page: unknown }).page),
+  );
+}
+
 // Landing Page Component
 function LandingPage({
   onNavigateToGraph,
   onNavigateToDirectory,
+  onNavigateToChangeset,
 }: {
   onNavigateToGraph: () => void;
   onNavigateToDirectory: (path?: string) => void;
+  onNavigateToChangeset: (node: string) => void;
 }) {
   const [directories, setDirectories] = useState<any[]>([]);
   const [files, setFiles] = useState<any[]>([]);
@@ -128,9 +326,7 @@
             <Graph
               data={graphData}
               maxRows={8}
-              onCommitClick={(node) => {
-                console.log('Clicked commit:', node);
-              }}
+              onCommitClick={onNavigateToChangeset}
             />
           ) : (
             <div className="empty-state">Failed to load commits</div>
@@ -186,10 +382,12 @@
   onBack,
   initialCommit,
   initialTip,
+  onOpenChangeset,
 }: {
   onBack: () => void;
   initialCommit?: string;
   initialTip?: string;
+  onOpenChangeset: (node: string) => void;
 }) {
   const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({
     initialCommit: initialCommit || null,
@@ -209,7 +407,7 @@
   return (
     <div>
       <div className="page-header">
-        <button className="back-button" onClick={onBack}>
+        <button className="back-button" onClick={onBack} aria-label="Back">
           &larr; Back
         </button>
         <span className="page-title">Commit Graph</span>
@@ -239,14 +437,127 @@
         loading={loading}
         hasMore={hasMore}
         onLoadMore={loadMore}
-        onCommitClick={(node) => {
-          console.log('Clicked commit:', node);
-        }}
+        onCommitClick={onOpenChangeset}
       />
     </div>
   );
 }
 
+function ChangesetPage({
+  changesetId,
+  onBack,
+  onOpenChangeset,
+}: {
+  changesetId: string;
+  onBack: () => void;
+  onOpenChangeset: (node: string) => void;
+}) {
+  const [changeset, setChangeset] = useState<ChangesetDetail | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    const controller = new AbortController();
+
+    setLoading(true);
+    setError(null);
+    setChangeset(null);
+
+    fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal })
+      .then(async response => {
+        if (!response.ok) {
+          const message = await response.text();
+          throw new Error(message || `Unable to load changeset (${response.status})`);
+        }
+        return response.json() as Promise<ChangesetDetail>;
+      })
+      .then(setChangeset)
+      .catch(err => {
+        if (err.name !== 'AbortError') setError(err.message);
+      })
+      .finally(() => {
+        if (!controller.signal.aborted) setLoading(false);
+      });
+
+    return () => controller.abort();
+  }, [changesetId]);
+
+  return (
+    <div>
+      <div className="page-header">
+        <button className="back-button" onClick={onBack} aria-label="Back">
+          &larr; Back
+        </button>
+        <span className="page-title">Changeset</span>
+      </div>
+
+      {loading && <div className="loading-state">Loading changeset...</div>}
+      {error && <div className="error-message">Error: {error}</div>}
+
+      {changeset && (
+        <article className="changeset-paper">
+          <div className="changeset-heading">
+            <code>{changeset.node}</code>
+            <span className="changeset-branch">{changeset.branch}</span>
+          </div>
+          <h2>{changeset.desc}</h2>
+          <div className="changeset-meta">
+            <span>{changeset.user}</span>
+            <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}>
+              {new Date(changeset.date[0] * 1000).toLocaleString()}
+            </time>
+          </div>
+
+          {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && (
+            <div className="changeset-labels">
+              {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)}
+              {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)}
+            </div>
+          )}
+
+          {changeset.parents.length > 0 && (
+            <div className="changeset-parents">
+              <strong>Parents</strong>
+              {changeset.parents.map(parent => (
+                <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}>
+                  {parent.substring(0, 12)}
+                </button>
+              ))}
+            </div>
+          )}
+
+          {changeset.files.length > 0 && (
+            <div className="changeset-files">
+              <strong>Files</strong>
+              {changeset.files.map(file => (
+                <code key={file.file}>
+                  <span className={`changeset-file-status status-${file.status}`}>
+                    {file.status}
+                  </span>
+                  {file.file}
+                </code>
+              ))}
+            </div>
+          )}
+
+          <section className="changeset-diff" aria-label="Changeset diff">
+            <h3>Diff</h3>
+            {changeset.diff.length === 0 ? (
+              <div className="empty-state">No textual changes in this changeset.</div>
+            ) : changeset.diff.map((block, index) => (
+              <SideBySideDiff
+                key={block.blockno}
+                block={block}
+                filename={diffBlockFilename(block, changeset.files[index]?.file)}
+              />
+            ))}
+          </section>
+        </article>
+      )}
+    </div>
+  );
+}
+
 // Directory Page Component
 function DirectoryPage({
   onBack,
@@ -260,7 +571,7 @@
   return (
     <div>
       <div className="page-header">
-        <button className="back-button" onClick={onBack}>
+        <button className="back-button" onClick={onBack} aria-label="Back">
           &larr; Back
         </button>
         <span className="page-title">Repository Files</span>
@@ -281,8 +592,8 @@
 
   // Handle browser back/forward
   useEffect(() => {
-    const handlePopState = () => {
-      setRoute(parseRoute());
+    const handlePopState = (event: PopStateEvent) => {
+      setRoute(isRouteState(event.state) ? event.state : parseRoute());
     };
     window.addEventListener('popstate', handlePopState);
     return () => window.removeEventListener('popstate', handlePopState);
@@ -306,6 +617,21 @@
     navigate({ page: 'directory', dirPath: path || '' });
   }, [navigate]);
 
+  const navigateToChangeset = useCallback((changesetId: string) => {
+    const returnDepth = route.page === 'changeset'
+      ? (route.returnDepth ?? 0) + 1
+      : 1;
+    navigate({ page: 'changeset', changesetId, returnDepth });
+  }, [navigate, route.page, route.returnDepth]);
+
+  const navigateBackFromChangeset = useCallback(() => {
+    if (route.returnDepth !== undefined && route.returnDepth > 0) {
+      window.history.go(-route.returnDepth);
+      return;
+    }
+    navigateToGraph();
+  }, [navigateToGraph, route.returnDepth]);
+
   const handleDirectoryPathChange = useCallback((path: string) => {
     // Update URL without full navigation
     const params = new URLSearchParams();
@@ -333,7 +659,7 @@
           Home
         </button>
         <button
-          className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`}
+          className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`}
           onClick={() => navigateToGraph()}
         >
           <GraphIcon />
@@ -353,6 +679,7 @@
         <LandingPage
           onNavigateToGraph={() => navigateToGraph()}
           onNavigateToDirectory={navigateToDirectory}
+          onNavigateToChangeset={navigateToChangeset}
         />
       )}
 
@@ -361,6 +688,7 @@
           onBack={navigateToLanding}
           initialCommit={route.graphCommit}
           initialTip={route.graphTip}
+          onOpenChangeset={navigateToChangeset}
         />
       )}
 
@@ -372,6 +700,14 @@
         />
       )}
 
+      {route.page === 'changeset' && route.changesetId && (
+        <ChangesetPage
+          changesetId={route.changesetId}
+          onBack={navigateBackFromChangeset}
+          onOpenChangeset={navigateToChangeset}
+        />
+      )}
+
       <Footer />
     </div>
   );