diff hg-web/src/components/app.tsx @ 221:ce7f4400c2de hg-web

[hg-web] Harden forge and add changeset UI
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 09:01:24 -0700
parents 9f4429c49733
children 0e7b9464248d
line wrap: on
line diff
--- a/hg-web/src/components/app.tsx	Sun Aug 02 08:34:54 2026 -0700
+++ b/hg-web/src/components/app.tsx	Sun Aug 02 09:01:24 2026 -0700
@@ -5,15 +5,32 @@
 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;
 }
 
+type ChangesetDetail = {
+  node: string;
+  date: [number, number];
+  desc: string;
+  branch: string;
+  bookmarks: string[];
+  tags: string[];
+  user: string;
+  parents: string[];
+  files: string[];
+  diff: Array<{
+    blockno: number;
+    lines: Array<{ t: string; n: number; l: string }>;
+  }>;
+};
+
 // Icons
 const ICONS = {
   folder: "/icons/folder.png",
@@ -40,6 +57,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,6 +99,8 @@
     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 '/';
   }
@@ -79,9 +110,11 @@
 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 +161,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 +217,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,
@@ -239,14 +272,130 @@
         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}>
+          &larr; Back to graph
+        </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}</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 => (
+              <pre key={block.blockno}>
+                {block.lines.map((line, index) => (
+                  <span
+                    key={`${line.n}-${index}`}
+                    className={
+                      line.t === '+' ? 'diff-add' :
+                      line.t === '-' ? 'diff-remove' :
+                      line.t === '@' ? 'diff-range' : 'diff-context'
+                    }
+                  >
+                    <span className="diff-line-number">{line.n}</span>
+                    <span>{line.l}</span>
+                  </span>
+                ))}
+              </pre>
+            ))}
+          </section>
+        </article>
+      )}
+    </div>
+  );
+}
+
 // Directory Page Component
 function DirectoryPage({
   onBack,
@@ -306,6 +455,10 @@
     navigate({ page: 'directory', dirPath: path || '' });
   }, [navigate]);
 
+  const navigateToChangeset = useCallback((changesetId: string) => {
+    navigate({ page: 'changeset', changesetId });
+  }, [navigate]);
+
   const handleDirectoryPathChange = useCallback((path: string) => {
     // Update URL without full navigation
     const params = new URLSearchParams();
@@ -333,7 +486,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 +506,7 @@
         <LandingPage
           onNavigateToGraph={() => navigateToGraph()}
           onNavigateToDirectory={navigateToDirectory}
+          onNavigateToChangeset={navigateToChangeset}
         />
       )}
 
@@ -361,6 +515,7 @@
           onBack={navigateToLanding}
           initialCommit={route.graphCommit}
           initialTip={route.graphTip}
+          onOpenChangeset={navigateToChangeset}
         />
       )}
 
@@ -372,6 +527,14 @@
         />
       )}
 
+      {route.page === 'changeset' && route.changesetId && (
+        <ChangesetPage
+          changesetId={route.changesetId}
+          onBack={() => navigateToGraph()}
+          onOpenChangeset={navigateToChangeset}
+        />
+      )}
+
       <Footer />
     </div>
   );