view hg-web/src/components/app.tsx @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -0700
parents 70de0c80d093
children
line wrap: on
line source

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 };