diff 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
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/app.tsx	Sun Aug 02 16:50:48 2026 -0700
@@ -0,0 +1,725 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { Graph, useGraphData } from "hg-web/src/components/graph";
+import { DirectoryBrowser } from "hg-web/src/components/directory-browser";
+import { Header } from "hg-web/src/components/header";
+import { Footer } from "hg-web/src/components/footer";
+import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
+
+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
+const ICONS = {
+  folder: "/icons/folder.png",
+};
+
+const GraphIcon = () => (
+  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <circle cx="6" cy="6" r="3"/>
+    <circle cx="6" cy="18" r="3"/>
+    <circle cx="18" cy="12" r="3"/>
+    <line x1="6" y1="9" x2="6" y2="15"/>
+    <path d="M8.5 7.5L15.5 11"/>
+  </svg>
+);
+
+const FolderIcon = () => (
+  <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
+    <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"/>
+  </svg>
+);
+
+const API_BASE = '/api/repo';
+
+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 {
+      page: 'graph',
+      graphCommit: params.get('commit') || undefined,
+      graphTip: params.get('tip') || undefined,
+    };
+  }
+
+  if (pathname.startsWith('/directory') || params.has('path')) {
+    return {
+      page: 'directory',
+      dirPath: params.get('path') || '',
+    };
+  }
+
+  return { page: 'landing' };
+}
+
+function buildUrl(state: RouteState): string {
+  const params = new URLSearchParams();
+
+  switch (state.page) {
+    case 'graph':
+      if (state.graphCommit) params.set('commit', state.graphCommit);
+      if (state.graphTip) params.set('tip', state.graphTip);
+      return `/graph${params.toString() ? '?' + params.toString() : ''}`;
+    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[]>([]);
+  const [dirLoading, setDirLoading] = useState(true);
+
+  const { data: graphData, loading: graphLoading } = useGraphData();
+
+  useEffect(() => {
+    fetch(`${API_BASE}/list`)
+      .then(r => r.json())
+      .then(data => {
+        setDirectories(data.directories || []);
+        setFiles(data.files || []);
+        setDirLoading(false);
+      })
+      .catch(() => setDirLoading(false));
+  }, []);
+
+  const previewItems = [
+    ...directories.slice(0, 6),
+    ...files.slice(0, Math.max(0, 6 - directories.length))
+  ].slice(0, 6);
+
+  return (
+    <div className="landing-grid">
+      {/* Graph Preview */}
+      <div className="landing-section">
+        <div className="landing-section-header">
+          <span className="landing-section-title">
+            <GraphIcon />
+            Recent Commits
+          </span>
+          <a href="/graph" className="landing-section-link" onClick={(e) => {
+            e.preventDefault();
+            onNavigateToGraph();
+          }}>
+            View all
+          </a>
+        </div>
+        <div className="landing-section-content">
+          {graphLoading ? (
+            <div className="loading-state">Loading commits...</div>
+          ) : graphData ? (
+            <Graph
+              data={graphData}
+              maxRows={8}
+              onCommitClick={onNavigateToChangeset}
+            />
+          ) : (
+            <div className="empty-state">Failed to load commits</div>
+          )}
+        </div>
+      </div>
+
+      {/* Directory Preview */}
+      <div className="landing-section">
+        <div className="landing-section-header">
+          <span className="landing-section-title">
+            <FolderIcon />
+            Repository Files
+          </span>
+          <a href="/directory" className="landing-section-link" onClick={(e) => {
+            e.preventDefault();
+            onNavigateToDirectory();
+          }}>
+            Browse all
+          </a>
+        </div>
+        <div className="landing-section-content">
+          {dirLoading ? (
+            <div className="loading-state">Loading files...</div>
+          ) : previewItems.length > 0 ? (
+            previewItems.map((item) => (
+              <div
+                key={item.abspath}
+                className="dir-item"
+                onClick={() => onNavigateToDirectory(item.abspath)}
+              >
+                <span className="dir-item-icon">
+                  <img
+                    className="icon-invert"
+                    src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"}
+                    alt=""
+                  />
+                </span>
+                <span className="dir-item-name">{item.basename}</span>
+              </div>
+            ))
+          ) : (
+            <div className="empty-state">No files found</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Graph Page Component
+function GraphPage({
+  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,
+    graphTop: initialTip || null,
+  });
+
+  useEffect(() => {
+    if (tip && currentCommit) {
+      const params = new URLSearchParams();
+      params.set('commit', currentCommit);
+      params.set('tip', tip);
+      const newUrl = `/graph?${params.toString()}`;
+      window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl);
+    }
+  }, [currentCommit, tip]);
+
+  return (
+    <div>
+      <div className="page-header">
+        <button className="back-button" onClick={onBack} aria-label="Back">
+          &larr; Back
+        </button>
+        <span className="page-title">Commit Graph</span>
+      </div>
+
+      {tip && (
+        <div className="graph-params">
+          <span className="graph-param">
+            <span className="graph-param-label">Tip:</span>
+            <span className="graph-param-value">{tip.substring(0, 12)}</span>
+          </span>
+          {currentCommit && currentCommit !== tip && (
+            <span className="graph-param">
+              <span className="graph-param-label">Current:</span>
+              <span className="graph-param-value">{currentCommit.substring(0, 12)}</span>
+            </span>
+          )}
+        </div>
+      )}
+
+      {error && (
+        <div className="error-message">Error: {error}</div>
+      )}
+
+      <Graph
+        data={data}
+        loading={loading}
+        hasMore={hasMore}
+        onLoadMore={loadMore}
+        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,
+  initialPath,
+  onPathChange,
+}: {
+  onBack: () => void;
+  initialPath?: string;
+  onPathChange: (path: string) => void;
+}) {
+  return (
+    <div>
+      <div className="page-header">
+        <button className="back-button" onClick={onBack} aria-label="Back">
+          &larr; Back
+        </button>
+        <span className="page-title">Repository Files</span>
+      </div>
+
+      <DirectoryBrowser
+        initialPath={initialPath}
+        onPathChange={onPathChange}
+      />
+    </div>
+  );
+}
+
+// Main App Content (uses theme context)
+function AppContent() {
+  const [route, setRoute] = useState<RouteState>(parseRoute);
+  const { isDark, toggleTheme } = useTheme();
+
+  // Handle browser back/forward
+  useEffect(() => {
+    const handlePopState = (event: PopStateEvent) => {
+      setRoute(isRouteState(event.state) ? event.state : parseRoute());
+    };
+    window.addEventListener('popstate', handlePopState);
+    return () => window.removeEventListener('popstate', handlePopState);
+  }, []);
+
+  const navigate = useCallback((newRoute: RouteState) => {
+    const url = buildUrl(newRoute);
+    window.history.pushState(newRoute, '', url);
+    setRoute(newRoute);
+  }, []);
+
+  const navigateToLanding = useCallback(() => {
+    navigate({ page: 'landing' });
+  }, [navigate]);
+
+  const navigateToGraph = useCallback((commit?: string, tip?: string) => {
+    navigate({ page: 'graph', graphCommit: commit, graphTip: tip });
+  }, [navigate]);
+
+  const navigateToDirectory = useCallback((path?: string) => {
+    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();
+    if (path) params.set('path', path);
+    const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`;
+    window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl);
+    setRoute(prev => ({ ...prev, dirPath: path }));
+  }, []);
+
+  return (
+    <div className="app-container">
+      <Header
+        title="Zenbu Repository"
+        showThemeToggle={true}
+        isDark={isDark}
+        onToggleTheme={toggleTheme}
+      />
+
+      {/* Navigation Tabs */}
+      <div className="nav-tabs">
+        <button
+          className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`}
+          onClick={navigateToLanding}
+        >
+          Home
+        </button>
+        <button
+          className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`}
+          onClick={() => navigateToGraph()}
+        >
+          <GraphIcon />
+          Graph
+        </button>
+        <button
+          className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`}
+          onClick={() => navigateToDirectory()}
+        >
+          <FolderIcon />
+          Files
+        </button>
+      </div>
+
+      {/* Page Content */}
+      {route.page === 'landing' && (
+        <LandingPage
+          onNavigateToGraph={() => navigateToGraph()}
+          onNavigateToDirectory={navigateToDirectory}
+          onNavigateToChangeset={navigateToChangeset}
+        />
+      )}
+
+      {route.page === 'graph' && (
+        <GraphPage
+          onBack={navigateToLanding}
+          initialCommit={route.graphCommit}
+          initialTip={route.graphTip}
+          onOpenChangeset={navigateToChangeset}
+        />
+      )}
+
+      {route.page === 'directory' && (
+        <DirectoryPage
+          onBack={navigateToLanding}
+          initialPath={route.dirPath}
+          onPathChange={handleDirectoryPathChange}
+        />
+      )}
+
+      {route.page === 'changeset' && route.changesetId && (
+        <ChangesetPage
+          changesetId={route.changesetId}
+          onBack={navigateBackFromChangeset}
+          onOpenChangeset={navigateToChangeset}
+        />
+      )}
+
+      <Footer />
+    </div>
+  );
+}
+
+// App wrapper with ThemeProvider
+function App() {
+  return (
+    <ThemeProvider>
+      <AppContent />
+    </ThemeProvider>
+  );
+}
+
+export { App };