From 445a9f52c9dadd1edcd9fa3eec509a1d05771186 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Mon, 1 Jun 2026 23:23:48 -0400 Subject: [PATCH 1/4] frontend: build visualizer and repository UI foundation Signed-off-by: Tony Lee --- compose.yaml | 16 + docs/get-started.md | 5 +- frontend/Dockerfile.dev | 8 + frontend/README.md | 88 ++- frontend/app/globals.css | 17 + frontend/app/layout.tsx | 29 +- frontend/app/page.tsx | 223 +----- .../app/{simulator => playground}/page.tsx | 16 +- .../components/common}/collapsible-card.tsx | 0 .../common}/enhanced-view-mode-toggle.tsx | 0 .../components/common}/quick-start-guide.tsx | 0 .../components/common}/view-mode-toggle.tsx | 0 .../components/common}/welcome-screen.tsx | 0 .../components/common}/welcome-section.tsx | 0 .../commit/commit-analysis.tsx | 0 .../commit/commit-compare.tsx | 6 +- .../page-components}/commit/commit-list.tsx | 0 .../commit/security-insights.tsx | 0 .../commit/security-recommendations.tsx | 0 .../page-components}/json/json-diff-stats.tsx | 0 .../json/json-diff-visualization.tsx | 2 +- .../page-components}/json/json-tree-view.tsx | 0 .../json/json-tree-visualization.tsx | 2 +- .../page-components}/json/tree-view-tab.tsx | 2 +- .../repository/repository-status.tsx | 0 .../visualization/file-selector.tsx | 2 +- .../visualization/visualization-tab.tsx | 2 +- frontend/assets/Logo.png | Bin 0 -> 11467 bytes frontend/assets/Settings.png | Bin 0 -> 2154 bytes frontend/assets/Users.png | Bin 0 -> 4697 bytes frontend/assets/add.png | Bin 0 -> 2059 bytes frontend/assets/arrow_drop_down.png | Bin 0 -> 244 bytes frontend/assets/ascending.png | Bin 0 -> 782 bytes frontend/assets/branch.png | Bin 0 -> 3001 bytes frontend/assets/clip.png | Bin 0 -> 485 bytes frontend/assets/commit.png | Bin 0 -> 526 bytes frontend/assets/compare.png | Bin 0 -> 1059 bytes frontend/assets/completed.png | Bin 0 -> 787 bytes frontend/assets/discending.png | Bin 0 -> 790 bytes frontend/assets/empty.png | Bin 0 -> 265 bytes frontend/assets/empty_file.png | Bin 0 -> 357 bytes frontend/assets/file.png | Bin 0 -> 2634 bytes frontend/assets/graph-source.png | Bin 0 -> 2111 bytes frontend/assets/graph.png | Bin 0 -> 1435 bytes frontend/assets/green_check_box.png | Bin 0 -> 419 bytes frontend/assets/history.png | Bin 0 -> 696 bytes frontend/assets/left.png | Bin 0 -> 1159 bytes frontend/assets/metadata.png | Bin 0 -> 582 bytes frontend/assets/policy-query.png | Bin 0 -> 2303 bytes frontend/assets/right.png | Bin 0 -> 1403 bytes frontend/assets/search.png | Bin 0 -> 1081 bytes frontend/assets/swap_vert.png | Bin 0 -> 921 bytes frontend/assets/user.png | Bin 0 -> 632 bytes frontend/assets/zoom-in.png | Bin 0 -> 1937 bytes frontend/assets/zoom-out.png | Bin 0 -> 1783 bytes frontend/components/app/header.tsx | 31 + .../{shared => common}/progress-indicator.tsx | 0 .../{shared => common}/status-card.tsx | 0 .../{shared => common}/story-modal.tsx | 2 +- .../repository/repository-selector.tsx | 515 ------------ frontend/components/layout/header.tsx | 49 -- frontend/components/ui/resizable.tsx | 2 + frontend/components/ui/scroll-area.tsx | 33 +- .../detail/workspace-detail-primitives.tsx | 583 ++++++++++++++ .../visualizer/workspace-action-button.tsx | 34 + .../visualizer/workspace-bottom-bar.tsx | 137 ++++ .../visualizer/workspace-detail-toggle.tsx | 35 + .../visualizer/workspace-menu-item.tsx | 33 + .../visualizer/workspace-panel-header.tsx | 51 ++ .../visualizer/workspace-search-field.tsx | 34 + frontend/hooks/explorer/use-repository.ts | 28 +- frontend/hooks/use-gittuf-explorer.ts | 2 + .../hooks/visualizer/use-workspace-history.ts | 63 ++ frontend/lib/demo-visualizer-data.ts | 733 ++++++++++++++++++ frontend/public/favicon.png | Bin 0 -> 170156 bytes .../playground}/simulator-analysis.tsx | 0 .../playground}/simulator-config-modal.tsx | 0 .../playground}/simulator-controls.tsx | 0 .../playground}/simulator-glossary.tsx | 0 .../playground}/simulator-graph.tsx | 2 +- .../playground}/simulator-header.tsx | 0 .../playground}/trust-graph.tsx | 0 .../repository/repository-selector.tsx | 317 ++++++++ .../screens/visualizer/compare-canvas.tsx | 91 +++ .../screens/visualizer/detail-content.tsx | 154 ++++ .../screens/visualizer/history-canvas.tsx | 225 ++++++ frontend/screens/visualizer/history.types.ts | 10 + .../visualizer/panel-tabs/detail-compare.tsx | 116 +++ .../panel-tabs/detail-graph-source.tsx | 92 +++ .../visualizer/panel-tabs/detail-history.tsx | 193 +++++ .../visualizer/panel-tabs/detail-metadata.tsx | 96 +++ .../visualizer/panel-tabs/detail-panels.tsx | 8 + .../panel-tabs/detail-policy-query.tsx | 146 ++++ .../visualizer/panel-tabs/detail-settings.tsx | 146 ++++ .../visualizer/policy-graph-canvas.tsx | 485 ++++++++++++ .../visualizer/policy-graph.constants.ts | 73 ++ .../screens/visualizer/policy-graph.types.ts | 39 + .../screens/visualizer/policy-graph.utils.ts | 109 +++ .../visualizer/use-visualizer-workspace.ts | 569 ++++++++++++++ .../visualizer/visualizer-workspace.tsx | 367 +++++++++ .../visualizer/visualizer.constants.ts | 19 + .../screens/visualizer/visualizer.types.ts | 41 + frontend/tsconfig.json | 7 +- 103 files changed, 5247 insertions(+), 841 deletions(-) create mode 100644 compose.yaml create mode 100644 frontend/Dockerfile.dev rename frontend/app/{simulator => playground}/page.tsx (75%) rename frontend/{components/shared => archive/components/common}/collapsible-card.tsx (100%) rename frontend/{components/shared => archive/components/common}/enhanced-view-mode-toggle.tsx (100%) rename frontend/{components/shared => archive/components/common}/quick-start-guide.tsx (100%) rename frontend/{components/shared => archive/components/common}/view-mode-toggle.tsx (100%) rename frontend/{components/shared => archive/components/common}/welcome-screen.tsx (100%) rename frontend/{components/shared => archive/components/common}/welcome-section.tsx (100%) rename frontend/{components/features => archive/page-components}/commit/commit-analysis.tsx (100%) rename frontend/{components/features => archive/page-components}/commit/commit-compare.tsx (99%) rename frontend/{components/features => archive/page-components}/commit/commit-list.tsx (100%) rename frontend/{components/features => archive/page-components}/commit/security-insights.tsx (100%) rename frontend/{components/features => archive/page-components}/commit/security-recommendations.tsx (100%) rename frontend/{components/features => archive/page-components}/json/json-diff-stats.tsx (100%) rename frontend/{components/features => archive/page-components}/json/json-diff-visualization.tsx (99%) rename frontend/{components/features => archive/page-components}/json/json-tree-view.tsx (100%) rename frontend/{components/features => archive/page-components}/json/json-tree-visualization.tsx (99%) rename frontend/{components/features => archive/page-components}/json/tree-view-tab.tsx (97%) rename frontend/{components/features => archive/page-components}/repository/repository-status.tsx (100%) rename frontend/{components/features => archive/page-components}/visualization/file-selector.tsx (97%) rename frontend/{components/features => archive/page-components}/visualization/visualization-tab.tsx (97%) create mode 100644 frontend/assets/Logo.png create mode 100644 frontend/assets/Settings.png create mode 100644 frontend/assets/Users.png create mode 100644 frontend/assets/add.png create mode 100644 frontend/assets/arrow_drop_down.png create mode 100644 frontend/assets/ascending.png create mode 100644 frontend/assets/branch.png create mode 100644 frontend/assets/clip.png create mode 100644 frontend/assets/commit.png create mode 100644 frontend/assets/compare.png create mode 100644 frontend/assets/completed.png create mode 100644 frontend/assets/discending.png create mode 100644 frontend/assets/empty.png create mode 100644 frontend/assets/empty_file.png create mode 100644 frontend/assets/file.png create mode 100644 frontend/assets/graph-source.png create mode 100644 frontend/assets/graph.png create mode 100644 frontend/assets/green_check_box.png create mode 100644 frontend/assets/history.png create mode 100644 frontend/assets/left.png create mode 100644 frontend/assets/metadata.png create mode 100644 frontend/assets/policy-query.png create mode 100644 frontend/assets/right.png create mode 100644 frontend/assets/search.png create mode 100644 frontend/assets/swap_vert.png create mode 100644 frontend/assets/user.png create mode 100644 frontend/assets/zoom-in.png create mode 100644 frontend/assets/zoom-out.png create mode 100644 frontend/components/app/header.tsx rename frontend/components/{shared => common}/progress-indicator.tsx (100%) rename frontend/components/{shared => common}/status-card.tsx (100%) rename frontend/components/{shared => common}/story-modal.tsx (99%) delete mode 100644 frontend/components/features/repository/repository-selector.tsx delete mode 100644 frontend/components/layout/header.tsx create mode 100644 frontend/components/visualizer/detail/workspace-detail-primitives.tsx create mode 100644 frontend/components/visualizer/workspace-action-button.tsx create mode 100644 frontend/components/visualizer/workspace-bottom-bar.tsx create mode 100644 frontend/components/visualizer/workspace-detail-toggle.tsx create mode 100644 frontend/components/visualizer/workspace-menu-item.tsx create mode 100644 frontend/components/visualizer/workspace-panel-header.tsx create mode 100644 frontend/components/visualizer/workspace-search-field.tsx create mode 100644 frontend/hooks/visualizer/use-workspace-history.ts create mode 100644 frontend/lib/demo-visualizer-data.ts create mode 100644 frontend/public/favicon.png rename frontend/{components/features/simulator => screens/playground}/simulator-analysis.tsx (100%) rename frontend/{components/features/simulator => screens/playground}/simulator-config-modal.tsx (100%) rename frontend/{components/features/simulator => screens/playground}/simulator-controls.tsx (100%) rename frontend/{components/features/simulator => screens/playground}/simulator-glossary.tsx (100%) rename frontend/{components/features/simulator => screens/playground}/simulator-graph.tsx (98%) rename frontend/{components/features/simulator => screens/playground}/simulator-header.tsx (100%) rename frontend/{components/features/visualization => screens/playground}/trust-graph.tsx (100%) create mode 100644 frontend/screens/repository/repository-selector.tsx create mode 100644 frontend/screens/visualizer/compare-canvas.tsx create mode 100644 frontend/screens/visualizer/detail-content.tsx create mode 100644 frontend/screens/visualizer/history-canvas.tsx create mode 100644 frontend/screens/visualizer/history.types.ts create mode 100644 frontend/screens/visualizer/panel-tabs/detail-compare.tsx create mode 100644 frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx create mode 100644 frontend/screens/visualizer/panel-tabs/detail-history.tsx create mode 100644 frontend/screens/visualizer/panel-tabs/detail-metadata.tsx create mode 100644 frontend/screens/visualizer/panel-tabs/detail-panels.tsx create mode 100644 frontend/screens/visualizer/panel-tabs/detail-policy-query.tsx create mode 100644 frontend/screens/visualizer/panel-tabs/detail-settings.tsx create mode 100644 frontend/screens/visualizer/policy-graph-canvas.tsx create mode 100644 frontend/screens/visualizer/policy-graph.constants.ts create mode 100644 frontend/screens/visualizer/policy-graph.types.ts create mode 100644 frontend/screens/visualizer/policy-graph.utils.ts create mode 100644 frontend/screens/visualizer/use-visualizer-workspace.ts create mode 100644 frontend/screens/visualizer/visualizer-workspace.tsx create mode 100644 frontend/screens/visualizer/visualizer.constants.ts create mode 100644 frontend/screens/visualizer/visualizer.types.ts diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..8c1eba0 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,16 @@ +services: + frontend: + build: + context: . + dockerfile: frontend/Dockerfile.dev + ports: + - "3000:3000" + environment: + CHOKIDAR_USEPOLLING: "true" + WATCHPACK_POLLING: "true" + volumes: + - ./frontend:/app + - frontend_node_modules:/app/node_modules + +volumes: + frontend_node_modules: diff --git a/docs/get-started.md b/docs/get-started.md index a78ab6b..92a682d 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -11,8 +11,7 @@ Ensure that you have [Docker] installed on your computer before proceeding. ### Frontend ```bash -docker build -t visualizer-frontend -f frontend/Dockerfile . -docker run --rm -p 3000:3000 visualizer-frontend +docker compose up --build # App at http://localhost:3000 ``` @@ -36,6 +35,7 @@ proceeding. First, build start the backend: Go (Gin) on port 5000: + ```bash cd go-backend go mod download @@ -58,6 +58,7 @@ commits, view/compare metadata, and analyze changes. ## Frontend usage From the home page: + 1. Enter a repository: - Remote: full Git URL (e.g., `https://github.com/gittuf/gittuf.git`) - Local: absolute path to a local Git repo diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..1207e77 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,8 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY frontend/package*.json ./ +RUN npm install + +CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0"] diff --git a/frontend/README.md b/frontend/README.md index 4dee1a0..9e56c95 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,18 +5,22 @@ in Next.js. ## Features -- **Commit Visualization**: Browse repository commits and view associated - security metadata JSON. -- **JSON Tree View**: Interactive tree visualization of JSON structures using - ReactFlow. -- **JSON Diff Visualization**: Visual diff between two commits’ metadata with - statistics. -- **JSON Diff Statistics**: Summarize added, removed, changed, and unchanged - elements. -- **Analysis Dashboard**: Chart the evolution, structure distribution, and - change frequency across multiple commits. -- **Dynamic File Selection**: Switch between different metadata files (e.g., - `root.json`, `targets.json`). +- **Repository Entry Flow**: Connect a remote repository, point at a local + repository, or launch the demo workspace from the home screen. +- **Policy Graph Workspace**: Explore one or more draggable policy graphs inside + the main visualizer canvas, with tabbed canvases along the bottom bar. +- **Graph Source Controls**: Inspect repository, policy ref, policy version, + metadata source, and active mode from the detail panel. +- **Policy Query Panel**: Query a branch and changed path to see the matched + rule, required approvals, and authorized users. +- **History Timeline**: Open a history view with sortable commits, a commit + strip, and graph canvases for browsing policy state across revisions. +- **Comparison Canvas**: Generate side-by-side base and compare graphs with + added, removed, modified, and unchanged diff highlighting. +- **Metadata and Settings Panels**: Review metadata status and summary views, + then adjust visible node/detail settings for the workspace. +- **Interactive Playground**: Use the `/playground` route for the trust graph + walkthrough, simulator controls, analysis, and glossary experience. ## Tech Stack @@ -66,35 +70,45 @@ Open [http://localhost:3000](http://localhost:3000) in your browser. ``` frontend/ ├── app/ -│ ├── globals.css # Tailwind & global styles -│ ├── layout.tsx # Root layout & metadata -│ └── page.tsx # Main page with commit form & tabs +│ ├── globals.css # Tailwind & global styles +│ ├── layout.tsx # Root layout & metadata +│ ├── page.tsx # Home route: repository entry + visualizer workspace +│ └── playground/ +│ └── page.tsx # Interactive visualizer tools route +├── screens/ +│ ├── repository/ +│ │ └── repository-selector.tsx # Repository selection screen +│ ├── playground/ # Route-sized playground sections and trust graph +│ └── visualizer/ +│ ├── visualizer-workspace.tsx +│ ├── policy-graph-canvas.tsx +│ ├── history-canvas.tsx +│ ├── detail-content.tsx +│ └── panel-tabs/ # Detail panel tabs shown inside the workspace ├── components/ -│ ├── collapsible-card.tsx -│ ├── commit-list.tsx -│ ├── commit-compare.tsx -│ ├── commit-analysis.tsx -│ ├── json-tree-visualization.tsx -│ ├── json-diff-visualization.tsx -│ └── ui/ # Reusable UI primitives (Button, Input, Card, etc.) -├── lib/ -│ ├── mock-api.ts # Mock fetching commits & metadata -│ ├── json-diff.ts # JSON comparison utilities -│ ├── utils.ts # Helper functions -│ └── types.ts # Type definitions -├── public/ # Static assets -├── components.json # shadcn config -├── next.config.ts +│ ├── app/ # Shared app shell pieces +│ ├── common/ # Reusable non-route-specific feature components +│ ├── ui/ # shadcn/Radix-based UI primitives +│ └── visualizer/ # Shared visualizer controls and primitives +├── hooks/ +│ ├── explorer/ # Repository explorer hooks +│ └── visualizer/ # Visualizer-specific hooks +├── lib/ # Utilities, constants, demo data, and API helpers +├── archive/ # Older and currently unused page/component implementations from a previous version +├── public/ # Static assets served by Next.js +├── assets/ # Imported image assets used by the UI +├── fixtures/ # Simulator fixture data +├── components.json # shadcn config +├── next.config.mjs ├── package.json └── tsconfig.json ``` ## Usage -1. Enter a GitHub repository URL containing gittuf metadata (e.g., - `https://github.com/gittuf/gittuf`). -2. Click **Fetch Repository** to load commits. -3. Select a commit to view its metadata or choose two commits to compare. -4. Switch between **Commits**, **Visualization**, **Compare**, and **Analysis** - tabs. -5. Toggle between `root.json` and `targets.json` using the file buttons. +1. Open the home route and enter a Git repository URL, choose a local + repository, or launch the demo workspace. +2. Explore the visualizer workspace, including the graph canvas, history strip, + and detail panel tabs. +3. Open `/playground` to use the interactive visualizer playground and trust + graph walkthrough. diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 23f10cf..740033f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -12,6 +12,23 @@ body { @layer base { :root { + --primary-color: #a2c5e8; + --secondary-color: #bad1ea; + --tertiary-color: #04080e; + --gray-highlight: #f3f4f4; + --dark-gray: #7e7e7e; + --light-gray: #f5f5f5; + --background-color: #dbe3e5; + --modified-color: #3989d9; + --logo-blue: #c5dee5; + --approve-color: #79bc89; + --reject-color: #cb5151; + --selected-color: #e8f4ff; + --selected-color-50: rgba(232, 244, 255, 0.5); + --approve-color-12: rgba(121, 188, 137, 0.12); + --reject-color-12: rgba(203, 81, 81, 0.12); + --modified-color-18: rgba(57, 137, 217, 0.18); + --grid-color: rgba(4, 8, 14, 0.06); --background: 0 0% 100%; --foreground: 0 0% 3.9%; --card: 0 0% 100%; diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 78b5f1c..128cb48 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,24 +1,25 @@ -import type React from "react" -import type { Metadata } from "next" -import { Inter } from "next/font/google" -import "./globals.css" - -const inter = Inter({ subsets: ["latin"] }) - +import type React from "react"; +import type { Metadata } from "next"; +import "./globals.css"; export const metadata: Metadata = { - title: "gittuf Metadata Visualizer", - description: "Visualize and analyze gittuf's security metadata structure across repository commits", - generator: 'v0.dev' -} + title: "Gittuf Visualizer", + description: + "Visualize and analyze gittuf's security metadata structure across repository commits", + generator: "v0.dev", + applicationName: "Gittuf Visualizer", + icons: { + icon: "/favicon.png", + }, +}; export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( - {children} + {children} - ) + ); } diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index bfc08ff..7880f4e 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,209 +1,56 @@ "use client" -import { GitCommit, GitCompare, BarChart3, FileJson } from "lucide-react" -import { Card, CardContent } from "@/components/ui/card" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import CommitList from "@/components/features/commit/commit-list" -import CommitCompare from "@/components/features/commit/commit-compare" -import CommitAnalysis from "@/components/features/commit/commit-analysis" -import QuickStartGuide from "@/components/shared/quick-start-guide" +import RepositorySelector from "@/screens/repository/repository-selector" +import VisualizerWorkspace from "@/screens/visualizer/visualizer-workspace" -import RepositoryStatus from "@/components/features/repository/repository-status" -import RepositorySelector from "@/components/features/repository/repository-selector" - -// New extracted components -import Header from "@/components/layout/header" -import WelcomeScreen from "@/components/shared/welcome-screen" -import FileSelector from "@/components/features/visualization/file-selector" -import VisualizationTab from "@/components/features/visualization/visualization-tab" -import TreeViewTab from "@/components/features/json/tree-view-tab" - -// Custom Hook +import Header from "@/components/app/header" import { useGittufExplorer } from "@/hooks/use-gittuf-explorer" export default function Home() { const { isLoading, - commits, - selectedCommit, - compareCommits, - jsonData, - compareData, - activeTab, - setActiveTab, error, - selectedFile, - selectedCommits, - globalViewMode, - setGlobalViewMode, - currentStep, currentRepository, + currentRepositoryData, showRepositorySelector, - setShowRepositorySelector, - steps, + handleDisconnect, handleTryDemo, handleRepositorySelect, handleRepositoryRefresh, - handleCommitSelect, - handleCompareSelect, - handleFileChange, - handleCommitRangeSelect, - hiddenCount, } = useGittufExplorer() - return ( -
-
-
setShowRepositorySelector(!showRepositorySelector)} - hasCommits={commits.length > 0} - currentStep={currentStep} - steps={steps} - /> - - + const isWorkspaceView = Boolean(currentRepository && !showRepositorySelector) - {showRepositorySelector && ( - - )} - - {currentRepository && !showRepositorySelector && ( - - )} - - {commits.length > 0 && ( - <> - +
+ +
+
+ {showRepositorySelector && ( + - - - - - - Browse Commits - - - - Graph View - - - - Tree View - - - - Compare - - - - Analysis - - - - - - -
- -

Step 2: Select Commits to Analyze

-
- -
-
-
- - - selectedCommit && handleCommitSelect(selectedCommit)} - /> - - - - selectedCommit && handleCommitSelect(selectedCommit)} - /> - - - - {compareCommits.base && compareCommits.compare && ( - - )} - - - - {selectedCommits.length >= 2 && ( - - )} - -
- - )} - - {commits.length === 0 && !isLoading && } + )} + + {currentRepository && !showRepositorySelector && ( + + )} +
) diff --git a/frontend/app/simulator/page.tsx b/frontend/app/playground/page.tsx similarity index 75% rename from frontend/app/simulator/page.tsx rename to frontend/app/playground/page.tsx index e32d7b8..9dfba76 100644 --- a/frontend/app/simulator/page.tsx +++ b/frontend/app/playground/page.tsx @@ -1,15 +1,15 @@ "use client" import { AnimatePresence, motion } from "framer-motion" -import { StoryModal } from "@/components/shared/story-modal" -import { StatusCard } from "@/components/shared/status-card" +import { StoryModal } from "@/components/common/story-modal" +import { StatusCard } from "@/components/common/status-card" import { useGittufSimulator } from "@/hooks/use-gittuf-simulator" -import { SimulatorHeader } from "@/components/features/simulator/simulator-header" -import { SimulatorControls } from "@/components/features/simulator/simulator-controls" -import { SimulatorGraph } from "@/components/features/simulator/simulator-graph" -import { SimulatorAnalysis } from "@/components/features/simulator/simulator-analysis" -import { SimulatorGlossary } from "@/components/features/simulator/simulator-glossary" -import { SimulatorConfigModal } from "@/components/features/simulator/simulator-config-modal" +import { SimulatorHeader } from "@/screens/playground/simulator-header" +import { SimulatorControls } from "@/screens/playground/simulator-controls" +import { SimulatorGraph } from "@/screens/playground/simulator-graph" +import { SimulatorAnalysis } from "@/screens/playground/simulator-analysis" +import { SimulatorGlossary } from "@/screens/playground/simulator-glossary" +import { SimulatorConfigModal } from "@/screens/playground/simulator-config-modal" export default function SimulatorPage() { const state = useGittufSimulator() diff --git a/frontend/components/shared/collapsible-card.tsx b/frontend/archive/components/common/collapsible-card.tsx similarity index 100% rename from frontend/components/shared/collapsible-card.tsx rename to frontend/archive/components/common/collapsible-card.tsx diff --git a/frontend/components/shared/enhanced-view-mode-toggle.tsx b/frontend/archive/components/common/enhanced-view-mode-toggle.tsx similarity index 100% rename from frontend/components/shared/enhanced-view-mode-toggle.tsx rename to frontend/archive/components/common/enhanced-view-mode-toggle.tsx diff --git a/frontend/components/shared/quick-start-guide.tsx b/frontend/archive/components/common/quick-start-guide.tsx similarity index 100% rename from frontend/components/shared/quick-start-guide.tsx rename to frontend/archive/components/common/quick-start-guide.tsx diff --git a/frontend/components/shared/view-mode-toggle.tsx b/frontend/archive/components/common/view-mode-toggle.tsx similarity index 100% rename from frontend/components/shared/view-mode-toggle.tsx rename to frontend/archive/components/common/view-mode-toggle.tsx diff --git a/frontend/components/shared/welcome-screen.tsx b/frontend/archive/components/common/welcome-screen.tsx similarity index 100% rename from frontend/components/shared/welcome-screen.tsx rename to frontend/archive/components/common/welcome-screen.tsx diff --git a/frontend/components/shared/welcome-section.tsx b/frontend/archive/components/common/welcome-section.tsx similarity index 100% rename from frontend/components/shared/welcome-section.tsx rename to frontend/archive/components/common/welcome-section.tsx diff --git a/frontend/components/features/commit/commit-analysis.tsx b/frontend/archive/page-components/commit/commit-analysis.tsx similarity index 100% rename from frontend/components/features/commit/commit-analysis.tsx rename to frontend/archive/page-components/commit/commit-analysis.tsx diff --git a/frontend/components/features/commit/commit-compare.tsx b/frontend/archive/page-components/commit/commit-compare.tsx similarity index 99% rename from frontend/components/features/commit/commit-compare.tsx rename to frontend/archive/page-components/commit/commit-compare.tsx index a2ff2a8..ebe76ee 100644 --- a/frontend/components/features/commit/commit-compare.tsx +++ b/frontend/archive/page-components/commit/commit-compare.tsx @@ -4,12 +4,12 @@ import { Loader2, GitCompare, AlertTriangle, Minus, Plus, Edit3 } from "lucide-r import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import JsonDiffVisualization from "@/components/features/json/json-diff-visualization" -import JsonDiffStats from "@/components/features/json/json-diff-stats" +import JsonDiffVisualization from "@/legacy/page-components/json/json-diff-visualization" +import JsonDiffStats from "@/legacy/page-components/json/json-diff-stats" import { Button } from "@/components/ui/button" import type { Commit, JsonObject, JsonValue } from "@/lib/types" import { useState } from "react" -import JsonTreeView from "@/components/features/json/json-tree-view" +import JsonTreeView from "@/legacy/page-components/json/json-tree-view" import { compareJsonObjects, countChanges, type DiffResult, type DiffEntry } from "@/lib/json-diff" import type { ViewMode } from "@/lib/view-mode-utils" import { motion } from "framer-motion" diff --git a/frontend/components/features/commit/commit-list.tsx b/frontend/archive/page-components/commit/commit-list.tsx similarity index 100% rename from frontend/components/features/commit/commit-list.tsx rename to frontend/archive/page-components/commit/commit-list.tsx diff --git a/frontend/components/features/commit/security-insights.tsx b/frontend/archive/page-components/commit/security-insights.tsx similarity index 100% rename from frontend/components/features/commit/security-insights.tsx rename to frontend/archive/page-components/commit/security-insights.tsx diff --git a/frontend/components/features/commit/security-recommendations.tsx b/frontend/archive/page-components/commit/security-recommendations.tsx similarity index 100% rename from frontend/components/features/commit/security-recommendations.tsx rename to frontend/archive/page-components/commit/security-recommendations.tsx diff --git a/frontend/components/features/json/json-diff-stats.tsx b/frontend/archive/page-components/json/json-diff-stats.tsx similarity index 100% rename from frontend/components/features/json/json-diff-stats.tsx rename to frontend/archive/page-components/json/json-diff-stats.tsx diff --git a/frontend/components/features/json/json-diff-visualization.tsx b/frontend/archive/page-components/json/json-diff-visualization.tsx similarity index 99% rename from frontend/components/features/json/json-diff-visualization.tsx rename to frontend/archive/page-components/json/json-diff-visualization.tsx index c5919eb..dca27fd 100644 --- a/frontend/components/features/json/json-diff-visualization.tsx +++ b/frontend/archive/page-components/json/json-diff-visualization.tsx @@ -19,7 +19,7 @@ import ReactFlow, { import "reactflow/dist/style.css" import dagre from "dagre" import { motion } from "framer-motion" -import { CollapsibleCard } from "@/components/shared/collapsible-card" +import { CollapsibleCard } from "@/legacy/components/common/collapsible-card" import { compareJsonObjects, type DiffEntry, type DiffResult } from "@/lib/json-diff" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Badge } from "@/components/ui/badge" diff --git a/frontend/components/features/json/json-tree-view.tsx b/frontend/archive/page-components/json/json-tree-view.tsx similarity index 100% rename from frontend/components/features/json/json-tree-view.tsx rename to frontend/archive/page-components/json/json-tree-view.tsx diff --git a/frontend/components/features/json/json-tree-visualization.tsx b/frontend/archive/page-components/json/json-tree-visualization.tsx similarity index 99% rename from frontend/components/features/json/json-tree-visualization.tsx rename to frontend/archive/page-components/json/json-tree-visualization.tsx index fc9f1d0..b440b92 100644 --- a/frontend/components/features/json/json-tree-visualization.tsx +++ b/frontend/archive/page-components/json/json-tree-visualization.tsx @@ -19,7 +19,7 @@ import ReactFlow, { } from "reactflow" import "reactflow/dist/style.css" import dagre from "dagre" -import { CollapsibleCard } from "@/components/shared/collapsible-card" +import { CollapsibleCard } from "@/legacy/components/common/collapsible-card" import { motion } from "framer-motion" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Badge } from "@/components/ui/badge" diff --git a/frontend/components/features/json/tree-view-tab.tsx b/frontend/archive/page-components/json/tree-view-tab.tsx similarity index 97% rename from frontend/components/features/json/tree-view-tab.tsx rename to frontend/archive/page-components/json/tree-view-tab.tsx index f99599d..e78ce1e 100644 --- a/frontend/components/features/json/tree-view-tab.tsx +++ b/frontend/archive/page-components/json/tree-view-tab.tsx @@ -4,7 +4,7 @@ import { FileJson, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import JsonTreeView from "@/components/features/json/json-tree-view" +import JsonTreeView from "@/legacy/page-components/json/json-tree-view" import type { Commit } from "@/lib/types" import type { ViewMode } from "@/lib/view-mode-utils" import type { JsonValue } from "@/lib/types" diff --git a/frontend/components/features/repository/repository-status.tsx b/frontend/archive/page-components/repository/repository-status.tsx similarity index 100% rename from frontend/components/features/repository/repository-status.tsx rename to frontend/archive/page-components/repository/repository-status.tsx diff --git a/frontend/components/features/visualization/file-selector.tsx b/frontend/archive/page-components/visualization/file-selector.tsx similarity index 97% rename from frontend/components/features/visualization/file-selector.tsx rename to frontend/archive/page-components/visualization/file-selector.tsx index 2153109..d174594 100644 --- a/frontend/components/features/visualization/file-selector.tsx +++ b/frontend/archive/page-components/visualization/file-selector.tsx @@ -4,7 +4,7 @@ import { FileJson } from "lucide-react" import { FILENAMES } from "@/lib/constants" import { Button } from "@/components/ui/button" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import EnhancedViewModeToggle from "@/components/shared/enhanced-view-mode-toggle" +import EnhancedViewModeToggle from "@/legacy/components/common/enhanced-view-mode-toggle" import type { ViewMode } from "@/lib/view-mode-utils" interface FileSelectorProps { diff --git a/frontend/components/features/visualization/visualization-tab.tsx b/frontend/archive/page-components/visualization/visualization-tab.tsx similarity index 97% rename from frontend/components/features/visualization/visualization-tab.tsx rename to frontend/archive/page-components/visualization/visualization-tab.tsx index ff85421..68da1b9 100644 --- a/frontend/components/features/visualization/visualization-tab.tsx +++ b/frontend/archive/page-components/visualization/visualization-tab.tsx @@ -8,7 +8,7 @@ import type { Commit } from "@/lib/types" import type { ViewMode } from "@/lib/view-mode-utils" import dynamic from "next/dynamic" -const JsonTreeVisualization = dynamic(() => import("@/components/features/json/json-tree-visualization"), { +const JsonTreeVisualization = dynamic(() => import("@/legacy/page-components/json/json-tree-visualization"), { ssr: false, loading: () => (
diff --git a/frontend/assets/Logo.png b/frontend/assets/Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f3dfe5d961ad537db9495e1663470391fa5b686a GIT binary patch literal 11467 zcmZvCV{j!*({^mz_J$kVwr$(CofF&1#o8SHZe!d?)J<~N+)7@2FJ#)81lb0lAE}eo2sLQo2RjhIf#U{rM+=}W4b8_2)%@~ zn6R1`=#@WgF2?A_i|P*l%QR7Q_vK6{!4TQRpQvIop%C!;OFl|@h8TpNA>IKF#5STCYFTPFMs%m=s8|h;TDPU znF}ro!lX{$^c@_)Xi*}OLH+s1I~emOph^6!Mi-=L^#!B~f9c)KDCx#Flw6qWA2f9Y zoYFp2#!uv1iQ?xSVtc+Jft5|T9XH(1(~m;&zg(8}vCtVZ6hzJ;)w%j0*<(F8roK;( z58#n$)*&q#7Q+_AL&kMv_|L~R&m{JDmP)%vmNNpZA)MAL02*73$2z>YJEFit_cCcs z&=@s*ar~rS341!w!2A+k#yYdgCV6qhvZd1jllLIGpsCF&M7&Wy`8Uti=hlkQg~pf` zqIr$}PKP;rGvH0&SB{S;rd+R`NxiA|Dp0Qjfu&;ly z-5?Navbo@|3>a*=3VTa()#T3QBfO*eOc8}`in<22s#SMQm2LI;*$U@Q{1tvB!SRT# z8U8AhGUNB)%?5*@Z-nr7CsR4$OM$tk&MRkx0*B(!T_^Q+4dYjFL^!SNwQSYnwP2rk zLEtki#hgKUs&W8}MB1%}U%f^9&td<@5W9L0Lp7kfg|w(?Gb&C#ljr4GQcHiv=F%KG zg?#I_+{5SPtKY)0wpOOE%Z?R2W|kM`K;Z>%yj|*r7()8vB8#WdY|P%yP*O_1P^}t3 zxs0u0C#kYCPKhr&#$B6e?-Eqi$nK`O;^Hwm)35&K*s7ehGTIrYsx2y~9!djV;O&a( zdkK(I(;3$C4S|iFvJi_(SPAb25 z@c@}DD|E9-7&&`Z66rmBhpr&lkw_C`7q?J0BQRgkfAvWG=ddEQZlyl>yq*_hi7Mg;@eizVYHi>^u`EvuCEv8bxtv;1&+J_H;W63#Yvf(z4 z^E(Y~6EPvozhEZtDS&474$MXzZNR}G;E331h1;Bj+n!_{3fveWkvwbx2@N))h?b@? zWgID@6lnXqmo=0*EcMNwgcZLcrVrE;8V@jVxzS9z(NthpcYzXtz@Yr|Je?$u$r$=KxurF=vm@d-MMH)JNE z$Jpk!;e+GpM|pc%H^Ax40gS!xPeo5*DVG`m-ZvOY_AbEeLhw=8Mo!J)&wan|cca8n zj%<|2X*zaYbfXV&TtgRzo6s*3%1)eGq^0Q}GZpwO&N;sQ^VUO&mO?-i&U+u+e=_@< zNe>n>0pZJ%99H}aES1?u&Ar_?K{VTJk%6h+tQV3OpQx-vRl?Rx9)`U0BIu;MMT~PS znZ(ia`gU`1nj^aPC2Xj!6a5?|%&7W99dO_rbii)}84jR*%y7!ZYH7>;-Vb3_2q}8& zq+qN@iKZSrZcE}XxCwihC5ptuFi+PLCFz5dvO{ke0UaXT9DC-fuAvFHOVtxc0)^Uu zpc!~EDF@L;N}{b&7%Z(p3#YRlr`RNRTqU7-R9V+wDn_ITQTv>{!j|2;|HM+++!$&E zsr->=LXRENUYqVR;)#zSH`646lfs~(|7+?&DJLWQ7e;<0dQVL$9t-^+doay}G!&5| zQtC<9*Nv>hINAd@*zp&#u|x!T#3O=$n3N>Z&)bbM zeP-8VF)M4tLCO>PazKHe^Vjzu%iVD4CI~Gqs(C}$NY1Z=%JI4E+#`3MLWYRrUW$+@ zMqyjo#MfT5)=n`h9~q;UI;4)9cKG!fPr)goj|V{|cA32~dun|@>d(Ed-GL8c`|wbC z(slJ6D5l;X2#Y)))?A+r{kT|~oV=i!K|-afDS8Vl*>p2gNi314J{~9TtL!c;3Rp(I z7d7aCRmb2z8UWl!!y)h3Ib7B%2xdv~TqOKh3G^T;T$*TzvCZOjBj&e-Ucg`hks(6j z5oL%x7&}4e?x!`kQlwPKAO%JoF(i(QY~!$cHnPbZiB_v$w-fN?I+B$N+59csG|6VY z83T;Yb6*k;tb{^vJiJakM7sD|nGLFV=yItKG*G}{a3wnL77qsl68>lPloLp>(MgSw zfng;gnNGW@PSjxB(_bk7l145&F|62&5a?&ZGDXZOMxHhnBSI{eLYG8i+((b?5{aFh znM<i|cr4~!-^b)G-* zFQf+7Nk=Hsj!nplE;$r1Mck(He+@GVi@E8b$qkAO?ty9b{mG{{=!%s_LNy4mX6XFr z>mSa32}BrI%!T-kwq)FH{L=+aPjc5I(9Yc9rsTl>SM9PKoc?pGWH)R$6K94v723D@ zd1yP9$g*`s9zq)gsUILEKz#Swo2gSsKVvQDoxn5dW5dh(IK}4SQtXp7U$b4m#*2v> zHHw+(w8$jwIA+sw`gB`@ zPZZL|75WK0O62sxxjTFtbZ8Hb3Nsp!<77$x{Yr{yu_WY!Ru3qQz^WepBJ=>YqYT|k zh%|^4@2x=IlF(-(1Oxr9yy-9CPog&yQ&=*e8qk3=3=Km^m)9GC5e6#(_R$bi zU2}ZHVNLBXnpEwffKgkRjptvNjw|wPsbx40PQW7_ZBynxojBitZ@YqfrxeV^9lmTi zqigZsgkPx)?@s7^PABNhwyRHjC>T;=WOhIQzWlyLskOKkNerER3r?AU9T(Qfo}W(- zuivez!fy6fTeopbeV*zDC(oR7m+M_7sb~OobV;ma1*uU16>pz9A7RCliyfAO`s?i_~1*sNv8-5!Jlq*71^UaUcQyC+^HjpHFf zMjW2yN`FTBkN1oi1pZZhC^UXK$&UC6Y%r@CH%xg5Dmb+~C2kF! z`|Bz6!ncQ7CyYHOGXP0`@ls{>GDVdT1e5v@eloCZ4tN1BufWyxOR@tBpi8yR4~YY# zK22u3@5EcYa*-d+Ue}Ka6%-sL9`B}&URPHhd}SG7EAT;9ON=3b-Jws(N+rEP+>>bs z`@Nv8$0C1^)D=R*f{;_OW4j(D%+msjOI@Z-I^H6W!!OZD#WT+|pcYyPHxhNl;)a1C zm*w8ZQ4{J9uL$vF^SK1x{~Ticb0qk^8EMtCQsHnXVmUL`FZRON*D1NgH06U}wzV)@ z9Q9qv89~{-XBORYjf*ED;$$xD^pahEfOruA0zM!#LiPd^zW0wE{gRT}f%$#j#Pt1R z$g1Ys@|U%m*SN;H&SIziciLn&;;xFAWbEF66A@GhWF-FWBq+syt%S2;(u>t$;a5Jv zrzbv&Z+hWhdNkhKjg84XN380i=aYlH8QueX_WEB*^Hh<;8&IL5tR(E>cLm520QibK zaGHitp;k?5nn@{LV)$L!iib2-?)>rdaKJP<@Gl-}UU6>|W@4 z=i8r6BmLJysv>w#9@LsEM|SCT@X=$s*1*a%N^{3MSSko+IJhwl@;<24<1+udE|WU&&!Nh!*eJTcgQKvbunGL{Jo00FV=@?rsFANnzO1(ok1^UZ-U4vwZjP%4*|PtDyUCQx=ZD zP4k;8l|es&m6NB@#V34*rh7__B(w-Rku#rwyz$j9sPK-7>(E;PHsWMyH8BT~3WtIw z3JckhLm-l{_HR;_TbD@UE7;Vk9-0CIdH28YMu_ponOwz$sZvg=k9$?VOFGWu!Vw5} z>ST|4+0wQ6hCFl1Y(V3msLG>6mFiF6ZoXQYS8sKL30`SRNOg7{sBL-$v)f_;A#eIF zL~r@e+4-Vwsv+OkcrCrk7e(?F)I2c;lw^wCXOLTIP9qGFmKtsfGl05i2-UAwH{y|& zQ0I-0J#_zzH$p<8YO6|u_Im+(spR=R2w^d}Te2i>a_mlh$6NkSW7iRSSkdk}obTF+ zNOr2~Frp)><7nD^%Zci(=Bw28qdH@GiHiFbFR$}$$!L$f#^_sM&`!?JJcK;zX&q53 z^@O(HkeuYkBeKayM*NlCwjSb7mpO6NBG8NHe&c=(2G(ZWa+;A+?+)bAdimn%!AD5y z=U+i^3Q=cYAmq+tFwUFP!!!&xS=p2jh2Fb>AB{<&v88;I^VZIWYMnQ-Ta$BBqSlvX zednc>k%eNGeKX+3>lk@W)4}g2BZmIu8~l^}Zyxt>#JcO>Id~$iXNXfV_Ii$fPM6gQ z_`qxaufOHIYc~^cYz}oSo1gGYTJS@S>wVx0R<`_sqb5(#g}uFV^w7aX+NM2*U$|Z` z%Z7AL-%Q|w$TO0EE)6BPDk>_Rf*fxVXmS#Sg}-xWAGlYBma_nzunbt3M^C5T!Y23VfVgfI>#mP`5 z+h;!}xq^<4d-0<<-_42g-##*=eN<-xH(JrUUK&w*yXrbcj7k-va$%mFKiybGD6K_i zCWZ@zb5E^5q;HR^kzpd={<-2yJBmV3nj8;3!SqobzvGFHO}J6dNIAO9>2X{h7N5zq zu}_(FFG?RsH0cKP2hnG}P$}Ch zv{{^~Uha$nEDT?-3JOqT;&Q;1eu}+=hK3Gy?$BqeW1x`)P`(~#PU2WxiV?-SOVdB@ z-^UZ=tL~kxKxi^w0KBezt;Xfc5ZxSY2OgtI`Yd4a2T2eQZa#49RFxE)RM0jYxc-%V zV#55C=+S6Mp&`#X@=i%wJ#<8Z4FJxDqtslI7z062U6@Q0%jup-933Szl8#u5bvte~ z3b;*wvS{ckfz|gXWd3MaB}yBgbC4Ml%^4|(_d(K$Tx569TFp1g4YP7 zw2_rl{HXeTQs!Z5n8T!XBHisKH6$_ORk7hmWBM0HAQM`0?_ayq=WZzpa~cA&+u8*F z#RB6|4hg2-Cs^l$efI94)fxBA6o&kwT`$dA13zIBCHxQ0`B>%t7mdM%HjoUMQP{> zAvFJe(;&7=N*NRtygj%K7-vYSs)DfFX0k1F1gXNNG*cs+s5|O`hleUcatENy^11Tp z{HF5#Yb9JN2Zum%8t9& zOuw<+ZL;x&5?v{6bbN%I%I%~~NwtEO_>Pid|0V7D0{N|oCQ~|;w8l&cE7O9gjt3Ao z!e@^bq1)c96{Rds8N<7iz)cyci2E_A9AHx2XMlT0u^}6iZG&MQhQkYm?9Ps}5(z~w zptC`kRxDEvPH>M4@@uoK(PbvC5|0_hKLJ5O z1d9@~Ot~u%%43R@GKHX32Ef2kJD>D@z9G%$VZQB#@541UG!jQu^{RcB)s-bLW0id_ zL%PKF!5gZvlk4cSAqNU1 zy)ho_zeeJc8zD&V843FEDNyVAWI*c`L&WR9lKmT9(LeWJZW7Un0%74I(qmXH6^eFy zfi|RDCnjPFXu>2dWK6Mg{4Wk$-<}!|TDy#=Onl3cbaF;=tJiP^Q-CVz(cHR3yGrdC zwF@!csd4dy;T(R*aHTd2#xl4g>zAc_IDLy=)XHM(01CwRdH|<-cSSagbYG7(gxF+_ z=n*xVt>bHMg-xPVNa$akd#!E}nGSFNxQe_A6>_9)?F8J3Q%L1%dNxO-wKX9hzIHXFHoGCG=Fq6(t>Z~tXeep!4dxk4sS%xa zxMFh6=r*JdEi%@jBF}TwhsXp%RvZJr!y6}c^EPKFiIFt~?0ftvHdS%nU)k&N$3f`R z=ooCO2I4EK&?S;-$=-oqxMAQ|4JJoEuV!ByK%j5B5CN02s45CZIdppO12hfeY$*ST z23Qu#tk+>Ha>SX09V1JV}Kr6L0Lefq3U8t3u3EYH3~N1X5iiLD_Ef zN#1YTtHv?J_I=rp{Q?(#_L*TefWs#Vy5HA4Idxd`5IGph+*;s%jdK3wknmeWy^Y{>hcVVEM~^%& ztWhNlL^%nQfMmCkU=#8gja!;la1j)ne<4jKz5^5!mFq08CseNIH$C%^7lN7$mEj{u zn2B`q$sCj$6FiA3jz*Mr;`o&jT5aX|N*qf2nGy(#xE7#0(wloMK7f@=w99;Tk@4HC zWn$ra;Z?znLFTOrtgeTM2s!+E0}Zx5Es@A_g%8Ow33({~EG$LU*E4p@dAQ|fIgG}| zT3kgWvGgXp%4fwmC9gBi{UR40))BzSN|m#!2xX7EK1DZ1z=?yFETvBmdK5Y}m>U8Y z5ntoK*h2zJe2v8Rdm%KL?X1k#c7%`OjBbK01Y0h?=oy|lQFlStRwUjmRG3@A>JmZJ zqYcnL87^nMymZ%NS@o&v^wcB_bHUPFJT8fNz{4_WB)jE+r`S7)@F7D^*zgavT{J;+ zo*zn|nRr&qoQgp;^iO0A-+D`9rkE)|j%U=M+a!P-BY2kP7T?jHkke-w#mW{gQFsH% z$%jANw-%9svOZoLflqD2f~v@fY> zCS9bvYEo~>aResFj$BTDtd^v!AHT;~mJ$>2%Rq`JXWl zAau7y5gs!71&_4JKh2p3YW;gk_t?k!)VXPDt1Dr?Z79r?*+$_qW*>^Pm)_k|VU$Wt z^%C-@Gy;{Ox6HTOq?U)|cjH0k8AlG}`A8i_aLCiyGp69g^wKkq&bk|AoF%y~D~XAu zO0CH#r`gXYWA9GJiaOhH0!`0q)t5Dx&-1&VP(%KA%S1gQHxP0EW!eSf>*Pg)cW7lf z{z_V5Qyu*6#^q^IPz>(mPCaV=KEkHReAbB$zUhm96hUH5xUz0|v5e0Qts-G=n%db8 zmjJ&5QtO8%f@>_-@{m#%1wo3d2ZTzhx43**?wTeGYwE&w{@K^j4FJI0PZhF120w19UN$BO#i3 z(7s%-0WY$jKpQROUj&Ik>S2WTx>D43N`NB(t8JGju_%LkjX-WRz#|v`Lu|s*_g9z^8w_h?^=G_s}sYf zg%K=XPLem2f|pZsS3htwI^-na%Tpn!XPds`sjhkNe2Phkx2{=M+~Z7!Spl>end5mo z#J$GOtMT@;B-Z!^+m|KLfs-BUAjP~m^LqM;hcvEJC$WctnWWTC;=yni8-QanzYe*x zK?Ipkl>czs0*={C`*y#Y9Uj|b3>Nx z1fdzo{zFNEDTa?oJ4xA*QAZMs&>r+o}Hktq^ow z;m(P!g}kr{IZf0c$CdKDj{8OIp5}v_uh(Zhbs!FC1GU{xW60#PpqVVCxZK*U+bM}b z9nFl5J*`eO-HgADOF}gC0AQt{Uxs^KJG?%-_Xi(62$H?<-7>WK0P9(33C5teGkN0q zJt25W2O;5nw0dSyMSY(VX|1~YNDUoOv!uekd|E072Rk7A-9`w^4UJI^OlGC3QWyqNTcZ75INlwMZHqu%sE(!J#~G=)NG&>hQXAM97BrfjGANR%1*V5 zXCQ6J8^&6NWE8^X)o1n?A4Lt?pqEG?DF|#L6xSS0yQ_6OGbUx={St3H2gl~{f#175 zh{0V{Ubip1CI7ws^ol8vLMk@sjr0hIc)(D1{}Bi<2c#~%*k%D{Z_ zAY_Jnbv>F{v}N!-z_U@qGG5k^eW9bR1<&6X%nSq_@YwxC1k6% z?rwyD_dUaP*LsJrgq`lF8N-;iEFm-?v*8$;UI4OOfjTT;+aJHJ0K}yRDdoKqraNGe zf_J0oPc{ZJqe-+>T+8LoE^g)SsAUha#KI#^%U?F8wMPJLl^v<=gxjfdX;dk~A6k@n zyWWw082;5Ft`-?{3o(WnaZEL<(;f0sCiqQ#k+Xl`p-x2ko~G{s5=5;0wF8Q1{NRyH z|4zK3N*0#eHcOUYctk2!DHx*6^XHO;sKQLyFwX66U@$X~amBKEnFWekVD}#HXis%C zw_e&mommkK5LltMjCrxF8HP^$2N!?7;X=rIl6*#N3u%IY6gUrZx z?4Sq@)>}%D5~_2hkTf`ja|@pr@aPYS@Dz1 z6+|d1{g+}-gkGAwXE_$8y?$Q{DXHHpX5I=k!2WLBay?oNy@9OG5U6Oyq9A3LV^mVd zwJWwQ_1|x;KYYzpjHd%jQP0SYiV|zduwoA=tiBL65A?~4xRXdwa`;ukmD^lV=skCx z%~B$fI@I^4=Q6L{yQLNT);p!Jkrr&LN)%;d2T4(YBX-Bo5HVv0y9xqV zz6qj1EQM1$i2?TENK%KyGX{!aYHl3C}n)Z8~SO|F94 zVHYJmuEcxf4@eq>aOud2pmZ?{!vJk4zF4$oT%`}F#b2izXGwuUv2l#DjL|<)8<814 zUC8dj96aizOR~V8lH?tJ`IkuMYo@7B;2EV@w9xdZ#-kDeQ?OH~rSp9Z67h(%{9je< zSFr{*>CxBl7Sl~_l;bMpVoYt5FgSwE;o4gr-W4rf#2|tMhmp8z)L{$$a|5~vpw0_> zTbR*dgNT(fbE|m}&%m~XV6MlO z#rb8r6p9(lO7uzVbnUHfyp4u88E~HOgLCMH(q}%5UxJ^ii->s&q3s&qXR1Zre_2o@ z18sW1Uwbz1y0$8w!TeY->_hY^b@t-U&aY6Q;8A(}M$#j9I)VX7{jwpf-Suq?U%bZ+ zr6C;Z9Hc$9&n_1?et`=I*B5>!h_;OCXpcv}3 zFbVXjLE7u9S(B)z2>&`3y&}J-+=nq)tj%9!se9+A;Z=2 znzqz;8NA2|Aiq^AwU0zKYCqlnyYU!mHg+}Sw6qwBC9nnCk2p7&`QuASZBFbGWrs>Q z1o47efQ4t~mm8p4E-^LZnIzgB&?Q)-%X*=cu@DuIzZqz~0)2izJT4inee>vO>h6LS zb@7AX#=6(Fm1@l#ue{==hr&#BA~8j~d#Voq3BF7UJ>IyF&>O;V7=Nl1lKQRuBR5`# zEJ(ppZjs3vjU8XzI=_|FI3&nWJY*ttn)1*~VEw5p;OX;7aS@8}||D&Cso zX5QOn9J@ZT!%ia;tpOdbx1Ls6ggu96wVy4VMov%>KTRwRf>boJmdQ-4!^rPtw(D!y zB&p5A(penR?{N~V!{i#~9vWmy#pft93rj6A3QoI`6-;qE;CIi@@$cZaMnMg>K*R^Ho?4_ze zJFy&_G862@zYWsFUyy4p2mcitLr0dJH%n#i@6xv0#<4)Gh#F)qxAj>hpr-T%+sAq1Eu%qempyTwIc}4ZM!dP+mNu zVa;w=q@MGc_3g1lDiox_dSiQN%at|QAM!B+Ljy_X4qLMTEZ9iSl0_!mKml3*p+Ad6YSg-SdWVEM_iEUZS4{mK)K5?ilj#=zZU(blH!k-FENz zdk^s;`PYBav7|*EnH0Y6O`k*p$NoZf5X;-7#A-_2|d^esp8UJnYh9kJ5-cHal)*XGHm(kCd!vY}>1t zew~+%-m(+wYkHtLZC4R9)q=K>&rGZTeV2I)r}NsrPG1Do%gQ~Kz$6eTak2~M1kKkO zoLENeO(P5@IZxAy`K0diKgFyL7}P~~|^En5||!*YW0oPjMsk}T>^1QlbIT6w4NmzwGV9wuVG z`WgZc!2V9{u`2wMBIRns{GWwM!1tkv&!)C4P@!+n zYDb{qNMcY!D>z?wg#L21%p!YGFV*ucwGjeS3L%686CT95a zA>%Za6Nvo{w;z!uo+q(^KDwT1ITF{ND#lmJFi;jBGU4I5%1l*$O!e@C4RWAydC{iz z*iVop!zCYyjCeUH388l}QtV0(Q6JP#a=T@-#h56zH3OSTsm z3fX5gnb)>q%8+k1NtbKvj0yMPF9ROHnTKiVr-YdmYE07-=N7>g-oHffA<9PtX8DTe zl#c%_Btgk?Bjv>aa_z@~a(WtmAj%n%Vl_lTuLd2$5ER&IFIJENdu(%9i6L!MOcO!X z>yR8(dH5Q;Yw=(Z2&03%I;8K(KQ7a&UJemDn6G;F4j-aW36?-mKP>o)@h?1xn~h9n zL@FjHpfO+f5Y#J)$t36RMN3>=?4U1S&G!#yhLX6sA+Si+E4{Q zlOk8}0?W$Blh3S^4+VLo!w-bE9Hz9!523&F@fF5!G*@N#7iKFQ8cGU|*A0oNWsfl9 z+XmrfhGuSY0{8ca%F=vI=eb~h0A^P>)9;8ju42pE)M|0fjrfUxoOGSV3{Rs?G1x0R zw&z%h2`G2k_{pN|g3HTEzjxga?C>eS%g?=#m)+f79%$$u&d`B#Ut^<*y zY1;r&2HhSH7F(YmyVvt6Ao{CKERK#D?+{Lu%+WknT3Hf1vE)%xGgf^)-uuwp37y-B z6;M~R2X2>pM^r}QBw37l4iCsLOT~9ah#F1d^*hifn&}mm z`riHa{~1pLr7SWlZ?T%y+$D8pZORz|F#6m>3t~_mT?pl@MWi@HcT#V1yXU74tcsvNj`piJ?+<)fON3vn5Lv#T$CGP)E$^BoF?*CeDpGJP5CrHwQ$RA5u|Kl2iNQ*0o J)rlB|{6Ds*BfbCt literal 0 HcmV?d00001 diff --git a/frontend/assets/Settings.png b/frontend/assets/Settings.png new file mode 100644 index 0000000000000000000000000000000000000000..c7abaa7e0e1c3400321f870d834845cb7ae3b22f GIT binary patch literal 2154 zcmV-w2$lDVP)@~0drDELIAGL9O(c600d`2O+f$vv5yP=Qsv5PLVXBH05` z-~dTZ5a$HI2_jBFI04$u3e9d7K;}JDVuaFF-K|81_kF^~-CgRguCCwCmTVAZS?++I9E^R+;qt7p%iJxL(h^nbwk#^}3#A2$F@ zdX2?hh+|519`8A4NkCKrNC={~k_=#yXS$$;1SU&q0o{4`H!SYTagbJM+r!sA$-wsZ z_5yS-y*{o4P>EsjXOam_mdB(Nd#CEan0F4ng=8+C;M^%WULThMYKB|_Lx3HMk15TzMaN_Sh+BjB;zRBj?LU;+K7KP7)tNBOVfP2xX-WjXUUG8s|3o>l6@7YY{Z!lB(!+D4 zjJwvSrL`Myz6R+k(+uJThfp9vKR(60Y#Q_tptFKL2#>1et$Gq*CMDFoWSgb2pt*+N z;wh0{g2l4}RacyQpm}*MzW`{h#LqQUPMfa-JdF($3`pcB!wZ>XR)=t-L26l?t;d#l z;dlDml?;IHT&uY1zG6*<KfDFYE`ax^s9JdkW zWo8zr2>r--1azW4O4JPM&fF1#@MG~*>U^zlf>qZW6Q46a9RwKos^7;%?s?0A~X)}`n<%s2|UkMfi ziqyhF0H(ySlqGR^eQFs1%@+iv$5n!AUu!%K&gBO>J}F1SDRb4DoiUZ5mePyBTxzDm zBmynS9-#kBmFZNRqce%wU&a9}bg(4V_oAF}Z;d9#6{n$Mg<1eAzrq+>Nd#d0Y3t3Z zA5w_3cy90xO`GRw^-z#FUc@7^v^vo}eInP_A$fKb#NFnxQ-Jl3_y2!0# zP|{ z1d)&v4B>mpeWFB~w!o3&Q+qBi&1j@pk%WY_R(Bqju_1sZovaK_=^7=)N8f%SBazey{=SqSBQ5srky-3vw8gMOBoH5LeW^*q< zW92F87D=Ts5c^H;04{_|jN&J~@>%B{gUV=+ z0?hq;lBhSzOvD&4bDB@eUnF8=hRX3d3eR8hilaPNh4I2SU0eFI!8Et$b9hmxsRUUR zfkqCtS)$ML2&sA8meIH?IG=AdimW9A-7=bVYKWl=nc9D)E^!fL}*kODxR!9PO6;z1?EangVrF{G3{L2>1;%*E zP%a|-7HL8#opeEh0m?E}5>zoU=3cPoXLOqeEW*^FSJCI-EmA78l=!2)MH&mGi6k>f zLM&;Cv#VP0Tr#oJYI*n)wL^B=8~;G!^H?)ioQP7sC3SXF7QY@Nna~szP2^f+vFLT( zunb>-_Dt!URzxEgDbDo{acC}GZ?!H5{J~{519EX!Q<^HuL$QfJn;UFORm`)JwYeCb z0e3N_VBTdp@cv*bM*q{y?{7Xj0=n_B-G^HEKG>xtVyU-L{DaJLH g_HOT(O0SRq0ND`f0qy6rp8x;=07*qoM6N<$f>fISIRF3v literal 0 HcmV?d00001 diff --git a/frontend/assets/Users.png b/frontend/assets/Users.png new file mode 100644 index 0000000000000000000000000000000000000000..14a6c684681e9928fab6267237a6a8f0b3ed0b54 GIT binary patch literal 4697 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 literal 0 HcmV?d00001 diff --git a/frontend/assets/arrow_drop_down.png b/frontend/assets/arrow_drop_down.png new file mode 100644 index 0000000000000000000000000000000000000000..b0723e0fad9735c006fdd88996d60d10bafa7888 GIT binary patch literal 244 zcmeAS@N?(olHy`uVBq!ia0vp^Dj>|k1|%Oc%$NbBI14-?iy0WWg+Z8+Vb&Z8pde#$ zkh>GZx^prwfgF}}M_)$E)e-c@Ne7+Lbh?3y^w370~qErUA%=FyEc^juC17-3( zT^vIy7~h`T$lGAR;}RIHGb!|jR_dvoqgs(HtU*eOBDXmL|ER~79~Eb0VE8fRoLsS< z=l6Y{?>%)wqT@@}Zr!qSx5SLaJ%-N|f~P1S6EzWi+Ed7G>&BYo@~0drDELIAGL9O(c600d`2O+f$vv5yPwzncG!ppPzJBjX|DR;dhY*5u1bWOI5dZ)H000000001RGm#9H zWm#P=m!Eol(I1P+Wb$%8pPvth!&Wl$Niwrqt(xGs`faJ#=i9GKE#d2YK9}S1_$nF6 zs(>bZ{j$DulPb3ovMHcN_$I3ovM3-Re3MNH$rsQee3L~9Nf*!~e3N_$$rdm`_$KKR zk}P1r@J+HMBv*h#_$J8`k}AM4e3M)W$rLa`_$H|mk|>}@_#3^NAS(%zObO8o=o9{a zzh7&JFB)d^N5x9QBvC@t0{Vt;Kjd6Ov;u|--{e|Clmdnh-{e@r=mmI%Z*nVP)B?Q2 zH#wCsS^;B)Z*nPNlmf;M-{eq&djYY+H>sE4TEKF-{I35jf0A&6gm3zkuvjc!>$?6Y zPNw2$I-P!#aD#?#Zuhy7K+XI`9Jv;t55AHxF5#OlCET`0Zd?n{x1T!T=C0wJ4kZ-g z$h83Vx8?0d3Eza2(5&vnk@qX0&66B8eABiBeGbp!$n_M|oE{#WK01ZpY(w>af9v(6 z*RP@|s@ZHdIh{^zUvY0*oQ^iT-LA_it~K}10nL-tqufre@1@O#v)1d6o{)a%liNz5 zp|_z!KfX{|t)p9hs9Wo+D)_h=3)|4h*oH>NHZ(G}p^>o-jf`z*WNbqtV;dS7+tA3^ zhDOFVlx-;6&?wl3Ua<|mVjJ25+t9w)hJM60^dq*R@30N+i*4wAY(wv38#)xWq3y5@ z9TMBn$JmBGz&6wg+t8~h;TyK0BWy1pN&WEw000000000006-7_0s7rj#t4J5QUCw| M07*qoM6N<$f~=8S$p8QV literal 0 HcmV?d00001 diff --git a/frontend/assets/branch.png b/frontend/assets/branch.png new file mode 100644 index 0000000000000000000000000000000000000000..700605c3700e5c921432f712a377cf7e8128da1b GIT binary patch literal 3001 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 b(68wg_62!(2neo$00000NkvXXu0mjfjuyi^ literal 0 HcmV?d00001 diff --git a/frontend/assets/commit.png b/frontend/assets/commit.png new file mode 100644 index 0000000000000000000000000000000000000000..f03c3d4f10fb0ac04884518df1a3100df87cf2b7 GIT binary patch literal 526 zcmV+p0`dKcP)J zJCO`+j3nDrK0H3|7}^~P(Ww*sw|j*}{SPN~f{y@a`&B0}r(oiOrCl+RC>w>G45%r9 z5f>{4MXB^WZwC>`V_nhY?Z7u*z-R2p8u{NygL7Ze>p_UpeY6Y)gW=-%0Azx~Crb3- Qpa1{>07*qoM6N<$f+aK8YybcN literal 0 HcmV?d00001 diff --git a/frontend/assets/compare.png b/frontend/assets/compare.png new file mode 100644 index 0000000000000000000000000000000000000000..a68d2152f2ffb1cf1f81f4e8f44fbad865c4f213 GIT binary patch literal 1059 zcmV+;1l;?HP)@~0drDELIAGL9O(c600d`2O+f$vv5yP;Lhr37`i)Pkk3$z*cDdL=(0@_Z_gerux0 zTv!xE@6ZzOr69;NJ~=tbm2aD(h|db2o}LPeT7dC3p`*!4vu#nN4^i}dffk8ydcbR3 zG<<*-($ej~XugIpG)^SSmV7&sltHbn{wTO{~lAM=wp`5O`x=@J4)O zQpX1hxKqMn(OE0Qbk9H>LVOVs~ zZ@Lnc<%z}Ev}^O2^oo~gTAQ!{70z2p^PH&)E^TqF7}o(FGsR(%aIA%Ay?EF?7Rxl_ z8_yR>d7@48OqFhlo)F@8rN!B%8R9Q8YvW`S%wsCvNELCm9-&P$%RGw*Q|Yzj1ZiXD zkph$zm(e4vp3zTben3Az!rM8{s+e(1gR}=_K00gmmP>r4;wg-_ex@^yX`}+%5a&xn zq{Y=CurXo8h}+{Oj_FIogpU3kTBXqztCLPx+vtR~jZRqG=!CV6PFUOMgtd)MSlj4? zwT(tj4HTiQJ-ze~o&-PvQ_0@`dCmRm;czJ5fYeA8dzQk?sezA@pAiU9)>o;4s`ku< z@zenR;18uZgZ=&eU$?in-^C#=?C~6IsXmzPoR@MGX>O0tQvFb7OF@fM z15R*dPpzj0%HYbL+D;8rz==Jzof;^MD|=j>8gPayd)Q)$7E$%oKv`Vb!%YcJXagnF zA!h#jJf{W_?s5g<#Gcwu4HU(dJ-s-Ne`B4+#qdbVfrUbpd+&M^uIyQ^?y)xeQPgd- zFQX9Wn|3bj+3Kl*B2=|U`yLyc@1MX@04K(%>IQpW%G%>)6ga|dfPu^b`wG`j`Frj@ dmIf$l&mRKEt%gWAwH*Ke002ovPDHLkV1g&k@^t_J literal 0 HcmV?d00001 diff --git a/frontend/assets/completed.png b/frontend/assets/completed.png new file mode 100644 index 0000000000000000000000000000000000000000..246f194f941322d10abd5e58848aed9897ddc4db GIT binary patch literal 787 zcmV+u1MK{XP)P000>X1^@s6#OZ}&00009a7bBm000XU z000XU0RWnu7ytkO0drDELIAGL9O(c600d`2O+f$vv5yP-5K=Pmg z%Bo!+3?x2?CqPa>`D$Xp5%2`=5qL`|Gt+j@uq(2#3yG$ywsv->_xrl1{}$jL1oIT$ zUwo_>1!W?t08%m8AdL8ViW-NfFWyaWMIzuDDPXh;($*86x0>ei>`A@;u+5_7PQ`%`6ivdB_ruLw~Lxj`-tg_u1RyLYm-wz(-TJ29oLQ zd-Hx+4QH@kCaW(T!NHinF0qGx$`kQG2(0x+AN6Xj3G5($2%y9sMx^wSJ&jnQIqnuw ztDp9zhfhzd*}=>wFx6hQ70I58l~8*Fxf4O>UlJrF?eT|_%_3HSg82eFNFG5#VzgJc z-f#x3D%_Poq~O5S6G?VsaJZ@soSidC9v72EB`cy-s&X=f zgb)az0B=JOjz5Xsi0f#O99Jwx>l6ujT$BMu16tX;LpvY0PY~ls8>QDc=4P`0#BqnZebct zo+WS#kr~8oDYP)2ew`4<3-Lx;yQ~q-XjdXPL22X+hR$-#Qj@WH+3RzAdE>}bXw=Ox zE6BDm%|XCN)UX7c7`^As#@~0drDELIAGL9O(c600d`2O+f$vv5yP zIiJrz;^kBP@n$latWT%Y{eHh+1qX6ozYoFKd_LC=Tg7h|@$&KV>qWegU#8P(I3AA= zA*0;a&$NKqZ1yeQkh?byX>yjJ@Z*=|8;3M?T|XQShfOe21w&O;eQj~r@)AT+EyDjx z4^6?4X#w%dH!TlMT0-0KzpMF z34gcSErT)h0wh-n-ouw91xV@=N(f()6(FffC?$MJT7X!WP*V7kya2H(p|tQNO93)= z2_=Rv*$R-cN+>ma$y$I6Eg@c-P-ys)rQccj1e>0>q#Mm*I=c0%XJz+=ef13y={?a2>w5EU;bEeHA*&y$v-z z11{UppH~iB#(Qm_`O08z-bIbYe%@?0>tKX!=oM^34X_P;8QV|;Y(uYL8+rxX&=If= zWgGe`wxKU$8~Oy>(6O)$eU5GDe{4frU>iCXwxN&MhO!N18|nhv(9GC|I>0v60k)wI zunl#9ZKwlmLmgln>HynN2j1&8w3q^ literal 0 HcmV?d00001 diff --git a/frontend/assets/empty.png b/frontend/assets/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..777849fa8378c4e54355b68802512a142623600a GIT binary patch literal 265 zcmeAS@N?(olHy`uVBq!ia0vp^5+KaM1|%Pp+x`GjoCO|{#S9GG!XV7ZFl!D-1!HlL zyA#8@b22Z19F}xPUq=Rpjs4tz5?O(Kz7p4nlHmNblJdl&R0g-q^xVXG8>b}$Wm-I4 z978G?-%h#B+n~VX8ePEPR$!c7z!)dIqiMDw6SqJM3-71o12cYHob2%H)ZV{Z3?CV; zbsDB|D&}a47_>c}lfCpxomj+O-$%b%E7J71<9yya{9Ch?gV{q<*@WBZhlG&=vynox zy~Le$no7SU7HfnTb}V`LYL4mIt+FpfzQ2@IsIp-A@lI~Tm2bZTf$m}OboFyt=akR{ E0H_*SJOBUy literal 0 HcmV?d00001 diff --git a/frontend/assets/empty_file.png b/frontend/assets/empty_file.png new file mode 100644 index 0000000000000000000000000000000000000000..d752da82d160bef9618b9a73af8714aa74dadb81 GIT binary patch literal 357 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y6fie#~ zT^vIy7~ck66l^l!@vY)Jz;=bVfO#fMOv^Oih=ZI3%(9H;35*A(ZJ3qHtSHnDxZ>s^NA>$_^7nfINUxW+0_gi$5_;#t)zo+69^4NDr{+D>|s+`>?` zrGB=&QI|l2&tWdd{oV~)L5AP|Z_wpVesNrI-y{DGk;>Pmv^{yntnE`cZO+f6p3H|9 x!7MKP6aHknDmZ)<(z;xIa>AC3#f4G}#VxFpE~P)NSOfGcgQu&X%Q~loCIHo;gG>Mb literal 0 HcmV?d00001 diff --git a/frontend/assets/file.png b/frontend/assets/file.png new file mode 100644 index 0000000000000000000000000000000000000000..7f986f8e43b453c156755b41ec22758c16fa046a GIT binary patch literal 2634 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 Q|3C>B{6*&$#~}KD0o9YnTmS$7 literal 0 HcmV?d00001 diff --git a/frontend/assets/graph-source.png b/frontend/assets/graph-source.png new file mode 100644 index 0000000000000000000000000000000000000000..a62fb5fd967d40e276e757f884bac8bf9b91d799 GIT binary patch literal 2111 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 Jmvv4FO#sCIt3&_* literal 0 HcmV?d00001 diff --git a/frontend/assets/history.png b/frontend/assets/history.png new file mode 100644 index 0000000000000000000000000000000000000000..bc71b5cada584e3b6cbf2dd2768cdc13b49ce09f GIT binary patch literal 696 zcmV;p0!RIcP)J=z?2K;eTfsBLjQ0h0yw3g$%91T2n%O@$=Y#DY8`T%c8(cKuLhi` zV_&jG*q1)8(?Itz8mTUb?2)?D>72FD)j;#h0&*KrG8Hx11?k@11QhM#3Fx-et_%yK zWNLYO^V51&P+kN8Bik@azsk?jCtPCu3xE#V;}f5K&I7%KTmW=0Wv1JP@u4ST;|2#(jseG{6r(1k=r&<|0FstGu*V%VQ`wiyWOpmC zI%FqM4aFi8@PjF>a>{*9CLSaEz-Lr;0n|xXsK{Z>%2GqdM#ca?gZVPk*DvUoC zW}g-vbjAlz;}q6{wqbl_NU}NtfLGTG(|qRe$M}&lBptruIR18B_Z^`303fga%VBMBdc)*PT+4#mr^lx@Wnw-qM`~XbXOzti$p@Vr|%ad3kyLQ}34kEB^Ps`tGvK zUH3$q)LBflAMVk7SfjUl{=d(c^ou>B85vhv&bz&-`rn!DFEYL~cDVSd>|}lvn7;e& zyRU~2A1?RQ`^r(0VB`NR@P(hyY?YPCj{~3nojxh%R72JgeZ<4oY zz|#taPNpou^GO?bOn(x@awY4D=JHQ0DhIdzxp7JR(xjLd4N~%-*X-0+`{dP>CD||c zd&*lcfzMtg?G-!t&A7z{K7J9^Qh4-I-^byIv0Fz&N4m^+M#1wD>zI}1eZ0fwVq><0 zr=#Lc0Z`$MM?i%ekNY|tNuJ!@&~aSvGoxUC?lNX2x!-4i3hib975*^*D*TZIRQT~Y z(8R}+JAn$e* zm14Z>5$p2g#6?Em%U^t7DJ!Y_J@L5xwye0SYM0LL_e<7oF7o+OxHG0scmLz%2bO5D zUrZI*m9?c#LwRChX#TQkf{(Z5T+PoE`K}t|_nvpvKMQujrp-9E#;n}3#SK)wXx|~A>J{@R0EJ&2z7oW4`cgjp`rQnR>YT5k zT{c|SOmDZ!bb`|+H-vpkqeSy$E5CF+&HK?qV5}bPZN4BS4uEVPn9oL@zmG;ytb$B(QFYYa|J;8>OX`Q_)vh@unikvm@ooHed$ZKt7r!QMzP}59js(g!@a|_&;}$!-RR1xk P++gr@^>bP0l+XkKpZ)C? literal 0 HcmV?d00001 diff --git a/frontend/assets/metadata.png b/frontend/assets/metadata.png new file mode 100644 index 0000000000000000000000000000000000000000..4fd9e0d75aa27bd3e81295fc6b847030e73d1329 GIT binary patch literal 582 zcmV-M0=fN(P)~%Atu-}+pZO6mNs?y-nw%E2W(EX`Rgy6+uwf0m%(Cq16mUA7 zjxo7n5CHQa2ny_4v}DKA#D~Ly35+;~-@{_DSO<28$WHDMZ1Y<%8?Xd+&7;?2bRArT zUEgN2nPGjf!8_E{0d$1+F+Tt{ayRNaU}H07jR+oM&h_EC;H&y#9LrkZ0AEntOC04I z$|7fQTGthGIqO9XuswWly%~zIK2n==B~X7XZiXXVA%hpO@x25Qz9hV$v9j037my6D zz}^Z>mzE?J{ez1bU%-SU0hqXnFqh?mmRABMuCr(7RSUinm*rP5cMAr4P}+M;XQE_Y z7$$~T`gk)GvGptLaHGzU|6v*F8x)zJXgWzBwaM*W^a@)Yd~WdhC^2YHG=bHnELy3a zRjrLtq-{m&tBr}|^Z9)8Euh_%%^^jQE0^I0G0?5sowV{Fi5XNuonJtevG3x40v&(z UEw0eUNdN!<07*qoM6N<$g3*)kZ~y=R literal 0 HcmV?d00001 diff --git a/frontend/assets/policy-query.png b/frontend/assets/policy-query.png new file mode 100644 index 0000000000000000000000000000000000000000..0ac970cbb5ff6814d4ec5ee705cbfc00d85718de GIT binary patch literal 2303 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 Y7GE73y>)q-*|rK41%{#xg2Va$13ZB-O8@`> literal 0 HcmV?d00001 diff --git a/frontend/assets/search.png b/frontend/assets/search.png new file mode 100644 index 0000000000000000000000000000000000000000..d15c7153d4d9d8f662464acf5f19f8825d417ee7 GIT binary patch literal 1081 zcmV-91jhS`P)@~0drDELIAGL9O(c600d`2O+f$vv5yP@}LUE82A%uq!T4RN61+*Op0t()A_MuXc4!!8}g}a2lB(Oj-3uIybU+k+I4xR-i zqenuI7bP^4z^#VZCu<$06sB`J6*@$Lq!zffDNab-0qh`2WT!}LrV`lP5NCA8Zs=rm z&C#2r4Up3zj}~aDXiFC=GO@r&%Q@)NvEzCj1Cz^%P0<=Zp1Tt=J&Sx-gu5mZ*xVGm z^mb-AS^5t4Zf;~B2@tP^7`u~s+J?jZ>s#4@Wnu+Vv=rk9T=GZlL|Ge?YyC{`TC`z3 zn5?Pr;rfR>hec{}Um$UZuqcipOYPDmK_L!KV3}BfFt`(6Z&}4X;nuO|{0~M?2+~YSL&60$19QXFCzo zChDnZC~u{;U>0``1{U(og>ohukDDdt2325Fa}g=9wuGKOMmxT5D^Jywq4qS@sCm|i zIyk*Q>ubXv72s#cNn=ACQi_n&a&v$p1aqv-Mxdd_n2 zM)~Nju6TF%zlV{LkE8GMvd32cWIC>RoptXk?bRPxBymrM+YP>pONG#6FX>kf$n~zY z^6M>)cafk-pz{Xx#|D(OJeaDhmOx}~Y>P})i}GDOw<_r47vzOHagHI+a+`5PJ)p@W z0SlxO$gs#sPX)%7LoCyC_O5Sahw^NvXpy-ezD(L0)MlWG+F=b|F521kI1T76#>q2| zhl$$<>RcP{np@y3+~6HK)Xk7mQo;NOJr#UGB!k(S|F2GWa>(0(eRIts(9$;JF){%dVx%sNUo6FnEw|jy^GE= z>#i)0dO97O9k^1!BB@<+$z>rbDk>@}D)R9UBBH6Qg+3*N00000NkvXXu0mjfUufWB literal 0 HcmV?d00001 diff --git a/frontend/assets/swap_vert.png b/frontend/assets/swap_vert.png new file mode 100644 index 0000000000000000000000000000000000000000..d421ec0147a06ed9882671e0b8473669f8828cdd GIT binary patch literal 921 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9EO-XP4l)OOlRpde#$ zkh>GZx^prwfgF}}M_)$E)e-c@Ne7+Lbh?3y^w370~qErUA%=FyEc^juCGcYiV zc)B=-R4~4sb20a}15cZO7JJ5hBR8{vKi3Rzadz<~gnwkYyI}E_dkV9+2;Z<|T9nAr zS2O9vVvqmw3MV#jdi=0IJ*_|?mB*j(rgYYgZ7o!p$unA{GfAM!R34iK;tj%w;`;CyJDs2U<}pj@N2q-7P8Sj+jA z$pO9%4nKS}I73-D+RnF3T%hNmSfkd$63W>1(D^^^qnc$7 zP2C?jH|}1LEo5>i-A!c&=X`-$z8zB*NZJ*!w=clt7sldWJAZM*v<;eW5_(^==k9ty9PjE_I`u;9n-QrX(fd0eOD z{gz*zUXpy{azL_>-9s-1!_cMx-i4P~-uW>Y1~#!|$^Ot|6%bWj(Pv+v-LOKzvG!X_ zHe<`87OiIcJKPE)Dj~CX2{|z+YAk$pVzvPj2P5az=e(^#4muMOjNWL@VMuCY+3Xpu z$SNSLGNaFuTf0GHLV{hg-WG-=mqS`?e_F*IVkSPw+m*zTVVf8c3`xNZ490Pc?<;Nv TcNCh%gM>U?{an^LB{Ts5(<)=f literal 0 HcmV?d00001 diff --git a/frontend/assets/user.png b/frontend/assets/user.png new file mode 100644 index 0000000000000000000000000000000000000000..ed214754d1f2d59aa04f317cb7e158691d93f3f6 GIT binary patch literal 632 zcmV-;0*C#HP)Y_HqZ=XZDwt4ZENk<8NXgyOVJa?S`IJh zl5Nnj(6`{nIcgb7!AUS?Ljw9tG(<%4<0-i3Qre4wK_0~Ynndcu+9%4+SN?TVlEAo-kKy;{%-Z|8 zFG{c0*-cbHhH4XX1i^1r8Tw%qXgJ!tCFitm4{jyJiQ@&K!(J@{POr*J&M}PW2?hsO zBcj+~I7luMpNq-8SztI6&JR}L{<@~0drDELIAGL9O(c600d`2O+f$vv5yP)K$L)MYimdNaZF+4xGvDuhM0~F z@p&|9w|F3;r+Hz97qkHp-b9#(A?)II6(R-;7$P1-7(s+nJWM~t$g`crk1oo17a(RO zA_fWQE8c`~A5%R5+g+4-Mkns$>SClZVwgzeKZ>VONl^P zIR=Q&olfTlzp^W6nKoq@SlYPn4@!iWfYOD2BSU4`hZW&Ir4()&ls$xq7xZK=Ewg|v zhzJeGe^LUx1jHAArKh*;1mX>G{etc~q#A&TBM9;Z^<$INHQcfV(ZBG06p2|tblo03 zxn@p8JT}(Nl1Tys znJW+5JY0J*;_-{(md+zAMR0q2dyF4y>%w!LQ6%a#h93%F^tYA|1IA{k&ErWkrXydc zrX=47p~y17;mTb3kiHL}xle(pEfC`ElFmuYfPwG$Fk?Q)FYZ!25^uOCunVWtLfh$p_E5W|7*wfV=tRsKDi5aHg9fypigmB8hGWGi&mx$D8b!W>%v`EA}ThZ1puv33#_(7 zyK$+2D3H5WMy{I?I;RDIZ6OB|TzSyjx)eaEfSPTR?wiwsfZn1-rd1bp7Nr70pbe!$ z16m-^TLwy&*A&33vIO2W0FlU1A@f>8u@j->d)~P2<(&)ShyM>mvU87}3ferCtJ(IQ zQIrY@@wCw~C%s1BJq)%l5Q0MBc^dBR&PqeM9NaZ|EG!J?CAVGYXZ8}%)rB*1lPl?E z9!6?>JQhuyj65k7P-}tO0HSypsWEvhJn(7dkSC^SOb7-KBQ>hWf<`fRrK8TzM?e_( zx?_T4EdyF$%atBdXO|6h7N*%8nY`vy6TDPv$fmWhbONHeYlT@BmiB5A(G*TI%q+Zl zeGVnv8rEWvST^RY96II!v+AS+XcBl?YlE>`lddvkiyX+m)bP0Iv`a4QnIKXh6@RWWv)DU#^5a;P61^> zMs0<><&6T-JQkyeF%!0ZhSnFgFsAy#mIpv3KE#a34v!^v!Jfs-TzO(6>!v8@`uh5% zHj2gW{*>@n;j#F77_-VVZGOl1{1mgcV-j%U7=y+!yoR~2)QQ*e&5-p(HX;RM+TO-n zz*?Ivw5;`4FZ^g0Ma+z8?~HFtOe$ajD&3Q#s2SCS>qu>tB32?`K^;Oa8P6hDC1Rxl z7DzOwC&%_PO2EW_FFLn|HkOFr9dnJ2nDtrsgG;~`>c zD}B)dGDZ67!?JrD0R<6plmFKi|IWhk;K6@OJqu`PXlQ6?Xh^|- XOa>M`u9O9m00000NkvXXu0mjf33hPG literal 0 HcmV?d00001 diff --git a/frontend/assets/zoom-out.png b/frontend/assets/zoom-out.png new file mode 100644 index 0000000000000000000000000000000000000000..2c0e0f6a76085f7c9bcf075add2d2b4d3ecbf8e3 GIT binary patch literal 1783 zcmV@~0drDELIAGL9O(c600d`2O+f$vv5yPUAp7Cbok0t zkiP)<51>nTa+j;_sPa;su1h*!mr{l%rQM|g@*Nl@emw1Jf6CIK&wF|{-o=)_n%$k< zoe@z_Pft%zPtR&tqZW`P$p9~d@=qM|@+gX8PA#T!0a>iu+uOsnwY5DQqx<{&gdRmx z!2%YjTio5%-7ub)HAf>uaW0U=xh;g0ck zO#e60px~zD_>IZU%}qutAc}w@+$lW@*AxY;`)3IGi5g^s0t4-iA>OZeyHCwBfHnTO zwY8NDhr_RPKn)Pg0@>7WPta7}##C4m>GH8r6Ram?+tq@rK1-cGhUpHWXl>%XGf#N7AKNoSQ9M z9<>1J7j>4Hglfvh7ASMu%6^Xr3>?vXEf?4l@p7rDaw_YPUKk0;)<>F!0Tyr7%Su(S z`@}a_QOrwhC*=DZCY8kcRAkUDrxwD#XHCmE_O-%V0d>|VzFyS1rZ0SRxqns^KP>c; z2ekrXLZ7P2&`D3)*mR!)0nasMZ^(mM0byZBnzD>KfX)he`OT*pYXub7B~=t>U9i3q zofY~tW37N_zDlPV)^|nSVk*mNRqT-?S^@Ecsirum4k4>SxuQ5FN9qLJQ+%GceZ#v7 zoc9++QR3PU)(s++O_-PAshj9FJ6)k>527FoWGOzSz~KA}?4{%ugPBr5>F=#0 z9+U#gx?g1(1`y*P!V1oNUuRVXnk0{)1y1$AL`3d(a6YQ<3f_dhC)(TF+lLf?b3Lh< zYCfex6d~h1_$S` z0+rz#F{WlQ;r@>lT@SjdFXV_;Kp6lEMe#9p2#M9M1TD=u5YuE=Zld^nfT`M;Isnh> z(M%7k%GebuJklmi_C#`38GgJIRtW1m()fQ9@}M@fV?Li3gTWxB>i*+p=xC?V$i9xU ziETR@v_X_CqC~Pbj;yw5#RHeP zj*J3f9V5+6*&vuZ<%oDR=|7u_m6o)Nj1a`JHpQqrG#2rJ#Mg%O8oD+Wb0lOh18_QgJ#sZJrxodJyXb~HK z^wTopri{vmOFS)?0Llm90z2WsL|mzWt0a_&R;w}J5-bX5)z?IiC3U&Q&(;D +
+ gittuf +
+ + {hasCommits && ( +
+ +
+ )} + + ) +} diff --git a/frontend/components/shared/progress-indicator.tsx b/frontend/components/common/progress-indicator.tsx similarity index 100% rename from frontend/components/shared/progress-indicator.tsx rename to frontend/components/common/progress-indicator.tsx diff --git a/frontend/components/shared/status-card.tsx b/frontend/components/common/status-card.tsx similarity index 100% rename from frontend/components/shared/status-card.tsx rename to frontend/components/common/status-card.tsx diff --git a/frontend/components/shared/story-modal.tsx b/frontend/components/common/story-modal.tsx similarity index 99% rename from frontend/components/shared/story-modal.tsx rename to frontend/components/common/story-modal.tsx index 8361a8c..773edec 100644 --- a/frontend/components/shared/story-modal.tsx +++ b/frontend/components/common/story-modal.tsx @@ -19,7 +19,7 @@ import { Key, FileText, } from "lucide-react" -import { TrustGraph } from "@/components/features/visualization/trust-graph" +import { TrustGraph } from "@/screens/playground/trust-graph" import type { SimulatorResponse } from "@/lib/simulator-types" interface StoryModalProps { diff --git a/frontend/components/features/repository/repository-selector.tsx b/frontend/components/features/repository/repository-selector.tsx deleted file mode 100644 index a474f65..0000000 --- a/frontend/components/features/repository/repository-selector.tsx +++ /dev/null @@ -1,515 +0,0 @@ -"use client" - -import type React from "react" - -import { useState } from "react" -import { motion, AnimatePresence } from "framer-motion" -import { - Github, - Folder, - Globe, - HardDrive, - CheckCircle, - AlertCircle, - Loader2, - GitBranch, - Calendar, - User, -} from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { REPOSITORY, FILENAMES } from "@/lib/constants" -import { Input } from "@/components/ui/input" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Badge } from "@/components/ui/badge" -import { Alert, AlertDescription } from "@/components/ui/alert" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { RepositoryInfo } from "@/lib/repository-handler" - - -interface RepositorySelectorProps { - onRepositorySelect: (info: RepositoryInfo) => void - isLoading: boolean - error: string | null - currentRepository: RepositoryInfo | null -} - -export default function RepositorySelector({ - onRepositorySelect, - isLoading, - error, - currentRepository, -}: RepositorySelectorProps) { -interface ValidationDetails { - type: "remote" | "local" - platform?: string - url?: string - path?: string - isGitRepo?: boolean - } - - const [activeTab, setActiveTab] = useState<"remote" | "local">("remote") - const [remoteUrl, setRemoteUrl] = useState("") - const [localPath, setLocalPath] = useState("") - const [validationStatus, setValidationStatus] = useState<{ - isValid: boolean - message: string - details?: ValidationDetails - } | null>(null) - - const handleRemoteSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!remoteUrl.trim()) { - setValidationStatus({ - isValid: false, - message: "Please enter a repository URL", - }) - return - } - - // Validate URL format - try { - const url = new URL(remoteUrl) - if ( - !url.hostname.includes("github.com") && - !url.hostname.includes("gitlab.com") && - !url.hostname.includes("bitbucket.org") - ) { - setValidationStatus({ - isValid: false, - message: "Please enter a valid Git repository URL (GitHub, GitLab, or Bitbucket)", - }) - return - } - } catch { - setValidationStatus({ - isValid: false, - message: "Please enter a valid URL", - }) - return - } - - setValidationStatus({ - isValid: true, - message: "Repository URL validated successfully", - details: { - type: "remote", - platform: remoteUrl.includes("github.com") - ? "GitHub" - : remoteUrl.includes("gitlab.com") - ? "GitLab" - : "Bitbucket", - url: remoteUrl, - }, - }) - - const repoInfo: RepositoryInfo = { - type: "remote", - path: remoteUrl, - name: remoteUrl.split("/").pop()?.replace(".git", "") || "Unknown Repository", - } - - onRepositorySelect(repoInfo) - } - - - - const handleTryDemo = () => { - const demoRepo: RepositoryInfo = { - type: "remote", - path: REPOSITORY.GITTUF_URL, - name: "gittuf", - } - - setRemoteUrl(demoRepo.path) - setValidationStatus({ - isValid: true, - message: "Demo repository loaded", - details: { - type: "remote", - platform: "GitHub", - url: demoRepo.path, - }, - }) - - onRepositorySelect(demoRepo) - } - - return ( - - -
-
-
- -
-
- Repository Selection -

- Choose a repository to analyze security metadata and compare commits -

-
-
- {currentRepository && ( -
- - - Connected - -

{currentRepository.name}

-
- )} -
-
- - setActiveTab(value as "remote" | "local")}> - - - - Remote Repository - - - - Local Repository - - - - -
-
-

- - Remote Repository URL -

-

- Enter the URL of a Git repository (GitHub, GitLab, or Bitbucket) that contains gittuf security - metadata. -

- -
-
- setRemoteUrl(e.target.value)} - className="flex-grow" - disabled={isLoading} - /> - -
-
- -
-
- Supported platforms: - - GitHub - - - GitLab - - - Bitbucket - -
- -
-
- - {/* Remote Repository Features */} -
-
-
- - Cloud Access -
-

Access repositories from anywhere

-
-
-
- - Full History -
-

Complete commit history available

-
-
-
- - Collaboration -
-

Team repository analysis

-
-
-
-
- - -
-
-

- - Local Repository Folder -

-

- Select a local Git repository folder from your computer that contains gittuf security metadata files. -

- -
-
- {/* */} -
{ - e.preventDefault() - - if (!localPath.trim()) { - setValidationStatus({ - isValid: false, - message: "Please enter a local repository path", - }) - return - } - - // Very basic validation – you can expand this as needed - const isGitRepo = true // Optionally, check for .git folder existence via IPC/electron/native bridge in a real app - - setValidationStatus({ - isValid: true, - message: isGitRepo - ? "Local repository path accepted" - : "Path entered (Git repo not verified, but accepted)", - details: { - type: "local", - path: localPath, - isGitRepo, - }, - }) - - const repoInfo: RepositoryInfo = { - type: "local", - path: localPath, - name: localPath.split("/").pop() || "Local Repo", - } - - onRepositorySelect(repoInfo) - }} - className="space-y-3 w-full" -> -
- setLocalPath(e.target.value)} - disabled={isLoading} - className="flex-grow" - /> - -
-
- - {localPath && ( -
-

Selected:

-

{localPath}

-
- )} -
- - - - - - - - Browser Compatibility: Local folder selection requires a modern browser - (Chrome 86+, Edge 86+) with File System Access API support. - - - - -
-

Supported Browsers:

-
    -
  • • Chrome 86+ ✅
  • -
  • • Microsoft Edge 86+ ✅
  • -
  • • Opera 72+ ✅
  • -
  • • Firefox ❌ (not supported)
  • -
  • • Safari ❌ (not supported)
  • -
-
-
-
-
-
-
- - {/* Local Repository Features */} -
-
-
- - Offline Access -
-

Work without internet connection

-
-
-
- - Privacy -
-

Data stays on your computer

-
-
-
- - Real-time -
-

Analyze latest changes instantly

-
-
-
-
-
- - {/* Validation Status */} - - {validationStatus && ( - - - {validationStatus.isValid ? ( - - ) : ( - - )} - - {validationStatus.message} - {validationStatus.details && ( -
- {validationStatus.details.type === "remote" && ( -
- - {validationStatus.details.platform} - - {validationStatus.details.url} -
- )} - {validationStatus.details.type === "local" && ( -
-
- Path: - {validationStatus.details.path} -
- {validationStatus.details.isGitRepo !== undefined && ( -
- Git Repository: - - {validationStatus.details.isGitRepo ? "Detected" : "Not Detected"} - -
- )} -
- )} -
- )} -
-
-
- )} -
- - {/* Error Display */} - - {error && ( - - - - - Error: {error} - - - - )} - - - {/* Repository Requirements */} -
-

Repository Requirements

-
    -
  • - - - Contains {FILENAMES.ROOT} and/or{" "} - {FILENAMES.TARGETS} files - -
  • -
  • - - Has commit history with gittuf metadata changes -
  • -
  • - - Accessible via Git protocol (for remote) or local file system -
  • -
-
-
-
- ) -} diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx deleted file mode 100644 index 8da638c..0000000 --- a/frontend/components/layout/header.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client" - -import { Github } from "lucide-react" -import { Button } from "@/components/ui/button" -import ProgressIndicator from "@/components/shared/progress-indicator" -import type { RepositoryInfo } from "@/lib/repository-handler" - -interface HeaderProps { - currentRepository: RepositoryInfo | null - showRepositorySelector: boolean - onToggleSelector: () => void - hasCommits: boolean - currentStep: number - steps: string[] -} - -export default function Header({ - currentRepository, - showRepositorySelector, - onToggleSelector, - hasCommits, - currentStep, - steps, -}: HeaderProps) { - return ( -
-
-
-
- -
-
-

- gittuf Security Explorer -

-

Interactive tool to understand Git repository security metadata

-
-
- {currentRepository && ( - - )} -
- - {hasCommits && } -
- ) -} diff --git a/frontend/components/ui/resizable.tsx b/frontend/components/ui/resizable.tsx index 489b7ee..24f008e 100644 --- a/frontend/components/ui/resizable.tsx +++ b/frontend/components/ui/resizable.tsx @@ -23,6 +23,7 @@ const ResizablePanel = Panel const ResizableHandle = ({ withHandle, className, + children, ...props }: React.ComponentProps & { withHandle?: boolean @@ -34,6 +35,7 @@ const ResizableHandle = ({ )} {...props} > + {children} {withHandle && (
diff --git a/frontend/components/ui/scroll-area.tsx b/frontend/components/ui/scroll-area.tsx index 0b4a48d..702a85a 100644 --- a/frontend/components/ui/scroll-area.tsx +++ b/frontend/components/ui/scroll-area.tsx @@ -5,22 +5,35 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import { cn } from "@/lib/utils" +type ScrollAreaProps = React.ComponentPropsWithoutRef< + typeof ScrollAreaPrimitive.Root +> & { + viewportRef?: React.Ref +} + const ScrollArea = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + ScrollAreaProps +>(({ className, children, viewportRef, ...props }, ref) => { + + return ( - + {children} - - + + + -)) + ) +}) ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName const ScrollBar = React.forwardRef< @@ -31,16 +44,16 @@ const ScrollBar = React.forwardRef< ref={ref} orientation={orientation} className={cn( - "flex touch-none select-none transition-colors", + "flex touch-none select-none bg-[#eef3f8] transition-colors", orientation === "vertical" && - "h-full w-2.5 border-l border-l-transparent p-[1px]", + "h-full w-3 border-l border-l-[#d7e2ec] p-[2px]", orientation === "horizontal" && - "h-2.5 flex-col border-t border-t-transparent p-[1px]", + "h-3 flex-col border-t border-t-[#d7e2ec] p-[2px]", className )} {...props} > - + )) ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName diff --git a/frontend/components/visualizer/detail/workspace-detail-primitives.tsx b/frontend/components/visualizer/detail/workspace-detail-primitives.tsx new file mode 100644 index 0000000..d892c97 --- /dev/null +++ b/frontend/components/visualizer/detail/workspace-detail-primitives.tsx @@ -0,0 +1,583 @@ +"use client"; + +import type React from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import Image, { type StaticImageData } from "next/image"; +import arrowDropDownIcon from "@/assets/arrow_drop_down.png"; +import commitIcon from "@/assets/commit.png"; +import emptyIcon from "@/assets/empty.png"; +import greenCheckboxIcon from "@/assets/green_check_box.png"; +import userIcon from "@/assets/user.png"; + +export interface SelectOption { + label: string; + value?: string; + icon?: StaticImageData; +} + +export interface MetricCardData { + value: string; + label: string; +} + +export const detailColors = { + bullet: "var(--secondary-color)", + chip: "var(--primary-color)", + summaryCard: "var(--background-color)", +} as const; + +export function SearchHighlightText({ + text, + query, + className = "", +}: { + text: string; + query?: string; + className?: string; +}) { + const normalizedQuery = query?.trim().toLowerCase() ?? ""; + + if (!normalizedQuery) { + return {text}; + } + + const lowerText = text.toLowerCase(); + const parts: Array<{ value: string; matched: boolean }> = []; + let currentIndex = 0; + + while (currentIndex < text.length) { + const matchIndex = lowerText.indexOf(normalizedQuery, currentIndex); + + if (matchIndex < 0) { + parts.push({ value: text.slice(currentIndex), matched: false }); + break; + } + + if (matchIndex > currentIndex) { + parts.push({ + value: text.slice(currentIndex, matchIndex), + matched: false, + }); + } + + parts.push({ + value: text.slice(matchIndex, matchIndex + normalizedQuery.length), + matched: true, + }); + + currentIndex = matchIndex + normalizedQuery.length; + } + + return ( + + {parts.map((part, index) => + part.matched ? ( + + {part.value} + + ) : ( + {part.value} + ), + )} + + ); +} + +export function SectionDivider() { + return
; +} + +export function SectionBulletLabel({ + label, + searchQuery, +}: { + label: string; + searchQuery?: string; +}) { + return ( +
+ + +
+ ); +} + +export function ValueChip({ + label, + searchQuery, +}: { + label: string; + searchQuery?: string; +}) { + return ( +
+ +
+ ); +} + +export function PanelSection({ + label, + children, + className = "", + searchQuery, +}: { + label: string; + children: React.ReactNode; + className?: string; + searchQuery?: string; +}) { + return ( +
+ + {children} + +
+ ); +} + +export function StaticValueRow({ + label, + value, + searchQuery, +}: { + label: string; + value: string; + searchQuery?: string; +}) { + return ( +
+
+ + +
+ +
+ ); +} + +export function SelectField({ + options, + selectedLabel, + displayLabel, + chips = [], + fullWidth = false, + className = "", + onChange, +}: { + options: SelectOption[]; + selectedLabel?: string; + displayLabel?: string; + chips?: string[]; + fullWidth?: boolean; + className?: string; + onChange?: (label: string) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const [touchedOptionValue, setTouchedOptionValue] = useState(null); + const containerRef = useRef(null); + const getOptionValue = (option: SelectOption) => option.value ?? option.label; + const selectedOption = useMemo( + () => + options.find((option) => getOptionValue(option) === selectedLabel) ?? options[0], + [options, selectedLabel], + ); + + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + setTouchedOptionValue(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, []); + + return ( +
+
+ + {isOpen ? ( +
+
+
+ {options.map((option) => { + const optionValue = getOptionValue(option); + const isSelected = optionValue === getOptionValue(selectedOption); + const isTouched = touchedOptionValue === optionValue; + + return ( + + ); + })} +
+ {options.length > 4 ? ( + <> +
+
+ + ) : null} +
+
+ ) : null} +
+ {chips.length > 0 ? ( +
+ {chips.map((chip, index) => ( + + ))} +
+ ) : null} +
+ ); +} + +export function InlineSelectRow({ + label, + options, + chips = [], + selectedLabel, + onChange, + searchQuery, +}: { + label: string; + options: SelectOption[]; + chips?: string[]; + selectedLabel?: string; + onChange?: (label: string) => void; + searchQuery?: string; +}) { + return ( +
+
+ + +
+ {chips.length > 0 ? ( +
+
+ {chips.map((chip, index) => ( + + ))} +
+
+ ) : null} + +
+ ); +} + +export function SummaryMetricGrid({ + items, + searchQuery, +}: { + items: MetricCardData[]; + searchQuery?: string; +}) { + return ( +
+ {items.map((item) => ( +
+ + +
+ ))} +
+ ); +} + +export function QueryUserCard({ + name, + searchQuery, +}: { + name: string; + searchQuery?: string; +}) { + return ( +
+
+ +
+ +
+ ); +} + +export function CommitHistoryItem({ + commitId, + message, + author, + searchQuery = "", + isSelected = false, + isTouched = false, + onSelect, + onTouch, +}: { + commitId: number; + message: string; + author: string; + searchQuery?: string; + isSelected?: boolean; + isTouched?: boolean; + onSelect: (commitId: number) => void; + onTouch: (commitId: number | null) => void; +}) { + return ( + + ); +} + +export function SegmentedControl({ + options, + selected, + onChange, +}: { + options: string[]; + selected: string; + onChange?: (option: string) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export function ToggleRow({ + label, + enabled, + onToggle, + searchQuery, +}: { + label: string; + enabled: boolean; + onToggle?: () => void; + searchQuery?: string; +}) { + return ( + + ); +} + +export function CheckboxRow({ + label, + checked, + onToggle, + searchQuery, +}: { + label: string; + checked: boolean; + onToggle?: () => void; + searchQuery?: string; +}) { + return ( + + ); +} + +export function StatusRow({ + icon, + label, + value, + searchQuery, +}: { + icon: StaticImageData; + label: string; + value?: string; + searchQuery?: string; +}) { + return ( +
+ + +
+ ); +} diff --git a/frontend/components/visualizer/workspace-action-button.tsx b/frontend/components/visualizer/workspace-action-button.tsx new file mode 100644 index 0000000..180bdf2 --- /dev/null +++ b/frontend/components/visualizer/workspace-action-button.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Image, { type StaticImageData } from "next/image"; +import { Button } from "@/components/ui/button"; + +interface WorkspaceActionButtonProps { + label: string; + onClick: () => void; + disabled?: boolean; + icon?: StaticImageData; + size?: "default" | "sm"; +} + +export function WorkspaceActionButton({ + label, + onClick, + disabled = false, + icon, + size = "sm", +}: WorkspaceActionButtonProps) { + return ( + + ); +} diff --git a/frontend/components/visualizer/workspace-bottom-bar.tsx b/frontend/components/visualizer/workspace-bottom-bar.tsx new file mode 100644 index 0000000..2ce3b53 --- /dev/null +++ b/frontend/components/visualizer/workspace-bottom-bar.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image, { type StaticImageData } from "next/image"; + +interface GraphTab { + id: string; + label: string; + closable?: boolean; + editable?: boolean; +} + +interface WorkspaceBottomBarProps { + leftWidthPx: number; + tabs: GraphTab[]; + activeTabId: string; + addIcon: StaticImageData; + onTabSelect: (tabId: string) => void; + onTabRename: (tabId: string, nextLabel: string) => void; + onTabAdd: () => void; + onTabDelete: (tabId: string) => void; +} + +export function WorkspaceBottomBar({ + leftWidthPx, + tabs, + activeTabId, + addIcon, + onTabSelect, + onTabRename, + onTabAdd, + onTabDelete, +}: WorkspaceBottomBarProps) { + const clampedLeftWidth = Math.max(0, leftWidthPx); + const [editingTabId, setEditingTabId] = useState(null); + const [draftLabel, setDraftLabel] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (editingTabId) { + inputRef.current?.focus(); + inputRef.current?.select(); + } + }, [editingTabId]); + + const commitRename = () => { + if (!editingTabId) return; + + const nextLabel = draftLabel.trim(); + if (nextLabel) { + onTabRename(editingTabId, nextLabel); + } + + setEditingTabId(null); + setDraftLabel(""); + }; + + return ( +
+
+
+
+ {tabs.map((tab) => { + const isActive = tab.id === activeTabId; + const isEditing = tab.id === editingTabId; + const isEditable = tab.editable ?? true; + const isClosable = tab.closable ?? true; + + return ( +
+ + {tabs.length > 1 && isClosable ? ( + + ) : null} +
+ ); + })} +
+ +
+
+ ); +} diff --git a/frontend/components/visualizer/workspace-detail-toggle.tsx b/frontend/components/visualizer/workspace-detail-toggle.tsx new file mode 100644 index 0000000..c333483 --- /dev/null +++ b/frontend/components/visualizer/workspace-detail-toggle.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Image, { type StaticImageData } from "next/image"; + +interface WorkspaceDetailToggleProps { + isCollapsed: boolean; + leftIcon: StaticImageData; + rightIcon: StaticImageData; + onToggle: (event: React.MouseEvent) => void; +} + +export function WorkspaceDetailToggle({ + isCollapsed, + leftIcon, + rightIcon, + onToggle, +}: WorkspaceDetailToggleProps) { + const label = isCollapsed ? "Expand detail panel" : "Collapse detail panel"; + + return ( + + ); +} diff --git a/frontend/components/visualizer/workspace-menu-item.tsx b/frontend/components/visualizer/workspace-menu-item.tsx new file mode 100644 index 0000000..c597751 --- /dev/null +++ b/frontend/components/visualizer/workspace-menu-item.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Image, { type StaticImageData } from "next/image"; + +interface WorkspaceMenuItemProps { + label: string; + icon: StaticImageData; + isActive: boolean; + isCompact: boolean; + onClick: () => void; +} + +export function WorkspaceMenuItem({ + label, + icon, + isActive, + isCompact, + onClick, +}: WorkspaceMenuItemProps) { + return ( + + ); +} diff --git a/frontend/components/visualizer/workspace-panel-header.tsx b/frontend/components/visualizer/workspace-panel-header.tsx new file mode 100644 index 0000000..6a8cfb1 --- /dev/null +++ b/frontend/components/visualizer/workspace-panel-header.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { WorkspaceSearchField } from "@/components/visualizer/workspace-search-field"; +import Image, { type StaticImageData } from "next/image"; +import type { ReactNode } from "react"; + +interface WorkspacePanelHeaderProps { + title: string; + placeholder: string; + searchIcon: StaticImageData; + titleIcon?: StaticImageData; + className?: string; + centerSlot?: ReactNode; + searchValue?: string; + onSearchChange?: (value: string) => void; +} + +export function WorkspacePanelHeader({ + title, + placeholder, + searchIcon, + titleIcon, + className = "bg-white", + centerSlot, + searchValue, + onSearchChange, +}: WorkspacePanelHeaderProps) { + return ( +
+
+ {titleIcon ? ( + + ) : null} +

{title}

+
+ {centerSlot ? ( +
+
{centerSlot}
+
+ ) : null} + +
+ ); +} diff --git a/frontend/components/visualizer/workspace-search-field.tsx b/frontend/components/visualizer/workspace-search-field.tsx new file mode 100644 index 0000000..1cd98d3 --- /dev/null +++ b/frontend/components/visualizer/workspace-search-field.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Image, { type StaticImageData } from "next/image"; +import { Input } from "@/components/ui/input"; + +interface WorkspaceSearchFieldProps { + placeholder: string; + icon: StaticImageData; + value?: string; + onChange?: (value: string) => void; +} + +export function WorkspaceSearchField({ + placeholder, + icon, + value, + onChange, +}: WorkspaceSearchFieldProps) { + return ( +
+ onChange?.(event.target.value)} + className="h-5 rounded-[5px] border-[var(--tertiary-color)] pr-8 text-[12px] focus-visible:ring-0 focus-visible:ring-offset-0 md:h-5 md:text-[12px]" + /> + +
+ ); +} diff --git a/frontend/hooks/explorer/use-repository.ts b/frontend/hooks/explorer/use-repository.ts index 6f4a9c7..a36ff8c 100644 --- a/frontend/hooks/explorer/use-repository.ts +++ b/frontend/hooks/explorer/use-repository.ts @@ -3,6 +3,7 @@ import type React from "react" import { useState } from "react" import { REPOSITORY } from "@/lib/constants" +import { demoVisualizerData, type DemoVisualizerData } from "@/lib/demo-visualizer-data" import { mockFetchCommits } from "@/lib/mock-api" import type { Commit } from "@/lib/types" import { RepositoryHandler, type RepositoryInfo } from "@/lib/repository-handler" @@ -13,20 +14,26 @@ export function useRepository() { const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState("") const [currentRepository, setCurrentRepository] = useState(null) + const [currentRepositoryData, setCurrentRepositoryData] = useState(null) const [showRepositorySelector, setShowRepositorySelector] = useState(true) const [repositoryHandler] = useState(() => new RepositoryHandler()) const handleTryDemo = async (onSuccess?: () => void) => { + const demoRepository: RepositoryInfo = demoVisualizerData.repository + setRepoUrl(REPOSITORY.GITTUF_URL) + setCurrentRepository(demoRepository) + setCurrentRepositoryData(demoVisualizerData) setIsLoading(true) setError("") try { - const commitsData = await mockFetchCommits(REPOSITORY.GITTUF_URL) - setCommits(commitsData) + await repositoryHandler.setRepository(demoRepository) + setCommits(demoVisualizerData.commits) + setShowRepositorySelector(false) if (onSuccess) onSuccess() - } catch { - setError("Failed to load demo data. Please try again.") + } catch (err) { + setError(`Failed to load demo data: ${err instanceof Error ? err.message : "Unknown error"}`) } finally { setIsLoading(false) } @@ -34,6 +41,7 @@ export function useRepository() { const handleRepositorySelect = async (repoInfo: RepositoryInfo, onSuccess?: () => void) => { setCurrentRepository(repoInfo) + setCurrentRepositoryData(null) setIsLoading(true) setError("") @@ -88,17 +96,29 @@ export function useRepository() { } } + const handleDisconnect = () => { + setRepoUrl("") + setCommits([]) + setIsLoading(false) + setError("") + setCurrentRepository(null) + setCurrentRepositoryData(null) + setShowRepositorySelector(true) + } + return { repoUrl, setRepoUrl, commits, setCommits, + currentRepositoryData, isLoading, error, setError, // Exposed to allow other hooks to set error if needed, or clear it currentRepository, showRepositorySelector, setShowRepositorySelector, + handleDisconnect, handleTryDemo, handleRepositorySelect, handleRepositoryRefresh, diff --git a/frontend/hooks/use-gittuf-explorer.ts b/frontend/hooks/use-gittuf-explorer.ts index 2bb5c4c..ae2a4cd 100644 --- a/frontend/hooks/use-gittuf-explorer.ts +++ b/frontend/hooks/use-gittuf-explorer.ts @@ -140,8 +140,10 @@ export function useGittufExplorer() { setRepoUrl: repository.setRepoUrl, commits: repository.commits, currentRepository: repository.currentRepository, + currentRepositoryData: repository.currentRepositoryData, showRepositorySelector: repository.showRepositorySelector, setShowRepositorySelector: repository.setShowRepositorySelector, + handleDisconnect: repository.handleDisconnect, handleRepositoryRefresh: repository.handleRepositoryRefresh, // Selection State diff --git a/frontend/hooks/visualizer/use-workspace-history.ts b/frontend/hooks/visualizer/use-workspace-history.ts new file mode 100644 index 0000000..a971743 --- /dev/null +++ b/frontend/hooks/visualizer/use-workspace-history.ts @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +interface HistoryCommit { + id: number; + hash: string; +} + +export function useWorkspaceHistory( + commits: TCommit[], + selectedCommitHash?: string, +) { + const commitListRef = useRef(null); + const [commitsPerPage, setCommitsPerPage] = useState(10); + const [currentPage, setCurrentPage] = useState(1); + const [touchedCommitId, setTouchedCommitId] = useState(null); + const [selectedCommitId, setSelectedCommitId] = useState( + Math.max(0, commits.findIndex((commit) => commit.hash === selectedCommitHash)), + ); + + useEffect(() => { + const commitList = commitListRef.current; + if (!commitList) return; + + const updateCommitsPerPage = () => { + setCommitsPerPage(Math.max(1, Math.floor(commitList.clientHeight / 56))); + }; + + updateCommitsPerPage(); + + const resizeObserver = new ResizeObserver(updateCommitsPerPage); + resizeObserver.observe(commitList); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + const totalPages = Math.ceil(commits.length / commitsPerPage); + const effectiveCurrentPage = Math.min(currentPage, totalPages); + const visibleCommits = useMemo( + () => + commits.slice( + (effectiveCurrentPage - 1) * commitsPerPage, + effectiveCurrentPage * commitsPerPage, + ), + [commits, commitsPerPage, effectiveCurrentPage], + ); + + return { + commitListRef, + commitsPerPage, + currentPage: effectiveCurrentPage, + setCurrentPage, + totalPages, + visibleCommits, + selectedCommitId, + setSelectedCommitId, + touchedCommitId, + setTouchedCommitId, + }; +} diff --git a/frontend/lib/demo-visualizer-data.ts b/frontend/lib/demo-visualizer-data.ts new file mode 100644 index 0000000..4462314 --- /dev/null +++ b/frontend/lib/demo-visualizer-data.ts @@ -0,0 +1,733 @@ +import { REPOSITORY } from "@/lib/constants" +import type { Commit, JsonObject } from "@/lib/types" +import type { RepositoryInfo } from "@/lib/repository-handler" + +export interface DemoPolicyGraphNode { + id: string + label: string + type: "repository" | "branch" | "policy-file" | "role" | "principal" | "rule" + x: number + y: number + metadata?: Record +} + +export interface DemoPolicyGraphEdge { + id: string + from: string + to: string + label?: string +} + +export interface DemoGraphSourceData { + repository: string + policyRef: string + policyVersion: string + metadataFile: string + activeMode: string + policyVersionOptions: string[] + metadataOptions: string[] + activeModeOptions: string[] + selectedPolicyVersionChips: string[] + selectedMetadataChips: string[] + selectedActiveModeChips: string[] + policyVersionChipsByOption?: Record + metadataChipsByOption?: Record + activeModeChipsByOption?: Record +} + +export interface DemoPolicyRequirement { + role: string + threshold: number + principals: string[] + paths: string[] + status: "satisfied" | "pending" +} + +export interface DemoPolicyQueryData { + branchOptions: string[] + selectedBranch: string + changedPathOptions: string[] + selectedChangedPath: string + queryResult: { + matchedBranch: string + matchedRule: string + requiredApprovals: number + } + authorizedUsers: string[] + queryScenarios?: Array<{ + branch: string + changedPath: string + matchedBranch: string + matchedRule: string + requiredApprovals: number + authorizedUsers: string[] + }> +} + +export interface DemoMetadataOverview { + policyFiles: Array<{ + name: string + status: "active" | "expired" | "draft" + type: "root" | "targets" | "delegation" + }> + views: Array<{ + id: "roles" | "principals" | "file-rules" | "status" + label: string + items: string[] + }> +} + +export interface DemoCommitHistoryData { + sortOptions: string[] + selectedSort: string + commits: Array<{ + hash: string + message: string + author: string + authorLabel: string + date: string + }> + selectedCommitHash: string +} + +export type DemoCompareDiffStatus = "added" | "removed" | "modified" | "unchanged" + +export interface DemoCompareGraphPrincipal { + name: string + status?: DemoCompareDiffStatus +} + +export interface DemoCompareGraphLane { + key: string + pathLabel: string + roleLabel: string + approvals: string + status?: DemoCompareDiffStatus + pathStatus?: DemoCompareDiffStatus + roleStatus?: DemoCompareDiffStatus + approvalsStatus?: DemoCompareDiffStatus + principals?: DemoCompareGraphPrincipal[] +} + +export interface DemoCompareGraph { + repositoryLabel?: string + branchLabel?: string + lanes: DemoCompareGraphLane[] + showLegend?: boolean +} + +export interface DemoCompareData { + baseVersionOptions: string[] + compareVersionOptions: string[] + selectedBaseVersion: string + selectedCompareVersion: string + changedMetadata: string[] + stats: Array<{ + value: string + label: string + }> + graphsByVersion: Record + comparisonsByPair?: Record< + string, + { + changedMetadata: string[] + stats: Array<{ + value: string + label: string + }> + compareGraph: DemoCompareGraph + } + > +} + +export interface DemoMetadataPanelData { + policyFiles: string[] + status: { + payloadDecoded: boolean + signaturesFound: string + sourceCommit: string + } + views: string[] + selectedView: string + summary: Array<{ + value: string + label: string + }> +} + +export interface DemoSettingsData { + detailLevels: string[] + selectedDetailLevel: string + layoutDirections: string[] + selectedLayoutDirection: string + visibleNodeTypes: Array<{ + label: string + checked: boolean + }> + labels: Array<{ + label: string + enabled: boolean + }> + dataOptions: Array<{ + label: string + enabled: boolean + }> +} + +export interface DemoWorkspaceDetails { + graphSource: DemoGraphSourceData + policyQuery: DemoPolicyQueryData + history: DemoCommitHistoryData + compare: DemoCompareData + metadata: DemoMetadataPanelData + settings: DemoSettingsData +} + +export interface DemoVisualizerData { + repository: RepositoryInfo + commits: Commit[] + graphSource: DemoGraphSourceData + policyGraph: { + title: string + nodes: DemoPolicyGraphNode[] + edges: DemoPolicyGraphEdge[] + } + policyQuery: DemoPolicyQueryData + workspaceDetails: DemoWorkspaceDetails + metadataOverview: DemoMetadataOverview + metadataByCommit: Record< + string, + { + "root.json": JsonObject + "targets.json": JsonObject + } + > +} + +export const demoVisualizerData: DemoVisualizerData = { + repository: { + type: "remote", + path: REPOSITORY.GITTUF_URL, + name: "gittuf_repo", + branch: "main", + }, + commits: [ + { + hash: "a1b2c3d4", + message: "Bootstrap policy graph and root roles", + author: "Alice Johnson", + date: "2026-05-26T14:22:00.000Z", + }, + { + hash: "b2c3d4e5", + message: "Require two maintainers for src and docs", + author: "Bob Smith", + date: "2026-05-28T09:10:00.000Z", + }, + { + hash: "c3d4e5f6", + message: "Add Carol as an authorized principal", + author: "Carol Davis", + date: "2026-05-30T17:45:00.000Z", + }, + { + hash: "d4e5f6g7", + message: "Refresh targets metadata and approval rules", + author: "Alice Johnson", + date: "2026-06-01T08:30:00.000Z", + }, + ], + graphSource: { + repository: "gittuf/gittuf_repo", + policyRef: "refs/gittuf/policy", + policyVersion: "Latest - d4e5f6g7", + metadataFile: "targets.json", + activeMode: "Approval Check", + policyVersionOptions: ["Latest - d4e5f6g7", "May 30 - c3d4e5f6", "May 28 - b2c3d4e5"], + metadataOptions: ["targets.json", "root.json"], + activeModeOptions: ["Approval Check", "Threshold Review", "Signature Audit"], + selectedPolicyVersionChips: ["Latest - d4e5f6g7"], + selectedMetadataChips: ["targets.json"], + selectedActiveModeChips: ["Approval Check"], + policyVersionChipsByOption: { + "Latest - d4e5f6g7": ["Latest - d4e5f6g7"], + "May 30 - c3d4e5f6": ["May 30 - c3d4e5f6"], + "May 28 - b2c3d4e5": ["May 28 - b2c3d4e5"], + }, + metadataChipsByOption: { + "targets.json": ["targets.json"], + "root.json": ["root.json"], + }, + activeModeChipsByOption: { + "Approval Check": ["Approval Check"], + "Threshold Review": ["Threshold Review"], + "Signature Audit": ["Signature Audit"], + }, + }, + policyGraph: { + title: "Policy Graph", + nodes: [ + { id: "repo", label: "gittuf_repo", type: "repository", x: 120, y: 80 }, + { id: "branch-main", label: "Branch: main", type: "branch", x: 280, y: 80 }, + { id: "targets-src", label: "src/**", type: "policy-file", x: 240, y: 170 }, + { id: "targets-docs", label: "docs/**", type: "policy-file", x: 420, y: 170 }, + { id: "role-maintainers", label: "Authorized users", type: "role", x: 240, y: 280, metadata: { threshold: 2 } }, + { id: "role-reviewers", label: "Docs reviewers", type: "role", x: 420, y: 280, metadata: { threshold: 1 } }, + { id: "alice", label: "Alice", type: "principal", x: 180, y: 400 }, + { id: "carol", label: "Carol", type: "principal", x: 260, y: 400 }, + { id: "bob", label: "Bob", type: "principal", x: 340, y: 400 }, + ], + edges: [ + { id: "repo-main", from: "repo", to: "branch-main" }, + { id: "main-src", from: "branch-main", to: "targets-src" }, + { id: "main-docs", from: "branch-main", to: "targets-docs" }, + { id: "src-role", from: "targets-src", to: "role-maintainers", label: "requires 2 approvals" }, + { id: "docs-role", from: "targets-docs", to: "role-reviewers", label: "requires 1 approval" }, + { id: "alice-maintainers", from: "alice", to: "role-maintainers" }, + { id: "carol-maintainers", from: "carol", to: "role-maintainers" }, + { id: "bob-maintainers", from: "bob", to: "role-maintainers" }, + { id: "alice-reviewers", from: "alice", to: "role-reviewers" }, + { id: "carol-reviewers", from: "carol", to: "role-reviewers" }, + ], + }, + policyQuery: { + branchOptions: ["main", "release", "hotfix"], + selectedBranch: "main", + changedPathOptions: ["src/auth/login.go", "src/**", "docs/**"], + selectedChangedPath: "src/auth/login.go", + queryResult: { + matchedBranch: "main", + matchedRule: "src/**", + requiredApprovals: 2, + }, + authorizedUsers: ["Alice", "Carol", "Bob"], + }, + workspaceDetails: { + graphSource: { + repository: "gittuf/gittuf_repo", + policyRef: "refs/gittuf/policy", + policyVersion: "Latest - d4e5f6g7", + metadataFile: "targets.json", + activeMode: "Approval Check", + policyVersionOptions: ["Latest - d4e5f6g7", "May 30 - c3d4e5f6", "May 28 - b2c3d4e5"], + metadataOptions: ["targets.json", "root.json"], + activeModeOptions: ["Approval Check", "Threshold Review", "Signature Audit"], + selectedPolicyVersionChips: ["Latest - d4e5f6g7"], + selectedMetadataChips: ["targets.json"], + selectedActiveModeChips: ["Approval Check"], + policyVersionChipsByOption: { + "Latest - d4e5f6g7": ["Latest - d4e5f6g7"], + "May 30 - c3d4e5f6": ["May 30 - c3d4e5f6"], + "May 28 - b2c3d4e5": ["May 28 - b2c3d4e5"], + }, + metadataChipsByOption: { + "targets.json": ["targets.json"], + "root.json": ["root.json"], + }, + activeModeChipsByOption: { + "Approval Check": ["Approval Check"], + "Threshold Review": ["Threshold Review"], + "Signature Audit": ["Signature Audit"], + }, + }, + policyQuery: { + branchOptions: ["main", "release", "hotfix"], + selectedBranch: "main", + changedPathOptions: ["src/auth/login.go", "src/**", "docs/**"], + selectedChangedPath: "src/auth/login.go", + queryResult: { + matchedBranch: "main", + matchedRule: "src/**", + requiredApprovals: 2, + }, + authorizedUsers: ["Alice", "Carol", "Bob"], + queryScenarios: [ + { + branch: "main", + changedPath: "src/auth/login.go", + matchedBranch: "main", + matchedRule: "src/**", + requiredApprovals: 2, + authorizedUsers: ["Alice", "Carol", "Bob"], + }, + { + branch: "release", + changedPath: "docs/**", + matchedBranch: "release", + matchedRule: "docs/**", + requiredApprovals: 1, + authorizedUsers: ["Alice", "Carol"], + }, + { + branch: "hotfix", + changedPath: "src/**", + matchedBranch: "main", + matchedRule: "src/**", + requiredApprovals: 2, + authorizedUsers: ["Alice", "Bob"], + }, + ], + }, + history: { + sortOptions: ["date", "oldest", "author"], + selectedSort: "date", + selectedCommitHash: "c3d4e5f6", + commits: [ + { + hash: "f6a7b8c9", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-03T10:45:00.000Z", + }, + { + hash: "e5f6a7b8", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-02T09:20:00.000Z", + }, + { + hash: "c3d4e5f6", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-01T12:10:00.000Z", + }, + { + hash: "b2c3d4e5", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-05-31T18:40:00.000Z", + }, + { + hash: "a1b2c3d4", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-05-30T14:00:00.000Z", + }, + { + hash: "98ab76cd", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-05-29T11:15:00.000Z", + }, + ], + }, + compare: { + baseVersionOptions: [ + "a1b1cd • May 1 • Add policy", + "91fe2a • Apr 28 • Update policy", + ], + compareVersionOptions: [ + "a2b3cd • May 5 • Update policy", + "b4c5de • May 8 • Tighten thresholds", + ], + selectedBaseVersion: "a1b1cd • May 1 • Add policy", + selectedCompareVersion: "a2b3cd • May 5 • Update policy", + changedMetadata: ["Trust setup", "File rules", "Root metadata"], + stats: [ + { value: "1", label: "role changed" }, + { value: "0", label: "rules added" }, + { value: "1 ↑", label: "threshold" }, + { value: "1", label: "principal removed" }, + ], + graphsByVersion: { + "a1b1cd • May 1 • Add policy": { + repositoryLabel: "a1b1cd", + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 2 approvals", + principals: [{ name: "Alice" }, { name: "Carol" }, { name: "Bob" }], + }, + ], + }, + "91fe2a • Apr 28 • Update policy": { + repositoryLabel: "91fe2a", + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 1 approval", + principals: [{ name: "Alice" }, { name: "Bob" }, { name: "Carol" }], + }, + ], + }, + "a2b3cd • May 5 • Update policy": { + repositoryLabel: "a2b3cd", + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 3 approvals", + principals: [ + { name: "Alice" }, + { name: "Carol" }, + { name: "Bob" }, + { name: "Steve" }, + ], + }, + ], + }, + "b4c5de • May 8 • Tighten thresholds": { + repositoryLabel: "b4c5de", + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 3 approvals", + principals: [ + { name: "Alice" }, + { name: "Carol" }, + { name: "Bob" }, + { name: "Steve" }, + ], + }, + ], + }, + }, + comparisonsByPair: { + "a1b1cd • May 1 • Add policy|a2b3cd • May 5 • Update policy": { + changedMetadata: ["Trust setup", "File rules", "Root metadata"], + stats: [ + { value: "1", label: "role changed" }, + { value: "0", label: "rules added" }, + { value: "1 ↑", label: "threshold" }, + { value: "1", label: "principal removed" }, + ], + compareGraph: { + repositoryLabel: "a2b3cd", + branchLabel: "Branch: main", + showLegend: true, + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 3 approvals", + approvalsStatus: "modified", + principals: [ + { name: "Alice", status: "unchanged" }, + { name: "Carol", status: "unchanged" }, + { name: "Bob", status: "removed" }, + { name: "Steve", status: "added" }, + ], + }, + ], + }, + }, + "91fe2a • Apr 28 • Update policy|b4c5de • May 8 • Tighten thresholds": { + changedMetadata: ["File rules", "Root metadata"], + stats: [ + { value: "1", label: "roles changed" }, + { value: "2", label: "rules added" }, + { value: "2 ↑", label: "threshold" }, + { value: "0", label: "principal removed" }, + ], + compareGraph: { + repositoryLabel: "b4c5de", + branchLabel: "Branch: main", + showLegend: true, + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 3 approvals", + approvalsStatus: "modified", + principals: [ + { name: "Alice", status: "unchanged" }, + { name: "Bob", status: "unchanged" }, + { name: "Carol", status: "unchanged" }, + ], + }, + { + key: "docs", + pathLabel: "docs/**", + roleLabel: "Docs reviewers", + approvals: "Requires: 2 approvals", + approvalsStatus: "modified", + principals: [ + { name: "Alice", status: "unchanged" }, + { name: "Carol", status: "unchanged" }, + { name: "Bob", status: "unchanged" }, + ], + }, + { + key: "ops", + pathLabel: "ops/**", + roleLabel: "Ops maintainers", + approvals: "Requires: 2 approvals", + status: "added", + principals: [ + { name: "Alice", status: "added" }, + { name: "Carol", status: "added" }, + { name: "Bob", status: "added" }, + ], + }, + ], + }, + }, + }, + }, + metadata: { + policyFiles: ["Trust setup: root.json", "File rules: target.json"], + status: { + payloadDecoded: true, + signaturesFound: "1 signature found", + sourceCommit: "a1b2c3d", + }, + views: ["Summary", "Decoded JSON", "Envelope"], + selectedView: "Summary", + summary: [ + { value: "3", label: "roles" }, + { value: "5", label: "principals" }, + { value: "4", label: "File rules" }, + { value: "1", label: "signatures" }, + ], + }, + settings: { + detailLevels: ["Simple", "Detailed"], + selectedDetailLevel: "Simple", + layoutDirections: ["Top → Bottom", "Left →right"], + selectedLayoutDirection: "Top → Bottom", + visibleNodeTypes: [ + { label: "File rules", checked: true }, + { label: "Roles", checked: true }, + { label: "Principals", checked: true }, + { label: "Thresholds", checked: true }, + { label: "Signatures", checked: false }, + { label: "Expiration", checked: false }, + ], + labels: [ + { label: "show edge labels", enabled: true }, + { label: "show approval counts", enabled: true }, + { label: "show legend", enabled: false }, + ], + dataOptions: [ + { label: "Use latest policy by default", enabled: true }, + { label: "Show raw metadata warnings", enabled: false }, + ], + }, + }, + metadataOverview: { + policyFiles: [ + { name: "root.json", status: "active", type: "root" }, + { name: "targets.json", status: "active", type: "targets" }, + { name: "delegations/docs.json", status: "draft", type: "delegation" }, + ], + views: [ + { id: "status", label: "Status", items: ["root.json active", "targets.json active", "docs delegation draft"] }, + { id: "roles", label: "Roles", items: ["root", "targets", "maintainers", "docs-reviewers"] }, + { id: "principals", label: "Principals", items: ["Alice", "Bob", "Carol"] }, + { id: "file-rules", label: "File Rules", items: ["src/** requires maintainers", "docs/** requires maintainers"] }, + ], + }, + metadataByCommit: { + a1b2c3d4: { + "root.json": { + type: "root", + expires: "2026-08-01T00:00:00Z", + principals: { + alice: { keyid: "alice-root-key", keytype: "ed25519" }, + bob: { keyid: "bob-root-key", keytype: "ed25519" }, + }, + roles: [ + { name: "root", threshold: 2, principalids: ["alice", "bob"] }, + { name: "targets", threshold: 1, principalids: ["alice"] }, + ], + }, + "targets.json": { + type: "targets", + expires: "2026-07-15T00:00:00Z", + targets: { + "src/**": { rule: "maintainers" }, + }, + }, + }, + b2c3d4e5: { + "root.json": { + type: "root", + expires: "2026-08-01T00:00:00Z", + principals: { + alice: { keyid: "alice-root-key", keytype: "ed25519" }, + bob: { keyid: "bob-root-key", keytype: "ed25519" }, + carol: { keyid: "carol-root-key", keytype: "ed25519" }, + }, + roles: [ + { name: "root", threshold: 2, principalids: ["alice", "bob"] }, + { name: "targets", threshold: 2, principalids: ["alice", "bob"] }, + ], + }, + "targets.json": { + type: "targets", + expires: "2026-07-18T00:00:00Z", + targets: { + "src/**": { rule: "maintainers" }, + "docs/**": { rule: "maintainers" }, + }, + }, + }, + c3d4e5f6: { + "root.json": { + type: "root", + expires: "2026-08-01T00:00:00Z", + principals: { + alice: { keyid: "alice-root-key", keytype: "ed25519" }, + bob: { keyid: "bob-root-key", keytype: "ed25519" }, + carol: { keyid: "carol-root-key", keytype: "ed25519" }, + }, + roles: [ + { name: "root", threshold: 2, principalids: ["alice", "bob"] }, + { name: "targets", threshold: 2, principalids: ["alice", "bob", "carol"] }, + ], + }, + "targets.json": { + type: "targets", + expires: "2026-07-20T00:00:00Z", + targets: { + "src/**": { rule: "maintainers" }, + "docs/**": { rule: "maintainers" }, + }, + }, + }, + d4e5f6g7: { + "root.json": { + type: "root", + expires: "2026-08-15T00:00:00Z", + principals: { + alice: { keyid: "alice-root-key", keytype: "ed25519" }, + bob: { keyid: "bob-root-key", keytype: "ed25519" }, + carol: { keyid: "carol-root-key", keytype: "ed25519" }, + }, + roles: [ + { name: "root", threshold: 2, principalids: ["alice", "bob"] }, + { name: "targets", threshold: 2, principalids: ["alice", "bob", "carol"] }, + { name: "docs-reviewers", threshold: 1, principalids: ["alice", "carol"] }, + ], + }, + "targets.json": { + type: "targets", + expires: "2026-07-30T00:00:00Z", + targets: { + "src/**": { rule: "maintainers" }, + "docs/**": { rule: "maintainers" }, + "metadata/**": { rule: "docs-reviewers" }, + }, + }, + }, + }, +} diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..4938e66d772ccd6a2f475723ccf54e1c53b9dd3d GIT binary patch literal 170156 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 void + onTryDemo: () => void + isLoading: boolean + error: string | null + currentRepository: RepositoryInfo | null +} + +interface ValidationDetails { + type: "remote" | "local" + platform?: string + url?: string + path?: string +} + +const SURFACE_BORDER = "var(--secondary-color)" +const CONTROL_BORDER = "var(--tertiary-color)" +const PARAGRAPH_COLOR = "var(--dark-gray)" +const BUTTON_COLOR = "var(--secondary-color)" + +function StepIndicator({ step }: { step: number }) { + return ( +
+ {step} +
+ ) +} + +export default function RepositorySelector({ + onRepositorySelect, + onTryDemo, + isLoading, + error, + currentRepository, +}: RepositorySelectorProps) { + const [remoteUrl, setRemoteUrl] = useState("") + const [localPath, setLocalPath] = useState("") + const [validationStatus, setValidationStatus] = useState<{ + isValid: boolean + message: string + details?: ValidationDetails + } | null>(null) + + const selectedPolicyLabel = useMemo(() => { + if (!currentRepository) return "Select a repository to load policy commits" + return currentRepository.type === "remote" ? currentRepository.path : currentRepository.name + }, [currentRepository]) + + const handleRemoteSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!remoteUrl.trim()) { + setValidationStatus({ + isValid: false, + message: "Please enter a repository URL.", + }) + return + } + + const normalizedUrl = /^https?:\/\//i.test(remoteUrl.trim()) ? remoteUrl.trim() : `https://${remoteUrl.trim()}` + + try { + const url = new URL(normalizedUrl) + if ( + !url.hostname.includes("github.com") && + !url.hostname.includes("gitlab.com") && + !url.hostname.includes("bitbucket.org") + ) { + setValidationStatus({ + isValid: false, + message: "Please enter a GitHub, GitLab, or Bitbucket repository URL.", + }) + return + } + } catch { + setValidationStatus({ + isValid: false, + message: "Please enter a valid URL.", + }) + return + } + + const repoInfo: RepositoryInfo = { + type: "remote", + path: normalizedUrl, + name: normalizedUrl.split("/").pop()?.replace(".git", "") || "Unknown Repository", + } + + setValidationStatus({ + isValid: true, + message: "Repository URL validated successfully.", + details: { + type: "remote", + platform: normalizedUrl.includes("github.com") + ? "GitHub" + : normalizedUrl.includes("gitlab.com") + ? "GitLab" + : "Bitbucket", + url: normalizedUrl, + }, + }) + + onRepositorySelect(repoInfo) + } + + const handleLocalSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!localPath.trim()) { + setValidationStatus({ + isValid: false, + message: "Please enter a local repository path.", + }) + return + } + + const repoInfo: RepositoryInfo = { + type: "local", + path: localPath, + name: localPath.split("/").pop() || "Local Repo", + } + + setValidationStatus({ + isValid: true, + message: "Local repository path accepted.", + details: { + type: "local", + path: localPath, + }, + }) + + onRepositorySelect(repoInfo) + } + + return ( +
+
+
+

Getting started with gittuf

+

+ In order to get started with gittuf visualizer, please select a repository you would like to visualize. The + following platforms are supported on gittuf: github, gitlab, bitbucket. +

+
+ +
+ +
+ +
+
+ +
+ +
+ +
+

Connect a repository

+
+
+

+ Enter the URL of a git repository that contains gittuf security metadata +

+
+
+ + https:// + + setRemoteUrl(e.target.value.replace(/^https?:\/\//i, ""))} + disabled={isLoading} + className="h-11 rounded-[5px] border px-4 text-[16px] text-black placeholder:text-[var(--dark-gray)] focus-visible:ring-0 focus-visible:ring-offset-0 md:text-[16px]" + style={{ borderColor: CONTROL_BORDER }} + /> +
+ +
+
+ +
or
+ +
+

+ Select a local Git repository folder from your computer that contains gittuf security metadata +

+
+ setLocalPath(e.target.value)} + disabled={isLoading} + className="h-11 rounded-[5px] border px-4 text-[16px] text-black placeholder:text-[var(--dark-gray)] focus-visible:ring-0 focus-visible:ring-offset-0 md:text-[16px]" + style={{ borderColor: CONTROL_BORDER }} + /> + +
+
+
+
+ +
+ +
+ +
+

Select policy source

+
+ +
+ {selectedPolicyLabel} +
+
+ + {currentRepository && ( +
+

Metadata Found:

+
+ + Metadata file ready to inspect +
+
+ )} + + {validationStatus && ( +
+ {validationStatus.isValid ? ( + + ) : ( + + )} +
+

{validationStatus.message}

+ {validationStatus.details?.url &&

{validationStatus.details.url}

} + {validationStatus.details?.path &&

{validationStatus.details.path}

} +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+
+
+ ) +} diff --git a/frontend/screens/visualizer/compare-canvas.tsx b/frontend/screens/visualizer/compare-canvas.tsx new file mode 100644 index 0000000..857e75d --- /dev/null +++ b/frontend/screens/visualizer/compare-canvas.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { PolicyGraphCanvas } from "@/screens/visualizer/policy-graph-canvas"; +import type { PolicyGraphCanvasVariant } from "@/screens/visualizer/policy-graph.types"; + +interface WorkspaceCompareCanvasProps { + baseVersionLabel: string; + compareVersionLabel: string; + baseGraph: PolicyGraphCanvasVariant; + compareGraph: PolicyGraphCanvasVariant; + zoom: number; + searchQuery?: string; +} + +export function WorkspaceCompareCanvas({ + baseVersionLabel, + compareVersionLabel, + baseGraph, + compareGraph, + zoom, + searchQuery, +}: WorkspaceCompareCanvasProps) { + const [baseOffset, setBaseOffset] = useState({ x: 0, y: 0 }); + const [compareOffset, setCompareOffset] = useState({ x: 0, y: 0 }); + + return ( +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ {[ + ["Added", "var(--approve-color)"], + ["Removed", "var(--reject-color)"], + ["Modified", "var(--modified-color)"], + ["Unchanged", "var(--dark-gray)"], + ].map(([label, color]) => ( +
+ + {label} +
+ ))} +
+
+
+
+ ); +} diff --git a/frontend/screens/visualizer/detail-content.tsx b/frontend/screens/visualizer/detail-content.tsx new file mode 100644 index 0000000..4a84e85 --- /dev/null +++ b/frontend/screens/visualizer/detail-content.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer-data"; +import { demoVisualizerData } from "@/lib/demo-visualizer-data"; +import type { RepositoryInfo } from "@/lib/repository-handler"; +import { + DetailPanelCompare, + DetailPanelGraphSource, + DetailPanelHistory, + DetailPanelMetadata, + DetailPanelPolicyQuery, + DetailPanelSettings, +} from "@/screens/visualizer/panel-tabs/detail-panels"; +import type { HistorySortField } from "@/screens/visualizer/history.types"; +import type { WorkspacePanelId } from "@/screens/visualizer/visualizer.types"; + +interface WorkspaceDetailContentProps { + activePanel: WorkspacePanelId; + repository: RepositoryInfo; + workspaceData?: DemoVisualizerData | null; + onRegenerate: () => void; + historyCommits: Array<{ + id: number; + hash: string; + message: string; + author: string; + authorLabel?: string; + date: string; + }>; + selectedHistoryCommitHash?: string | null; + onHistoryCommitSelect?: (commitHash: string) => void; + searchQuery?: string; + selectedHistorySort: HistorySortField; + isHistorySortAscending: boolean; + onHistorySortChange: (sortField: HistorySortField) => void; + onHistorySortDirectionToggle: () => void; + selectedBaseVersion: string; + selectedCompareVersion: string; + hasCompared: boolean; + onBaseVersionChange: (value: string) => void; + onCompareVersionChange: (value: string) => void; + onSwapVersions: () => void; + onCompare: () => void; +} + +export function WorkspaceDetailContent({ + activePanel, + repository, + workspaceData, + onRegenerate, + historyCommits, + selectedHistoryCommitHash, + onHistoryCommitSelect, + searchQuery, + selectedHistorySort, + isHistorySortAscending, + onHistorySortChange, + onHistorySortDirectionToggle, + selectedBaseVersion, + selectedCompareVersion, + hasCompared, + onBaseVersionChange, + onCompareVersionChange, + onSwapVersions, + onCompare, +}: WorkspaceDetailContentProps) { + const policyQueryDefaults = + workspaceData?.workspaceDetails.policyQuery ?? + demoVisualizerData.workspaceDetails.policyQuery; + const [selectedBranch, setSelectedBranch] = useState( + policyQueryDefaults.selectedBranch ?? policyQueryDefaults.branchOptions[0], + ); + const [selectedChangedPath, setSelectedChangedPath] = useState( + policyQueryDefaults.selectedChangedPath ?? + policyQueryDefaults.changedPathOptions[0], + ); + const [showPolicyQueryResults, setShowPolicyQueryResults] = useState(false); + const [policyQueryResultState, setPolicyQueryResultState] = useState({ + matchedBranch: policyQueryDefaults.queryResult.matchedBranch, + matchedRule: policyQueryDefaults.queryResult.matchedRule, + requiredApprovals: policyQueryDefaults.queryResult.requiredApprovals, + authorizedUsers: policyQueryDefaults.authorizedUsers, + }); + + switch (activePanel) { + case "graph-source": + return ( + + ); + case "policy-query": + return ( + { + setSelectedBranch(value); + setShowPolicyQueryResults(false); + }} + onChangedPathChange={(value) => { + setSelectedChangedPath(value); + setShowPolicyQueryResults(false); + }} + onQuery={(result) => { + setPolicyQueryResultState(result); + setShowPolicyQueryResults(true); + }} + /> + ); + case "history": + return ( + + ); + case "compare": + return ( + + ); + case "metadata": + return ; + case "settings": + return ; + default: + return null; + } +} diff --git a/frontend/screens/visualizer/history-canvas.tsx b/frontend/screens/visualizer/history-canvas.tsx new file mode 100644 index 0000000..56bcdf1 --- /dev/null +++ b/frontend/screens/visualizer/history-canvas.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer-data"; +import { demoVisualizerData } from "@/lib/demo-visualizer-data"; +import type { + HistorySortField, + HistoryTimelineCommit, +} from "@/screens/visualizer/history.types"; +import { PolicyGraphCanvas } from "@/screens/visualizer/policy-graph-canvas"; + +interface WorkspaceHistoryCanvasProps { + commits: HistoryTimelineCommit[]; + activeCommitId: string | null; + zoom: number; + searchQuery?: string; +} + +interface WorkspaceHistoryTimelineStripProps { + commits: HistoryTimelineCommit[]; + activeCommitId: string | null; + onSelect: (commitId: string) => void; +} + +const historyCanvasWidth = 980; +const historyCanvasHeight = 980; +const selectedCommitColor = "var(--selected-color-50)"; +const selectedGraphColor = "var(--selected-color-50)"; + +function formatHistoryDate(date: string) { + return new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + }).format(new Date(date)); +} + +export function getHistoryTimelineCommits( + workspaceData?: DemoVisualizerData | null, +): HistoryTimelineCommit[] { + const historyData = + workspaceData?.workspaceDetails.history ?? + demoVisualizerData.workspaceDetails.history; + + return historyData.commits.map((commit) => ({ + id: commit.hash, + hash: commit.hash, + message: commit.message, + author: commit.author, + authorLabel: commit.authorLabel, + date: commit.date, + })); +} + +export function getDefaultHistorySortState( + workspaceData?: DemoVisualizerData | null, +) { + const historyData = + workspaceData?.workspaceDetails.history ?? + demoVisualizerData.workspaceDetails.history; + const selectedSort = historyData.selectedSort ?? historyData.sortOptions[0] ?? "date"; + + return { + sortField: selectedSort === "author" ? "author" : "date", + isAscending: selectedSort === "oldest", + } satisfies { + sortField: HistorySortField; + isAscending: boolean; + }; +} + +export function sortHistoryTimelineCommits( + commits: HistoryTimelineCommit[], + sortField: HistorySortField, + isAscending: boolean, +) { + const sortedCommits = [...commits]; + + if (sortField === "author") { + sortedCommits.sort((a, b) => a.author.localeCompare(b.author)); + return isAscending ? sortedCommits : sortedCommits.reverse(); + } + + sortedCommits.sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + return isAscending ? sortedCommits : sortedCommits.reverse(); +} + +export function getDefaultHistoryCommitId( + workspaceData?: DemoVisualizerData | null, +) { + return ( + workspaceData?.workspaceDetails.history.selectedCommitHash ?? + demoVisualizerData.workspaceDetails.history.selectedCommitHash ?? + null + ); +} + +export function WorkspaceHistoryTimelineStrip({ + commits, + activeCommitId, + onSelect, +}: WorkspaceHistoryTimelineStripProps) { + const cardRefs = useRef>({}); + + useEffect(() => { + if (!activeCommitId) return; + + cardRefs.current[activeCommitId]?.scrollIntoView({ + behavior: "smooth", + inline: "center", + block: "nearest", + }); + }, [activeCommitId]); + + return ( +
+
+ {commits.map((commit) => { + const isActive = commit.id === activeCommitId; + + return ( + + ); + })} +
+
+ ); +} + +export function WorkspaceHistoryCanvas({ + commits, + activeCommitId, + zoom, + searchQuery, +}: WorkspaceHistoryCanvasProps) { + const [graphOffsets, setGraphOffsets] = useState< + Record + >({}); + const canvasViewportRef = useRef(null); + const graphRefs = useRef>({}); + + useEffect(() => { + if (!activeCommitId) return; + + graphRefs.current[activeCommitId]?.scrollIntoView({ + behavior: "smooth", + inline: "center", + block: "nearest", + }); + }, [activeCommitId]); + + return ( +
+
+
+ {commits.map((commit) => ( +
{ + graphRefs.current[commit.id] = node; + }} + data-commit-id={commit.id} + className="relative h-[980px] w-[980px] shrink-0 overflow-visible" + > + { + setGraphOffsets((currentOffsets) => ({ + ...currentOffsets, + [commit.id]: nextOffset, + })); + }} + allowOverflowDrag + variant={{ + repositoryLabel: commit.hash.slice(0, 7), + repositoryLabelColor: + commit.id === activeCommitId + ? "var(--modified-color)" + : "var(--dark-gray)", + boundaryFill: + commit.id === activeCommitId ? selectedGraphColor : "none", + }} + /> +
+ ))} +
+
+
+ ); +} diff --git a/frontend/screens/visualizer/history.types.ts b/frontend/screens/visualizer/history.types.ts new file mode 100644 index 0000000..80f442b --- /dev/null +++ b/frontend/screens/visualizer/history.types.ts @@ -0,0 +1,10 @@ +export interface HistoryTimelineCommit { + id: string; + hash: string; + message?: string; + author: string; + authorLabel?: string; + date: string; +} + +export type HistorySortField = "date" | "author"; diff --git a/frontend/screens/visualizer/panel-tabs/detail-compare.tsx b/frontend/screens/visualizer/panel-tabs/detail-compare.tsx new file mode 100644 index 0000000..c0b283f --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-compare.tsx @@ -0,0 +1,116 @@ +"use client"; + +import Image from "next/image"; +import { useMemo } from "react"; +import emptyFileIcon from "@/assets/empty_file.png"; +import swapVertIcon from "@/assets/swap_vert.png"; +import { + demoVisualizerData, + type DemoVisualizerData, +} from "@/lib/demo-visualizer-data"; +import { + detailColors, + PanelSection, + SearchHighlightText, + SectionBulletLabel, + SelectField, + SummaryMetricGrid, +} from "@/components/visualizer/detail/workspace-detail-primitives"; + +interface DetailPanelCompareProps { + workspaceData?: DemoVisualizerData | null; + searchQuery?: string; + selectedBaseVersion: string; + selectedCompareVersion: string; + hasCompared: boolean; + onBaseVersionChange: (value: string) => void; + onCompareVersionChange: (value: string) => void; + onSwapVersions: () => void; + onCompare: () => void; +} + +export function DetailPanelCompare({ + workspaceData, + searchQuery, + selectedBaseVersion, + selectedCompareVersion, + hasCompared, + onBaseVersionChange, + onCompareVersionChange, + onSwapVersions, + onCompare, +}: DetailPanelCompareProps) { + const compareData = + workspaceData?.workspaceDetails.compare ?? + demoVisualizerData.workspaceDetails.compare; + const baseOptions = compareData.baseVersionOptions; + const compareOptions = compareData.compareVersionOptions; + const comparisonResult = useMemo(() => { + const key = `${selectedBaseVersion}|${selectedCompareVersion}`; + return ( + compareData.comparisonsByPair?.[key] ?? { + changedMetadata: compareData.changedMetadata, + stats: compareData.stats, + } + ); + }, [compareData, selectedBaseVersion, selectedCompareVersion]); + + return ( +
+ + ({ label, icon: emptyFileIcon }))} + selectedLabel={selectedBaseVersion} + onChange={onBaseVersionChange} + fullWidth + /> + +
+ +
+ + ({ label, icon: emptyFileIcon }))} + selectedLabel={selectedCompareVersion} + onChange={onCompareVersionChange} + fullWidth + /> + +
+ +
+ {hasCompared ? ( +
+ +
+ {comparisonResult.changedMetadata.map((item, index) => ( +
+ {index < 2 ? "✓" : "—"}{" "} + +
+ ))} +
+ +
+ ) : null} +
+ ); +} diff --git a/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx b/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx new file mode 100644 index 0000000..9b997d3 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState } from "react"; +import { + demoVisualizerData, + type DemoVisualizerData, +} from "@/lib/demo-visualizer-data"; +import type { RepositoryInfo } from "@/lib/repository-handler"; +import { + detailColors, + InlineSelectRow, + StaticValueRow, +} from "@/components/visualizer/detail/workspace-detail-primitives"; + +interface DetailPanelGraphSourceProps { + repository: RepositoryInfo; + workspaceData?: DemoVisualizerData | null; + onRegenerate: () => void; + searchQuery?: string; +} + +export function DetailPanelGraphSource({ + repository, + workspaceData, + onRegenerate, + searchQuery, +}: DetailPanelGraphSourceProps) { + const graphSource = + workspaceData?.workspaceDetails.graphSource ?? + demoVisualizerData.workspaceDetails.graphSource; + const policyVersionOptions = graphSource.policyVersionOptions; + const metadataOptions = graphSource.metadataOptions; + const activeModeOptions = graphSource.activeModeOptions; + const [selectedPolicyVersion, setSelectedPolicyVersion] = useState( + graphSource.policyVersion ?? policyVersionOptions[0], + ); + const [selectedMetadataFile, setSelectedMetadataFile] = useState( + graphSource.metadataFile ?? metadataOptions[0], + ); + const [selectedActiveMode, setSelectedActiveMode] = useState( + graphSource.activeMode ?? activeModeOptions[0], + ); + + return ( +
+ + + ({ label }))} + selectedLabel={selectedPolicyVersion} + chips={[selectedPolicyVersion]} + onChange={setSelectedPolicyVersion} + searchQuery={searchQuery} + /> + ({ label }))} + selectedLabel={selectedMetadataFile} + chips={[selectedMetadataFile]} + onChange={setSelectedMetadataFile} + searchQuery={searchQuery} + /> + ({ label }))} + selectedLabel={selectedActiveMode} + chips={[selectedActiveMode]} + onChange={setSelectedActiveMode} + searchQuery={searchQuery} + /> +
+ +
+
+ ); +} diff --git a/frontend/screens/visualizer/panel-tabs/detail-history.tsx b/frontend/screens/visualizer/panel-tabs/detail-history.tsx new file mode 100644 index 0000000..bb108f9 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-history.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useEffect } from "react"; +import Image from "next/image"; +import ascendingIcon from "@/assets/ascending.png"; +import discendingIcon from "@/assets/discending.png"; +import { + demoVisualizerData, + type DemoVisualizerData, +} from "@/lib/demo-visualizer-data"; +import { useWorkspaceHistory } from "@/hooks/visualizer/use-workspace-history"; +import { + CommitHistoryItem, + detailColors, + SelectField, +} from "@/components/visualizer/detail/workspace-detail-primitives"; +import type { + HistorySortField, +} from "@/screens/visualizer/history.types"; + +interface DetailHistoryCommit { + id: number; + hash: string; + message: string; + author: string; + authorLabel?: string; + date: string; +} + +interface DetailPanelHistoryProps { + workspaceData?: DemoVisualizerData | null; + commits: DetailHistoryCommit[]; + selectedCommitHash?: string | null; + onSelectedCommitChange?: (commitHash: string) => void; + searchQuery?: string; + selectedSort: HistorySortField; + isAscending: boolean; + onSortChange: (sortField: HistorySortField) => void; + onSortDirectionToggle: () => void; +} + +export function DetailPanelHistory({ + workspaceData, + commits, + selectedCommitHash, + onSelectedCommitChange, + searchQuery = "", + selectedSort, + isAscending, + onSortChange, + onSortDirectionToggle, +}: DetailPanelHistoryProps) { + const historyData = + workspaceData?.workspaceDetails.history ?? + demoVisualizerData.workspaceDetails.history; + const sortOptions = Array.from( + new Set( + historyData.sortOptions.map((option) => + option === "author" ? "author" : "date", + ), + ), + ) as HistorySortField[]; + const { + commitListRef, + commitsPerPage, + currentPage, + selectedCommitId, + setCurrentPage, + setSelectedCommitId, + setTouchedCommitId, + totalPages, + touchedCommitId, + visibleCommits, + } = useWorkspaceHistory(commits, selectedCommitHash ?? historyData?.selectedCommitHash); + + useEffect(() => { + if (!selectedCommitHash) return; + + const nextSelectedCommit = commits.find( + (commit) => commit.hash === selectedCommitHash, + ); + if (!nextSelectedCommit || nextSelectedCommit.id === selectedCommitId) return; + + const nextSelectedCommitIndex = commits.findIndex( + (commit) => commit.id === nextSelectedCommit.id, + ); + setSelectedCommitId(nextSelectedCommit.id); + setCurrentPage(Math.floor(nextSelectedCommitIndex / commitsPerPage) + 1); + }, [ + commits, + commitsPerPage, + selectedCommitHash, + selectedCommitId, + setCurrentPage, + setSelectedCommitId, + ]); + + return ( +
+
+ ({ + label: `Sort by: ${option}`, + value: option, + }))} + selectedLabel={selectedSort} + displayLabel={`Sort by: ${selectedSort}`} + onChange={(value) => onSortChange(value as HistorySortField)} + className="w-[132px]" + /> + +
+
+
+ {visibleCommits.map((commit) => ( + { + setSelectedCommitId(commitId); + const selectedCommit = commits.find( + (historyCommit) => historyCommit.id === commitId, + ); + if (!selectedCommit || !onSelectedCommitChange) return; + if (selectedCommit.hash === selectedCommitHash) return; + + onSelectedCommitChange(selectedCommit.hash); + }} + onTouch={setTouchedCommitId} + /> + ))} +
+ {totalPages > 1 ? ( +
+ +
+ {Array.from({ length: totalPages }, (_, index) => { + const pageNumber = index + 1; + const isActive = currentPage === pageNumber; + + return ( + + ); + })} +
+ +
+ ) : null} +
+
+ ); +} diff --git a/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx b/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx new file mode 100644 index 0000000..4bc6606 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { + demoVisualizerData, + type DemoVisualizerData, +} from "@/lib/demo-visualizer-data"; +import completedIcon from "@/assets/completed.png"; +import metadataIcon from "@/assets/metadata.png"; +import { + PanelSection, + SearchHighlightText, + SectionBulletLabel, + StatusRow, + SummaryMetricGrid, + ValueChip, +} from "@/components/visualizer/detail/workspace-detail-primitives"; + +interface DetailPanelMetadataProps { + workspaceData?: DemoVisualizerData | null; + searchQuery?: string; +} + +export function DetailPanelMetadata({ + workspaceData, + searchQuery, +}: DetailPanelMetadataProps) { + const metadataData = + workspaceData?.workspaceDetails.metadata ?? + demoVisualizerData.workspaceDetails.metadata; + + return ( +
+ +
+ +
+ {metadataData.policyFiles.map((item, index) => ( + + ))} +
+
+
+ +
+ {metadataData.status.payloadDecoded ? ( + + ) : null} + + +
+
+
+ +
+
+ {metadataData.views.map((tab) => ( + + ))} +
+ +
+
+
+ ); +} diff --git a/frontend/screens/visualizer/panel-tabs/detail-panels.tsx b/frontend/screens/visualizer/panel-tabs/detail-panels.tsx new file mode 100644 index 0000000..3cf31f0 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-panels.tsx @@ -0,0 +1,8 @@ +"use client"; + +export { DetailPanelGraphSource } from "@/screens/visualizer/panel-tabs/detail-graph-source"; +export { DetailPanelPolicyQuery } from "@/screens/visualizer/panel-tabs/detail-policy-query"; +export { DetailPanelHistory } from "@/screens/visualizer/panel-tabs/detail-history"; +export { DetailPanelCompare } from "@/screens/visualizer/panel-tabs/detail-compare"; +export { DetailPanelMetadata } from "@/screens/visualizer/panel-tabs/detail-metadata"; +export { DetailPanelSettings } from "@/screens/visualizer/panel-tabs/detail-settings"; diff --git a/frontend/screens/visualizer/panel-tabs/detail-policy-query.tsx b/frontend/screens/visualizer/panel-tabs/detail-policy-query.tsx new file mode 100644 index 0000000..0c635d8 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-policy-query.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useMemo } from "react"; +import branchIcon from "@/assets/branch.png"; +import emptyFileIcon from "@/assets/empty_file.png"; +import { + demoVisualizerData, + type DemoVisualizerData, +} from "@/lib/demo-visualizer-data"; +import { + detailColors, + PanelSection, + QueryUserCard, + SectionBulletLabel, + SelectField, + SummaryMetricGrid, +} from "@/components/visualizer/detail/workspace-detail-primitives"; + +interface DetailPanelPolicyQueryProps { + workspaceData?: DemoVisualizerData | null; + searchQuery?: string; + selectedBranch: string; + selectedChangedPath: string; + showResults: boolean; + resultState: { + matchedBranch: string; + matchedRule: string; + requiredApprovals: number; + authorizedUsers: string[]; + }; + onBranchChange: (value: string) => void; + onChangedPathChange: (value: string) => void; + onQuery: (result: { + matchedBranch: string; + matchedRule: string; + requiredApprovals: number; + authorizedUsers: string[]; + }) => void; +} + +export function DetailPanelPolicyQuery({ + workspaceData, + searchQuery, + selectedBranch, + selectedChangedPath, + showResults, + resultState, + onBranchChange, + onChangedPathChange, + onQuery, +}: DetailPanelPolicyQueryProps) { + const policyQuery = + workspaceData?.workspaceDetails.policyQuery ?? + demoVisualizerData.workspaceDetails.policyQuery; + const branchOptions = policyQuery.branchOptions; + const changedPathOptions = policyQuery.changedPathOptions; + const queryScenario = useMemo( + () => + policyQuery.queryScenarios?.find( + (scenario) => + scenario.branch === selectedBranch && + scenario.changedPath === selectedChangedPath, + ), + [policyQuery.queryScenarios, selectedBranch, selectedChangedPath], + ); + + return ( +
+ + ({ label, icon: branchIcon }))} + selectedLabel={selectedBranch} + onChange={onBranchChange} + fullWidth + /> + + + ({ label, icon: emptyFileIcon }))} + selectedLabel={selectedChangedPath} + onChange={onChangedPathChange} + fullWidth + /> + +
+ +
+ {showResults ? ( + <> + + + +
+ +
+ {resultState.authorizedUsers.map((user) => ( + + ))} +
+
+ + ) : null} +
+ ); +} diff --git a/frontend/screens/visualizer/panel-tabs/detail-settings.tsx b/frontend/screens/visualizer/panel-tabs/detail-settings.tsx new file mode 100644 index 0000000..09e7f51 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-settings.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState } from "react"; +import { + demoVisualizerData, + type DemoVisualizerData, +} from "@/lib/demo-visualizer-data"; +import { + CheckboxRow, + detailColors, + PanelSection, + SectionBulletLabel, + SectionDivider, + SegmentedControl, + ToggleRow, +} from "@/components/visualizer/detail/workspace-detail-primitives"; + +interface DetailPanelSettingsProps { + workspaceData?: DemoVisualizerData | null; + searchQuery?: string; +} + +export function DetailPanelSettings({ + workspaceData, + searchQuery, +}: DetailPanelSettingsProps) { + const settingsData = + workspaceData?.workspaceDetails.settings ?? + demoVisualizerData.workspaceDetails.settings; + const defaultDetailLevels = settingsData.detailLevels; + const defaultLayoutDirections = settingsData.layoutDirections; + const defaultVisibleNodeTypes = settingsData.visibleNodeTypes; + const defaultLabels = settingsData.labels; + const defaultDataOptions = settingsData.dataOptions; + const [selectedDetailLevel, setSelectedDetailLevel] = useState( + settingsData.selectedDetailLevel ?? defaultDetailLevels[0], + ); + const [selectedLayoutDirection, setSelectedLayoutDirection] = useState( + settingsData.selectedLayoutDirection ?? defaultLayoutDirections[0], + ); + const [visibleNodeTypes, setVisibleNodeTypes] = useState(defaultVisibleNodeTypes); + const [labels, setLabels] = useState(defaultLabels); + const [dataOptions, setDataOptions] = useState(defaultDataOptions); + + const toggleCheckedItem = (label: string) => { + setVisibleNodeTypes((items) => + items.map((item) => + item.label === label ? { ...item, checked: !item.checked } : item, + ), + ); + }; + + const toggleEnabledItem = ( + label: string, + setter: React.Dispatch< + React.SetStateAction> + >, + ) => { + setter((items) => + items.map((item) => + item.label === label ? { ...item, enabled: !item.enabled } : item, + ), + ); + }; + + return ( +
+ +
+ + +
+
+
+ +
+ {visibleNodeTypes.map(({ label, checked }) => ( + toggleCheckedItem(label)} + /> + ))} +
+ +
+
+ +
+ {labels.map(({ label, enabled }) => ( + toggleEnabledItem(label, setLabels)} + /> + ))} +
+ +
+
+ +
+ {dataOptions.map(({ label, enabled }) => ( + toggleEnabledItem(label, setDataOptions)} + /> + ))} +
+
+
+ +
+
+ ); +} diff --git a/frontend/screens/visualizer/policy-graph-canvas.tsx b/frontend/screens/visualizer/policy-graph-canvas.tsx new file mode 100644 index 0000000..6e7c595 --- /dev/null +++ b/frontend/screens/visualizer/policy-graph-canvas.tsx @@ -0,0 +1,485 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; +import Image from "next/image"; +import branchIcon from "@/assets/branch.png"; +import fileIcon from "@/assets/file.png"; +import usersIcon from "@/assets/Users.png"; +import userIcon from "@/assets/user.png"; +import { + boundary, + branchBox, + defaultLanes, + defaultPolicyGraphVariant, + defaultPrincipalNames, + fileBox, + layoutHeight, + layoutWidth, + principalBox, + roleBox, + rowY, + scrollPadding, +} from "@/screens/visualizer/policy-graph.constants"; +import { + compareStatusColors, + getEdgeColor, + getIconClassName, + getIconFilter, + getLaneCenters, + getLaneNodeChangeTypes, + getNodeTextStyle, + getPrincipalChangeType, + getPrincipalOffsets, + getTextClassName, +} from "@/screens/visualizer/policy-graph.utils"; +import type { + PolicyGraphCanvasVariant, + PolicyGraphEdge, +} from "@/screens/visualizer/policy-graph.types"; + +interface PolicyGraphCanvasProps { + graphId: string; + zoom: number; + viewportWidth: number; + viewportHeight: number; + offset: { + x: number; + y: number; + }; + onOffsetChange: (offset: { x: number; y: number }) => void; + onDelete?: () => void; + variant?: PolicyGraphCanvasVariant; + searchQuery?: string; + allowOverflowDrag?: boolean; +} + +export function PolicyGraphCanvas({ + graphId, + zoom, + viewportWidth, + viewportHeight, + offset, + onOffsetChange, + onDelete, + variant, + searchQuery = "", + allowOverflowDrag = false, +}: PolicyGraphCanvasProps) { + const [isDraggingBoundary, setIsDraggingBoundary] = useState(false); + const lanes = variant?.lanes ?? [...defaultLanes]; + const principalNames = variant?.principalNames ?? defaultPrincipalNames; + const repositoryLabel = + variant?.repositoryLabel ?? defaultPolicyGraphVariant.repositoryLabel; + const repositoryLabelColor = + variant?.repositoryLabelColor ?? defaultPolicyGraphVariant.repositoryLabelColor; + const branchLabel = variant?.branchLabel ?? defaultPolicyGraphVariant.branchLabel; + const boundaryFill = variant?.boundaryFill ?? "none"; + const normalizedSearchQuery = searchQuery.trim().toLowerCase(); + const laneCenters = getLaneCenters(lanes.length); + const scaledWidth = layoutWidth * zoom; + const scaledHeight = layoutHeight * zoom; + const canvasWidth = Math.max(scaledWidth + scrollPadding * 2, viewportWidth); + const canvasHeight = Math.max( + scaledHeight + scrollPadding * 2, + viewportHeight, + ); + const canvasOffsetX = + scaledWidth + scrollPadding * 2 <= viewportWidth + ? (viewportWidth - scaledWidth) / 2 + : scrollPadding; + const canvasOffsetY = + scaledHeight + scrollPadding * 2 <= viewportHeight + ? (viewportHeight - scaledHeight) / 2 + : scrollPadding; + const graphLeft = allowOverflowDrag + ? canvasOffsetX + offset.x + : Math.min( + Math.max(0, canvasOffsetX + offset.x), + Math.max(0, canvasWidth - scaledWidth), + ); + const graphTop = allowOverflowDrag + ? canvasOffsetY + offset.y + : Math.min( + Math.max(0, canvasOffsetY + offset.y), + Math.max(0, canvasHeight - scaledHeight), + ); + + const verticalPaths: PolicyGraphEdge[] = lanes.flatMap((lane, laneIndex) => { + const centerX = laneCenters[laneIndex]; + const changeTypes = getLaneNodeChangeTypes(lane); + + return [ + { + d: `M ${centerX} ${rowY.branch + branchBox.height} L ${centerX} ${rowY.file - 18}`, + arrow: true, + changeType: changeTypes.path, + }, + { + d: `M ${centerX} ${rowY.file + fileBox.height} L ${centerX} ${rowY.role - 18}`, + arrow: true, + changeType: changeTypes.role, + }, + ]; + }); + + const principalPaths: PolicyGraphEdge[] = lanes.flatMap((lane, laneIndex) => { + const centerX = laneCenters[laneIndex]; + const lanePrincipals = + lane.principals ?? + principalNames.map((name) => ({ + name, + })); + const principalOffsets = getPrincipalOffsets(lanePrincipals.length); + + return principalOffsets.map((offset, index) => { + const principal = lanePrincipals[index] ?? { name: "" }; + const principalChangeType = getPrincipalChangeType(principal, lane); + + return { + d: `M ${centerX} ${rowY.role + roleBox.height} L ${centerX + offset} ${rowY.principals - 18}`, + arrow: true, + changeType: principalChangeType, + }; + }); + }); + + const handleBoundaryPointerDown = ( + event: React.PointerEvent, + ) => { + if (event.button !== 0) return; + + event.preventDefault(); + + const target = event.currentTarget; + const startX = event.clientX; + const startY = event.clientY; + const startOffset = offset; + + target.setPointerCapture(event.pointerId); + setIsDraggingBoundary(true); + + const handlePointerMove = (moveEvent: PointerEvent) => { + const nextOffsetX = startOffset.x + (moveEvent.clientX - startX); + const nextOffsetY = startOffset.y + (moveEvent.clientY - startY); + const minOffsetX = -canvasOffsetX; + const maxOffsetX = canvasWidth - scaledWidth - canvasOffsetX; + const minOffsetY = -canvasOffsetY; + const maxOffsetY = canvasHeight - scaledHeight - canvasOffsetY; + + onOffsetChange( + allowOverflowDrag + ? { + x: nextOffsetX, + y: nextOffsetY, + } + : { + x: Math.min(maxOffsetX, Math.max(minOffsetX, nextOffsetX)), + y: Math.min(maxOffsetY, Math.max(minOffsetY, nextOffsetY)), + }, + ); + }; + + const handlePointerEnd = (endEvent: PointerEvent) => { + setIsDraggingBoundary(false); + target.releasePointerCapture(endEvent.pointerId); + target.removeEventListener("pointermove", handlePointerMove); + target.removeEventListener("pointerup", handlePointerEnd); + target.removeEventListener("pointercancel", handlePointerEnd); + }; + + target.addEventListener("pointermove", handlePointerMove); + target.addEventListener("pointerup", handlePointerEnd); + target.addEventListener("pointercancel", handlePointerEnd); + }; + + return ( +
+
+
+
+ + + {[ + { id: "default", color: "var(--tertiary-color)" }, + ...Object.entries(compareStatusColors).map(([id, color]) => ({ + id, + color, + })), + ].map((marker) => ( + + + + ))} + + + + + {[...verticalPaths, ...principalPaths].map((path, index) => ( + + ))} + + +
+ + {onDelete ? ( + + ) : null} + +
+ {repositoryLabel} +
+ + {lanes.map((lane, laneIndex) => { + const centerX = laneCenters[laneIndex]; + const changeTypes = getLaneNodeChangeTypes(lane); + const lanePrincipals = + lane.principals ?? + principalNames.map((name) => ({ + name, + })); + const principalOffsets = getPrincipalOffsets(lanePrincipals.length); + + return ( +
+
+ +
+ {branchLabel} +
+
+ +
+ +
+ {lane.pathLabel} +
+
+ +
+ +
+ {lane.roleLabel} +
+
+ {lane.approvals} +
+
+ + {lanePrincipals.map((principal, principalIndex) => { + const center = centerX + principalOffsets[principalIndex]; + const principalChangeType = getPrincipalChangeType( + principal, + lane, + ); + + return ( +
+ +
+ {principal.name} +
+
+ ); + })} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/frontend/screens/visualizer/policy-graph.constants.ts b/frontend/screens/visualizer/policy-graph.constants.ts new file mode 100644 index 0000000..d2467f4 --- /dev/null +++ b/frontend/screens/visualizer/policy-graph.constants.ts @@ -0,0 +1,73 @@ +import type { + PolicyGraphCanvasVariant, + PolicyGraphChangeStatus, + PolicyGraphLane, +} from "@/screens/visualizer/policy-graph.types"; + +export const layoutWidth = 980; +export const layoutHeight = 980; +export const boundary = { x: 80, y: 60, width: 820, height: 840 }; +export const rowY = { + branch: 120, + file: 300, + role: 500, + principals: 760, +}; +export const defaultLanes: PolicyGraphLane[] = [ + { + key: "left", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 2 approvals", + }, + { + key: "right", + pathLabel: "docs/**", + roleLabel: "Authorized users", + approvals: "Requires: 2 approvals", + }, +]; +export const defaultPrincipalNames = ["Alice", "Carol", "Bob"]; +export const branchBox = { width: 140, height: 92 }; +export const fileBox = { width: 120, height: 118 }; +export const roleBox = { width: 180, height: 132 }; +export const principalBox = { width: 92, height: 108 }; +export const scrollPadding = 96; + +export const DIFF_COLORS = { + unchanged: { + text: "text-black", + icon: "text-black", + edge: "var(--tertiary-color)", + }, + added: { + text: "text-green-500", + icon: "text-green-500", + edge: "var(--approve-color)", + }, + removed: { + text: "text-red-500", + icon: "text-red-500", + edge: "var(--reject-color)", + }, + modified: { + text: "text-blue-500", + icon: "text-blue-500", + edge: "var(--modified-color)", + }, +} as const satisfies Record< + PolicyGraphChangeStatus, + { + text: string; + icon: string; + edge: string; + } +>; + +export const defaultPolicyGraphVariant: Required< + Pick +> = { + repositoryLabel: "gittuf_repo", + repositoryLabelColor: "var(--dark-gray)", + branchLabel: "Branch: main", +}; diff --git a/frontend/screens/visualizer/policy-graph.types.ts b/frontend/screens/visualizer/policy-graph.types.ts new file mode 100644 index 0000000..b59cc08 --- /dev/null +++ b/frontend/screens/visualizer/policy-graph.types.ts @@ -0,0 +1,39 @@ +export type PolicyGraphChangeStatus = + | "added" + | "removed" + | "modified" + | "unchanged"; + +export interface PolicyGraphPrincipal { + name: string; + status?: PolicyGraphChangeStatus; +} + +export interface PolicyGraphLane { + key: string; + pathLabel: string; + roleLabel: string; + approvals: string; + branchStatus?: PolicyGraphChangeStatus; + status?: PolicyGraphChangeStatus; + pathStatus?: PolicyGraphChangeStatus; + roleStatus?: PolicyGraphChangeStatus; + approvalsStatus?: PolicyGraphChangeStatus; + principals?: PolicyGraphPrincipal[]; +} + +export interface PolicyGraphCanvasVariant { + repositoryLabel?: string; + branchLabel?: string; + lanes?: PolicyGraphLane[]; + principalNames?: string[]; + boundaryFill?: string; + repositoryLabelColor?: string; + showCompareLegend?: boolean; +} + +export interface PolicyGraphEdge { + d: string; + arrow: boolean; + changeType: PolicyGraphChangeStatus; +} diff --git a/frontend/screens/visualizer/policy-graph.utils.ts b/frontend/screens/visualizer/policy-graph.utils.ts new file mode 100644 index 0000000..aec377c --- /dev/null +++ b/frontend/screens/visualizer/policy-graph.utils.ts @@ -0,0 +1,109 @@ +import { DIFF_COLORS, boundary } from "@/screens/visualizer/policy-graph.constants"; +import type { + PolicyGraphChangeStatus, + PolicyGraphLane, + PolicyGraphPrincipal, +} from "@/screens/visualizer/policy-graph.types"; + +export function getChangeType( + status?: PolicyGraphChangeStatus, +): PolicyGraphChangeStatus { + return status ?? "unchanged"; +} + +export function getEdgeColor(changeType: PolicyGraphChangeStatus) { + return DIFF_COLORS[changeType].edge; +} + +export function getTextClassName(changeType: PolicyGraphChangeStatus) { + return DIFF_COLORS[changeType].text; +} + +export function getIconClassName(changeType: PolicyGraphChangeStatus) { + return DIFF_COLORS[changeType].icon; +} + +export function getIconFilter(changeType: PolicyGraphChangeStatus) { + if (changeType === "unchanged") { + return "grayscale(1)"; + } + + if (changeType === "modified") { + return "brightness(0) saturate(100%) invert(51%) sepia(92%) saturate(1736%) hue-rotate(204deg) brightness(98%) contrast(90%)"; + } + + if (changeType === "removed") { + return "brightness(0) saturate(100%) invert(56%) sepia(90%) saturate(3018%) hue-rotate(331deg) brightness(96%) contrast(94%)"; + } + + return "brightness(0) saturate(100%) invert(62%) sepia(62%) saturate(560%) hue-rotate(83deg) brightness(93%) contrast(91%)"; +} + +export function getNodeTextStyle( + value: string, + normalizedSearchQuery: string, +) { + return normalizedSearchQuery && + value.toLowerCase().includes(normalizedSearchQuery) + ? { + backgroundColor: "var(--selected-color)", + borderRadius: "4px", + } + : undefined; +} + +export function getLaneNodeChangeTypes(lane: PolicyGraphLane) { + const branch = getChangeType(lane.branchStatus); + const laneDefault = lane.status; + const path = getChangeType(lane.pathStatus ?? laneDefault); + const role = getChangeType(lane.roleStatus); + const approvals = getChangeType(lane.approvalsStatus ?? laneDefault); + const roleIcon = getChangeType( + lane.roleStatus ?? lane.approvalsStatus ?? laneDefault, + ); + + return { + branch, + path, + role, + approvals, + roleIcon, + }; +} + +export function getPrincipalChangeType( + principal: PolicyGraphPrincipal, + lane: PolicyGraphLane, +) { + return getChangeType(principal.status ?? lane.status); +} + +export const compareStatusColors: Record = { + added: DIFF_COLORS.added.edge, + removed: DIFF_COLORS.removed.edge, + modified: DIFF_COLORS.modified.edge, + unchanged: DIFF_COLORS.unchanged.edge, +}; + +export function getLaneCenters(laneCount: number) { + if (laneCount <= 1) { + return [boundary.x + boundary.width / 2]; + } + + const usableWidth = boundary.width - 420; + return Array.from({ length: laneCount }, (_, index) => { + const ratio = laneCount === 1 ? 0.5 : index / (laneCount - 1); + return boundary.x + 210 + usableWidth * ratio; + }); +} + +export function getPrincipalOffsets(principalCount: number) { + if (principalCount <= 1) return [0]; + + const maxSpread = 300; + const spread = Math.min(maxSpread, Math.max(120, (principalCount - 1) * 90)); + const start = -spread / 2; + const step = spread / (principalCount - 1); + + return Array.from({ length: principalCount }, (_, index) => start + step * index); +} diff --git a/frontend/screens/visualizer/use-visualizer-workspace.ts b/frontend/screens/visualizer/use-visualizer-workspace.ts new file mode 100644 index 0000000..254978d --- /dev/null +++ b/frontend/screens/visualizer/use-visualizer-workspace.ts @@ -0,0 +1,569 @@ +"use client"; + +import { useDefaultLayout } from "react-resizable-panels"; +import { useEffect, useMemo, useRef, useState } from "react"; +import type { PanelImperativeHandle } from "react-resizable-panels"; +import { demoVisualizerData } from "@/lib/demo-visualizer-data"; +import { + getDefaultHistoryCommitId, + getDefaultHistorySortState, + getHistoryTimelineCommits, + sortHistoryTimelineCommits, +} from "@/screens/visualizer/history-canvas"; +import type { HistorySortField } from "@/screens/visualizer/history.types"; +import { + compareTabId, + historyTabId, + visualizerMenuItems, +} from "@/screens/visualizer/visualizer.constants"; +import type { + GraphWorkspaceTab, + VisualizerWorkspaceProps, + WorkspacePanelId, +} from "@/screens/visualizer/visualizer.types"; + +const compactMenuWidthPx = 132; +const autoCollapseMenuWidthPx = 1180; +const autoCollapseDetailWidthPx = 980; + +function shouldUseCompactMenu( + menuWidthPercent: number, + totalWidthPx: number, +) { + if (totalWidthPx > 0) { + return totalWidthPx * (menuWidthPercent / 100) <= compactMenuWidthPx; + } + + return menuWidthPercent <= 8; +} + +export function useVisualizerWorkspace({ + workspaceData, + onReload, +}: Pick) { + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "visualizer-workspace-layout", + }); + const initialMenuWidth = defaultLayout?.["workspace-menu-panel"] ?? 18; + const initialDetailWidth = defaultLayout?.["workspace-detail-panel"] ?? 25; + + const [activePanel, setActivePanel] = useState("graph-source"); + const [isMenuCompact, setIsMenuCompact] = useState(initialMenuWidth <= 8); + const [isMenuCollapsed, setIsMenuCollapsed] = useState(false); + const [isDetailCollapsed, setIsDetailCollapsed] = useState(false); + const [detailSearchQuery, setDetailSearchQuery] = useState(""); + const [graphZoom, setGraphZoom] = useState(0.75); + const [graphSearchQuery, setGraphSearchQuery] = useState(""); + const [menuPanelWidth, setMenuPanelWidth] = useState(initialMenuWidth); + const [detailPanelWidth, setDetailPanelWidth] = useState(initialDetailWidth); + const [panelGroupWidth, setPanelGroupWidth] = useState(0); + const [graphViewportSize, setGraphViewportSize] = useState({ + width: 0, + height: 0, + }); + const [graphTabs, setGraphTabs] = useState([ + { + id: "graph-tab-1", + label: "Policy Graph", + closable: true, + editable: true, + graphs: [{ id: "graph-instance-1", offset: { x: 0, y: 0 } }], + }, + ]); + const [activeGraphTabId, setActiveGraphTabId] = useState("graph-tab-1"); + const [selectedBaseVersion, setSelectedBaseVersion] = useState(""); + const [selectedCompareVersion, setSelectedCompareVersion] = useState(""); + const [hasCompared, setHasCompared] = useState(false); + const [activeHistoryCommitId, setActiveHistoryCommitId] = useState( + null, + ); + const [isHistoryStripCollapsed, setIsHistoryStripCollapsed] = useState(false); + const [historySortField, setHistorySortField] = useState("date"); + const [isHistorySortAscending, setIsHistorySortAscending] = useState(false); + + const panelGroupRef = useRef(null); + const graphViewportRef = useRef(null); + const menuPanelRef = useRef(null); + const detailPanelRef = useRef(null); + const didAutoCollapseMenuRef = useRef(false); + const didAutoCollapseDetailRef = useRef(false); + const nextGraphTabNumberRef = useRef(2); + const nextGraphInstanceNumberRef = useRef(2); + + const compareData = + workspaceData?.workspaceDetails.compare ?? + demoVisualizerData.workspaceDetails.compare; + + const baseHistoryCommits = useMemo( + () => getHistoryTimelineCommits(workspaceData), + [workspaceData], + ); + const defaultHistorySortState = useMemo( + () => getDefaultHistorySortState(workspaceData), + [workspaceData], + ); + const historyCommits = useMemo( + () => + sortHistoryTimelineCommits( + baseHistoryCommits, + historySortField, + isHistorySortAscending, + ), + [baseHistoryCommits, historySortField, isHistorySortAscending], + ); + const detailHistoryCommits = useMemo( + () => + historyCommits.map((commit) => { + const historyData = + workspaceData?.workspaceDetails.history ?? + demoVisualizerData.workspaceDetails.history; + const sourceCommit = historyData.commits.find( + (historyCommit) => historyCommit.hash === commit.hash, + ); + + return { + id: sourceCommit + ? historyData.commits.findIndex( + (historyCommit) => historyCommit.hash === commit.hash, + ) + : -1, + hash: commit.hash, + message: sourceCommit?.message ?? "", + author: commit.author, + authorLabel: commit.authorLabel, + date: commit.date, + }; + }), + [historyCommits, workspaceData], + ); + const defaultHistoryCommitId = useMemo( + () => getDefaultHistoryCommitId(workspaceData) ?? historyCommits[0]?.id ?? null, + [historyCommits, workspaceData], + ); + + const comparePairKey = `${selectedBaseVersion}|${selectedCompareVersion}`; + const activeComparison = useMemo( + () => compareData.comparisonsByPair?.[comparePairKey], + [compareData, comparePairKey], + ); + const baseCompareGraph = useMemo(() => { + const baseGraph = compareData.graphsByVersion[selectedBaseVersion]; + return { + repositoryLabel: + baseGraph?.repositoryLabel ?? selectedBaseVersion.split(" • ")[0], + branchLabel: baseGraph?.branchLabel ?? "Branch: main", + lanes: baseGraph?.lanes, + }; + }, [compareData.graphsByVersion, selectedBaseVersion]); + const compareGraph = useMemo(() => { + if (activeComparison?.compareGraph) { + return { + repositoryLabel: + activeComparison.compareGraph.repositoryLabel ?? + selectedCompareVersion.split(" • ")[0], + branchLabel: activeComparison.compareGraph.branchLabel ?? "Branch: main", + lanes: activeComparison.compareGraph.lanes, + showCompareLegend: activeComparison.compareGraph.showLegend ?? true, + }; + } + + const fallbackGraph = compareData.graphsByVersion[selectedCompareVersion]; + return { + repositoryLabel: + fallbackGraph?.repositoryLabel ?? selectedCompareVersion.split(" • ")[0], + branchLabel: fallbackGraph?.branchLabel ?? "Branch: main", + lanes: fallbackGraph?.lanes, + showCompareLegend: true, + }; + }, [activeComparison, compareData.graphsByVersion, selectedCompareVersion]); + + const activeLabel = + visualizerMenuItems.find((item) => item.id === activePanel)?.label ?? + "Graph Source"; + const activePanelIcon = + visualizerMenuItems.find((item) => item.id === activePanel)?.icon ?? + visualizerMenuItems[0].icon; + const activeGraphTab = + graphTabs.find((tab) => tab.id === activeGraphTabId) ?? graphTabs[0]; + const isHistoryPanel = activeGraphTabId === historyTabId; + const isComparePanel = activeGraphTabId === compareTabId; + const footerLeftWidthPx = + panelGroupWidth > 0 + ? panelGroupWidth * ((menuPanelWidth + detailPanelWidth) / 100) + 2 + : 0; + + useEffect(() => { + setActiveHistoryCommitId(defaultHistoryCommitId); + }, [defaultHistoryCommitId]); + + useEffect(() => { + setHistorySortField(defaultHistorySortState.sortField); + setIsHistorySortAscending(defaultHistorySortState.isAscending); + }, [defaultHistorySortState]); + + useEffect(() => { + setSelectedBaseVersion( + compareData.selectedBaseVersion ?? compareData.baseVersionOptions[0], + ); + setSelectedCompareVersion( + compareData.selectedCompareVersion ?? compareData.compareVersionOptions[0], + ); + setHasCompared(false); + }, [compareData]); + + useEffect(() => { + if (activePanel !== "history") return; + + setGraphTabs((currentTabs) => { + if (currentTabs.some((tab) => tab.id === historyTabId)) { + return currentTabs; + } + + return [ + ...currentTabs, + { + id: historyTabId, + label: "History", + closable: false, + editable: false, + graphs: [], + }, + ]; + }); + setActiveGraphTabId(historyTabId); + }, [activePanel]); + + useEffect(() => { + if (activeGraphTabId !== historyTabId || activePanel === "history") return; + + const fallbackTab = graphTabs.find((tab) => tab.id !== historyTabId); + if (fallbackTab) { + setActiveGraphTabId(fallbackTab.id); + } + }, [activeGraphTabId, activePanel, graphTabs]); + + useEffect(() => { + if (activeGraphTabId !== compareTabId || activePanel === "compare") return; + + const compareTab = graphTabs.find((tab) => tab.id === compareTabId); + if (compareTab) return; + + const fallbackTab = graphTabs.find((tab) => tab.id !== compareTabId); + if (fallbackTab) { + setActiveGraphTabId(fallbackTab.id); + } + }, [activeGraphTabId, activePanel, graphTabs]); + + useEffect(() => { + const panelGroup = panelGroupRef.current; + if (!panelGroup) return; + + const updatePanelGroupWidth = () => { + setPanelGroupWidth(panelGroup.clientWidth); + }; + + updatePanelGroupWidth(); + + const resizeObserver = new ResizeObserver(updatePanelGroupWidth); + resizeObserver.observe(panelGroup); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + setIsMenuCompact(shouldUseCompactMenu(menuPanelWidth, panelGroupWidth)); + }, [menuPanelWidth, panelGroupWidth]); + + useEffect(() => { + if (!menuPanelRef.current || !detailPanelRef.current || panelGroupWidth <= 0) return; + + if (panelGroupWidth <= autoCollapseMenuWidthPx && !isMenuCollapsed) { + menuPanelRef.current.collapse(); + didAutoCollapseMenuRef.current = true; + } else if ( + panelGroupWidth > autoCollapseMenuWidthPx && + isMenuCollapsed && + didAutoCollapseMenuRef.current + ) { + menuPanelRef.current.expand(); + didAutoCollapseMenuRef.current = false; + } + + if (panelGroupWidth <= autoCollapseDetailWidthPx && !isDetailCollapsed) { + detailPanelRef.current.collapse(); + didAutoCollapseDetailRef.current = true; + } else if ( + panelGroupWidth > autoCollapseDetailWidthPx && + isDetailCollapsed && + didAutoCollapseDetailRef.current + ) { + detailPanelRef.current.expand(); + didAutoCollapseDetailRef.current = false; + } + }, [isDetailCollapsed, isMenuCollapsed, panelGroupWidth]); + + useEffect(() => { + const viewport = graphViewportRef.current; + if (!viewport) return; + + const updateViewportSize = () => { + setGraphViewportSize({ + width: viewport.clientWidth, + height: viewport.clientHeight, + }); + }; + + updateViewportSize(); + + const resizeObserver = new ResizeObserver(updateViewportSize); + resizeObserver.observe(viewport); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + const viewport = graphViewportRef.current; + if (!viewport) return; + + const animationFrame = window.requestAnimationFrame(() => { + viewport.scrollLeft = Math.max( + 0, + (viewport.scrollWidth - viewport.clientWidth) / 2, + ); + viewport.scrollTop = Math.max( + 0, + (viewport.scrollHeight - viewport.clientHeight) / 2, + ); + }); + + return () => { + window.cancelAnimationFrame(animationFrame); + }; + }, [graphViewportSize.height, graphViewportSize.width, graphZoom]); + + const handleDetailPanelToggle = () => { + if (!detailPanelRef.current) return; + + didAutoCollapseDetailRef.current = false; + + if (isDetailCollapsed) { + detailPanelRef.current.expand(); + setIsDetailCollapsed(false); + return; + } + + detailPanelRef.current.collapse(); + setIsDetailCollapsed(true); + }; + + const handleGenerateGraph = () => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === activeGraphTabId + ? { + ...tab, + graphs: [ + ...tab.graphs, + { + id: `graph-instance-${nextGraphInstanceNumberRef.current++}`, + offset: { x: 0, y: 0 }, + }, + ], + } + : tab, + ), + ); + onReload(); + }; + + const handleGenerateCompareGraph = () => { + setGraphTabs((currentTabs) => { + const label = `Compare ${selectedBaseVersion.split(" • ")[0]} vs ${selectedCompareVersion.split(" • ")[0]}`; + const existingCompareTab = currentTabs.find((tab) => tab.id === compareTabId); + if (existingCompareTab) { + return currentTabs.map((tab) => + tab.id === compareTabId ? { ...tab, label } : tab, + ); + } + + return [ + ...currentTabs, + { + id: compareTabId, + label, + closable: true, + editable: false, + graphs: [], + }, + ]; + }); + setHasCompared(true); + setActivePanel("compare"); + setActiveGraphTabId(compareTabId); + }; + + const handleAddGraphTab = () => { + const nextTabId = `graph-tab-${nextGraphTabNumberRef.current}`; + const nextTabLabel = `Canvas ${nextGraphTabNumberRef.current}`; + + nextGraphTabNumberRef.current += 1; + + setGraphTabs((currentTabs) => [ + ...currentTabs, + { + id: nextTabId, + label: nextTabLabel, + graphs: [], + }, + ]); + setActiveGraphTabId(nextTabId); + }; + + const handleDeleteGraphTab = (tabId: string) => { + if (graphTabs.length <= 1) return; + + const tabIndex = graphTabs.findIndex((tab) => tab.id === tabId); + const nextTabs = graphTabs.filter((tab) => tab.id !== tabId); + + setGraphTabs(nextTabs); + + if (tabId === activeGraphTabId) { + const fallbackTab = nextTabs[Math.max(0, tabIndex - 1)] ?? nextTabs[0]; + setActiveGraphTabId(fallbackTab.id); + } + }; + + const handleTabRename = (tabId: string, nextLabel: string) => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === tabId ? { ...tab, label: nextLabel } : tab, + ), + ); + }; + + const handleGraphOffsetChange = ( + graphId: string, + nextOffset: { x: number; y: number }, + ) => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === activeGraphTabId + ? { + ...tab, + graphs: tab.graphs.map((graph) => + graph.id === graphId ? { ...graph, offset: nextOffset } : graph, + ), + } + : tab, + ), + ); + }; + + const handleDeleteGraphInstance = (graphId: string) => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === activeGraphTabId + ? { + ...tab, + graphs: tab.graphs.filter((graph) => graph.id !== graphId), + } + : tab, + ), + ); + }; + + const handleMenuItemSelect = (panelId: WorkspacePanelId) => { + setActivePanel(panelId); + if (panelId === "history") { + setActiveGraphTabId(historyTabId); + } + }; + + const handleBottomTabSelect = (tabId: string) => { + if (tabId === historyTabId) { + setActivePanel("history"); + setActiveGraphTabId(historyTabId); + return; + } + + if (tabId === compareTabId) { + setActivePanel("compare"); + setActiveGraphTabId(compareTabId); + return; + } + + setActiveGraphTabId(tabId); + + if (activePanel === "history" || activePanel === "compare") { + setActivePanel("graph-source"); + } + }; + + return { + activeGraphTab, + activeGraphTabId, + activeHistoryCommitId, + activeLabel, + activePanel, + activePanelIcon, + baseCompareGraph, + compareGraph, + defaultLayout, + detailHistoryCommits, + detailPanelRef, + detailPanelWidth, + detailSearchQuery, + footerLeftWidthPx, + graphSearchQuery, + graphTabs, + graphViewportRef, + graphViewportSize, + graphZoom, + handleAddGraphTab, + handleBottomTabSelect, + handleDeleteGraphInstance, + handleDeleteGraphTab, + handleDetailPanelToggle, + handleGenerateCompareGraph, + handleGenerateGraph, + handleGraphOffsetChange, + handleMenuItemSelect, + handleTabRename, + hasCompared, + historyCommits, + historySortField, + isComparePanel, + isDetailCollapsed, + isHistoryPanel, + isHistorySortAscending, + isHistoryStripCollapsed, + isMenuCollapsed, + isMenuCompact, + menuPanelWidth, + menuPanelRef, + onLayoutChanged, + panelGroupRef, + selectedBaseVersion, + selectedCompareVersion, + setActiveHistoryCommitId, + setDetailPanelWidth, + setDetailSearchQuery, + setGraphSearchQuery, + setGraphZoom, + setHistorySortField, + setIsDetailCollapsed, + setIsHistorySortAscending, + setIsHistoryStripCollapsed, + setIsMenuCollapsed, + setIsMenuCompact, + setMenuPanelWidth, + setSelectedBaseVersion, + setSelectedCompareVersion, + setHasCompared, + visualizerMenuItems, + }; +} diff --git a/frontend/screens/visualizer/visualizer-workspace.tsx b/frontend/screens/visualizer/visualizer-workspace.tsx new file mode 100644 index 0000000..8e97e13 --- /dev/null +++ b/frontend/screens/visualizer/visualizer-workspace.tsx @@ -0,0 +1,367 @@ +"use client"; + +import Image from "next/image"; +import addIcon from "@/assets/add.png"; +import leftArrowIcon from "@/assets/left.png"; +import rightArrowIcon from "@/assets/right.png"; +import searchIcon from "@/assets/search.png"; +import zoomInIcon from "@/assets/zoom-in.png"; +import zoomOutIcon from "@/assets/zoom-out.png"; +import { WorkspaceCompareCanvas as CompareCanvas } from "@/screens/visualizer/compare-canvas"; +import { WorkspaceDetailContent as DetailContent } from "@/screens/visualizer/detail-content"; +import { + WorkspaceHistoryCanvas as HistoryCanvas, + WorkspaceHistoryTimelineStrip as HistoryTimelineStrip, +} from "@/screens/visualizer/history-canvas"; +import { useVisualizerWorkspace } from "@/screens/visualizer/use-visualizer-workspace"; +import { PolicyGraphCanvas } from "@/screens/visualizer/policy-graph-canvas"; +import { WorkspaceActionButton } from "@/components/visualizer/workspace-action-button"; +import { WorkspaceBottomBar } from "@/components/visualizer/workspace-bottom-bar"; +import { WorkspaceDetailToggle } from "@/components/visualizer/workspace-detail-toggle"; +import { WorkspaceMenuItem } from "@/components/visualizer/workspace-menu-item"; +import { WorkspacePanelHeader } from "@/components/visualizer/workspace-panel-header"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { historyTabId } from "@/screens/visualizer/visualizer.constants"; +import type { VisualizerWorkspaceProps } from "@/screens/visualizer/visualizer.types"; + +export default function VisualizerWorkspace(props: VisualizerWorkspaceProps) { + const { + activeGraphTab, + activeGraphTabId, + activeHistoryCommitId, + activeLabel, + activePanel, + activePanelIcon, + baseCompareGraph, + compareGraph, + defaultLayout, + detailHistoryCommits, + detailPanelRef, + detailSearchQuery, + footerLeftWidthPx, + graphSearchQuery, + graphTabs, + graphViewportRef, + graphViewportSize, + graphZoom, + handleAddGraphTab, + handleBottomTabSelect, + handleDeleteGraphInstance, + handleDeleteGraphTab, + handleDetailPanelToggle, + handleGenerateCompareGraph, + handleGenerateGraph, + handleGraphOffsetChange, + handleMenuItemSelect, + handleTabRename, + hasCompared, + historyCommits, + historySortField, + isComparePanel, + isDetailCollapsed, + isHistoryPanel, + isHistorySortAscending, + isHistoryStripCollapsed, + isMenuCollapsed, + isMenuCompact, + menuPanelRef, + onLayoutChanged, + panelGroupRef, + selectedBaseVersion, + selectedCompareVersion, + setActiveHistoryCommitId, + setDetailPanelWidth, + setDetailSearchQuery, + setGraphSearchQuery, + setGraphZoom, + setHistorySortField, + setIsDetailCollapsed, + setIsHistorySortAscending, + setIsHistoryStripCollapsed, + setIsMenuCollapsed, + setMenuPanelWidth, + setSelectedBaseVersion, + setSelectedCompareVersion, + setHasCompared, + visualizerMenuItems, + } = useVisualizerWorkspace(props); + + return ( +
+
+

{props.repository.name}

+
+ + +
+
+ +
+ + { + setMenuPanelWidth(panelSize.asPercentage); + setIsMenuCollapsed(panelSize.asPercentage <= 1); + }} + > + + + + + + { + setDetailPanelWidth(panelSize.asPercentage); + setIsDetailCollapsed(panelSize.asPercentage <= 1); + }} + > +
+ + + + setIsHistorySortAscending((current) => !current) + } + selectedBaseVersion={selectedBaseVersion} + selectedCompareVersion={selectedCompareVersion} + hasCompared={hasCompared} + onBaseVersionChange={(value) => { + setSelectedBaseVersion(value); + setHasCompared(false); + }} + onCompareVersionChange={(value) => { + setSelectedCompareVersion(value); + setHasCompared(false); + }} + onSwapVersions={() => { + setSelectedBaseVersion(selectedCompareVersion); + setSelectedCompareVersion(selectedBaseVersion); + setHasCompared(false); + }} + onCompare={handleGenerateCompareGraph} + /> + +
+
+ + + { + event.preventDefault(); + event.stopPropagation(); + handleDetailPanelToggle(); + }} + /> + + + +
+ {isHistoryPanel && !isHistoryStripCollapsed ? ( + + ) : null} + {isHistoryPanel ? ( +
+ +
+ ) : null} + +
+
+ + setGraphZoom((current) => Math.min(1.8, Number((current + 0.1).toFixed(2)))) + } + /> + + setGraphZoom((current) => Math.max(0.6, Number((current - 0.1).toFixed(2)))) + } + /> +
+ + {isHistoryPanel ? ( + + ) : isComparePanel ? ( + hasCompared ? ( + + ) : ( +
+ ) + ) : ( +
+ +
+
+ {activeGraphTab?.graphs.length ? ( + activeGraphTab.graphs.map((graph) => ( +
+ + handleGraphOffsetChange(graph.id, nextOffset) + } + onDelete={() => handleDeleteGraphInstance(graph.id)} + /> +
+ )) + ) : ( +
+ )} +
+
+ +
+ )} +
+
+
+
+
+ + ({ + id, + label, + closable, + editable, + }))} + activeTabId={isHistoryPanel ? historyTabId : activeGraphTabId} + addIcon={addIcon} + onTabSelect={handleBottomTabSelect} + onTabRename={handleTabRename} + onTabAdd={handleAddGraphTab} + onTabDelete={handleDeleteGraphTab} + /> +
+ ); +} diff --git a/frontend/screens/visualizer/visualizer.constants.ts b/frontend/screens/visualizer/visualizer.constants.ts new file mode 100644 index 0000000..6b39ebf --- /dev/null +++ b/frontend/screens/visualizer/visualizer.constants.ts @@ -0,0 +1,19 @@ +import compareIcon from "@/assets/compare.png"; +import graphSourceIcon from "@/assets/graph-source.png"; +import historyIcon from "@/assets/history.png"; +import metadataIcon from "@/assets/metadata.png"; +import policyQueryIcon from "@/assets/policy-query.png"; +import settingsIcon from "@/assets/Settings.png"; +import type { WorkspaceMenuItemConfig } from "@/screens/visualizer/visualizer.types"; + +export const historyTabId = "history-tab"; +export const compareTabId = "compare-tab"; + +export const visualizerMenuItems: WorkspaceMenuItemConfig[] = [ + { id: "graph-source", label: "Graph Source", icon: graphSourceIcon }, + { id: "policy-query", label: "Policy Query", icon: policyQueryIcon }, + { id: "history", label: "History", icon: historyIcon }, + { id: "compare", label: "Compare", icon: compareIcon }, + { id: "metadata", label: "MetaData", icon: metadataIcon }, + { id: "settings", label: "Settings", icon: settingsIcon }, +]; diff --git a/frontend/screens/visualizer/visualizer.types.ts b/frontend/screens/visualizer/visualizer.types.ts new file mode 100644 index 0000000..a624d99 --- /dev/null +++ b/frontend/screens/visualizer/visualizer.types.ts @@ -0,0 +1,41 @@ +import type { StaticImageData } from "next/image"; +import type { RepositoryInfo } from "@/lib/repository-handler"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer-data"; + +export type WorkspacePanelId = + | "graph-source" + | "policy-query" + | "history" + | "compare" + | "metadata" + | "settings"; + +export interface VisualizerWorkspaceProps { + repository: RepositoryInfo; + workspaceData?: DemoVisualizerData | null; + isLoading: boolean; + onReload: () => void; + onDisconnect: () => void; +} + +export interface GraphInstance { + id: string; + offset: { + x: number; + y: number; + }; +} + +export interface GraphWorkspaceTab { + id: string; + label: string; + closable?: boolean; + editable?: boolean; + graphs: GraphInstance[]; +} + +export interface WorkspaceMenuItemConfig { + id: WorkspacePanelId; + label: string; + icon: StaticImageData; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 48d6d82..3cc6f17 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -31,11 +31,10 @@ "include": [ "next-env.d.ts", "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + "**/*.tsx" ], "exclude": [ - "node_modules" + "node_modules", + "archive" ] } From 05240db98acc3b601e7e23e8978b745a84afdc85 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Wed, 10 Jun 2026 20:02:53 -0400 Subject: [PATCH 2/4] frontend: expand detail panel metadata, implement search, and add loading states for buttons and graphs Signed-off-by: Tony Lee --- frontend/AGENT.md | 359 +++++++++++ frontend/README.md | 37 +- frontend/app/page.tsx | 4 +- .../playground/app}/page.tsx | 0 .../playground/components}/status-card.tsx | 0 .../playground/components}/story-modal.tsx | 0 .../playground}/fixtures/fixture-allowed.json | 0 .../playground}/fixtures/fixture-blocked.json | 0 .../playground}/hooks/use-gittuf-simulator.ts | 0 .../playground}/lib/simulator-types.ts | 0 .../screens/playground/simulator-analysis.tsx | 0 .../playground/simulator-config-modal.tsx | 0 .../screens/playground/simulator-controls.tsx | 0 .../screens/playground/simulator-glossary.tsx | 0 .../screens/playground/simulator-graph.tsx | 0 .../screens/playground/simulator-header.tsx | 0 .../screens/playground/trust-graph.tsx | 0 frontend/assets/spinner.png | Bin 0 -> 1150 bytes frontend/components/app/header.tsx | 6 +- frontend/components/ui/use-mobile.tsx | 19 - frontend/components/ui/use-toast.ts | 185 ------ .../detail/detail-action-button.tsx | 38 ++ .../detail/detail-display-cards.tsx | 113 ++++ .../detail/detail-primitives.types.ts | 20 + .../detail/detail-select-controls.tsx | 203 ++++++ .../detail/detail-setting-controls.tsx | 124 ++++ .../detail/detail-text-sections.tsx | 152 +++++ .../detail/workspace-detail-primitives.tsx | 610 +----------------- .../visualizer/workspace-action-button.tsx | 17 +- .../visualizer/workspace-bottom-bar.tsx | 12 +- .../visualizer/workspace-detail-toggle.tsx | 2 +- .../visualizer/workspace-menu-item.tsx | 2 +- .../visualizer/workspace-panel-header.tsx | 2 +- .../visualizer/workspace-search-field.tsx | 4 +- .../hooks/explorer/use-commit-analysis.ts | 65 -- .../hooks/explorer/use-commit-comparison.ts | 90 --- .../hooks/explorer/use-commit-selection.ts | 82 --- frontend/hooks/use-gittuf-explorer.ts | 181 ------ ...epository.ts => use-repository-session.ts} | 50 +- ...zer-data.ts => demo-visualizer-fixture.ts} | 205 +----- frontend/lib/demo-visualizer.types.ts | 204 ++++++ .../repository/repository-selector.tsx | 22 +- .../screens/visualizer/compare-canvas.tsx | 4 +- .../screens/visualizer/detail-content.tsx | 7 +- .../screens/visualizer/history-canvas.tsx | 16 +- .../visualizer/panel-tabs/detail-compare.tsx | 34 +- .../panel-tabs/detail-graph-source.tsx | 21 +- .../visualizer/panel-tabs/detail-history.tsx | 16 +- .../visualizer/panel-tabs/detail-metadata.tsx | 18 +- .../panel-tabs/detail-policy-query.tsx | 58 +- .../visualizer/panel-tabs/detail-settings.tsx | 38 +- .../visualizer/policy-graph-canvas.tsx | 234 +------ .../visualizer/policy-graph-lane-column.tsx | 165 +++++ .../screens/visualizer/policy-graph-svg.tsx | 84 +++ .../screens/visualizer/policy-graph.types.ts | 1 + .../screens/visualizer/policy-graph.utils.ts | 7 + .../screens/visualizer/use-graph-viewport.ts | 62 ++ .../use-visualizer-history-compare.ts | 157 +++++ .../visualizer/use-visualizer-layout.ts | 136 ++++ .../screens/visualizer/use-visualizer-tabs.ts | 234 +++++++ .../visualizer/use-visualizer-workspace.ts | 544 ++-------------- .../visualizer/visualizer-workspace.tsx | 76 ++- .../screens/visualizer/visualizer.types.ts | 2 +- frontend/styles/globals.css | 93 --- frontend/tsconfig.json | 4 +- 65 files changed, 2409 insertions(+), 2410 deletions(-) create mode 100644 frontend/AGENT.md rename frontend/{app/playground => archive/playground/app}/page.tsx (100%) rename frontend/{components/common => archive/playground/components}/status-card.tsx (100%) rename frontend/{components/common => archive/playground/components}/story-modal.tsx (100%) rename frontend/{ => archive/playground}/fixtures/fixture-allowed.json (100%) rename frontend/{ => archive/playground}/fixtures/fixture-blocked.json (100%) rename frontend/{ => archive/playground}/hooks/use-gittuf-simulator.ts (100%) rename frontend/{ => archive/playground}/lib/simulator-types.ts (100%) rename frontend/{ => archive/playground}/screens/playground/simulator-analysis.tsx (100%) rename frontend/{ => archive/playground}/screens/playground/simulator-config-modal.tsx (100%) rename frontend/{ => archive/playground}/screens/playground/simulator-controls.tsx (100%) rename frontend/{ => archive/playground}/screens/playground/simulator-glossary.tsx (100%) rename frontend/{ => archive/playground}/screens/playground/simulator-graph.tsx (100%) rename frontend/{ => archive/playground}/screens/playground/simulator-header.tsx (100%) rename frontend/{ => archive/playground}/screens/playground/trust-graph.tsx (100%) create mode 100644 frontend/assets/spinner.png delete mode 100644 frontend/components/ui/use-mobile.tsx delete mode 100644 frontend/components/ui/use-toast.ts create mode 100644 frontend/components/visualizer/detail/detail-action-button.tsx create mode 100644 frontend/components/visualizer/detail/detail-display-cards.tsx create mode 100644 frontend/components/visualizer/detail/detail-primitives.types.ts create mode 100644 frontend/components/visualizer/detail/detail-select-controls.tsx create mode 100644 frontend/components/visualizer/detail/detail-setting-controls.tsx create mode 100644 frontend/components/visualizer/detail/detail-text-sections.tsx delete mode 100644 frontend/hooks/explorer/use-commit-analysis.ts delete mode 100644 frontend/hooks/explorer/use-commit-comparison.ts delete mode 100644 frontend/hooks/explorer/use-commit-selection.ts delete mode 100644 frontend/hooks/use-gittuf-explorer.ts rename frontend/hooks/{explorer/use-repository.ts => use-repository-session.ts} (62%) rename frontend/lib/{demo-visualizer-data.ts => demo-visualizer-fixture.ts} (80%) create mode 100644 frontend/lib/demo-visualizer.types.ts create mode 100644 frontend/screens/visualizer/policy-graph-lane-column.tsx create mode 100644 frontend/screens/visualizer/policy-graph-svg.tsx create mode 100644 frontend/screens/visualizer/use-graph-viewport.ts create mode 100644 frontend/screens/visualizer/use-visualizer-history-compare.ts create mode 100644 frontend/screens/visualizer/use-visualizer-layout.ts create mode 100644 frontend/screens/visualizer/use-visualizer-tabs.ts delete mode 100644 frontend/styles/globals.css diff --git a/frontend/AGENT.md b/frontend/AGENT.md new file mode 100644 index 0000000..3fcdf80 --- /dev/null +++ b/frontend/AGENT.md @@ -0,0 +1,359 @@ +--- +name: frontend_agent +description: Expert coding agent for the gittuf visualizer frontend +--- + +# AGENT.md + +This file is a frontend-specific guide for AI coding agents and human contributors. +It documents the active architecture in `frontend/` and should be preferred over +historical patterns found under `frontend/archive/`. + +## Your role + +You are an expert coding agent for this frontend. + +- You are fluent in TypeScript, React, Next.js App Router, and Tailwind CSS. +- You read from the active frontend runtime code in `app/`, `screens/`, + `components/`, `hooks/`, and `lib/`. +- You make changes that preserve the current repository selector -> visualizer + workspace flow unless the task explicitly asks to change product behavior. +- You prefer extending established frontend patterns over inventing parallel + abstractions. +- You treat `archive/` and `.next/` as non-authoritative for active architecture. + +## Project Overview + +- This frontend is a Next.js app for the gittuf visualizer. +- It lets users: + - connect a repository or launch demo data + - open a policy-graph workspace + - inspect graph source, policy query, history, compare, metadata, and settings + - generate additional canvases and compare policy revisions visually +- Main technologies: + - Next.js 16 App Router + - React 19 + TypeScript + - Tailwind CSS + - shadcn/Radix UI primitives + - Framer Motion + - React Resizable Panels +- High-level architecture: + - `app/page.tsx` is the route shell + - `hooks/use-repository-session.ts` owns repository/demo session state + - `screens/repository/` owns the entry screen + - `screens/visualizer/` owns the main workspace feature +- Primary user workflow: + 1. Open `/` + 2. Choose demo, remote repo, or local repo + 3. Enter the visualizer workspace + 4. Use the side menu and bottom tabs to inspect graph/history/compare views + +## Local Workflow + +- Preferred package manager: `npm` +- Install dependencies with: + - `npm install` +- Run the dev server with: + - `npm run dev` +- Build for production with: + - `npm run build` +- Global app styles live in: + - `app/globals.css` +- Do not add new global styles to `styles/`; that path is not part of the active app flow. + +## Repository Structure + +Active runtime code: + +- `app/` + - Next.js routes and app-level styling + - `app/page.tsx` is the current home flow +- `screens/` + - Feature-level route surfaces + - `screens/repository/` contains the repository selector screen + - `screens/visualizer/` contains the policy-graph workspace +- `components/` + - Shared UI outside route ownership + - `components/app/` app shell pieces + - `components/ui/` shadcn/Radix-based primitives + - `components/visualizer/` reusable visualizer UI +- `hooks/` + - Shared custom hooks used by active runtime code + - `hooks/visualizer/` contains visualizer-specific shared hooks +- `lib/` + - flat shared support code for the active app + - currently includes: + - constants + - repository/mock API helpers + - JSON helpers + - demo fixture data + - shared/shared-feature types +- `assets/` + - imported image assets used by the UI +- `public/` + - standard Next.js static assets + +Non-runtime or generated code: + +- `.next/` + - generated Next.js output; do not edit +- `node_modules/` + - installed dependencies; do not edit +- `archive/` + - historical code only; do not treat this as the active architecture +- `styles/` + - currently not part of the active frontend flow; check before adding new globals there + +Important note: + +- Top-level `frontend/pages/` must not be introduced. + This app uses the App Router, and a top-level `pages` directory can create a + routing/bundling conflict in Next.js. + +## Architecture + +### Feature boundaries + +- `screens/repository/` + - repository entry UX only + - should not own visualizer workspace state +- `screens/visualizer/` + - owns the policy-graph workspace feature + - owns visualizer-specific state composition + - owns policy graph rendering, history/compare state, and panel-tab behavior +- `components/visualizer/` + - reusable visualizer UI pieces + - should not become a second state-management layer + +### Component hierarchy + +- `app/page.tsx` + - renders `Header` + - renders either: + - `screens/repository/repository-selector.tsx` + - or `screens/visualizer/visualizer-workspace.tsx` +- `screens/visualizer/visualizer-workspace.tsx` + - composes resizable panels, graph viewport, bottom bar, and detail content +- `screens/visualizer/detail-content.tsx` + - routes active detail-panel state to the correct panel tab component +- `screens/visualizer/policy-graph-canvas.tsx` + - composes graph layout, SVG shell, drag behavior, and lane rendering + +### State management approach + +- State is local React state plus focused custom hooks. +- There is no global app store. +- This is intentional: the current app is feature-centric and the workspace state + is easier to evolve by composing focused hooks than by maintaining a global store. +- Current major state owners: + - `use-repository-session.ts` + - repository/demo session state + - `use-visualizer-workspace.ts` + - top-level visualizer state composition + - `use-visualizer-layout.ts` + - panel layout persistence and responsive collapse behavior + - `use-visualizer-tabs.ts` + - bottom-tab and graph-instance state + - `use-visualizer-history-compare.ts` + - history sorting/selection and compare version state + - `use-graph-viewport.ts` + - graph viewport sizing and centering + +### Data flow + +- Repository selection flows: + - `RepositorySelector` -> `useRepositorySession` + - `useRepositorySession` -> `RepositoryHandler` + - active repository/demo payload -> `VisualizerWorkspace` +- Visualizer flows: + - `VisualizerWorkspace` -> `DetailContent`, graph canvases, bottom bar + - detail-panel actions update visualizer hooks + - visualizer hooks feed graph/history/compare renderers + +### API interaction patterns + +- Repository interaction is abstracted behind `lib/repository-handler.ts`. +- Demo/mock behavior is provided through: + - `lib/mock-api.ts` + - `lib/demo-visualizer-fixture.ts` +- Repository connection loading/error handling is currently local-state driven: + - async handlers set inline `isLoading` / `error` state in `use-repository-session.ts` + - the repository selector renders that state directly rather than using a global error layer +- Avoid calling mock/demo helpers directly from unrelated UI components if a + higher-level repository/session abstraction already exists. + +### Environment and config + +- Runtime/frontend config is currently code-driven rather than env-driven. +- Start by checking: + - `next.config.mjs` + - `app/` + - `lib/repository-handler.ts` +- Do not assume there is an existing `.env` contract unless you confirm it first. + +### Type organization + +- Shared JSON/domain types: + - `lib/types.ts` +- Demo visualizer feature types: + - `lib/demo-visualizer.types.ts` +- Visualizer feature-specific UI/state types: + - `screens/visualizer/*.types.ts` +- Keep types close to the feature that owns them unless they are clearly shared. + +## Development Conventions + +### Naming conventions + +- Components: PascalCase exports +- Files: kebab-case is the current active convention for feature files and hooks +- Hooks: `use-*.ts` or `use-*.tsx` +- Visualizer files currently use descriptive kebab-case names, e.g. + - `use-visualizer-layout.ts` + - `policy-graph-canvas.tsx` +- Types files use `*.types.ts` +- Constants files use `*.constants.ts` +- Utilities files use `*.utils.ts` + +### File placement rules + +- New route/feature surface: + - put it in `screens//` +- New reusable app-wide or feature-shared component: + - put it in `components/` +- New visualizer-only reusable component: + - prefer `components/visualizer/` +- New screen-local component: + - keep it under the feature folder in `screens/visualizer/` if it is not broadly reusable +- New hook: + - if it is feature-specific, put it next to that feature or in `hooks/visualizer/` + - if it is app/shared, put it in `hooks/` +- New type: + - feature-specific: nearest `*.types.ts` + - broadly shared: `lib/types.ts` or a focused shared types file in `lib/` +- New utility: + - feature-specific: nearest `*.utils.ts` + - broadly shared: `lib/` +- New fixture/demo data: + - active demo/runtime fixture data goes in `lib/demo-visualizer-fixture.ts` + - archived/historical examples stay in `archive/` + +## Code Style Guidelines + +- Use TypeScript strictly; avoid `any`. +- Prefer explicit interfaces/types when state or props are non-trivial. +- Keep React components focused on rendering/composition. +- Extract a hook when a component starts owning: + - multiple related effects + - derived state plus event handlers + - synchronization between multiple panels/tabs/views +- Extract a reusable component when UI is repeated or when a file becomes hard to scan. +- Prefer extending current visualizer patterns instead of introducing a new state model. +- Add comments only for: + - architectural decisions + - non-obvious state synchronization + - graph/layout math + - diff-color logic + - persistence/auto-collapse behavior + - temporary workarounds +- Avoid comments that merely restate the code. +- Before considering a coding task done, always run: + - `npm run lint` + - `npx tsc --noEmit` + +## Important Architectural Decisions + +- Reserved history/compare tabs: + - `history` and `compare` are stable workspace concepts, not disposable one-off tabs + - see `screens/visualizer/use-visualizer-tabs.ts` +- Layout persistence: + - panel sizing is managed through `react-resizable-panels` + - responsive auto-collapse exists, but manual collapse/expand is still respected + - see `screens/visualizer/use-visualizer-layout.ts` +- State synchronization: + - history sort/selection drives the detail panel, timeline strip, and history canvases together + - compare version state drives the compare tab label and compare canvas payload +- Graph/layout algorithms: + - lane centering and principal spread are computed in `screens/visualizer/policy-graph.utils.ts` + - keep principals inside the dotted boundary while preserving readable spacing +- Diff/comparison logic: + - graph colors are derived from explicit change-type semantics + - do not color whole subtrees when only leaf values changed +- Persistence/UX tradeoff: + - the current workspace prefers local hook composition over a centralized global store + - this keeps the feature easier to refactor but means state ownership must stay disciplined + +## Testing Expectations + +- Current validation commands: + - `npm run lint` + - `npx tsc --noEmit` +- There is no broad automated UI test suite yet. +- When making changes, manually verify: + - repository selector -> workspace transition + - demo launch and disconnect/reconnect flow + - history tab creation and commit strip sync + - compare tab generation and diff highlighting + - graph dragging/zooming/overlap behavior + - responsive panel collapse behavior + +## Contributor Guidance + +Before making changes: + +1. Understand the feature boundary first. +2. Reuse existing abstractions before creating new ones. +3. Preserve existing user workflows. +4. Avoid introducing duplicate utilities, hooks, or types. +5. Prefer extending existing patterns over inventing new ones. + +When preparing commits: + +- Use the existing branch unless the task explicitly asks for a new one. +- Prefer small, focused commits. +- If DCO is required for the branch/PR, use signed commits/messages such as: + - `git commit -s -m "your message"` +- If you rewrite commit history, manually verify the visualizer flow again before pushing. + +## Common Pitfalls + +- Do not treat `archive/` as active architecture. +- Do not add a top-level `pages/` directory in `frontend/`. +- Be careful with `.next/` errors after route/file moves; stale generated types can mislead you. +- Common merge-conflict hotspots: + - `screens/visualizer/visualizer-workspace.tsx` + - `screens/visualizer/use-visualizer-workspace.ts` + - `screens/visualizer/use-visualizer-tabs.ts` + - `lib/demo-visualizer-fixture.ts` +- Why these are hotspots: + - workspace composition and tab state are touched by most visualizer features + - demo fixture data is shared across multiple panels/canvases and often changes with UI updates +- Easy-to-misuse areas: + - history/compare tab synchronization + - drag/viewport math in policy graph canvases + - demo data vs shared type ownership + +## Safe Refactoring Guidelines + +Usually safe: + +- splitting large render components into smaller presentational pieces +- extracting feature-local hooks from a large feature hook +- moving feature-local types into `*.types.ts` +- moving repeated visualizer UI into `components/visualizer/` + +Requires extra caution: + +- changing repository entry flow in `app/page.tsx` and `use-repository-session.ts` +- changing history/compare reserved-tab behavior +- changing graph drag boundaries or lane/principal spacing +- changing diff-color semantics +- changing panel persistence or collapse behavior + +After refactoring, manually verify: + +- home -> repo/demo -> workspace flow +- history tab behavior +- compare tab behavior +- policy graph rendering and drag behavior +- responsive workspace layout diff --git a/frontend/README.md b/frontend/README.md index 9e56c95..e94a01d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,7 +1,7 @@ # gittuf Metadata Visualizer (Frontend) -This directory contains the frontend for the gittuf metadata visualizer, written -in Next.js. +This directory contains the active frontend for the gittuf metadata visualizer, +written in Next.js. ## Features @@ -19,18 +19,16 @@ in Next.js. added, removed, modified, and unchanged diff highlighting. - **Metadata and Settings Panels**: Review metadata status and summary views, then adjust visible node/detail settings for the workspace. -- **Interactive Playground**: Use the `/playground` route for the trust graph - walkthrough, simulator controls, analysis, and glossary experience. ## Tech Stack -- **Framework**: Next.js 15 (App Router) +- **Framework**: Next.js 16 (App Router) - **UI**: Tailwind CSS, shadcn UI components, Radix UI - **Visualization**: ReactFlow, Chart.js & react-chartjs-2, Framer Motion - **Icons**: Lucide React - **Language**: TypeScript - **Linting**: ESLint (Next.js Core Web Vitals, TypeScript) -- **Testing**: (Add when available) +- **Testing**: Typecheck + lint today, broader automated UI tests still to be added ## Getting Started @@ -73,12 +71,9 @@ frontend/ │ ├── globals.css # Tailwind & global styles │ ├── layout.tsx # Root layout & metadata │ ├── page.tsx # Home route: repository entry + visualizer workspace -│ └── playground/ -│ └── page.tsx # Interactive visualizer tools route ├── screens/ │ ├── repository/ │ │ └── repository-selector.tsx # Repository selection screen -│ ├── playground/ # Route-sized playground sections and trust graph │ └── visualizer/ │ ├── visualizer-workspace.tsx │ ├── policy-graph-canvas.tsx @@ -91,13 +86,11 @@ frontend/ │ ├── ui/ # shadcn/Radix-based UI primitives │ └── visualizer/ # Shared visualizer controls and primitives ├── hooks/ -│ ├── explorer/ # Repository explorer hooks │ └── visualizer/ # Visualizer-specific hooks -├── lib/ # Utilities, constants, demo data, and API helpers -├── archive/ # Older and currently unused page/component implementations from a previous version +├── lib/ # Utilities, constants, demo fixtures, and API helpers +├── archive/ # Non-runtime historical code only; do not treat this as active architecture ├── public/ # Static assets served by Next.js ├── assets/ # Imported image assets used by the UI -├── fixtures/ # Simulator fixture data ├── components.json # shadcn config ├── next.config.mjs ├── package.json @@ -110,5 +103,19 @@ frontend/ repository, or launch the demo workspace. 2. Explore the visualizer workspace, including the graph canvas, history strip, and detail panel tabs. -3. Open `/playground` to use the interactive visualizer playground and trust - graph walkthrough. + +## Contributor Notes + +- Active runtime code lives in `app/`, `screens/`, `components/`, `hooks/`, + and `lib/`. +- `archive/` is intentionally kept only for historical reference and is not + part of the active frontend architecture. +- The current home flow is: + `app/page.tsx` -> `hooks/use-repository-session.ts` -> + `screens/repository/repository-selector.tsx` -> + `screens/visualizer/visualizer-workspace.tsx` +- The visualizer feature is organized by responsibility: + `use-visualizer-layout.ts`, `use-visualizer-tabs.ts`, + `use-visualizer-history-compare.ts`, and `use-graph-viewport.ts` + coordinate the workspace state, while the policy graph renderer is split + between canvas composition, SVG rendering, and per-lane rendering helpers. diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 7880f4e..aaacb9c 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -4,7 +4,7 @@ import RepositorySelector from "@/screens/repository/repository-selector" import VisualizerWorkspace from "@/screens/visualizer/visualizer-workspace" import Header from "@/components/app/header" -import { useGittufExplorer } from "@/hooks/use-gittuf-explorer" +import { useRepositorySession } from "@/hooks/use-repository-session" export default function Home() { const { @@ -17,7 +17,7 @@ export default function Home() { handleTryDemo, handleRepositorySelect, handleRepositoryRefresh, - } = useGittufExplorer() + } = useRepositorySession() const isWorkspaceView = Boolean(currentRepository && !showRepositorySelector) diff --git a/frontend/app/playground/page.tsx b/frontend/archive/playground/app/page.tsx similarity index 100% rename from frontend/app/playground/page.tsx rename to frontend/archive/playground/app/page.tsx diff --git a/frontend/components/common/status-card.tsx b/frontend/archive/playground/components/status-card.tsx similarity index 100% rename from frontend/components/common/status-card.tsx rename to frontend/archive/playground/components/status-card.tsx diff --git a/frontend/components/common/story-modal.tsx b/frontend/archive/playground/components/story-modal.tsx similarity index 100% rename from frontend/components/common/story-modal.tsx rename to frontend/archive/playground/components/story-modal.tsx diff --git a/frontend/fixtures/fixture-allowed.json b/frontend/archive/playground/fixtures/fixture-allowed.json similarity index 100% rename from frontend/fixtures/fixture-allowed.json rename to frontend/archive/playground/fixtures/fixture-allowed.json diff --git a/frontend/fixtures/fixture-blocked.json b/frontend/archive/playground/fixtures/fixture-blocked.json similarity index 100% rename from frontend/fixtures/fixture-blocked.json rename to frontend/archive/playground/fixtures/fixture-blocked.json diff --git a/frontend/hooks/use-gittuf-simulator.ts b/frontend/archive/playground/hooks/use-gittuf-simulator.ts similarity index 100% rename from frontend/hooks/use-gittuf-simulator.ts rename to frontend/archive/playground/hooks/use-gittuf-simulator.ts diff --git a/frontend/lib/simulator-types.ts b/frontend/archive/playground/lib/simulator-types.ts similarity index 100% rename from frontend/lib/simulator-types.ts rename to frontend/archive/playground/lib/simulator-types.ts diff --git a/frontend/screens/playground/simulator-analysis.tsx b/frontend/archive/playground/screens/playground/simulator-analysis.tsx similarity index 100% rename from frontend/screens/playground/simulator-analysis.tsx rename to frontend/archive/playground/screens/playground/simulator-analysis.tsx diff --git a/frontend/screens/playground/simulator-config-modal.tsx b/frontend/archive/playground/screens/playground/simulator-config-modal.tsx similarity index 100% rename from frontend/screens/playground/simulator-config-modal.tsx rename to frontend/archive/playground/screens/playground/simulator-config-modal.tsx diff --git a/frontend/screens/playground/simulator-controls.tsx b/frontend/archive/playground/screens/playground/simulator-controls.tsx similarity index 100% rename from frontend/screens/playground/simulator-controls.tsx rename to frontend/archive/playground/screens/playground/simulator-controls.tsx diff --git a/frontend/screens/playground/simulator-glossary.tsx b/frontend/archive/playground/screens/playground/simulator-glossary.tsx similarity index 100% rename from frontend/screens/playground/simulator-glossary.tsx rename to frontend/archive/playground/screens/playground/simulator-glossary.tsx diff --git a/frontend/screens/playground/simulator-graph.tsx b/frontend/archive/playground/screens/playground/simulator-graph.tsx similarity index 100% rename from frontend/screens/playground/simulator-graph.tsx rename to frontend/archive/playground/screens/playground/simulator-graph.tsx diff --git a/frontend/screens/playground/simulator-header.tsx b/frontend/archive/playground/screens/playground/simulator-header.tsx similarity index 100% rename from frontend/screens/playground/simulator-header.tsx rename to frontend/archive/playground/screens/playground/simulator-header.tsx diff --git a/frontend/screens/playground/trust-graph.tsx b/frontend/archive/playground/screens/playground/trust-graph.tsx similarity index 100% rename from frontend/screens/playground/trust-graph.tsx rename to frontend/archive/playground/screens/playground/trust-graph.tsx diff --git a/frontend/assets/spinner.png b/frontend/assets/spinner.png new file mode 100644 index 0000000000000000000000000000000000000000..38df3fd7c5de8dbac19e148435b2f0b81fc9ceef GIT binary patch literal 1150 zcmV-^1cCdBP)@~0drDELIAGL9O(c600d`2O+f$vv5yPuX$qV zfV|}mr~o<$>A+MFF*KkQfSrHEiLo`3y(is@W#+yakMYSq-F>#C)!rQ=GJcA^j3_rJ8YUt>k*0aKUdeZ$+E0U z61B9wrMwa8FRIwG&7`ayk}Bf!3JFQC8kmh$~f2LNp~Y!2zd+EW|Wd_4@cV$}c6K*OpX% zo;u=#IF3);mbZz6Qs3EgbmkCsKC--ki(>!2P4$T~tB3sL}4Hm8tX+x8fem|i!< z0f*t76xiS2pVI)6nBLg~p{zqvU|XsjRIlpikc^U;v@UMxREwu{=d2dq@3I%@Twl99R&6 zKF!gm+c6|vP#j#_gs`D`d3nhwf+PlgD(@`#ra0R0IHVtw0nm<&;!&QC#yf_gf(XfR zE0a?ss*DJW66K{O6-sm5+BD_hyHfdiyXx`OW$Jj<3(0X0#d;2&I>1K<`gCh$R&pYd zNF)*ob7+jhO#R646tT!+h^1sab&@Bta_lpiOdd4ix#;71^}BWieD@gFJ0m4#v)MJC z=4(YNvz4Pw$Ztz+@ZhAMBM==<5z(fkvMCyq*`kQrmH*MoDxluB%tv%ty+sL=WRIi( z4za;zEB6v5oy3I>X`Rq-hMH5_YsOv7nQpZlw>SqMd*)vookS@}YN}71gC9axm(^9# z=S;TbxWzg6A*OZ*iPDhdKa;hTi?5d2vc#gaB*jEiR-a{P8@#uqiV#^8fOHue2X?M) zyA??lQdurwC2Q}1)0(6Tp)8eUXnDQds-%i-vIbj*W?fQ6lPvsQaDONO@#1U0Q2Dca zT{c_f0SlMk8~ -
- gittuf +
+
+ gittuf
{hasCommits && ( diff --git a/frontend/components/ui/use-mobile.tsx b/frontend/components/ui/use-mobile.tsx deleted file mode 100644 index 2b0fe1d..0000000 --- a/frontend/components/ui/use-mobile.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import * as React from "react" - -const MOBILE_BREAKPOINT = 768 - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(undefined) - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - } - mql.addEventListener("change", onChange) - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) - return () => mql.removeEventListener("change", onChange) - }, []) - - return !!isMobile -} diff --git a/frontend/components/ui/use-toast.ts b/frontend/components/ui/use-toast.ts deleted file mode 100644 index fdbb4f8..0000000 --- a/frontend/components/ui/use-toast.ts +++ /dev/null @@ -1,185 +0,0 @@ -"use client" - -// Inspired by react-hot-toast library -import * as React from "react" - -import type { - ToastActionElement, - ToastProps, -} from "@/components/ui/toast" - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() -} - -type Action = - | { - type: "ADD_TOAST" - toast: ToasterToast - } - | { - type: "UPDATE_TOAST" - toast: Partial - } - | { - type: "DISMISS_TOAST" - toastId?: ToasterToast["id"] - } - | { - type: "REMOVE_TOAST" - toastId?: ToasterToast["id"] - } - -interface State { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - -export const reducer = (state: State, action: Action): State => { - switch (action.type) { - case "ADD_TOAST": - return { - ...state, - toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } - - case "UPDATE_TOAST": - return { - ...state, - toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t - ), - } - - case "DISMISS_TOAST": { - const { toastId } = action - - // ! Side effects ! - This could be extracted into a dismissToast() action, - // but I'll keep it here for simplicity - if (toastId) { - addToRemoveQueue(toastId) - } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) - } - - return { - ...state, - toasts: state.toasts.map((t) => - t.id === toastId || toastId === undefined - ? { - ...t, - open: false, - } - : t - ), - } - } - case "REMOVE_TOAST": - if (action.toastId === undefined) { - return { - ...state, - toasts: [], - } - } - return { - ...state, - toasts: state.toasts.filter((t) => t.id !== action.toastId), - } - } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (props: ToasterToast) => - dispatch({ - type: "UPDATE_TOAST", - toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) - - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss() - }, - }, - }) - - return { - id: id, - dismiss, - update, - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - React.useEffect(() => { - listeners.push(setState) - return () => { - const index = listeners.indexOf(setState) - if (index > -1) { - listeners.splice(index, 1) - } - } - }, [state]) - - return { - ...state, - toast, - dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } -} - -export { useToast, toast } diff --git a/frontend/components/visualizer/detail/detail-action-button.tsx b/frontend/components/visualizer/detail/detail-action-button.tsx new file mode 100644 index 0000000..d90e045 --- /dev/null +++ b/frontend/components/visualizer/detail/detail-action-button.tsx @@ -0,0 +1,38 @@ +"use client"; + +import Image from "next/image"; +import spinnerIcon from "@/assets/spinner.png"; +import { detailColors } from "@/components/visualizer/detail/detail-primitives.types"; + +interface DetailActionButtonProps { + label: string; + onClick: () => void; + loading?: boolean; + className?: string; +} + +export function DetailActionButton({ + label, + onClick, + loading = false, + className = "", +}: DetailActionButtonProps) { + return ( + + ); +} diff --git a/frontend/components/visualizer/detail/detail-display-cards.tsx b/frontend/components/visualizer/detail/detail-display-cards.tsx new file mode 100644 index 0000000..fdb40d2 --- /dev/null +++ b/frontend/components/visualizer/detail/detail-display-cards.tsx @@ -0,0 +1,113 @@ +"use client"; + +import Image from "next/image"; +import commitIcon from "@/assets/commit.png"; +import userIcon from "@/assets/user.png"; +import { + detailColors, + type MetricCardData, +} from "@/components/visualizer/detail/detail-primitives.types"; +import { SearchHighlightText } from "@/components/visualizer/detail/detail-text-sections"; + +export function SummaryMetricGrid({ + items, + searchQuery, +}: { + items: MetricCardData[]; + searchQuery?: string; +}) { + return ( +
+ {items.map((item) => ( +
+ + +
+ ))} +
+ ); +} + +export function QueryUserCard({ + name, + searchQuery, +}: { + name: string; + searchQuery?: string; +}) { + return ( +
+
+ +
+ +
+ ); +} + +export function CommitHistoryItem({ + commitId, + message, + author, + searchQuery = "", + isSelected = false, + isTouched = false, + onSelect, + onTouch, +}: { + commitId: number; + message: string; + author: string; + searchQuery?: string; + isSelected?: boolean; + isTouched?: boolean; + onSelect: (commitId: number) => void; + onTouch: (commitId: number | null) => void; +}) { + return ( + + ); +} diff --git a/frontend/components/visualizer/detail/detail-primitives.types.ts b/frontend/components/visualizer/detail/detail-primitives.types.ts new file mode 100644 index 0000000..9ba3ba4 --- /dev/null +++ b/frontend/components/visualizer/detail/detail-primitives.types.ts @@ -0,0 +1,20 @@ +"use client"; + +import type { StaticImageData } from "next/image"; + +export interface SelectOption { + label: string; + value?: string; + icon?: StaticImageData; +} + +export interface MetricCardData { + value: string; + label: string; +} + +export const detailColors = { + bullet: "var(--secondary-color)", + chip: "var(--primary-color)", + summaryCard: "var(--background-color)", +} as const; diff --git a/frontend/components/visualizer/detail/detail-select-controls.tsx b/frontend/components/visualizer/detail/detail-select-controls.tsx new file mode 100644 index 0000000..ca4192e --- /dev/null +++ b/frontend/components/visualizer/detail/detail-select-controls.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import Image from "next/image"; +import arrowDropDownIcon from "@/assets/arrow_drop_down.png"; +import type { SelectOption } from "@/components/visualizer/detail/detail-primitives.types"; +import { + SectionBulletLabel, + SectionDivider, + ValueChip, +} from "@/components/visualizer/detail/detail-text-sections"; + +export function SelectField({ + options, + selectedLabel, + displayLabel, + chips = [], + fullWidth = false, + className = "", + onChange, +}: { + options: SelectOption[]; + selectedLabel?: string; + displayLabel?: string; + chips?: string[]; + fullWidth?: boolean; + className?: string; + onChange?: (label: string) => void; +}) { + const [isOpen, setIsOpen] = useState(false); + const [touchedOptionValue, setTouchedOptionValue] = useState(null); + const containerRef = useRef(null); + const getOptionValue = (option: SelectOption) => option.value ?? option.label; + const selectedOption = useMemo( + () => + options.find((option) => getOptionValue(option) === selectedLabel) ?? options[0], + [options, selectedLabel], + ); + + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + setTouchedOptionValue(null); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, []); + + return ( +
+
+ + {isOpen ? ( +
+
+
+ {options.map((option) => { + const optionValue = getOptionValue(option); + const isSelected = optionValue === getOptionValue(selectedOption); + const isTouched = touchedOptionValue === optionValue; + + return ( + + ); + })} +
+ {options.length > 4 ? ( + <> +
+
+ + ) : null} +
+
+ ) : null} +
+ {chips.length > 0 ? ( +
+ {chips.map((chip, index) => ( + + ))} +
+ ) : null} +
+ ); +} + +export function InlineSelectRow({ + label, + options, + chips = [], + selectedLabel, + onChange, + searchQuery, +}: { + label: string; + options: SelectOption[]; + chips?: string[]; + selectedLabel?: string; + onChange?: (label: string) => void; + searchQuery?: string; +}) { + return ( +
+
+ + +
+ {chips.length > 0 ? ( +
+
+ {chips.map((chip, index) => ( + + ))} +
+
+ ) : null} + +
+ ); +} diff --git a/frontend/components/visualizer/detail/detail-setting-controls.tsx b/frontend/components/visualizer/detail/detail-setting-controls.tsx new file mode 100644 index 0000000..95aec7b --- /dev/null +++ b/frontend/components/visualizer/detail/detail-setting-controls.tsx @@ -0,0 +1,124 @@ +"use client"; + +import Image, { type StaticImageData } from "next/image"; +import emptyIcon from "@/assets/empty.png"; +import greenCheckboxIcon from "@/assets/green_check_box.png"; +import { SearchHighlightText } from "@/components/visualizer/detail/detail-text-sections"; + +export function SegmentedControl({ + options, + selected, + onChange, +}: { + options: string[]; + selected: string; + onChange?: (option: string) => void; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +export function ToggleRow({ + label, + enabled, + onToggle, + searchQuery, +}: { + label: string; + enabled: boolean; + onToggle?: () => void; + searchQuery?: string; +}) { + return ( + + ); +} + +export function CheckboxRow({ + label, + checked, + onToggle, + searchQuery, +}: { + label: string; + checked: boolean; + onToggle?: () => void; + searchQuery?: string; +}) { + return ( + + ); +} + +export function StatusRow({ + icon, + label, + value, + searchQuery, +}: { + icon: StaticImageData; + label: string; + value?: string; + searchQuery?: string; +}) { + return ( +
+ + +
+ ); +} diff --git a/frontend/components/visualizer/detail/detail-text-sections.tsx b/frontend/components/visualizer/detail/detail-text-sections.tsx new file mode 100644 index 0000000..5a44dad --- /dev/null +++ b/frontend/components/visualizer/detail/detail-text-sections.tsx @@ -0,0 +1,152 @@ +"use client"; + +import type React from "react"; +import { detailColors } from "@/components/visualizer/detail/detail-primitives.types"; + +export function SearchHighlightText({ + text, + query, + className = "", +}: { + text: string; + query?: string; + className?: string; +}) { + const normalizedQuery = query?.trim().toLowerCase() ?? ""; + + if (!normalizedQuery) { + return {text}; + } + + const lowerText = text.toLowerCase(); + const parts: Array<{ value: string; matched: boolean }> = []; + let currentIndex = 0; + + while (currentIndex < text.length) { + const matchIndex = lowerText.indexOf(normalizedQuery, currentIndex); + + if (matchIndex < 0) { + parts.push({ value: text.slice(currentIndex), matched: false }); + break; + } + + if (matchIndex > currentIndex) { + parts.push({ + value: text.slice(currentIndex, matchIndex), + matched: false, + }); + } + + parts.push({ + value: text.slice(matchIndex, matchIndex + normalizedQuery.length), + matched: true, + }); + + currentIndex = matchIndex + normalizedQuery.length; + } + + return ( + + {parts.map((part, index) => + part.matched ? ( + + {part.value} + + ) : ( + {part.value} + ), + )} + + ); +} + +export function SectionDivider() { + return
; +} + +export function SectionBulletLabel({ + label, + searchQuery, +}: { + label: string; + searchQuery?: string; +}) { + return ( +
+ + +
+ ); +} + +export function ValueChip({ + label, + searchQuery, +}: { + label: string; + searchQuery?: string; +}) { + return ( +
+ +
+ ); +} + +export function PanelSection({ + label, + children, + className = "", + searchQuery, +}: { + label: string; + children: React.ReactNode; + className?: string; + searchQuery?: string; +}) { + return ( +
+ + {children} + +
+ ); +} + +export function StaticValueRow({ + label, + value, + searchQuery, +}: { + label: string; + value: string; + searchQuery?: string; +}) { + return ( +
+
+ + +
+ +
+ ); +} diff --git a/frontend/components/visualizer/detail/workspace-detail-primitives.tsx b/frontend/components/visualizer/detail/workspace-detail-primitives.tsx index d892c97..4d14c4e 100644 --- a/frontend/components/visualizer/detail/workspace-detail-primitives.tsx +++ b/frontend/components/visualizer/detail/workspace-detail-primitives.tsx @@ -1,583 +1,31 @@ "use client"; -import type React from "react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import Image, { type StaticImageData } from "next/image"; -import arrowDropDownIcon from "@/assets/arrow_drop_down.png"; -import commitIcon from "@/assets/commit.png"; -import emptyIcon from "@/assets/empty.png"; -import greenCheckboxIcon from "@/assets/green_check_box.png"; -import userIcon from "@/assets/user.png"; - -export interface SelectOption { - label: string; - value?: string; - icon?: StaticImageData; -} - -export interface MetricCardData { - value: string; - label: string; -} - -export const detailColors = { - bullet: "var(--secondary-color)", - chip: "var(--primary-color)", - summaryCard: "var(--background-color)", -} as const; - -export function SearchHighlightText({ - text, - query, - className = "", -}: { - text: string; - query?: string; - className?: string; -}) { - const normalizedQuery = query?.trim().toLowerCase() ?? ""; - - if (!normalizedQuery) { - return {text}; - } - - const lowerText = text.toLowerCase(); - const parts: Array<{ value: string; matched: boolean }> = []; - let currentIndex = 0; - - while (currentIndex < text.length) { - const matchIndex = lowerText.indexOf(normalizedQuery, currentIndex); - - if (matchIndex < 0) { - parts.push({ value: text.slice(currentIndex), matched: false }); - break; - } - - if (matchIndex > currentIndex) { - parts.push({ - value: text.slice(currentIndex, matchIndex), - matched: false, - }); - } - - parts.push({ - value: text.slice(matchIndex, matchIndex + normalizedQuery.length), - matched: true, - }); - - currentIndex = matchIndex + normalizedQuery.length; - } - - return ( - - {parts.map((part, index) => - part.matched ? ( - - {part.value} - - ) : ( - {part.value} - ), - )} - - ); -} - -export function SectionDivider() { - return
; -} - -export function SectionBulletLabel({ - label, - searchQuery, -}: { - label: string; - searchQuery?: string; -}) { - return ( -
- - -
- ); -} - -export function ValueChip({ - label, - searchQuery, -}: { - label: string; - searchQuery?: string; -}) { - return ( -
- -
- ); -} - -export function PanelSection({ - label, - children, - className = "", - searchQuery, -}: { - label: string; - children: React.ReactNode; - className?: string; - searchQuery?: string; -}) { - return ( -
- - {children} - -
- ); -} - -export function StaticValueRow({ - label, - value, - searchQuery, -}: { - label: string; - value: string; - searchQuery?: string; -}) { - return ( -
-
- - -
- -
- ); -} - -export function SelectField({ - options, - selectedLabel, - displayLabel, - chips = [], - fullWidth = false, - className = "", - onChange, -}: { - options: SelectOption[]; - selectedLabel?: string; - displayLabel?: string; - chips?: string[]; - fullWidth?: boolean; - className?: string; - onChange?: (label: string) => void; -}) { - const [isOpen, setIsOpen] = useState(false); - const [touchedOptionValue, setTouchedOptionValue] = useState(null); - const containerRef = useRef(null); - const getOptionValue = (option: SelectOption) => option.value ?? option.label; - const selectedOption = useMemo( - () => - options.find((option) => getOptionValue(option) === selectedLabel) ?? options[0], - [options, selectedLabel], - ); - - useEffect(() => { - const handlePointerDown = (event: MouseEvent) => { - if (!containerRef.current?.contains(event.target as Node)) { - setIsOpen(false); - setTouchedOptionValue(null); - } - }; - - document.addEventListener("mousedown", handlePointerDown); - - return () => { - document.removeEventListener("mousedown", handlePointerDown); - }; - }, []); - - return ( -
-
- - {isOpen ? ( -
-
-
- {options.map((option) => { - const optionValue = getOptionValue(option); - const isSelected = optionValue === getOptionValue(selectedOption); - const isTouched = touchedOptionValue === optionValue; - - return ( - - ); - })} -
- {options.length > 4 ? ( - <> -
-
- - ) : null} -
-
- ) : null} -
- {chips.length > 0 ? ( -
- {chips.map((chip, index) => ( - - ))} -
- ) : null} -
- ); -} - -export function InlineSelectRow({ - label, - options, - chips = [], - selectedLabel, - onChange, - searchQuery, -}: { - label: string; - options: SelectOption[]; - chips?: string[]; - selectedLabel?: string; - onChange?: (label: string) => void; - searchQuery?: string; -}) { - return ( -
-
- - -
- {chips.length > 0 ? ( -
-
- {chips.map((chip, index) => ( - - ))} -
-
- ) : null} - -
- ); -} - -export function SummaryMetricGrid({ - items, - searchQuery, -}: { - items: MetricCardData[]; - searchQuery?: string; -}) { - return ( -
- {items.map((item) => ( -
- - -
- ))} -
- ); -} - -export function QueryUserCard({ - name, - searchQuery, -}: { - name: string; - searchQuery?: string; -}) { - return ( -
-
- -
- -
- ); -} - -export function CommitHistoryItem({ - commitId, - message, - author, - searchQuery = "", - isSelected = false, - isTouched = false, - onSelect, - onTouch, -}: { - commitId: number; - message: string; - author: string; - searchQuery?: string; - isSelected?: boolean; - isTouched?: boolean; - onSelect: (commitId: number) => void; - onTouch: (commitId: number | null) => void; -}) { - return ( - - ); -} - -export function SegmentedControl({ - options, - selected, - onChange, -}: { - options: string[]; - selected: string; - onChange?: (option: string) => void; -}) { - return ( -
- {options.map((option) => ( - - ))} -
- ); -} - -export function ToggleRow({ - label, - enabled, - onToggle, - searchQuery, -}: { - label: string; - enabled: boolean; - onToggle?: () => void; - searchQuery?: string; -}) { - return ( - - ); -} - -export function CheckboxRow({ - label, - checked, - onToggle, - searchQuery, -}: { - label: string; - checked: boolean; - onToggle?: () => void; - searchQuery?: string; -}) { - return ( - - ); -} - -export function StatusRow({ - icon, - label, - value, - searchQuery, -}: { - icon: StaticImageData; - label: string; - value?: string; - searchQuery?: string; -}) { - return ( -
- - -
- ); -} +export type { + MetricCardData, + SelectOption, +} from "@/components/visualizer/detail/detail-primitives.types"; +export { detailColors } from "@/components/visualizer/detail/detail-primitives.types"; +export { + PanelSection, + SearchHighlightText, + SectionBulletLabel, + SectionDivider, + StaticValueRow, + ValueChip, +} from "@/components/visualizer/detail/detail-text-sections"; +export { + InlineSelectRow, + SelectField, +} from "@/components/visualizer/detail/detail-select-controls"; +export { + CommitHistoryItem, + QueryUserCard, + SummaryMetricGrid, +} from "@/components/visualizer/detail/detail-display-cards"; +export { + CheckboxRow, + SegmentedControl, + StatusRow, + ToggleRow, +} from "@/components/visualizer/detail/detail-setting-controls"; +export { DetailActionButton } from "@/components/visualizer/detail/detail-action-button"; diff --git a/frontend/components/visualizer/workspace-action-button.tsx b/frontend/components/visualizer/workspace-action-button.tsx index 180bdf2..951383e 100644 --- a/frontend/components/visualizer/workspace-action-button.tsx +++ b/frontend/components/visualizer/workspace-action-button.tsx @@ -1,12 +1,14 @@ "use client"; import Image, { type StaticImageData } from "next/image"; +import spinnerIcon from "@/assets/spinner.png"; import { Button } from "@/components/ui/button"; interface WorkspaceActionButtonProps { label: string; onClick: () => void; disabled?: boolean; + loading?: boolean; icon?: StaticImageData; size?: "default" | "sm"; } @@ -15,6 +17,7 @@ export function WorkspaceActionButton({ label, onClick, disabled = false, + loading = false, icon, size = "sm", }: WorkspaceActionButtonProps) { @@ -24,10 +27,18 @@ export function WorkspaceActionButton({ variant="outline" size={size} onClick={onClick} - disabled={disabled} - className="rounded-[5px] border-[var(--tertiary-color)] bg-white px-3 text-[14px] font-normal text-black hover:bg-[var(--gray-highlight)]" + disabled={disabled || loading} + className="rounded-[5px] border-(--tertiary-color) bg-white px-3 text-[14px] font-normal text-black hover:bg-(--gray-highlight)" > - {icon ? : null} + {loading ? ( + + ) : icon ? ( + + ) : null} {label} ); diff --git a/frontend/components/visualizer/workspace-bottom-bar.tsx b/frontend/components/visualizer/workspace-bottom-bar.tsx index 2ce3b53..399f0d9 100644 --- a/frontend/components/visualizer/workspace-bottom-bar.tsx +++ b/frontend/components/visualizer/workspace-bottom-bar.tsx @@ -56,12 +56,12 @@ export function WorkspaceBottomBar({ }; return ( -
+
-
+
{tabs.map((tab) => { const isActive = tab.id === activeTabId; @@ -72,10 +72,10 @@ export function WorkspaceBottomBar({ return (
diff --git a/frontend/components/visualizer/workspace-detail-toggle.tsx b/frontend/components/visualizer/workspace-detail-toggle.tsx index c333483..1c01bd9 100644 --- a/frontend/components/visualizer/workspace-detail-toggle.tsx +++ b/frontend/components/visualizer/workspace-detail-toggle.tsx @@ -21,7 +21,7 @@ export function WorkspaceDetailToggle({ diff --git a/frontend/screens/visualizer/panel-tabs/detail-compare.tsx b/frontend/screens/visualizer/panel-tabs/detail-compare.tsx index c0b283f..5518242 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-compare.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-compare.tsx @@ -1,15 +1,13 @@ "use client"; import Image from "next/image"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import emptyFileIcon from "@/assets/empty_file.png"; import swapVertIcon from "@/assets/swap_vert.png"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; import { - demoVisualizerData, - type DemoVisualizerData, -} from "@/lib/demo-visualizer-data"; -import { - detailColors, + DetailActionButton, PanelSection, SearchHighlightText, SectionBulletLabel, @@ -40,6 +38,7 @@ export function DetailPanelCompare({ onSwapVersions, onCompare, }: DetailPanelCompareProps) { + const [isComparing, setIsComparing] = useState(false); const compareData = workspaceData?.workspaceDetails.compare ?? demoVisualizerData.workspaceDetails.compare; @@ -69,7 +68,7 @@ export function DetailPanelCompare({ @@ -83,14 +82,17 @@ export function DetailPanelCompare({ />
- + { + setIsComparing(true); + window.requestAnimationFrame(() => { + onCompare(); + window.setTimeout(() => setIsComparing(false), 250); + }); + }} + />
{hasCompared ? (
@@ -100,7 +102,7 @@ export function DetailPanelCompare({
{index < 2 ? "✓" : "—"}{" "} diff --git a/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx b/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx index 9b997d3..d6eda16 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx @@ -1,13 +1,11 @@ "use client"; import { useState } from "react"; -import { - demoVisualizerData, - type DemoVisualizerData, -} from "@/lib/demo-visualizer-data"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; import type { RepositoryInfo } from "@/lib/repository-handler"; import { - detailColors, + DetailActionButton, InlineSelectRow, StaticValueRow, } from "@/components/visualizer/detail/workspace-detail-primitives"; @@ -16,6 +14,7 @@ interface DetailPanelGraphSourceProps { repository: RepositoryInfo; workspaceData?: DemoVisualizerData | null; onRegenerate: () => void; + isLoading?: boolean; searchQuery?: string; } @@ -23,6 +22,7 @@ export function DetailPanelGraphSource({ repository, workspaceData, onRegenerate, + isLoading = false, searchQuery, }: DetailPanelGraphSourceProps) { const graphSource = @@ -78,14 +78,11 @@ export function DetailPanelGraphSource({ searchQuery={searchQuery} />
- + loading={isLoading} + />
); diff --git a/frontend/screens/visualizer/panel-tabs/detail-history.tsx b/frontend/screens/visualizer/panel-tabs/detail-history.tsx index bb108f9..d85efaf 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-history.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-history.tsx @@ -4,10 +4,8 @@ import { useEffect } from "react"; import Image from "next/image"; import ascendingIcon from "@/assets/ascending.png"; import discendingIcon from "@/assets/discending.png"; -import { - demoVisualizerData, - type DemoVisualizerData, -} from "@/lib/demo-visualizer-data"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; import { useWorkspaceHistory } from "@/hooks/visualizer/use-workspace-history"; import { CommitHistoryItem, @@ -106,7 +104,7 @@ export function DetailPanelHistory({ selectedLabel={selectedSort} displayLabel={`Sort by: ${selectedSort}`} onChange={(value) => onSortChange(value as HistorySortField)} - className="w-[132px]" + className="w-33" /> @@ -168,8 +166,8 @@ export function DetailPanelHistory({ key={pageNumber} type="button" onClick={() => setCurrentPage(pageNumber)} - className={`rounded-[4px] px-2 py-1 ${ - isActive ? "text-black" : "text-[var(--dark-gray)]" + className={`rounded-sm px-2 py-1 ${ + isActive ? "text-black" : "text-(--dark-gray)" }`} style={isActive ? { backgroundColor: detailColors.bullet } : undefined} > @@ -181,7 +179,7 @@ export function DetailPanelHistory({ diff --git a/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx b/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx index 4bc6606..8432d51 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx @@ -1,9 +1,7 @@ "use client"; -import { - demoVisualizerData, - type DemoVisualizerData, -} from "@/lib/demo-visualizer-data"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; import completedIcon from "@/assets/completed.png"; import metadataIcon from "@/assets/metadata.png"; import { @@ -31,11 +29,11 @@ export function DetailPanelMetadata({ return (
-
+
{metadataData.policyFiles.map((item, index) => ( @@ -73,15 +71,15 @@ export function DetailPanelMetadata({
-
+
{metadataData.views.map((tab) => ( + />
{showResults ? ( <> diff --git a/frontend/screens/visualizer/panel-tabs/detail-settings.tsx b/frontend/screens/visualizer/panel-tabs/detail-settings.tsx index 09e7f51..fb50389 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-settings.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-settings.tsx @@ -1,13 +1,11 @@ "use client"; import { useState } from "react"; -import { - demoVisualizerData, - type DemoVisualizerData, -} from "@/lib/demo-visualizer-data"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; import { CheckboxRow, - detailColors, + DetailActionButton, PanelSection, SectionBulletLabel, SectionDivider, @@ -38,6 +36,7 @@ export function DetailPanelSettings({ const [selectedLayoutDirection, setSelectedLayoutDirection] = useState( settingsData.selectedLayoutDirection ?? defaultLayoutDirections[0], ); + const [isResetting, setIsResetting] = useState(false); const [visibleNodeTypes, setVisibleNodeTypes] = useState(defaultVisibleNodeTypes); const [labels, setLabels] = useState(defaultLabels); const [dataOptions, setDataOptions] = useState(defaultDataOptions); @@ -124,22 +123,23 @@ export function DetailPanelSettings({
- + />
); diff --git a/frontend/screens/visualizer/policy-graph-canvas.tsx b/frontend/screens/visualizer/policy-graph-canvas.tsx index 6e7c595..b647e95 100644 --- a/frontend/screens/visualizer/policy-graph-canvas.tsx +++ b/frontend/screens/visualizer/policy-graph-canvas.tsx @@ -2,11 +2,6 @@ import type React from "react"; import { useState } from "react"; -import Image from "next/image"; -import branchIcon from "@/assets/branch.png"; -import fileIcon from "@/assets/file.png"; -import usersIcon from "@/assets/Users.png"; -import userIcon from "@/assets/user.png"; import { boundary, branchBox, @@ -16,27 +11,23 @@ import { fileBox, layoutHeight, layoutWidth, - principalBox, roleBox, rowY, scrollPadding, } from "@/screens/visualizer/policy-graph.constants"; import { - compareStatusColors, - getEdgeColor, - getIconClassName, - getIconFilter, getLaneCenters, getLaneNodeChangeTypes, getNodeTextStyle, getPrincipalChangeType, getPrincipalOffsets, - getTextClassName, } from "@/screens/visualizer/policy-graph.utils"; import type { PolicyGraphCanvasVariant, PolicyGraphEdge, } from "@/screens/visualizer/policy-graph.types"; +import { PolicyGraphLaneColumn } from "@/screens/visualizer/policy-graph-lane-column"; +import { PolicyGraphSvg } from "@/screens/visualizer/policy-graph-svg"; interface PolicyGraphCanvasProps { graphId: string; @@ -223,69 +214,12 @@ export function PolicyGraphCanvas({ transform: `scale(${zoom})`, }} > - - - {[ - { id: "default", color: "var(--tertiary-color)" }, - ...Object.entries(compareStatusColors).map(([id, color]) => ({ - id, - color, - })), - ].map((marker) => ( - - - - ))} - - - - - {[...verticalPaths, ...principalPaths].map((path, index) => ( - - ))} - +
{ const centerX = laneCenters[laneIndex]; - const changeTypes = getLaneNodeChangeTypes(lane); - const lanePrincipals = - lane.principals ?? - principalNames.map((name) => ({ - name, - })); - const principalOffsets = getPrincipalOffsets(lanePrincipals.length); return ( -
-
- -
- {branchLabel} -
-
- -
- -
- {lane.pathLabel} -
-
- -
- -
- {lane.roleLabel} -
-
- {lane.approvals} -
-
- - {lanePrincipals.map((principal, principalIndex) => { - const center = centerX + principalOffsets[principalIndex]; - const principalChangeType = getPrincipalChangeType( - principal, - lane, - ); - - return ( -
- -
- {principal.name} -
-
- ); - })} -
+ ); })}
diff --git a/frontend/screens/visualizer/policy-graph-lane-column.tsx b/frontend/screens/visualizer/policy-graph-lane-column.tsx new file mode 100644 index 0000000..f66777c --- /dev/null +++ b/frontend/screens/visualizer/policy-graph-lane-column.tsx @@ -0,0 +1,165 @@ +"use client"; + +import Image from "next/image"; +import branchIcon from "@/assets/branch.png"; +import fileIcon from "@/assets/file.png"; +import usersIcon from "@/assets/Users.png"; +import userIcon from "@/assets/user.png"; +import { + branchBox, + fileBox, + principalBox, + roleBox, + rowY, +} from "@/screens/visualizer/policy-graph.constants"; +import { + getIconClassName, + getIconFilter, + getLaneNodeChangeTypes, + getNodeTextStyle, + getPrincipalChangeType, + getPrincipalOffsets, + getTextClassName, +} from "@/screens/visualizer/policy-graph.utils"; +import type { PolicyGraphLane } from "@/screens/visualizer/policy-graph.types"; + +interface PolicyGraphLaneColumnProps { + branchLabel: string; + centerX: number; + lane: PolicyGraphLane; + normalizedSearchQuery: string; + principalNames: string[]; +} + +export function PolicyGraphLaneColumn({ + branchLabel, + centerX, + lane, + normalizedSearchQuery, + principalNames, +}: PolicyGraphLaneColumnProps) { + const changeTypes = getLaneNodeChangeTypes(lane); + const lanePrincipals = + lane.principals ?? + principalNames.map((name) => ({ + name, + })); + const principalOffsets = getPrincipalOffsets(lanePrincipals.length); + + return ( +
+
+ +
+ {branchLabel} +
+
+ +
+ +
+ {lane.pathLabel} +
+
+ +
+ +
+ {lane.roleLabel} +
+
+ {lane.approvals} +
+
+ + {lanePrincipals.map((principal, principalIndex) => { + const center = centerX + principalOffsets[principalIndex]; + const principalChangeType = getPrincipalChangeType(principal, lane); + + return ( +
+ +
+ {principal.name} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/screens/visualizer/policy-graph-svg.tsx b/frontend/screens/visualizer/policy-graph-svg.tsx new file mode 100644 index 0000000..61d77e8 --- /dev/null +++ b/frontend/screens/visualizer/policy-graph-svg.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { boundary, layoutHeight, layoutWidth } from "@/screens/visualizer/policy-graph.constants"; +import { + compareStatusColors, + getEdgeColor, +} from "@/screens/visualizer/policy-graph.utils"; +import type { PolicyGraphEdge } from "@/screens/visualizer/policy-graph.types"; + +interface PolicyGraphSvgProps { + boundaryFill: string; + graphId: string; + isDraggingBoundary: boolean; + paths: PolicyGraphEdge[]; +} + +export function PolicyGraphSvg({ + boundaryFill, + graphId, + isDraggingBoundary, + paths, +}: PolicyGraphSvgProps) { + return ( + + + {[ + { id: "default", color: "var(--tertiary-color)" }, + ...Object.entries(compareStatusColors).map(([id, color]) => ({ + id, + color, + })), + ].map((marker) => ( + + + + ))} + + + + + {paths.map((path, index) => ( + + ))} + + ); +} diff --git a/frontend/screens/visualizer/policy-graph.types.ts b/frontend/screens/visualizer/policy-graph.types.ts index b59cc08..53c8506 100644 --- a/frontend/screens/visualizer/policy-graph.types.ts +++ b/frontend/screens/visualizer/policy-graph.types.ts @@ -1,3 +1,4 @@ +// for policy graph comparison export type PolicyGraphChangeStatus = | "added" | "removed" diff --git a/frontend/screens/visualizer/policy-graph.utils.ts b/frontend/screens/visualizer/policy-graph.utils.ts index aec377c..dee135c 100644 --- a/frontend/screens/visualizer/policy-graph.utils.ts +++ b/frontend/screens/visualizer/policy-graph.utils.ts @@ -53,6 +53,9 @@ export function getNodeTextStyle( } export function getLaneNodeChangeTypes(lane: PolicyGraphLane) { + // Diff colors are applied at the most specific rendered element we can infer. + // A changed approval count should color the approval value and role icon + // without implicitly coloring unchanged principals beneath that lane. const branch = getChangeType(lane.branchStatus); const laneDefault = lane.status; const path = getChangeType(lane.pathStatus ?? laneDefault); @@ -90,6 +93,8 @@ export function getLaneCenters(laneCount: number) { return [boundary.x + boundary.width / 2]; } + // Keep multi-lane graphs visually centered while leaving enough side padding + // for principal spreads and freeform dragging within the dotted boundary. const usableWidth = boundary.width - 420; return Array.from({ length: laneCount }, (_, index) => { const ratio = laneCount === 1 ? 0.5 : index / (laneCount - 1); @@ -100,6 +105,8 @@ export function getLaneCenters(laneCount: number) { export function getPrincipalOffsets(principalCount: number) { if (principalCount <= 1) return [0]; + // Cap the spread so larger principal groups stay inside the graph boundary + // without small groups compressed. const maxSpread = 300; const spread = Math.min(maxSpread, Math.max(120, (principalCount - 1) * 90)); const start = -spread / 2; diff --git a/frontend/screens/visualizer/use-graph-viewport.ts b/frontend/screens/visualizer/use-graph-viewport.ts new file mode 100644 index 0000000..a9807de --- /dev/null +++ b/frontend/screens/visualizer/use-graph-viewport.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface GraphViewportSize { + width: number; + height: number; +} + +export function useGraphViewport(graphZoom: number) { + const graphViewportRef = useRef(null); + const [graphViewportSize, setGraphViewportSize] = useState({ + width: 0, + height: 0, + }); + + useEffect(() => { + const viewport = graphViewportRef.current; + if (!viewport) return; + + const updateViewportSize = () => { + setGraphViewportSize({ + width: viewport.clientWidth, + height: viewport.clientHeight, + }); + }; + + updateViewportSize(); + + const resizeObserver = new ResizeObserver(updateViewportSize); + resizeObserver.observe(viewport); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + const viewport = graphViewportRef.current; + if (!viewport) return; + + const animationFrame = window.requestAnimationFrame(() => { + viewport.scrollLeft = Math.max( + 0, + (viewport.scrollWidth - viewport.clientWidth) / 2, + ); + viewport.scrollTop = Math.max( + 0, + (viewport.scrollHeight - viewport.clientHeight) / 2, + ); + }); + + return () => { + window.cancelAnimationFrame(animationFrame); + }; + }, [graphViewportSize.height, graphViewportSize.width, graphZoom]); + + return { + graphViewportRef, + graphViewportSize, + }; +} diff --git a/frontend/screens/visualizer/use-visualizer-history-compare.ts b/frontend/screens/visualizer/use-visualizer-history-compare.ts new file mode 100644 index 0000000..4c4d48c --- /dev/null +++ b/frontend/screens/visualizer/use-visualizer-history-compare.ts @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import { + getDefaultHistoryCommitId, + getDefaultHistorySortState, + getHistoryTimelineCommits, + sortHistoryTimelineCommits, +} from "@/screens/visualizer/history-canvas"; +import type { HistorySortField } from "@/screens/visualizer/history.types"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; + +export function useVisualizerHistoryCompare( + workspaceData?: DemoVisualizerData | null, +) { + const [selectedBaseVersion, setSelectedBaseVersion] = useState(""); + const [selectedCompareVersion, setSelectedCompareVersion] = useState(""); + const [hasCompared, setHasCompared] = useState(false); + const [activeHistoryCommitId, setActiveHistoryCommitId] = useState( + null, + ); + const [isHistoryStripCollapsed, setIsHistoryStripCollapsed] = useState(false); + const [historySortField, setHistorySortField] = useState("date"); + const [isHistorySortAscending, setIsHistorySortAscending] = useState(false); + + const compareData = + workspaceData?.workspaceDetails.compare ?? + demoVisualizerData.workspaceDetails.compare; + + const baseHistoryCommits = useMemo( + () => getHistoryTimelineCommits(workspaceData), + [workspaceData], + ); + const defaultHistorySortState = useMemo( + () => getDefaultHistorySortState(workspaceData), + [workspaceData], + ); + const historyCommits = useMemo( + () => + sortHistoryTimelineCommits( + baseHistoryCommits, + historySortField, + isHistorySortAscending, + ), + [baseHistoryCommits, historySortField, isHistorySortAscending], + ); + const detailHistoryCommits = useMemo( + () => + historyCommits.map((commit) => { + const historyData = + workspaceData?.workspaceDetails.history ?? + demoVisualizerData.workspaceDetails.history; + const sourceCommit = historyData.commits.find( + (historyCommit) => historyCommit.hash === commit.hash, + ); + + return { + id: sourceCommit + ? historyData.commits.findIndex( + (historyCommit) => historyCommit.hash === commit.hash, + ) + : -1, + hash: commit.hash, + message: sourceCommit?.message ?? "", + author: commit.author, + authorLabel: commit.authorLabel, + date: commit.date, + }; + }), + [historyCommits, workspaceData], + ); + const defaultHistoryCommitId = useMemo( + () => getDefaultHistoryCommitId(workspaceData) ?? historyCommits[0]?.id ?? null, + [historyCommits, workspaceData], + ); + + const comparePairKey = `${selectedBaseVersion}|${selectedCompareVersion}`; + const activeComparison = useMemo( + () => compareData.comparisonsByPair?.[comparePairKey], + [compareData, comparePairKey], + ); + const baseCompareGraph = useMemo(() => { + const baseGraph = compareData.graphsByVersion[selectedBaseVersion]; + return { + repositoryLabel: + baseGraph?.repositoryLabel ?? selectedBaseVersion.split(" • ")[0], + branchLabel: baseGraph?.branchLabel ?? "Branch: main", + lanes: baseGraph?.lanes, + }; + }, [compareData.graphsByVersion, selectedBaseVersion]); + const compareGraph = useMemo(() => { + if (activeComparison?.compareGraph) { + return { + repositoryLabel: + activeComparison.compareGraph.repositoryLabel ?? + selectedCompareVersion.split(" • ")[0], + branchLabel: activeComparison.compareGraph.branchLabel ?? "Branch: main", + lanes: activeComparison.compareGraph.lanes, + showCompareLegend: activeComparison.compareGraph.showLegend ?? true, + }; + } + + const fallbackGraph = compareData.graphsByVersion[selectedCompareVersion]; + return { + repositoryLabel: + fallbackGraph?.repositoryLabel ?? selectedCompareVersion.split(" • ")[0], + branchLabel: fallbackGraph?.branchLabel ?? "Branch: main", + lanes: fallbackGraph?.lanes, + showCompareLegend: true, + }; + }, [activeComparison, compareData.graphsByVersion, selectedCompareVersion]); + + useEffect(() => { + // History selection follows the currently sorted commit list so the detail + // panel, timeline strip, and history canvases stay synchronized. + setActiveHistoryCommitId(defaultHistoryCommitId); + }, [defaultHistoryCommitId]); + + useEffect(() => { + setHistorySortField(defaultHistorySortState.sortField); + setIsHistorySortAscending(defaultHistorySortState.isAscending); + }, [defaultHistorySortState]); + + useEffect(() => { + // Compare state is seeded from the current workspace payload whenever the + // repository/demo source changes, and it resets stale cross-repository pairs. + setSelectedBaseVersion( + compareData.selectedBaseVersion ?? compareData.baseVersionOptions[0], + ); + setSelectedCompareVersion( + compareData.selectedCompareVersion ?? compareData.compareVersionOptions[0], + ); + setHasCompared(false); + }, [compareData]); + + return { + activeHistoryCommitId, + baseCompareGraph, + compareGraph, + detailHistoryCommits, + hasCompared, + historyCommits, + historySortField, + isHistorySortAscending, + isHistoryStripCollapsed, + selectedBaseVersion, + selectedCompareVersion, + setActiveHistoryCommitId, + setHasCompared, + setHistorySortField, + setIsHistorySortAscending, + setIsHistoryStripCollapsed, + setSelectedBaseVersion, + setSelectedCompareVersion, + }; +} diff --git a/frontend/screens/visualizer/use-visualizer-layout.ts b/frontend/screens/visualizer/use-visualizer-layout.ts new file mode 100644 index 0000000..39c91c9 --- /dev/null +++ b/frontend/screens/visualizer/use-visualizer-layout.ts @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; +import type { PanelImperativeHandle } from "react-resizable-panels"; + +const compactMenuWidthPx = 132; +const autoCollapseMenuWidthPx = 1180; +const autoCollapseDetailWidthPx = 980; + +function shouldUseCompactMenu( + menuWidthPercent: number, + totalWidthPx: number, +) { + if (totalWidthPx > 0) { + return totalWidthPx * (menuWidthPercent / 100) <= compactMenuWidthPx; + } + + return menuWidthPercent <= 8; +} + +export function useVisualizerLayout() { + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "visualizer-workspace-layout", + }); + const initialMenuWidth = defaultLayout?.["workspace-menu-panel"] ?? 18; + const initialDetailWidth = defaultLayout?.["workspace-detail-panel"] ?? 25; + + const [isMenuCompact, setIsMenuCompact] = useState(initialMenuWidth <= 8); + const [isMenuCollapsed, setIsMenuCollapsed] = useState(false); + const [isDetailCollapsed, setIsDetailCollapsed] = useState(false); + const [menuPanelWidth, setMenuPanelWidth] = useState(initialMenuWidth); + const [detailPanelWidth, setDetailPanelWidth] = useState(initialDetailWidth); + const [panelGroupWidth, setPanelGroupWidth] = useState(0); + + const panelGroupRef = useRef(null); + const menuPanelRef = useRef(null); + const detailPanelRef = useRef(null); + const didAutoCollapseMenuRef = useRef(false); + const didAutoCollapseDetailRef = useRef(false); + + useEffect(() => { + const panelGroup = panelGroupRef.current; + if (!panelGroup) return; + + const updatePanelGroupWidth = () => { + setPanelGroupWidth(panelGroup.clientWidth); + }; + + updatePanelGroupWidth(); + + const resizeObserver = new ResizeObserver(updatePanelGroupWidth); + resizeObserver.observe(panelGroup); + + return () => { + resizeObserver.disconnect(); + }; + }, []); + + useEffect(() => { + setIsMenuCompact(shouldUseCompactMenu(menuPanelWidth, panelGroupWidth)); + }, [menuPanelWidth, panelGroupWidth]); + + useEffect(() => { + if (!menuPanelRef.current || !detailPanelRef.current || panelGroupWidth <= 0) { + return; + } + + // Auto-collapse is width-driven, but only auto-expand panels that this hook + // previously collapsed. Manual user collapses should stay under user control. + if (panelGroupWidth <= autoCollapseMenuWidthPx && !isMenuCollapsed) { + menuPanelRef.current.collapse(); + didAutoCollapseMenuRef.current = true; + } else if ( + panelGroupWidth > autoCollapseMenuWidthPx && + isMenuCollapsed && + didAutoCollapseMenuRef.current + ) { + menuPanelRef.current.expand(); + didAutoCollapseMenuRef.current = false; + } + + if (panelGroupWidth <= autoCollapseDetailWidthPx && !isDetailCollapsed) { + detailPanelRef.current.collapse(); + didAutoCollapseDetailRef.current = true; + } else if ( + panelGroupWidth > autoCollapseDetailWidthPx && + isDetailCollapsed && + didAutoCollapseDetailRef.current + ) { + detailPanelRef.current.expand(); + didAutoCollapseDetailRef.current = false; + } + }, [isDetailCollapsed, isMenuCollapsed, panelGroupWidth]); + + const handleDetailPanelToggle = () => { + if (!detailPanelRef.current) return; + + // A manual toggle takes precedence over the auto-collapse bookkeeping until + // the next width-driven layout decision. + didAutoCollapseDetailRef.current = false; + + if (isDetailCollapsed) { + detailPanelRef.current.expand(); + setIsDetailCollapsed(false); + return; + } + + detailPanelRef.current.collapse(); + setIsDetailCollapsed(true); + }; + + const footerLeftWidthPx = + panelGroupWidth > 0 + ? panelGroupWidth * ((menuPanelWidth + detailPanelWidth) / 100) + 2 + : 0; + + return { + defaultLayout, + detailPanelRef, + detailPanelWidth, + footerLeftWidthPx, + handleDetailPanelToggle, + isDetailCollapsed, + isMenuCollapsed, + isMenuCompact, + menuPanelRef, + menuPanelWidth, + onLayoutChanged, + panelGroupRef, + setDetailPanelWidth, + setIsDetailCollapsed, + setIsMenuCollapsed, + setMenuPanelWidth, + }; +} diff --git a/frontend/screens/visualizer/use-visualizer-tabs.ts b/frontend/screens/visualizer/use-visualizer-tabs.ts new file mode 100644 index 0000000..605c7c4 --- /dev/null +++ b/frontend/screens/visualizer/use-visualizer-tabs.ts @@ -0,0 +1,234 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { + compareTabId, + historyTabId, +} from "@/screens/visualizer/visualizer.constants"; +import type { + GraphWorkspaceTab, + WorkspacePanelId, +} from "@/screens/visualizer/visualizer.types"; + +interface UseVisualizerTabsOptions { + activePanel: WorkspacePanelId; + onReload: () => void; + selectedBaseVersion: string; + selectedCompareVersion: string; + setActiveGraphTabId?: (tabId: string) => void; + setActivePanel: (panelId: WorkspacePanelId) => void; + setHasCompared: (value: boolean) => void; +} + +export function useVisualizerTabs({ + activePanel, + onReload, + selectedBaseVersion, + selectedCompareVersion, + setActivePanel, + setHasCompared, +}: UseVisualizerTabsOptions) { + const [graphTabs, setGraphTabs] = useState([ + { + id: "graph-tab-1", + label: "Policy Graph", + closable: true, + editable: true, + graphs: [{ id: "graph-instance-1", offset: { x: 0, y: 0 } }], + }, + ]); + const [activeGraphTabId, setActiveGraphTabId] = useState("graph-tab-1"); + + const nextGraphTabNumberRef = useRef(2); + const nextGraphInstanceNumberRef = useRef(2); + + const activeGraphTab = + graphTabs.find((tab) => tab.id === activeGraphTabId) ?? graphTabs[0]; + const isHistoryPanel = activeGraphTabId === historyTabId; + const isComparePanel = activeGraphTabId === compareTabId; + + const handleHistoryPanelSelect = () => { + // History owns a reserved tab ID so menu selection, bottom-bar selection, + // and history-only canvas behavior all point at the same workspace surface. + setGraphTabs((currentTabs) => { + if (currentTabs.some((tab) => tab.id === historyTabId)) { + return currentTabs; + } + + return [ + ...currentTabs, + { + id: historyTabId, + label: "History", + closable: false, + editable: false, + graphs: [], + }, + ]; + }); + setActiveGraphTabId(historyTabId); + }; + + const handleGenerateGraph = () => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === activeGraphTabId + ? { + ...tab, + graphs: [ + ...tab.graphs, + { + id: `graph-instance-${nextGraphInstanceNumberRef.current++}`, + offset: { x: 0, y: 0 }, + }, + ], + } + : tab, + ), + ); + onReload(); + }; + + const compareTabLabel = useMemo( + () => + `Compare ${selectedBaseVersion.split(" • ")[0]} vs ${selectedCompareVersion.split(" • ")[0]}`, + [selectedBaseVersion, selectedCompareVersion], + ); + + const handleGenerateCompareGraph = () => { + // Compare behaves like history: it gets a stable reserved tab instead of + // replacing the active graph tab, which preserves user canvas context. + setGraphTabs((currentTabs) => { + const existingCompareTab = currentTabs.find((tab) => tab.id === compareTabId); + if (existingCompareTab) { + return currentTabs.map((tab) => + tab.id === compareTabId ? { ...tab, label: compareTabLabel } : tab, + ); + } + + return [ + ...currentTabs, + { + id: compareTabId, + label: compareTabLabel, + closable: true, + editable: false, + graphs: [], + }, + ]; + }); + setHasCompared(true); + setActivePanel("compare"); + setActiveGraphTabId(compareTabId); + }; + + const handleAddGraphTab = () => { + const nextTabId = `graph-tab-${nextGraphTabNumberRef.current}`; + const nextTabLabel = `Canvas ${nextGraphTabNumberRef.current}`; + + nextGraphTabNumberRef.current += 1; + + setGraphTabs((currentTabs) => [ + ...currentTabs, + { + id: nextTabId, + label: nextTabLabel, + graphs: [], + }, + ]); + setActiveGraphTabId(nextTabId); + }; + + const handleDeleteGraphTab = (tabId: string) => { + if (graphTabs.length <= 1) return; + + const tabIndex = graphTabs.findIndex((tab) => tab.id === tabId); + const nextTabs = graphTabs.filter((tab) => tab.id !== tabId); + + setGraphTabs(nextTabs); + + if (tabId === activeGraphTabId) { + const fallbackTab = nextTabs[Math.max(0, tabIndex - 1)] ?? nextTabs[0]; + setActiveGraphTabId(fallbackTab.id); + } + }; + + const handleTabRename = (tabId: string, nextLabel: string) => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === tabId ? { ...tab, label: nextLabel } : tab, + ), + ); + }; + + const handleGraphOffsetChange = ( + graphId: string, + nextOffset: { x: number; y: number }, + ) => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === activeGraphTabId + ? { + ...tab, + graphs: tab.graphs.map((graph) => + graph.id === graphId ? { ...graph, offset: nextOffset } : graph, + ), + } + : tab, + ), + ); + }; + + const handleDeleteGraphInstance = (graphId: string) => { + setGraphTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === activeGraphTabId + ? { + ...tab, + graphs: tab.graphs.filter((graph) => graph.id !== graphId), + } + : tab, + ), + ); + }; + + const handleBottomTabSelect = (tabId: string) => { + if (tabId === historyTabId) { + setActivePanel("history"); + setActiveGraphTabId(historyTabId); + return; + } + + if (tabId === compareTabId) { + setActivePanel("compare"); + setActiveGraphTabId(compareTabId); + return; + } + + setActiveGraphTabId(tabId); + + // Returning to a normal graph tab resets the side panel to the default graph + // controls if the user was previously in a reserved history/compare panel. + if (activePanel === "history" || activePanel === "compare") { + setActivePanel("graph-source"); + } + }; + + return { + activeGraphTab, + activeGraphTabId, + graphTabs, + handleAddGraphTab, + handleBottomTabSelect, + handleDeleteGraphInstance, + handleDeleteGraphTab, + handleGenerateCompareGraph, + handleGenerateGraph, + handleGraphOffsetChange, + handleHistoryPanelSelect, + handleTabRename, + isComparePanel, + isHistoryPanel, + setActiveGraphTabId, + }; +} diff --git a/frontend/screens/visualizer/use-visualizer-workspace.ts b/frontend/screens/visualizer/use-visualizer-workspace.ts index 254978d..c483086 100644 --- a/frontend/screens/visualizer/use-visualizer-workspace.ts +++ b/frontend/screens/visualizer/use-visualizer-workspace.ts @@ -1,181 +1,86 @@ "use client"; -import { useDefaultLayout } from "react-resizable-panels"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { PanelImperativeHandle } from "react-resizable-panels"; -import { demoVisualizerData } from "@/lib/demo-visualizer-data"; -import { - getDefaultHistoryCommitId, - getDefaultHistorySortState, - getHistoryTimelineCommits, - sortHistoryTimelineCommits, -} from "@/screens/visualizer/history-canvas"; -import type { HistorySortField } from "@/screens/visualizer/history.types"; -import { - compareTabId, - historyTabId, - visualizerMenuItems, -} from "@/screens/visualizer/visualizer.constants"; +import { useState } from "react"; +import { visualizerMenuItems } from "@/screens/visualizer/visualizer.constants"; import type { - GraphWorkspaceTab, VisualizerWorkspaceProps, WorkspacePanelId, } from "@/screens/visualizer/visualizer.types"; - -const compactMenuWidthPx = 132; -const autoCollapseMenuWidthPx = 1180; -const autoCollapseDetailWidthPx = 980; - -function shouldUseCompactMenu( - menuWidthPercent: number, - totalWidthPx: number, -) { - if (totalWidthPx > 0) { - return totalWidthPx * (menuWidthPercent / 100) <= compactMenuWidthPx; - } - - return menuWidthPercent <= 8; -} +import { useGraphViewport } from "@/screens/visualizer/use-graph-viewport"; +import { useVisualizerHistoryCompare } from "@/screens/visualizer/use-visualizer-history-compare"; +import { useVisualizerLayout } from "@/screens/visualizer/use-visualizer-layout"; +import { useVisualizerTabs } from "@/screens/visualizer/use-visualizer-tabs"; export function useVisualizerWorkspace({ workspaceData, onReload, }: Pick) { - const { defaultLayout, onLayoutChanged } = useDefaultLayout({ - id: "visualizer-workspace-layout", - }); - const initialMenuWidth = defaultLayout?.["workspace-menu-panel"] ?? 18; - const initialDetailWidth = defaultLayout?.["workspace-detail-panel"] ?? 25; - const [activePanel, setActivePanel] = useState("graph-source"); - const [isMenuCompact, setIsMenuCompact] = useState(initialMenuWidth <= 8); - const [isMenuCollapsed, setIsMenuCollapsed] = useState(false); - const [isDetailCollapsed, setIsDetailCollapsed] = useState(false); const [detailSearchQuery, setDetailSearchQuery] = useState(""); const [graphZoom, setGraphZoom] = useState(0.75); const [graphSearchQuery, setGraphSearchQuery] = useState(""); - const [menuPanelWidth, setMenuPanelWidth] = useState(initialMenuWidth); - const [detailPanelWidth, setDetailPanelWidth] = useState(initialDetailWidth); - const [panelGroupWidth, setPanelGroupWidth] = useState(0); - const [graphViewportSize, setGraphViewportSize] = useState({ - width: 0, - height: 0, + const { + defaultLayout, + detailPanelRef, + detailPanelWidth, + footerLeftWidthPx, + handleDetailPanelToggle, + isDetailCollapsed, + isMenuCollapsed, + isMenuCompact, + menuPanelRef, + menuPanelWidth, + onLayoutChanged, + panelGroupRef, + setDetailPanelWidth, + setIsDetailCollapsed, + setIsMenuCollapsed, + setMenuPanelWidth, + } = useVisualizerLayout(); + const { + activeHistoryCommitId, + baseCompareGraph, + compareGraph, + detailHistoryCommits, + hasCompared, + historyCommits, + historySortField, + isHistorySortAscending, + isHistoryStripCollapsed, + selectedBaseVersion, + selectedCompareVersion, + setActiveHistoryCommitId, + setHasCompared, + setHistorySortField, + setIsHistorySortAscending, + setIsHistoryStripCollapsed, + setSelectedBaseVersion, + setSelectedCompareVersion, + } = useVisualizerHistoryCompare(workspaceData); + const { graphViewportRef, graphViewportSize } = useGraphViewport(graphZoom); + const { + activeGraphTab, + activeGraphTabId, + graphTabs, + handleAddGraphTab, + handleBottomTabSelect, + handleDeleteGraphInstance, + handleDeleteGraphTab, + handleGenerateCompareGraph, + handleGenerateGraph, + handleGraphOffsetChange, + handleHistoryPanelSelect, + handleTabRename, + isComparePanel, + isHistoryPanel, + } = useVisualizerTabs({ + activePanel, + onReload, + selectedBaseVersion, + selectedCompareVersion, + setActivePanel, + setHasCompared, }); - const [graphTabs, setGraphTabs] = useState([ - { - id: "graph-tab-1", - label: "Policy Graph", - closable: true, - editable: true, - graphs: [{ id: "graph-instance-1", offset: { x: 0, y: 0 } }], - }, - ]); - const [activeGraphTabId, setActiveGraphTabId] = useState("graph-tab-1"); - const [selectedBaseVersion, setSelectedBaseVersion] = useState(""); - const [selectedCompareVersion, setSelectedCompareVersion] = useState(""); - const [hasCompared, setHasCompared] = useState(false); - const [activeHistoryCommitId, setActiveHistoryCommitId] = useState( - null, - ); - const [isHistoryStripCollapsed, setIsHistoryStripCollapsed] = useState(false); - const [historySortField, setHistorySortField] = useState("date"); - const [isHistorySortAscending, setIsHistorySortAscending] = useState(false); - - const panelGroupRef = useRef(null); - const graphViewportRef = useRef(null); - const menuPanelRef = useRef(null); - const detailPanelRef = useRef(null); - const didAutoCollapseMenuRef = useRef(false); - const didAutoCollapseDetailRef = useRef(false); - const nextGraphTabNumberRef = useRef(2); - const nextGraphInstanceNumberRef = useRef(2); - - const compareData = - workspaceData?.workspaceDetails.compare ?? - demoVisualizerData.workspaceDetails.compare; - - const baseHistoryCommits = useMemo( - () => getHistoryTimelineCommits(workspaceData), - [workspaceData], - ); - const defaultHistorySortState = useMemo( - () => getDefaultHistorySortState(workspaceData), - [workspaceData], - ); - const historyCommits = useMemo( - () => - sortHistoryTimelineCommits( - baseHistoryCommits, - historySortField, - isHistorySortAscending, - ), - [baseHistoryCommits, historySortField, isHistorySortAscending], - ); - const detailHistoryCommits = useMemo( - () => - historyCommits.map((commit) => { - const historyData = - workspaceData?.workspaceDetails.history ?? - demoVisualizerData.workspaceDetails.history; - const sourceCommit = historyData.commits.find( - (historyCommit) => historyCommit.hash === commit.hash, - ); - - return { - id: sourceCommit - ? historyData.commits.findIndex( - (historyCommit) => historyCommit.hash === commit.hash, - ) - : -1, - hash: commit.hash, - message: sourceCommit?.message ?? "", - author: commit.author, - authorLabel: commit.authorLabel, - date: commit.date, - }; - }), - [historyCommits, workspaceData], - ); - const defaultHistoryCommitId = useMemo( - () => getDefaultHistoryCommitId(workspaceData) ?? historyCommits[0]?.id ?? null, - [historyCommits, workspaceData], - ); - - const comparePairKey = `${selectedBaseVersion}|${selectedCompareVersion}`; - const activeComparison = useMemo( - () => compareData.comparisonsByPair?.[comparePairKey], - [compareData, comparePairKey], - ); - const baseCompareGraph = useMemo(() => { - const baseGraph = compareData.graphsByVersion[selectedBaseVersion]; - return { - repositoryLabel: - baseGraph?.repositoryLabel ?? selectedBaseVersion.split(" • ")[0], - branchLabel: baseGraph?.branchLabel ?? "Branch: main", - lanes: baseGraph?.lanes, - }; - }, [compareData.graphsByVersion, selectedBaseVersion]); - const compareGraph = useMemo(() => { - if (activeComparison?.compareGraph) { - return { - repositoryLabel: - activeComparison.compareGraph.repositoryLabel ?? - selectedCompareVersion.split(" • ")[0], - branchLabel: activeComparison.compareGraph.branchLabel ?? "Branch: main", - lanes: activeComparison.compareGraph.lanes, - showCompareLegend: activeComparison.compareGraph.showLegend ?? true, - }; - } - - const fallbackGraph = compareData.graphsByVersion[selectedCompareVersion]; - return { - repositoryLabel: - fallbackGraph?.repositoryLabel ?? selectedCompareVersion.split(" • ")[0], - branchLabel: fallbackGraph?.branchLabel ?? "Branch: main", - lanes: fallbackGraph?.lanes, - showCompareLegend: true, - }; - }, [activeComparison, compareData.graphsByVersion, selectedCompareVersion]); const activeLabel = visualizerMenuItems.find((item) => item.id === activePanel)?.label ?? @@ -183,323 +88,11 @@ export function useVisualizerWorkspace({ const activePanelIcon = visualizerMenuItems.find((item) => item.id === activePanel)?.icon ?? visualizerMenuItems[0].icon; - const activeGraphTab = - graphTabs.find((tab) => tab.id === activeGraphTabId) ?? graphTabs[0]; - const isHistoryPanel = activeGraphTabId === historyTabId; - const isComparePanel = activeGraphTabId === compareTabId; - const footerLeftWidthPx = - panelGroupWidth > 0 - ? panelGroupWidth * ((menuPanelWidth + detailPanelWidth) / 100) + 2 - : 0; - - useEffect(() => { - setActiveHistoryCommitId(defaultHistoryCommitId); - }, [defaultHistoryCommitId]); - - useEffect(() => { - setHistorySortField(defaultHistorySortState.sortField); - setIsHistorySortAscending(defaultHistorySortState.isAscending); - }, [defaultHistorySortState]); - - useEffect(() => { - setSelectedBaseVersion( - compareData.selectedBaseVersion ?? compareData.baseVersionOptions[0], - ); - setSelectedCompareVersion( - compareData.selectedCompareVersion ?? compareData.compareVersionOptions[0], - ); - setHasCompared(false); - }, [compareData]); - - useEffect(() => { - if (activePanel !== "history") return; - - setGraphTabs((currentTabs) => { - if (currentTabs.some((tab) => tab.id === historyTabId)) { - return currentTabs; - } - - return [ - ...currentTabs, - { - id: historyTabId, - label: "History", - closable: false, - editable: false, - graphs: [], - }, - ]; - }); - setActiveGraphTabId(historyTabId); - }, [activePanel]); - - useEffect(() => { - if (activeGraphTabId !== historyTabId || activePanel === "history") return; - - const fallbackTab = graphTabs.find((tab) => tab.id !== historyTabId); - if (fallbackTab) { - setActiveGraphTabId(fallbackTab.id); - } - }, [activeGraphTabId, activePanel, graphTabs]); - - useEffect(() => { - if (activeGraphTabId !== compareTabId || activePanel === "compare") return; - - const compareTab = graphTabs.find((tab) => tab.id === compareTabId); - if (compareTab) return; - - const fallbackTab = graphTabs.find((tab) => tab.id !== compareTabId); - if (fallbackTab) { - setActiveGraphTabId(fallbackTab.id); - } - }, [activeGraphTabId, activePanel, graphTabs]); - - useEffect(() => { - const panelGroup = panelGroupRef.current; - if (!panelGroup) return; - - const updatePanelGroupWidth = () => { - setPanelGroupWidth(panelGroup.clientWidth); - }; - - updatePanelGroupWidth(); - - const resizeObserver = new ResizeObserver(updatePanelGroupWidth); - resizeObserver.observe(panelGroup); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - useEffect(() => { - setIsMenuCompact(shouldUseCompactMenu(menuPanelWidth, panelGroupWidth)); - }, [menuPanelWidth, panelGroupWidth]); - - useEffect(() => { - if (!menuPanelRef.current || !detailPanelRef.current || panelGroupWidth <= 0) return; - - if (panelGroupWidth <= autoCollapseMenuWidthPx && !isMenuCollapsed) { - menuPanelRef.current.collapse(); - didAutoCollapseMenuRef.current = true; - } else if ( - panelGroupWidth > autoCollapseMenuWidthPx && - isMenuCollapsed && - didAutoCollapseMenuRef.current - ) { - menuPanelRef.current.expand(); - didAutoCollapseMenuRef.current = false; - } - - if (panelGroupWidth <= autoCollapseDetailWidthPx && !isDetailCollapsed) { - detailPanelRef.current.collapse(); - didAutoCollapseDetailRef.current = true; - } else if ( - panelGroupWidth > autoCollapseDetailWidthPx && - isDetailCollapsed && - didAutoCollapseDetailRef.current - ) { - detailPanelRef.current.expand(); - didAutoCollapseDetailRef.current = false; - } - }, [isDetailCollapsed, isMenuCollapsed, panelGroupWidth]); - - useEffect(() => { - const viewport = graphViewportRef.current; - if (!viewport) return; - - const updateViewportSize = () => { - setGraphViewportSize({ - width: viewport.clientWidth, - height: viewport.clientHeight, - }); - }; - - updateViewportSize(); - - const resizeObserver = new ResizeObserver(updateViewportSize); - resizeObserver.observe(viewport); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - useEffect(() => { - const viewport = graphViewportRef.current; - if (!viewport) return; - - const animationFrame = window.requestAnimationFrame(() => { - viewport.scrollLeft = Math.max( - 0, - (viewport.scrollWidth - viewport.clientWidth) / 2, - ); - viewport.scrollTop = Math.max( - 0, - (viewport.scrollHeight - viewport.clientHeight) / 2, - ); - }); - - return () => { - window.cancelAnimationFrame(animationFrame); - }; - }, [graphViewportSize.height, graphViewportSize.width, graphZoom]); - - const handleDetailPanelToggle = () => { - if (!detailPanelRef.current) return; - - didAutoCollapseDetailRef.current = false; - - if (isDetailCollapsed) { - detailPanelRef.current.expand(); - setIsDetailCollapsed(false); - return; - } - - detailPanelRef.current.collapse(); - setIsDetailCollapsed(true); - }; - - const handleGenerateGraph = () => { - setGraphTabs((currentTabs) => - currentTabs.map((tab) => - tab.id === activeGraphTabId - ? { - ...tab, - graphs: [ - ...tab.graphs, - { - id: `graph-instance-${nextGraphInstanceNumberRef.current++}`, - offset: { x: 0, y: 0 }, - }, - ], - } - : tab, - ), - ); - onReload(); - }; - - const handleGenerateCompareGraph = () => { - setGraphTabs((currentTabs) => { - const label = `Compare ${selectedBaseVersion.split(" • ")[0]} vs ${selectedCompareVersion.split(" • ")[0]}`; - const existingCompareTab = currentTabs.find((tab) => tab.id === compareTabId); - if (existingCompareTab) { - return currentTabs.map((tab) => - tab.id === compareTabId ? { ...tab, label } : tab, - ); - } - - return [ - ...currentTabs, - { - id: compareTabId, - label, - closable: true, - editable: false, - graphs: [], - }, - ]; - }); - setHasCompared(true); - setActivePanel("compare"); - setActiveGraphTabId(compareTabId); - }; - - const handleAddGraphTab = () => { - const nextTabId = `graph-tab-${nextGraphTabNumberRef.current}`; - const nextTabLabel = `Canvas ${nextGraphTabNumberRef.current}`; - - nextGraphTabNumberRef.current += 1; - - setGraphTabs((currentTabs) => [ - ...currentTabs, - { - id: nextTabId, - label: nextTabLabel, - graphs: [], - }, - ]); - setActiveGraphTabId(nextTabId); - }; - - const handleDeleteGraphTab = (tabId: string) => { - if (graphTabs.length <= 1) return; - - const tabIndex = graphTabs.findIndex((tab) => tab.id === tabId); - const nextTabs = graphTabs.filter((tab) => tab.id !== tabId); - - setGraphTabs(nextTabs); - - if (tabId === activeGraphTabId) { - const fallbackTab = nextTabs[Math.max(0, tabIndex - 1)] ?? nextTabs[0]; - setActiveGraphTabId(fallbackTab.id); - } - }; - - const handleTabRename = (tabId: string, nextLabel: string) => { - setGraphTabs((currentTabs) => - currentTabs.map((tab) => - tab.id === tabId ? { ...tab, label: nextLabel } : tab, - ), - ); - }; - - const handleGraphOffsetChange = ( - graphId: string, - nextOffset: { x: number; y: number }, - ) => { - setGraphTabs((currentTabs) => - currentTabs.map((tab) => - tab.id === activeGraphTabId - ? { - ...tab, - graphs: tab.graphs.map((graph) => - graph.id === graphId ? { ...graph, offset: nextOffset } : graph, - ), - } - : tab, - ), - ); - }; - - const handleDeleteGraphInstance = (graphId: string) => { - setGraphTabs((currentTabs) => - currentTabs.map((tab) => - tab.id === activeGraphTabId - ? { - ...tab, - graphs: tab.graphs.filter((graph) => graph.id !== graphId), - } - : tab, - ), - ); - }; const handleMenuItemSelect = (panelId: WorkspacePanelId) => { setActivePanel(panelId); if (panelId === "history") { - setActiveGraphTabId(historyTabId); - } - }; - - const handleBottomTabSelect = (tabId: string) => { - if (tabId === historyTabId) { - setActivePanel("history"); - setActiveGraphTabId(historyTabId); - return; - } - - if (tabId === compareTabId) { - setActivePanel("compare"); - setActiveGraphTabId(compareTabId); - return; - } - - setActiveGraphTabId(tabId); - - if (activePanel === "history" || activePanel === "compare") { - setActivePanel("graph-source"); + handleHistoryPanelSelect(); } }; @@ -559,7 +152,6 @@ export function useVisualizerWorkspace({ setIsHistorySortAscending, setIsHistoryStripCollapsed, setIsMenuCollapsed, - setIsMenuCompact, setMenuPanelWidth, setSelectedBaseVersion, setSelectedCompareVersion, diff --git a/frontend/screens/visualizer/visualizer-workspace.tsx b/frontend/screens/visualizer/visualizer-workspace.tsx index 8e97e13..7293539 100644 --- a/frontend/screens/visualizer/visualizer-workspace.tsx +++ b/frontend/screens/visualizer/visualizer-workspace.tsx @@ -93,15 +93,21 @@ export default function VisualizerWorkspace(props: VisualizerWorkspaceProps) { return (
-
-

{props.repository.name}

+
+

+ {props.repository.name} +

+ -
@@ -147,7 +153,7 @@ export default function VisualizerWorkspace(props: VisualizerWorkspaceProps) { - + - + - +
{isHistoryPanel && !isHistoryStripCollapsed ? ( ) : null} {isHistoryPanel ? ( -
+
) : null}
-
+
- setGraphZoom((current) => Math.min(1.8, Number((current + 0.1).toFixed(2)))) + setGraphZoom((current) => + Math.min(1.8, Number((current + 0.1).toFixed(2))), + ) } /> - setGraphZoom((current) => Math.max(0.6, Number((current - 0.1).toFixed(2)))) + setGraphZoom((current) => + Math.max(0.6, Number((current - 0.1).toFixed(2))), + ) } />
@@ -313,23 +337,37 @@ export default function VisualizerWorkspace(props: VisualizerWorkspaceProps) { ) ) : (
- +
{activeGraphTab?.graphs.length ? ( activeGraphTab.graphs.map((graph) => ( -
+
- handleGraphOffsetChange(graph.id, nextOffset) + handleGraphOffsetChange( + graph.id, + nextOffset, + ) + } + onDelete={() => + handleDeleteGraphInstance(graph.id) } - onDelete={() => handleDeleteGraphInstance(graph.id)} />
)) diff --git a/frontend/screens/visualizer/visualizer.types.ts b/frontend/screens/visualizer/visualizer.types.ts index a624d99..3933ff3 100644 --- a/frontend/screens/visualizer/visualizer.types.ts +++ b/frontend/screens/visualizer/visualizer.types.ts @@ -1,6 +1,6 @@ import type { StaticImageData } from "next/image"; import type { RepositoryInfo } from "@/lib/repository-handler"; -import type { DemoVisualizerData } from "@/lib/demo-visualizer-data"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; export type WorkspacePanelId = | "graph-source" diff --git a/frontend/styles/globals.css b/frontend/styles/globals.css deleted file mode 100644 index 23f10cf..0000000 --- a/frontend/styles/globals.css +++ /dev/null @@ -1,93 +0,0 @@ -@import "tailwindcss"; - -body { - font-family: Arial, Helvetica, sans-serif; -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } -} - -@layer base { - :root { - --background: 0 0% 100%; - --foreground: 0 0% 3.9%; - --card: 0 0% 100%; - --card-foreground: 0 0% 3.9%; - --popover: 0 0% 100%; - --popover-foreground: 0 0% 3.9%; - --primary: 0 0% 9%; - --primary-foreground: 0 0% 98%; - --secondary: 0 0% 96.1%; - --secondary-foreground: 0 0% 9%; - --muted: 0 0% 96.1%; - --muted-foreground: 0 0% 45.1%; - --accent: 0 0% 96.1%; - --accent-foreground: 0 0% 9%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 89.8%; - --input: 0 0% 89.8%; - --ring: 0 0% 3.9%; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - --radius: 0.5rem; - --sidebar-background: 0 0% 98%; - --sidebar-foreground: 240 5.3% 26.1%; - --sidebar-primary: 240 5.9% 10%; - --sidebar-primary-foreground: 0 0% 98%; - --sidebar-accent: 240 4.8% 95.9%; - --sidebar-accent-foreground: 240 5.9% 10%; - --sidebar-border: 220 13% 91%; - --sidebar-ring: 217.2 91.2% 59.8%; - } - .dark { - --background: 0 0% 3.9%; - --foreground: 0 0% 98%; - --card: 0 0% 3.9%; - --card-foreground: 0 0% 98%; - --popover: 0 0% 3.9%; - --popover-foreground: 0 0% 98%; - --primary: 0 0% 98%; - --primary-foreground: 0 0% 9%; - --secondary: 0 0% 14.9%; - --secondary-foreground: 0 0% 98%; - --muted: 0 0% 14.9%; - --muted-foreground: 0 0% 63.9%; - --accent: 0 0% 14.9%; - --accent-foreground: 0 0% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 0% 98%; - --border: 0 0% 14.9%; - --input: 0 0% 14.9%; - --ring: 0 0% 83.1%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; - --sidebar-background: 240 5.9% 10%; - --sidebar-foreground: 240 4.8% 95.9%; - --sidebar-primary: 224.3 76.3% 48%; - --sidebar-primary-foreground: 0 0% 100%; - --sidebar-accent: 240 3.7% 15.9%; - --sidebar-accent-foreground: 240 4.8% 95.9%; - --sidebar-border: 240 3.7% 15.9%; - --sidebar-ring: 217.2 91.2% 59.8%; - } -} - -@layer base { - * { - border-color: hsl(var(--border)); - } - body { - background-color: hsl(var(--background)); - color: hsl(var(--foreground)); - } -} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 3cc6f17..d19d012 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -31,7 +31,9 @@ "include": [ "next-env.d.ts", "**/*.ts", - "**/*.tsx" + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules", From 226a0f2f2ca60dbbbe126b52feb50501ede55f29 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Thu, 11 Jun 2026 16:05:43 -0400 Subject: [PATCH 3/4] frontend: improve graph, history, and compare workflows Signed-off-by: Tony Lee --- frontend/app/layout.tsx | 2 +- .../detail/detail-select-controls.tsx | 4 +- .../hooks/visualizer/use-workspace-history.ts | 25 +- frontend/lib/demo-visualizer-fixture.ts | 439 +++++++++--------- frontend/lib/demo-visualizer.types.ts | 11 - frontend/screens/visualizer/compare.utils.ts | 222 +++++++++ .../screens/visualizer/detail-content.tsx | 4 + .../visualizer/panel-tabs/detail-compare.tsx | 42 +- .../visualizer/panel-tabs/detail-history.tsx | 72 +-- .../visualizer/panel-tabs/detail-metadata.tsx | 104 ++++- .../use-visualizer-history-compare.ts | 39 +- .../screens/visualizer/use-visualizer-tabs.ts | 40 +- .../visualizer/use-visualizer-workspace.ts | 2 + .../visualizer/visualizer-workspace.tsx | 2 + 14 files changed, 651 insertions(+), 357 deletions(-) create mode 100644 frontend/screens/visualizer/compare.utils.ts diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 128cb48..40f52a8 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,7 +2,7 @@ import type React from "react"; import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "Gittuf Visualizer", + title: "gittuf Visualizer", description: "Visualize and analyze gittuf's security metadata structure across repository commits", generator: "v0.dev", diff --git a/frontend/components/visualizer/detail/detail-select-controls.tsx b/frontend/components/visualizer/detail/detail-select-controls.tsx index ca4192e..a6f6cf5 100644 --- a/frontend/components/visualizer/detail/detail-select-controls.tsx +++ b/frontend/components/visualizer/detail/detail-select-controls.tsx @@ -97,7 +97,7 @@ export function SelectField({ {isOpen ? (
-
+
{options.map((option) => { const optionValue = getOptionValue(option); const isSelected = optionValue === getOptionValue(selectedOption); @@ -137,7 +137,7 @@ export function SelectField({ ); })}
- {options.length > 4 ? ( + {options.length > 3 ? ( <>
diff --git a/frontend/hooks/visualizer/use-workspace-history.ts b/frontend/hooks/visualizer/use-workspace-history.ts index a971743..c11d33b 100644 --- a/frontend/hooks/visualizer/use-workspace-history.ts +++ b/frontend/hooks/visualizer/use-workspace-history.ts @@ -1,42 +1,25 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; interface HistoryCommit { id: number; hash: string; } +const commitsPerPage = 10; + export function useWorkspaceHistory( commits: TCommit[], selectedCommitHash?: string, ) { const commitListRef = useRef(null); - const [commitsPerPage, setCommitsPerPage] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [touchedCommitId, setTouchedCommitId] = useState(null); const [selectedCommitId, setSelectedCommitId] = useState( Math.max(0, commits.findIndex((commit) => commit.hash === selectedCommitHash)), ); - useEffect(() => { - const commitList = commitListRef.current; - if (!commitList) return; - - const updateCommitsPerPage = () => { - setCommitsPerPage(Math.max(1, Math.floor(commitList.clientHeight / 56))); - }; - - updateCommitsPerPage(); - - const resizeObserver = new ResizeObserver(updateCommitsPerPage); - resizeObserver.observe(commitList); - - return () => { - resizeObserver.disconnect(); - }; - }, []); - const totalPages = Math.ceil(commits.length / commitsPerPage); const effectiveCurrentPage = Math.min(currentPage, totalPages); const visibleCommits = useMemo( @@ -45,7 +28,7 @@ export function useWorkspaceHistory( (effectiveCurrentPage - 1) * commitsPerPage, effectiveCurrentPage * commitsPerPage, ), - [commits, commitsPerPage, effectiveCurrentPage], + [commits, effectiveCurrentPage], ); return { diff --git a/frontend/lib/demo-visualizer-fixture.ts b/frontend/lib/demo-visualizer-fixture.ts index 99ffa72..e9c0e56 100644 --- a/frontend/lib/demo-visualizer-fixture.ts +++ b/frontend/lib/demo-visualizer-fixture.ts @@ -1,5 +1,210 @@ import { REPOSITORY } from "@/lib/constants" import type { DemoVisualizerData } from "@/lib/demo-visualizer.types" +// this is the mock data structure used in the demo visualizer. +const demoHistoryCommits = [ + { + hash: "0a1b2c3d", + message: "[Linux fedora] Policy graph fails to refresh after reconnect.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-08T16:30:00.000Z", + }, + { + hash: "1b2c3d4e", + message: "[Ubuntu] History canvas should preserve selected commit focus.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-07T13:10:00.000Z", + }, + { + hash: "2c3d4e5f", + message: "[macOS] Compare graph should keep diff legend visible on resize.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-06T11:25:00.000Z", + }, + { + hash: "3d4e5f6a", + message: "[Windows] Branch query panel should reset stale results when path changes.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-05T17:55:00.000Z", + }, + { + hash: "4e5f6a7b", + message: "[Linux] Commit ordering should stay synced between strip and panel.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-04T09:35:00.000Z", + }, + { + hash: "5f6a7b8c", + message: "[Fedora] Regenerating a graph should not drop manual canvas positions.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-03T14:20:00.000Z", + }, + { + hash: "f6a7b8c9", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-03T10:45:00.000Z", + }, + { + hash: "e5f6a7b8", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-02T09:20:00.000Z", + }, + { + hash: "c3d4e5f6", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-06-01T12:10:00.000Z", + }, + { + hash: "b2c3d4e5", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-05-31T18:40:00.000Z", + }, + { + hash: "a1b2c3d4", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-05-30T14:00:00.000Z", + }, + { + hash: "98ab76cd", + message: "[Linux fedora] Installation / dependency error with Webui already installed.", + author: "tonylee12345", + authorLabel: "opened by tonylee12345", + date: "2026-05-29T11:15:00.000Z", + }, +] + +function formatCompareVersionLabel(commit: (typeof demoHistoryCommits)[number]) { + const dateLabel = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + timeZone: "UTC", + }).format(new Date(commit.date)) + const shortMessage = commit.message.replace(/^\[[^\]]+\]\s*/, "") + + return `${commit.hash.slice(0, 6)} • ${dateLabel} • ${shortMessage}` +} + +const demoCompareVersionOptions = demoHistoryCommits.map(formatCompareVersionLabel) +const compareLabelByHash = Object.fromEntries( + demoHistoryCommits.map((commit) => [commit.hash, formatCompareVersionLabel(commit)]) +) + +function getCompareGraphForCommit(hash: string) { + if (hash === "0a1b2c3d" || hash === "1b2c3d4e" || hash === "2c3d4e5f") { + return { + repositoryLabel: hash.slice(0, 6), + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 3 approvals", + principals: [ + { name: "Alice" }, + { name: "Carol" }, + { name: "Bob" }, + { name: "Steve" }, + ], + }, + ], + } + } + + if (hash === "3d4e5f6a" || hash === "4e5f6a7b" || hash === "5f6a7b8c") { + return { + repositoryLabel: hash.slice(0, 6), + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 3 approvals", + principals: [ + { name: "Alice" }, + { name: "Carol" }, + { name: "Bob" }, + ], + }, + { + key: "docs", + pathLabel: "docs/**", + roleLabel: "Authorized users", + approvals: "Requires: 2 approvals", + principals: [ + { name: "Alice" }, + { name: "Carol" }, + { name: "Bob" }, + ], + }, + { + key: "ops", + pathLabel: "ops/**", + roleLabel: "Authorized users", + approvals: "Requires: 2 approvals", + principals: [ + { name: "Alice" }, + { name: "Carol" }, + { name: "Bob" }, + ], + }, + ], + } + } + + if (hash === "f6a7b8c9" || hash === "e5f6a7b8" || hash === "c3d4e5f6") { + return { + repositoryLabel: hash.slice(0, 6), + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 2 approvals", + principals: [{ name: "Alice" }, { name: "Carol" }, { name: "Bob" }], + }, + ], + } + } + + return { + repositoryLabel: hash.slice(0, 6), + branchLabel: "Branch: main", + lanes: [ + { + key: "src", + pathLabel: "src/**", + roleLabel: "Authorized users", + approvals: "Requires: 1 approval", + principals: [{ name: "Alice" }, { name: "Bob" }, { name: "Carol" }], + }, + ], + } +} + +const demoGraphsByVersion = Object.fromEntries( + demoHistoryCommits.map((commit) => [ + formatCompareVersionLabel(commit), + getCompareGraphForCommit(commit.hash), + ]) +) export const demoVisualizerData: DemoVisualizerData = { repository: { @@ -17,7 +222,7 @@ export const demoVisualizerData: DemoVisualizerData = { }, { hash: "b2c3d4e5", - message: "Require two maintainers for src and docs", + message: "Require two authorized users for src and docs", author: "Bob Smith", date: "2026-05-28T09:10:00.000Z", }, @@ -69,7 +274,7 @@ export const demoVisualizerData: DemoVisualizerData = { { id: "targets-src", label: "src/**", type: "policy-file", x: 240, y: 170 }, { id: "targets-docs", label: "docs/**", type: "policy-file", x: 420, y: 170 }, { id: "role-maintainers", label: "Authorized users", type: "role", x: 240, y: 280, metadata: { threshold: 2 } }, - { id: "role-reviewers", label: "Docs reviewers", type: "role", x: 420, y: 280, metadata: { threshold: 1 } }, + { id: "role-reviewers", label: "Authorized users", type: "role", x: 420, y: 280, metadata: { threshold: 1 } }, { id: "alice", label: "Alice", type: "principal", x: 180, y: 400 }, { id: "carol", label: "Carol", type: "principal", x: 260, y: 400 }, { id: "bob", label: "Bob", type: "principal", x: 340, y: 400 }, @@ -169,62 +374,13 @@ export const demoVisualizerData: DemoVisualizerData = { sortOptions: ["date", "oldest", "author"], selectedSort: "date", selectedCommitHash: "c3d4e5f6", - commits: [ - { - hash: "f6a7b8c9", - message: "[Linux fedora] Installation / dependency error with Webui already installed.", - author: "tonylee12345", - authorLabel: "opened by tonylee12345", - date: "2026-06-03T10:45:00.000Z", - }, - { - hash: "e5f6a7b8", - message: "[Linux fedora] Installation / dependency error with Webui already installed.", - author: "tonylee12345", - authorLabel: "opened by tonylee12345", - date: "2026-06-02T09:20:00.000Z", - }, - { - hash: "c3d4e5f6", - message: "[Linux fedora] Installation / dependency error with Webui already installed.", - author: "tonylee12345", - authorLabel: "opened by tonylee12345", - date: "2026-06-01T12:10:00.000Z", - }, - { - hash: "b2c3d4e5", - message: "[Linux fedora] Installation / dependency error with Webui already installed.", - author: "tonylee12345", - authorLabel: "opened by tonylee12345", - date: "2026-05-31T18:40:00.000Z", - }, - { - hash: "a1b2c3d4", - message: "[Linux fedora] Installation / dependency error with Webui already installed.", - author: "tonylee12345", - authorLabel: "opened by tonylee12345", - date: "2026-05-30T14:00:00.000Z", - }, - { - hash: "98ab76cd", - message: "[Linux fedora] Installation / dependency error with Webui already installed.", - author: "tonylee12345", - authorLabel: "opened by tonylee12345", - date: "2026-05-29T11:15:00.000Z", - }, - ], + commits: demoHistoryCommits, }, compare: { - baseVersionOptions: [ - "a1b1cd • May 1 • Add policy", - "91fe2a • Apr 28 • Update policy", - ], - compareVersionOptions: [ - "a2b3cd • May 5 • Update policy", - "b4c5de • May 8 • Tighten thresholds", - ], - selectedBaseVersion: "a1b1cd • May 1 • Add policy", - selectedCompareVersion: "a2b3cd • May 5 • Update policy", + baseVersionOptions: demoCompareVersionOptions, + compareVersionOptions: demoCompareVersionOptions, + selectedBaseVersion: compareLabelByHash["c3d4e5f6"], + selectedCompareVersion: compareLabelByHash["0a1b2c3d"], changedMetadata: ["Trust setup", "File rules", "Root metadata"], stats: [ { value: "1", label: "role changed" }, @@ -232,153 +388,8 @@ export const demoVisualizerData: DemoVisualizerData = { { value: "1 ↑", label: "threshold" }, { value: "1", label: "principal removed" }, ], - graphsByVersion: { - "a1b1cd • May 1 • Add policy": { - repositoryLabel: "a1b1cd", - branchLabel: "Branch: main", - lanes: [ - { - key: "src", - pathLabel: "src/**", - roleLabel: "Authorized users", - approvals: "Requires: 2 approvals", - principals: [{ name: "Alice" }, { name: "Carol" }, { name: "Bob" }], - }, - ], - }, - "91fe2a • Apr 28 • Update policy": { - repositoryLabel: "91fe2a", - branchLabel: "Branch: main", - lanes: [ - { - key: "src", - pathLabel: "src/**", - roleLabel: "Authorized users", - approvals: "Requires: 1 approval", - principals: [{ name: "Alice" }, { name: "Bob" }, { name: "Carol" }], - }, - ], - }, - "a2b3cd • May 5 • Update policy": { - repositoryLabel: "a2b3cd", - branchLabel: "Branch: main", - lanes: [ - { - key: "src", - pathLabel: "src/**", - roleLabel: "Authorized users", - approvals: "Requires: 3 approvals", - principals: [ - { name: "Alice" }, - { name: "Carol" }, - { name: "Bob" }, - { name: "Steve" }, - ], - }, - ], - }, - "b4c5de • May 8 • Tighten thresholds": { - repositoryLabel: "b4c5de", - branchLabel: "Branch: main", - lanes: [ - { - key: "src", - pathLabel: "src/**", - roleLabel: "Authorized users", - approvals: "Requires: 3 approvals", - principals: [ - { name: "Alice" }, - { name: "Carol" }, - { name: "Bob" }, - { name: "Steve" }, - ], - }, - ], - }, - }, - comparisonsByPair: { - "a1b1cd • May 1 • Add policy|a2b3cd • May 5 • Update policy": { - changedMetadata: ["Trust setup", "File rules", "Root metadata"], - stats: [ - { value: "1", label: "role changed" }, - { value: "0", label: "rules added" }, - { value: "1 ↑", label: "threshold" }, - { value: "1", label: "principal removed" }, - ], - compareGraph: { - repositoryLabel: "a2b3cd", - branchLabel: "Branch: main", - showLegend: true, - lanes: [ - { - key: "src", - pathLabel: "src/**", - roleLabel: "Authorized users", - approvals: "Requires: 3 approvals", - approvalsStatus: "modified", - principals: [ - { name: "Alice", status: "unchanged" }, - { name: "Carol", status: "unchanged" }, - { name: "Bob", status: "removed" }, - { name: "Steve", status: "added" }, - ], - }, - ], - }, - }, - "91fe2a • Apr 28 • Update policy|b4c5de • May 8 • Tighten thresholds": { - changedMetadata: ["File rules", "Root metadata"], - stats: [ - { value: "1", label: "roles changed" }, - { value: "2", label: "rules added" }, - { value: "2 ↑", label: "threshold" }, - { value: "0", label: "principal removed" }, - ], - compareGraph: { - repositoryLabel: "b4c5de", - branchLabel: "Branch: main", - showLegend: true, - lanes: [ - { - key: "src", - pathLabel: "src/**", - roleLabel: "Authorized users", - approvals: "Requires: 3 approvals", - approvalsStatus: "modified", - principals: [ - { name: "Alice", status: "unchanged" }, - { name: "Bob", status: "unchanged" }, - { name: "Carol", status: "unchanged" }, - ], - }, - { - key: "docs", - pathLabel: "docs/**", - roleLabel: "Docs reviewers", - approvals: "Requires: 2 approvals", - approvalsStatus: "modified", - principals: [ - { name: "Alice", status: "unchanged" }, - { name: "Carol", status: "unchanged" }, - { name: "Bob", status: "unchanged" }, - ], - }, - { - key: "ops", - pathLabel: "ops/**", - roleLabel: "Ops maintainers", - approvals: "Requires: 2 approvals", - status: "added", - principals: [ - { name: "Alice", status: "added" }, - { name: "Carol", status: "added" }, - { name: "Bob", status: "added" }, - ], - }, - ], - }, - }, - }, + graphsByVersion: demoGraphsByVersion, + }, metadata: { policyFiles: ["Trust setup: root.json", "File rules: target.json"], @@ -428,9 +439,9 @@ export const demoVisualizerData: DemoVisualizerData = { ], views: [ { id: "status", label: "Status", items: ["root.json active", "targets.json active", "docs delegation draft"] }, - { id: "roles", label: "Roles", items: ["root", "targets", "maintainers", "docs-reviewers"] }, + { id: "roles", label: "Roles", items: ["root", "targets", "authorized-users"] }, { id: "principals", label: "Principals", items: ["Alice", "Bob", "Carol"] }, - { id: "file-rules", label: "File Rules", items: ["src/** requires maintainers", "docs/** requires maintainers"] }, + { id: "file-rules", label: "File Rules", items: ["src/** requires authorized users", "docs/** requires authorized users"] }, ], }, metadataByCommit: { @@ -451,7 +462,7 @@ export const demoVisualizerData: DemoVisualizerData = { type: "targets", expires: "2026-07-15T00:00:00Z", targets: { - "src/**": { rule: "maintainers" }, + "src/**": { rule: "authorized-users" }, }, }, }, @@ -473,8 +484,8 @@ export const demoVisualizerData: DemoVisualizerData = { type: "targets", expires: "2026-07-18T00:00:00Z", targets: { - "src/**": { rule: "maintainers" }, - "docs/**": { rule: "maintainers" }, + "src/**": { rule: "authorized-users" }, + "docs/**": { rule: "authorized-users" }, }, }, }, @@ -496,8 +507,8 @@ export const demoVisualizerData: DemoVisualizerData = { type: "targets", expires: "2026-07-20T00:00:00Z", targets: { - "src/**": { rule: "maintainers" }, - "docs/**": { rule: "maintainers" }, + "src/**": { rule: "authorized-users" }, + "docs/**": { rule: "authorized-users" }, }, }, }, @@ -513,16 +524,16 @@ export const demoVisualizerData: DemoVisualizerData = { roles: [ { name: "root", threshold: 2, principalids: ["alice", "bob"] }, { name: "targets", threshold: 2, principalids: ["alice", "bob", "carol"] }, - { name: "docs-reviewers", threshold: 1, principalids: ["alice", "carol"] }, + { name: "authorized-users", threshold: 1, principalids: ["alice", "carol"] }, ], }, "targets.json": { type: "targets", expires: "2026-07-30T00:00:00Z", targets: { - "src/**": { rule: "maintainers" }, - "docs/**": { rule: "maintainers" }, - "metadata/**": { rule: "docs-reviewers" }, + "src/**": { rule: "authorized-users" }, + "docs/**": { rule: "authorized-users" }, + "metadata/**": { rule: "authorized-users" }, }, }, }, diff --git a/frontend/lib/demo-visualizer.types.ts b/frontend/lib/demo-visualizer.types.ts index 4d0a560..6f4f8b5 100644 --- a/frontend/lib/demo-visualizer.types.ts +++ b/frontend/lib/demo-visualizer.types.ts @@ -126,17 +126,6 @@ export interface DemoCompareData { label: string }> graphsByVersion: Record - comparisonsByPair?: Record< - string, - { - changedMetadata: string[] - stats: Array<{ - value: string - label: string - }> - compareGraph: DemoCompareGraph - } - > } export interface DemoMetadataPanelData { diff --git a/frontend/screens/visualizer/compare.utils.ts b/frontend/screens/visualizer/compare.utils.ts new file mode 100644 index 0000000..df80a2a --- /dev/null +++ b/frontend/screens/visualizer/compare.utils.ts @@ -0,0 +1,222 @@ +"use client"; + +import type { + DemoCompareGraph, + DemoCompareGraphLane, + DemoCompareGraphPrincipal, +} from "@/lib/demo-visualizer.types"; + +export interface VisualizerComparisonResult { + changedMetadata: string[]; + stats: Array<{ + value: string; + label: string; + }>; + compareGraph: DemoCompareGraph; +} + +interface LaneComparisonResult { + lane: DemoCompareGraphLane; + stats: { + rolesChanged: number; + rulesAdded: number; + thresholdDelta: number; + principalChanges: number; + }; + metadataFlags: { + trustSetup: boolean; + fileRules: boolean; + rootMetadata: boolean; + }; +} + +function parseApprovalCount(approvals: string) { + const match = approvals.match(/\d+/); + return match ? Number(match[0]) : 0; +} + +function comparePrincipals( + basePrincipals: DemoCompareGraphPrincipal[], + comparePrincipals: DemoCompareGraphPrincipal[], +) { + const baseByName = new Map(basePrincipals.map((principal) => [principal.name, principal])); + const compareByName = new Map( + comparePrincipals.map((principal) => [principal.name, principal]), + ); + const orderedNames = [ + ...comparePrincipals.map((principal) => principal.name), + ...basePrincipals + .map((principal) => principal.name) + .filter((name) => !compareByName.has(name)), + ]; + + return orderedNames.map((name) => { + const existsInBase = baseByName.has(name); + const comparePrincipal = compareByName.get(name); + + return { + name, + status: !existsInBase + ? "added" + : comparePrincipal + ? "unchanged" + : "removed", + } satisfies DemoCompareGraphPrincipal; + }); +} + +function compareLane( + baseLane: DemoCompareGraphLane | undefined, + compareLane: DemoCompareGraphLane | undefined, +): LaneComparisonResult { + if (!baseLane && compareLane) { + return { + lane: { + ...compareLane, + status: "added", + principals: (compareLane.principals ?? []).map((principal) => ({ + ...principal, + status: "added", + })), + } satisfies DemoCompareGraphLane, + stats: { + rolesChanged: 1, + rulesAdded: 1, + thresholdDelta: parseApprovalCount(compareLane.approvals), + principalChanges: (compareLane.principals ?? []).length, + }, + metadataFlags: { + trustSetup: true, + fileRules: true, + rootMetadata: (compareLane.principals ?? []).length > 0, + }, + }; + } + + if (baseLane && !compareLane) { + return { + lane: { + ...baseLane, + status: "removed", + principals: (baseLane.principals ?? []).map((principal) => ({ + ...principal, + status: "removed", + })), + } satisfies DemoCompareGraphLane, + stats: { + rolesChanged: 1, + rulesAdded: 0, + thresholdDelta: -parseApprovalCount(baseLane.approvals), + principalChanges: (baseLane.principals ?? []).length, + }, + metadataFlags: { + trustSetup: true, + fileRules: true, + rootMetadata: (baseLane.principals ?? []).length > 0, + }, + }; + } + + if (!baseLane || !compareLane) { + throw new Error("compareLane requires at least one lane to compare"); + } + + const pathChanged = baseLane.pathLabel !== compareLane.pathLabel; + const roleChanged = baseLane.roleLabel !== compareLane.roleLabel; + const baseApprovalCount = parseApprovalCount(baseLane.approvals); + const compareApprovalCount = parseApprovalCount(compareLane.approvals); + const approvalsChanged = baseApprovalCount !== compareApprovalCount; + const principals = comparePrincipals( + baseLane.principals ?? [], + compareLane.principals ?? [], + ); + const principalChanges = principals.filter( + (principal) => principal.status !== "unchanged", + ).length; + + return { + lane: { + ...compareLane, + pathStatus: pathChanged ? "modified" : undefined, + roleStatus: roleChanged ? "modified" : undefined, + approvalsStatus: approvalsChanged ? "modified" : undefined, + principals, + } satisfies DemoCompareGraphLane, + stats: { + rolesChanged: pathChanged || roleChanged || approvalsChanged ? 1 : 0, + rulesAdded: 0, + thresholdDelta: compareApprovalCount - baseApprovalCount, + principalChanges, + }, + metadataFlags: { + trustSetup: approvalsChanged, + fileRules: pathChanged || roleChanged, + rootMetadata: principalChanges > 0, + }, + }; +} + +export function buildComparisonResult( + baseGraph: DemoCompareGraph | undefined, + compareGraph: DemoCompareGraph | undefined, + compareVersionLabel: string, +): VisualizerComparisonResult { + const baseLanes = baseGraph?.lanes ?? []; + const compareLanes = compareGraph?.lanes ?? []; + const baseLaneMap = new Map(baseLanes.map((lane) => [lane.key, lane])); + const compareLaneMap = new Map(compareLanes.map((lane) => [lane.key, lane])); + const orderedLaneKeys = [ + ...compareLanes.map((lane) => lane.key), + ...baseLanes.map((lane) => lane.key).filter((key) => !compareLaneMap.has(key)), + ]; + + let rolesChanged = 0; + let rulesAdded = 0; + let thresholdDelta = 0; + let principalChanges = 0; + let trustSetupChanged = false; + let fileRulesChanged = false; + let rootMetadataChanged = false; + + const lanes = orderedLaneKeys + .map((key) => { + const result = compareLane(baseLaneMap.get(key), compareLaneMap.get(key)); + rolesChanged += result.stats.rolesChanged; + rulesAdded += result.stats.rulesAdded; + thresholdDelta += result.stats.thresholdDelta; + principalChanges += result.stats.principalChanges; + trustSetupChanged ||= result.metadataFlags.trustSetup; + fileRulesChanged ||= result.metadataFlags.fileRules; + rootMetadataChanged ||= result.metadataFlags.rootMetadata; + return result.lane; + }); + + const changedMetadata = [ + trustSetupChanged ? "Trust setup" : null, + fileRulesChanged ? "File rules" : null, + rootMetadataChanged ? "Root metadata" : null, + ].filter((item): item is string => Boolean(item)); + + return { + changedMetadata, + stats: [ + { value: String(rolesChanged), label: "roles changed" }, + { value: String(rulesAdded), label: "rules added" }, + { + value: + thresholdDelta === 0 + ? "0" + : `${Math.abs(thresholdDelta)} ${thresholdDelta > 0 ? "↑" : "↓"}`, + label: "threshold delta", + }, + { value: String(principalChanges), label: "principal changes" }, + ], + compareGraph: { + repositoryLabel: + compareGraph?.repositoryLabel ?? compareVersionLabel.split(" • ")[0], + branchLabel: compareGraph?.branchLabel ?? "Branch: main", + showLegend: true, + lanes, + }, + }; +} diff --git a/frontend/screens/visualizer/detail-content.tsx b/frontend/screens/visualizer/detail-content.tsx index 6b0e615..58a05bf 100644 --- a/frontend/screens/visualizer/detail-content.tsx +++ b/frontend/screens/visualizer/detail-content.tsx @@ -12,6 +12,7 @@ import { DetailPanelPolicyQuery, DetailPanelSettings, } from "@/screens/visualizer/panel-tabs/detail-panels"; +import type { VisualizerComparisonResult } from "@/screens/visualizer/compare.utils"; import type { HistorySortField } from "@/screens/visualizer/history.types"; import type { WorkspacePanelId } from "@/screens/visualizer/visualizer.types"; @@ -38,6 +39,7 @@ interface WorkspaceDetailContentProps { onHistorySortDirectionToggle: () => void; selectedBaseVersion: string; selectedCompareVersion: string; + comparisonResult: VisualizerComparisonResult; hasCompared: boolean; onBaseVersionChange: (value: string) => void; onCompareVersionChange: (value: string) => void; @@ -61,6 +63,7 @@ export function WorkspaceDetailContent({ onHistorySortDirectionToggle, selectedBaseVersion, selectedCompareVersion, + comparisonResult, hasCompared, onBaseVersionChange, onCompareVersionChange, @@ -140,6 +143,7 @@ export function WorkspaceDetailContent({ searchQuery={searchQuery} selectedBaseVersion={selectedBaseVersion} selectedCompareVersion={selectedCompareVersion} + comparisonResult={comparisonResult} hasCompared={hasCompared} onBaseVersionChange={onBaseVersionChange} onCompareVersionChange={onCompareVersionChange} diff --git a/frontend/screens/visualizer/panel-tabs/detail-compare.tsx b/frontend/screens/visualizer/panel-tabs/detail-compare.tsx index 5518242..b1cdafd 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-compare.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-compare.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import emptyFileIcon from "@/assets/empty_file.png"; import swapVertIcon from "@/assets/swap_vert.png"; import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; @@ -14,12 +14,14 @@ import { SelectField, SummaryMetricGrid, } from "@/components/visualizer/detail/workspace-detail-primitives"; +import type { VisualizerComparisonResult } from "@/screens/visualizer/compare.utils"; interface DetailPanelCompareProps { workspaceData?: DemoVisualizerData | null; searchQuery?: string; selectedBaseVersion: string; selectedCompareVersion: string; + comparisonResult: VisualizerComparisonResult; hasCompared: boolean; onBaseVersionChange: (value: string) => void; onCompareVersionChange: (value: string) => void; @@ -32,6 +34,7 @@ export function DetailPanelCompare({ searchQuery, selectedBaseVersion, selectedCompareVersion, + comparisonResult, hasCompared, onBaseVersionChange, onCompareVersionChange, @@ -44,15 +47,6 @@ export function DetailPanelCompare({ demoVisualizerData.workspaceDetails.compare; const baseOptions = compareData.baseVersionOptions; const compareOptions = compareData.compareVersionOptions; - const comparisonResult = useMemo(() => { - const key = `${selectedBaseVersion}|${selectedCompareVersion}`; - return ( - compareData.comparisonsByPair?.[key] ?? { - changedMetadata: compareData.changedMetadata, - stats: compareData.stats, - } - ); - }, [compareData, selectedBaseVersion, selectedCompareVersion]); return (
@@ -68,9 +62,13 @@ export function DetailPanelCompare({
@@ -98,17 +96,15 @@ export function DetailPanelCompare({
- {comparisonResult.changedMetadata.map((item, index) => ( -
- {index < 2 ? "✓" : "—"}{" "} - -
- ))} + {comparisonResult.changedMetadata.length > 0 ? ( + comparisonResult.changedMetadata.map((item) => ( +
+ ✓ +
+ )) + ) : ( +
— No metadata changes
+ )}
diff --git a/frontend/screens/visualizer/panel-tabs/detail-history.tsx b/frontend/screens/visualizer/panel-tabs/detail-history.tsx index d85efaf..497a920 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-history.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-history.tsx @@ -147,44 +147,44 @@ export function DetailPanelHistory({ /> ))}
- {totalPages > 1 ? ( -
- -
- {Array.from({ length: totalPages }, (_, index) => { - const pageNumber = index + 1; - const isActive = currentPage === pageNumber; +
+ +
+ {Array.from({ length: totalPages }, (_, index) => { + const pageNumber = index + 1; + const isActive = currentPage === pageNumber; - return ( - - ); - })} -
- + return ( + + ); + })}
- ) : null} + +
); diff --git a/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx b/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx index 8432d51..86a7a27 100644 --- a/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx +++ b/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx @@ -1,9 +1,11 @@ "use client"; +import { useMemo, useState } from "react"; import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; import completedIcon from "@/assets/completed.png"; import metadataIcon from "@/assets/metadata.png"; +import { detailColors } from "@/components/visualizer/detail/workspace-detail-primitives"; import { PanelSection, SearchHighlightText, @@ -18,6 +20,32 @@ interface DetailPanelMetadataProps { searchQuery?: string; } +function MetadataCodeCard({ + label, + value, + searchQuery, +}: { + label: string; + value: string; + searchQuery?: string; +}) { + return ( +
+ +
+        
+      
+
+ ); +} + export function DetailPanelMetadata({ workspaceData, searchQuery, @@ -25,6 +53,51 @@ export function DetailPanelMetadata({ const metadataData = workspaceData?.workspaceDetails.metadata ?? demoVisualizerData.workspaceDetails.metadata; + const metadataByCommit = + workspaceData?.metadataByCommit ?? demoVisualizerData.metadataByCommit; + const [selectedView, setSelectedView] = useState( + metadataData.selectedView ?? metadataData.views[0] ?? "Summary", + ); + const activeView = metadataData.views.includes(selectedView) + ? selectedView + : metadataData.selectedView ?? metadataData.views[0] ?? "Summary"; + const sourceCommitKey = useMemo( + () => + Object.keys(metadataByCommit).find((commitHash) => + commitHash.startsWith(metadataData.status.sourceCommit), + ) ?? Object.keys(metadataByCommit)[0], + [metadataByCommit, metadataData.status.sourceCommit], + ); + const sourceMetadata = sourceCommitKey ? metadataByCommit[sourceCommitKey] : undefined; + const decodedJsonCards = useMemo( + () => + sourceMetadata + ? Object.entries(sourceMetadata).map(([fileName, value]) => ({ + label: fileName, + value: JSON.stringify(value, null, 2), + })) + : [], + [sourceMetadata], + ); + const envelopeCards = useMemo( + () => + sourceMetadata + ? Object.entries(sourceMetadata).map(([fileName, value]) => ({ + label: `${fileName} envelope`, + value: JSON.stringify( + { + sourceCommit: sourceCommitKey, + fileName, + payload: value, + signatures: metadataData.status.signaturesFound, + }, + null, + 2, + ), + })) + : [], + [metadataData.status.signaturesFound, sourceCommitKey, sourceMetadata], + ); return (
@@ -76,8 +149,9 @@ export function DetailPanelMetadata({ ))}
- + {activeView === "Summary" ? ( + + ) : null} + {activeView === "Decoded JSON" ? ( +
+ {decodedJsonCards.map((card) => ( + + ))} +
+ ) : null} + {activeView === "Envelope" ? ( +
+ {envelopeCards.map((card) => ( + + ))} +
+ ) : null}
diff --git a/frontend/screens/visualizer/use-visualizer-history-compare.ts b/frontend/screens/visualizer/use-visualizer-history-compare.ts index 4c4d48c..8c46828 100644 --- a/frontend/screens/visualizer/use-visualizer-history-compare.ts +++ b/frontend/screens/visualizer/use-visualizer-history-compare.ts @@ -8,6 +8,7 @@ import { getHistoryTimelineCommits, sortHistoryTimelineCommits, } from "@/screens/visualizer/history-canvas"; +import { buildComparisonResult } from "@/screens/visualizer/compare.utils"; import type { HistorySortField } from "@/screens/visualizer/history.types"; import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; @@ -75,11 +76,6 @@ export function useVisualizerHistoryCompare( [historyCommits, workspaceData], ); - const comparePairKey = `${selectedBaseVersion}|${selectedCompareVersion}`; - const activeComparison = useMemo( - () => compareData.comparisonsByPair?.[comparePairKey], - [compareData, comparePairKey], - ); const baseCompareGraph = useMemo(() => { const baseGraph = compareData.graphsByVersion[selectedBaseVersion]; return { @@ -89,27 +85,23 @@ export function useVisualizerHistoryCompare( lanes: baseGraph?.lanes, }; }, [compareData.graphsByVersion, selectedBaseVersion]); + const comparisonResult = useMemo( + () => + buildComparisonResult( + compareData.graphsByVersion[selectedBaseVersion], + compareData.graphsByVersion[selectedCompareVersion], + selectedCompareVersion, + ), + [compareData.graphsByVersion, selectedBaseVersion, selectedCompareVersion], + ); const compareGraph = useMemo(() => { - if (activeComparison?.compareGraph) { - return { - repositoryLabel: - activeComparison.compareGraph.repositoryLabel ?? - selectedCompareVersion.split(" • ")[0], - branchLabel: activeComparison.compareGraph.branchLabel ?? "Branch: main", - lanes: activeComparison.compareGraph.lanes, - showCompareLegend: activeComparison.compareGraph.showLegend ?? true, - }; - } - - const fallbackGraph = compareData.graphsByVersion[selectedCompareVersion]; return { - repositoryLabel: - fallbackGraph?.repositoryLabel ?? selectedCompareVersion.split(" • ")[0], - branchLabel: fallbackGraph?.branchLabel ?? "Branch: main", - lanes: fallbackGraph?.lanes, - showCompareLegend: true, + repositoryLabel: comparisonResult.compareGraph.repositoryLabel, + branchLabel: comparisonResult.compareGraph.branchLabel, + lanes: comparisonResult.compareGraph.lanes, + showCompareLegend: comparisonResult.compareGraph.showLegend ?? true, }; - }, [activeComparison, compareData.graphsByVersion, selectedCompareVersion]); + }, [comparisonResult]); useEffect(() => { // History selection follows the currently sorted commit list so the detail @@ -137,6 +129,7 @@ export function useVisualizerHistoryCompare( return { activeHistoryCommitId, baseCompareGraph, + comparisonResult, compareGraph, detailHistoryCommits, hasCompared, diff --git a/frontend/screens/visualizer/use-visualizer-tabs.ts b/frontend/screens/visualizer/use-visualizer-tabs.ts index 605c7c4..6fe82e4 100644 --- a/frontend/screens/visualizer/use-visualizer-tabs.ts +++ b/frontend/screens/visualizer/use-visualizer-tabs.ts @@ -41,11 +41,12 @@ export function useVisualizerTabs({ const nextGraphTabNumberRef = useRef(2); const nextGraphInstanceNumberRef = useRef(2); + const nextCompareTabNumberRef = useRef(1); const activeGraphTab = graphTabs.find((tab) => tab.id === activeGraphTabId) ?? graphTabs[0]; const isHistoryPanel = activeGraphTabId === historyTabId; - const isComparePanel = activeGraphTabId === compareTabId; + const isComparePanel = activeGraphTabId.startsWith(compareTabId); const handleHistoryPanelSelect = () => { // History owns a reserved tab ID so menu selection, bottom-bar selection, @@ -96,30 +97,21 @@ export function useVisualizerTabs({ ); const handleGenerateCompareGraph = () => { - // Compare behaves like history: it gets a stable reserved tab instead of - // replacing the active graph tab, which preserves user canvas context. - setGraphTabs((currentTabs) => { - const existingCompareTab = currentTabs.find((tab) => tab.id === compareTabId); - if (existingCompareTab) { - return currentTabs.map((tab) => - tab.id === compareTabId ? { ...tab, label: compareTabLabel } : tab, - ); - } + const nextCompareTabId = `${compareTabId}-${nextCompareTabNumberRef.current++}`; - return [ - ...currentTabs, - { - id: compareTabId, - label: compareTabLabel, - closable: true, - editable: false, - graphs: [], - }, - ]; - }); + setGraphTabs((currentTabs) => [ + ...currentTabs, + { + id: nextCompareTabId, + label: compareTabLabel, + closable: true, + editable: false, + graphs: [], + }, + ]); setHasCompared(true); setActivePanel("compare"); - setActiveGraphTabId(compareTabId); + setActiveGraphTabId(nextCompareTabId); }; const handleAddGraphTab = () => { @@ -199,9 +191,9 @@ export function useVisualizerTabs({ return; } - if (tabId === compareTabId) { + if (tabId.startsWith(compareTabId)) { setActivePanel("compare"); - setActiveGraphTabId(compareTabId); + setActiveGraphTabId(tabId); return; } diff --git a/frontend/screens/visualizer/use-visualizer-workspace.ts b/frontend/screens/visualizer/use-visualizer-workspace.ts index c483086..3ee68f1 100644 --- a/frontend/screens/visualizer/use-visualizer-workspace.ts +++ b/frontend/screens/visualizer/use-visualizer-workspace.ts @@ -40,6 +40,7 @@ export function useVisualizerWorkspace({ const { activeHistoryCommitId, baseCompareGraph, + comparisonResult, compareGraph, detailHistoryCommits, hasCompared, @@ -104,6 +105,7 @@ export function useVisualizerWorkspace({ activePanel, activePanelIcon, baseCompareGraph, + comparisonResult, compareGraph, defaultLayout, detailHistoryCommits, diff --git a/frontend/screens/visualizer/visualizer-workspace.tsx b/frontend/screens/visualizer/visualizer-workspace.tsx index 7293539..b74fb5b 100644 --- a/frontend/screens/visualizer/visualizer-workspace.tsx +++ b/frontend/screens/visualizer/visualizer-workspace.tsx @@ -38,6 +38,7 @@ export default function VisualizerWorkspace(props: VisualizerWorkspaceProps) { activePanel, activePanelIcon, baseCompareGraph, + comparisonResult, compareGraph, defaultLayout, detailHistoryCommits, @@ -197,6 +198,7 @@ export default function VisualizerWorkspace(props: VisualizerWorkspaceProps) { } selectedBaseVersion={selectedBaseVersion} selectedCompareVersion={selectedCompareVersion} + comparisonResult={comparisonResult} hasCompared={hasCompared} onBaseVersionChange={(value) => { setSelectedBaseVersion(value); From cc1aeb4a062221eb1ae8cb1aed32bcffcae97527 Mon Sep 17 00:00:00 2001 From: Tony Lee Date: Mon, 15 Jun 2026 00:04:31 -0400 Subject: [PATCH 4/4] docs: update frontend setup and agent guidance Signed-off-by: Tony Lee --- frontend/AGENT.md | 3 +-- frontend/README.md | 38 +++++++++++--------------------------- frontend/app/layout.tsx | 4 ++-- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/frontend/AGENT.md b/frontend/AGENT.md index 3fcdf80..a0fccfa 100644 --- a/frontend/AGENT.md +++ b/frontend/AGENT.md @@ -311,9 +311,8 @@ When preparing commits: - Use the existing branch unless the task explicitly asks for a new one. - Prefer small, focused commits. -- If DCO is required for the branch/PR, use signed commits/messages such as: - - `git commit -s -m "your message"` - If you rewrite commit history, manually verify the visualizer flow again before pushing. +- Never automatically open PRs. A human must create the PR manually, add the required sign-off, and use the [pull_request_template.md](https://github.com/gittuf/gittuf/blob/main/.github/pull_request_template.md) as a checklist. ## Common Pitfalls diff --git a/frontend/README.md b/frontend/README.md index e94a01d..dd37c64 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,34 +1,18 @@ # gittuf Metadata Visualizer (Frontend) This directory contains the active frontend for the gittuf metadata visualizer, -written in Next.js. +written in Next.js. (The current version supports only a demo mode using mock data. Support for actually repo data is still under development.) ## Features -- **Repository Entry Flow**: Connect a remote repository, point at a local - repository, or launch the demo workspace from the home screen. -- **Policy Graph Workspace**: Explore one or more draggable policy graphs inside - the main visualizer canvas, with tabbed canvases along the bottom bar. -- **Graph Source Controls**: Inspect repository, policy ref, policy version, - metadata source, and active mode from the detail panel. -- **Policy Query Panel**: Query a branch and changed path to see the matched - rule, required approvals, and authorized users. -- **History Timeline**: Open a history view with sortable commits, a commit - strip, and graph canvases for browsing policy state across revisions. -- **Comparison Canvas**: Generate side-by-side base and compare graphs with - added, removed, modified, and unchanged diff highlighting. -- **Metadata and Settings Panels**: Review metadata status and summary views, - then adjust visible node/detail settings for the workspace. - -## Tech Stack - -- **Framework**: Next.js 16 (App Router) -- **UI**: Tailwind CSS, shadcn UI components, Radix UI -- **Visualization**: ReactFlow, Chart.js & react-chartjs-2, Framer Motion -- **Icons**: Lucide React -- **Language**: TypeScript -- **Linting**: ESLint (Next.js Core Web Vitals, TypeScript) -- **Testing**: Typecheck + lint today, broader automated UI tests still to be added +- **Repository Selection**: Start building policy graphs from a remote repository, a local repository, or the demo workspace. +- **Interactive Policy Graphs**: Explore trust relationships through draggable policy graphs in a tabbed workspace. +- **Policy Queries**: Check which rules and approvals apply to a given branch and file path. +- **History View**: Browse how policy state has evolved across commit history. +- **Side-by-Side Comparison**: Compare two versions of a policy graph with + visual added, removed, modified, and unchanged diff highlighting. +- **Metadata Inspection**: Review metadata status, summary metrics, decoded + JSON, and envelope views. ## Getting Started @@ -99,8 +83,8 @@ frontend/ ## Usage -1. Open the home route and enter a Git repository URL, choose a local - repository, or launch the demo workspace. +1. Open the app's landing page and enter a Git repository URL, choose a local + repository, or launch the demo workspace. (current version only supports the demo workspace) 2. Explore the visualizer workspace, including the graph canvas, history strip, and detail panel tabs. diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 40f52a8..1fdf0ea 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,11 +2,11 @@ import type React from "react"; import type { Metadata } from "next"; import "./globals.css"; export const metadata: Metadata = { - title: "gittuf Visualizer", + title: "gittuf visualizer", description: "Visualize and analyze gittuf's security metadata structure across repository commits", generator: "v0.dev", - applicationName: "Gittuf Visualizer", + applicationName: "gittuf visualizer", icons: { icon: "/favicon.png", },