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/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/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..e94a01d 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,32 +1,34 @@ # 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 -- **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. ## 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 @@ -66,35 +68,54 @@ 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 +├── screens/ +│ ├── repository/ +│ │ └── repository-selector.tsx # Repository selection screen +│ └── 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/ +│ └── visualizer/ # Visualizer-specific hooks +├── 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 +├── 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. + +## 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/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..aaacb9c 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 { useGittufExplorer } from "@/hooks/use-gittuf-explorer" +import Header from "@/components/app/header" +import { useRepositorySession } from "@/hooks/use-repository-session" 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} - /> + } = useRepositorySession() - + 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/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/app/simulator/page.tsx b/frontend/archive/playground/app/page.tsx similarity index 75% rename from frontend/app/simulator/page.tsx rename to frontend/archive/playground/app/page.tsx index e32d7b8..9dfba76 100644 --- a/frontend/app/simulator/page.tsx +++ b/frontend/archive/playground/app/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/status-card.tsx b/frontend/archive/playground/components/status-card.tsx similarity index 100% rename from frontend/components/shared/status-card.tsx rename to frontend/archive/playground/components/status-card.tsx diff --git a/frontend/components/shared/story-modal.tsx b/frontend/archive/playground/components/story-modal.tsx similarity index 99% rename from frontend/components/shared/story-modal.tsx rename to frontend/archive/playground/components/story-modal.tsx index 8361a8c..773edec 100644 --- a/frontend/components/shared/story-modal.tsx +++ b/frontend/archive/playground/components/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/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/components/features/simulator/simulator-analysis.tsx b/frontend/archive/playground/screens/playground/simulator-analysis.tsx similarity index 100% rename from frontend/components/features/simulator/simulator-analysis.tsx rename to frontend/archive/playground/screens/playground/simulator-analysis.tsx diff --git a/frontend/components/features/simulator/simulator-config-modal.tsx b/frontend/archive/playground/screens/playground/simulator-config-modal.tsx similarity index 100% rename from frontend/components/features/simulator/simulator-config-modal.tsx rename to frontend/archive/playground/screens/playground/simulator-config-modal.tsx diff --git a/frontend/components/features/simulator/simulator-controls.tsx b/frontend/archive/playground/screens/playground/simulator-controls.tsx similarity index 100% rename from frontend/components/features/simulator/simulator-controls.tsx rename to frontend/archive/playground/screens/playground/simulator-controls.tsx diff --git a/frontend/components/features/simulator/simulator-glossary.tsx b/frontend/archive/playground/screens/playground/simulator-glossary.tsx similarity index 100% rename from frontend/components/features/simulator/simulator-glossary.tsx rename to frontend/archive/playground/screens/playground/simulator-glossary.tsx diff --git a/frontend/components/features/simulator/simulator-graph.tsx b/frontend/archive/playground/screens/playground/simulator-graph.tsx similarity index 98% rename from frontend/components/features/simulator/simulator-graph.tsx rename to frontend/archive/playground/screens/playground/simulator-graph.tsx index cd996e6..39cbc0b 100644 --- a/frontend/components/features/simulator/simulator-graph.tsx +++ b/frontend/archive/playground/screens/playground/simulator-graph.tsx @@ -5,7 +5,7 @@ import { Sparkles, Maximize2, Minimize2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" -import { TrustGraph } from "@/components/features/visualization/trust-graph" +import { TrustGraph } from "@/screens/playground/trust-graph" import type { SimulatorState } from "@/hooks/use-gittuf-simulator" interface SimulatorGraphProps { diff --git a/frontend/components/features/simulator/simulator-header.tsx b/frontend/archive/playground/screens/playground/simulator-header.tsx similarity index 100% rename from frontend/components/features/simulator/simulator-header.tsx rename to frontend/archive/playground/screens/playground/simulator-header.tsx diff --git a/frontend/components/features/visualization/trust-graph.tsx b/frontend/archive/playground/screens/playground/trust-graph.tsx similarity index 100% rename from frontend/components/features/visualization/trust-graph.tsx rename to frontend/archive/playground/screens/playground/trust-graph.tsx diff --git a/frontend/assets/Logo.png b/frontend/assets/Logo.png new file mode 100644 index 0000000..f3dfe5d Binary files /dev/null and b/frontend/assets/Logo.png differ diff --git a/frontend/assets/Settings.png b/frontend/assets/Settings.png new file mode 100644 index 0000000..c7abaa7 Binary files /dev/null and b/frontend/assets/Settings.png differ diff --git a/frontend/assets/Users.png b/frontend/assets/Users.png new file mode 100644 index 0000000..14a6c68 Binary files /dev/null and b/frontend/assets/Users.png differ diff --git a/frontend/assets/add.png b/frontend/assets/add.png new file mode 100644 index 0000000..581170d Binary files /dev/null and b/frontend/assets/add.png differ diff --git a/frontend/assets/arrow_drop_down.png b/frontend/assets/arrow_drop_down.png new file mode 100644 index 0000000..b0723e0 Binary files /dev/null and b/frontend/assets/arrow_drop_down.png differ diff --git a/frontend/assets/ascending.png b/frontend/assets/ascending.png new file mode 100644 index 0000000..e3dcf6a Binary files /dev/null and b/frontend/assets/ascending.png differ diff --git a/frontend/assets/branch.png b/frontend/assets/branch.png new file mode 100644 index 0000000..700605c Binary files /dev/null and b/frontend/assets/branch.png differ diff --git a/frontend/assets/clip.png b/frontend/assets/clip.png new file mode 100644 index 0000000..41df8a1 Binary files /dev/null and b/frontend/assets/clip.png differ diff --git a/frontend/assets/commit.png b/frontend/assets/commit.png new file mode 100644 index 0000000..f03c3d4 Binary files /dev/null and b/frontend/assets/commit.png differ diff --git a/frontend/assets/compare.png b/frontend/assets/compare.png new file mode 100644 index 0000000..a68d215 Binary files /dev/null and b/frontend/assets/compare.png differ diff --git a/frontend/assets/completed.png b/frontend/assets/completed.png new file mode 100644 index 0000000..246f194 Binary files /dev/null and b/frontend/assets/completed.png differ diff --git a/frontend/assets/discending.png b/frontend/assets/discending.png new file mode 100644 index 0000000..a6a5897 Binary files /dev/null and b/frontend/assets/discending.png differ diff --git a/frontend/assets/empty.png b/frontend/assets/empty.png new file mode 100644 index 0000000..777849f Binary files /dev/null and b/frontend/assets/empty.png differ diff --git a/frontend/assets/empty_file.png b/frontend/assets/empty_file.png new file mode 100644 index 0000000..d752da8 Binary files /dev/null and b/frontend/assets/empty_file.png differ diff --git a/frontend/assets/file.png b/frontend/assets/file.png new file mode 100644 index 0000000..7f986f8 Binary files /dev/null and b/frontend/assets/file.png differ diff --git a/frontend/assets/graph-source.png b/frontend/assets/graph-source.png new file mode 100644 index 0000000..a62fb5f Binary files /dev/null and b/frontend/assets/graph-source.png differ diff --git a/frontend/assets/graph.png b/frontend/assets/graph.png new file mode 100644 index 0000000..e50881e Binary files /dev/null and b/frontend/assets/graph.png differ diff --git a/frontend/assets/green_check_box.png b/frontend/assets/green_check_box.png new file mode 100644 index 0000000..524bd0a Binary files /dev/null and b/frontend/assets/green_check_box.png differ diff --git a/frontend/assets/history.png b/frontend/assets/history.png new file mode 100644 index 0000000..bc71b5c Binary files /dev/null and b/frontend/assets/history.png differ diff --git a/frontend/assets/left.png b/frontend/assets/left.png new file mode 100644 index 0000000..d79520c Binary files /dev/null and b/frontend/assets/left.png differ diff --git a/frontend/assets/metadata.png b/frontend/assets/metadata.png new file mode 100644 index 0000000..4fd9e0d Binary files /dev/null and b/frontend/assets/metadata.png differ diff --git a/frontend/assets/policy-query.png b/frontend/assets/policy-query.png new file mode 100644 index 0000000..0ac970c Binary files /dev/null and b/frontend/assets/policy-query.png differ diff --git a/frontend/assets/right.png b/frontend/assets/right.png new file mode 100644 index 0000000..ad0fa68 Binary files /dev/null and b/frontend/assets/right.png differ diff --git a/frontend/assets/search.png b/frontend/assets/search.png new file mode 100644 index 0000000..d15c715 Binary files /dev/null and b/frontend/assets/search.png differ diff --git a/frontend/assets/spinner.png b/frontend/assets/spinner.png new file mode 100644 index 0000000..38df3fd Binary files /dev/null and b/frontend/assets/spinner.png differ diff --git a/frontend/assets/swap_vert.png b/frontend/assets/swap_vert.png new file mode 100644 index 0000000..d421ec0 Binary files /dev/null and b/frontend/assets/swap_vert.png differ diff --git a/frontend/assets/user.png b/frontend/assets/user.png new file mode 100644 index 0000000..ed21475 Binary files /dev/null and b/frontend/assets/user.png differ diff --git a/frontend/assets/zoom-in.png b/frontend/assets/zoom-in.png new file mode 100644 index 0000000..586a9f5 Binary files /dev/null and b/frontend/assets/zoom-in.png differ diff --git a/frontend/assets/zoom-out.png b/frontend/assets/zoom-out.png new file mode 100644 index 0000000..2c0e0f6 Binary files /dev/null and b/frontend/assets/zoom-out.png differ diff --git a/frontend/components/app/header.tsx b/frontend/components/app/header.tsx new file mode 100644 index 0000000..3d381d0 --- /dev/null +++ b/frontend/components/app/header.tsx @@ -0,0 +1,31 @@ +"use client" + +import Image from "next/image" +import logo from "@/assets/Logo.png" +import ProgressIndicator from "@/components/common/progress-indicator" + +interface HeaderProps { + hasCommits: boolean + currentStep: number + steps: string[] +} + +export default function Header({ + hasCommits, + currentStep, + steps, +}: HeaderProps) { + return ( +
+
+ 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/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/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..a6f6cf5 --- /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 > 3 ? ( + <> +
+
+ + ) : 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 new file mode 100644 index 0000000..4d14c4e --- /dev/null +++ b/frontend/components/visualizer/detail/workspace-detail-primitives.tsx @@ -0,0 +1,31 @@ +"use client"; + +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 new file mode 100644 index 0000000..951383e --- /dev/null +++ b/frontend/components/visualizer/workspace-action-button.tsx @@ -0,0 +1,45 @@ +"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"; +} + +export function WorkspaceActionButton({ + label, + onClick, + disabled = false, + loading = 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..399f0d9 --- /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..1c01bd9 --- /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..ae2e26d --- /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..a636c99 --- /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..c830b6e --- /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-(--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-commit-analysis.ts b/frontend/hooks/explorer/use-commit-analysis.ts deleted file mode 100644 index ec9f84c..0000000 --- a/frontend/hooks/explorer/use-commit-analysis.ts +++ /dev/null @@ -1,65 +0,0 @@ -"use client" - -import { useState, useEffect } from "react" -import type { Commit, JsonObject } from "@/lib/types" -import { REPOSITORY } from "@/lib/constants" -import { mockFetchMetadata } from "@/lib/mock-api" -import type { RepositoryInfo } from "@/lib/repository-handler" - -export function useCommitAnalysis( - activeTab: string, - repoUrl: string, - currentRepository: RepositoryInfo | null, - selectedFile: string -) { - const [selectedCommits, setSelectedCommits] = useState([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState("") - - const handleCommitRangeSelect = (commits: Commit[]) => { - setSelectedCommits(commits) - } - - useEffect(() => { - // Only fetch if we have selected commits and any of them are missing data - const needsData = selectedCommits.some(commit => !commit.data) - - if (activeTab === "analysis" && selectedCommits.length > 0 && needsData) { - const loadAnalysisData = async () => { - setLoading(true) - setError("") - - try { - const fallbackUrl = currentRepository?.path || repoUrl || REPOSITORY.GITTUF_URL - const dataPromises = selectedCommits.map((commit) => - mockFetchMetadata(fallbackUrl, commit.hash, selectedFile), - ) - const results = await Promise.all(dataPromises) - const commitsWithData = selectedCommits.map((commit, index) => ({ - ...commit, - data: results[index] as JsonObject, - })) - - setSelectedCommits(commitsWithData) - } catch (err) { - console.error("Failed to load analysis data:", err) - setError("Failed to load analysis data for selected commits. Please try again.") - } finally { - setLoading(false) - } - } - - loadAnalysisData() - } - - }, [activeTab, selectedCommits.length, selectedFile, currentRepository?.path, repoUrl, selectedCommits]) - - return { - selectedCommits, - setSelectedCommits, - loading, - error, - setError, - handleCommitRangeSelect, - } -} diff --git a/frontend/hooks/explorer/use-commit-comparison.ts b/frontend/hooks/explorer/use-commit-comparison.ts deleted file mode 100644 index 9ee6e6b..0000000 --- a/frontend/hooks/explorer/use-commit-comparison.ts +++ /dev/null @@ -1,90 +0,0 @@ -"use client" - -import { useState } from "react" -import { REPOSITORY } from "@/lib/constants" -import type { Commit, JsonObject } from "@/lib/types" -import { mockFetchMetadata } from "@/lib/mock-api" -import type { RepositoryInfo } from "@/lib/repository-handler" - -interface CompareCommits { - base: Commit | null - compare: Commit | null -} - -interface CompareData { - base: JsonObject | null - compare: JsonObject | null -} - -export function useCommitComparison() { - const [compareCommits, setCompareCommits] = useState({ base: null, compare: null }) - const [compareData, setCompareData] = useState({ base: null, compare: null }) - const [loading, setLoading] = useState(false) - const [error, setError] = useState("") - - const handleCompareSelect = async ( - base: Commit, - compare: Commit, - repoUrl: string, - currentRepository: RepositoryInfo | null, - selectedFile: string, - onSuccess?: () => void - ) => { - setCompareCommits({ base, compare }) - setLoading(true) - setError("") - - try { - const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" - const [baseData, compareDataResult] = await Promise.all([ - mockFetchMetadata(fallbackUrl, base.hash, selectedFile), - mockFetchMetadata(fallbackUrl, compare.hash, selectedFile), - ]) - - setCompareData({ base: baseData as JsonObject | null, compare: compareDataResult as JsonObject | null }) - if (onSuccess) onSuccess() - } catch (err) { - console.error("Failed to fetch comparison data:", err) - setError(`Failed to fetch comparison data: ${err instanceof Error ? err.message : "Unknown error"}`) - } finally { - setLoading(false) - } - } - - const handleFileChange = async ( - file: string, - repoUrl: string, - currentRepository: RepositoryInfo | null - ) => { - if (compareCommits.base && compareCommits.compare) { - setLoading(true) - setError("") - try { - const fallbackUrl = currentRepository?.path || repoUrl || REPOSITORY.GITTUF_URL - const [baseData, compareDataResult] = await Promise.all([ - mockFetchMetadata(fallbackUrl, compareCommits.base.hash, file), - mockFetchMetadata(fallbackUrl, compareCommits.compare.hash, file), - ]) - - setCompareData({ base: baseData as JsonObject | null, compare: compareDataResult as JsonObject | null }) - } catch (err) { - console.error("Failed to fetch file comparison data:", err) - setError(`Failed to fetch comparison data: ${err instanceof Error ? err.message : "Unknown error"}`) - } finally { - setLoading(false) - } - } - } - - return { - compareCommits, - setCompareCommits, - compareData, - setCompareData, - loading, - error, - setError, - handleCompareSelect, - handleFileChange, - } -} diff --git a/frontend/hooks/explorer/use-commit-selection.ts b/frontend/hooks/explorer/use-commit-selection.ts deleted file mode 100644 index 330787f..0000000 --- a/frontend/hooks/explorer/use-commit-selection.ts +++ /dev/null @@ -1,82 +0,0 @@ -"use client" - -import { useState } from "react" -import type { Commit, JsonValue } from "@/lib/types" -import { mockFetchMetadata } from "@/lib/mock-api" -import type { ViewMode } from "@/lib/view-mode-utils" -import type { RepositoryInfo } from "@/lib/repository-handler" - -import { FILENAMES, REPOSITORY } from "@/lib/constants" - -export function useCommitSelection() { - const [selectedCommit, setSelectedCommit] = useState(null) - const [jsonData, setJsonData] = useState(null) - const [selectedFile, setSelectedFile] = useState(FILENAMES.ROOT) - const [globalViewMode, setGlobalViewMode] = useState("normal") - const [loading, setLoading] = useState(false) - const [error, setError] = useState("") - - const handleCommitSelect = async ( - commit: Commit, - repoUrl: string, - currentRepository: RepositoryInfo | null, - onSuccess?: () => void - ) => { - setSelectedCommit(commit) - setLoading(true) - setError("") - - try { - const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf" - const metadata = await mockFetchMetadata(fallbackUrl, commit.hash, selectedFile) - setJsonData(metadata as JsonValue) - if (onSuccess) onSuccess() - } catch (err) { - console.error("Failed to fetch metadata:", err) - setError( - `Failed to fetch ${selectedFile} for this commit: ${err instanceof Error ? err.message : "Unknown error"}`, - ) - } finally { - setLoading(false) - } - } - - const handleFileChange = async ( - file: string, - repoUrl: string, - currentRepository: RepositoryInfo | null - ) => { - setSelectedFile(file) - - if (selectedCommit) { - setLoading(true) - setError("") - try { - const fallbackUrl = currentRepository?.path || repoUrl || REPOSITORY.GITTUF_URL - const metadata = await mockFetchMetadata(fallbackUrl, selectedCommit.hash, file) - setJsonData(metadata as JsonValue) - } catch (err) { - console.error("Failed to fetch file data:", err) - setError(`Failed to fetch ${file} for this commit: ${err instanceof Error ? err.message : "Unknown error"}`) - } finally { - setLoading(false) - } - } - } - - return { - selectedCommit, - setSelectedCommit, - jsonData, - setJsonData, - selectedFile, - setSelectedFile, - globalViewMode, - setGlobalViewMode, - loading, - error, - setError, - handleCommitSelect, - handleFileChange, - } -} diff --git a/frontend/hooks/use-gittuf-explorer.ts b/frontend/hooks/use-gittuf-explorer.ts deleted file mode 100644 index 2bb5c4c..0000000 --- a/frontend/hooks/use-gittuf-explorer.ts +++ /dev/null @@ -1,179 +0,0 @@ -"use client" - -import type React from "react" -import { useState } from "react" -import { getHiddenFieldsCount } from "@/lib/view-mode-utils" -import { useRepository } from "./explorer/use-repository" -import { useCommitSelection } from "./explorer/use-commit-selection" -import { useCommitComparison } from "./explorer/use-commit-comparison" -import { useCommitAnalysis } from "./explorer/use-commit-analysis" -import type { Commit } from "@/lib/types" -import type { RepositoryInfo } from "@/lib/repository-handler" - -export function useGittufExplorer() { - const [activeTab, setActiveTab] = useState("commits") - const [currentStep, setCurrentStep] = useState(0) - - const steps = ["Repository", "Commits", "Visualization", "Analysis"] - - // Initialize sub-hooks - const repository = useRepository() - const selection = useCommitSelection() - const comparison = useCommitComparison() - const analysis = useCommitAnalysis( - activeTab, - repository.repoUrl, - repository.currentRepository, - selection.selectedFile - ) - - // Aggregated state - const isLoading = repository.isLoading || selection.loading || comparison.loading || analysis.loading - const error = repository.error || selection.error || comparison.error || analysis.error - - // Wrapped Handlers to coordinate state across hooks - const handleTryDemo = async () => { - setCurrentStep(1) - - // Reset other states - selection.setSelectedCommit(null) - selection.setJsonData(null) - comparison.setCompareCommits({ base: null, compare: null }) - comparison.setCompareData({ base: null, compare: null }) - analysis.setSelectedCommits([]) - - await repository.handleTryDemo(() => { - setActiveTab("commits") - setCurrentStep(2) - }) - } - - const handleRepositorySelect = async (repoInfo: RepositoryInfo) => { - setCurrentStep(1) - - // Reset other states - selection.setSelectedCommit(null) - selection.setJsonData(null) - comparison.setCompareCommits({ base: null, compare: null }) - comparison.setCompareData({ base: null, compare: null }) - analysis.setSelectedCommits([]) - - await repository.handleRepositorySelect(repoInfo, () => { - setActiveTab("commits") - setCurrentStep(2) - }) - } - - const handleRepoSubmit = async (e: React.FormEvent) => { - setCurrentStep(1) - - // Reset other states - selection.setSelectedCommit(null) - selection.setJsonData(null) - comparison.setCompareCommits({ base: null, compare: null }) - comparison.setCompareData({ base: null, compare: null }) - analysis.setSelectedCommits([]) - - await repository.handleRepoSubmit(e, () => { - setActiveTab("commits") - setCurrentStep(2) - }) - } - - const handleCommitSelect = async (commit: Commit) => { - setActiveTab("visualization") - setCurrentStep(3) - - await selection.handleCommitSelect( - commit, - repository.repoUrl, - repository.currentRepository - ) - } - - const handleCompareSelect = async (base: Commit, compare: Commit) => { - setActiveTab("compare") - setCurrentStep(3) - - await comparison.handleCompareSelect( - base, - compare, - repository.repoUrl, - repository.currentRepository, - selection.selectedFile - ) - } - - const handleFileChange = async (file: string) => { - selection.setSelectedFile(file) - - if (activeTab === "visualization" || activeTab === "tree") { - await selection.handleFileChange( - file, - repository.repoUrl, - repository.currentRepository - ) - } - - if (activeTab === "compare") { - await comparison.handleFileChange( - file, - repository.repoUrl, - repository.currentRepository - ) - } - } - - const handleCommitRangeSelect = (commits: Commit[]) => { - analysis.handleCommitRangeSelect(commits) - setActiveTab("analysis") - setCurrentStep(4) - } - - const hiddenCount = selection.globalViewMode === "normal" && selection.jsonData - ? getHiddenFieldsCount(selection.jsonData) - : 0 - - return { - // Repository State - repoUrl: repository.repoUrl, - setRepoUrl: repository.setRepoUrl, - commits: repository.commits, - currentRepository: repository.currentRepository, - showRepositorySelector: repository.showRepositorySelector, - setShowRepositorySelector: repository.setShowRepositorySelector, - handleRepositoryRefresh: repository.handleRepositoryRefresh, - - // Selection State - selectedCommit: selection.selectedCommit, - jsonData: selection.jsonData, - selectedFile: selection.selectedFile, - globalViewMode: selection.globalViewMode, - setGlobalViewMode: selection.setGlobalViewMode, - - // Comparison State - compareCommits: comparison.compareCommits, - compareData: comparison.compareData, - - // Analysis State - selectedCommits: analysis.selectedCommits, - - // Shared / Aggregated State - isLoading, - error, - activeTab, - setActiveTab, - currentStep, - steps, - hiddenCount, - - // Handlers - handleTryDemo, - handleRepositorySelect, - handleRepoSubmit, - handleCommitSelect, - handleCompareSelect, - handleFileChange, - handleCommitRangeSelect, - } -} diff --git a/frontend/hooks/explorer/use-repository.ts b/frontend/hooks/use-repository-session.ts similarity index 53% rename from frontend/hooks/explorer/use-repository.ts rename to frontend/hooks/use-repository-session.ts index 6f4a9c7..c041d27 100644 --- a/frontend/hooks/explorer/use-repository.ts +++ b/frontend/hooks/use-repository-session.ts @@ -1,32 +1,32 @@ "use client" -import type React from "react" import { useState } from "react" -import { REPOSITORY } from "@/lib/constants" -import { mockFetchCommits } from "@/lib/mock-api" -import type { Commit } from "@/lib/types" +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture" +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types" import { RepositoryHandler, type RepositoryInfo } from "@/lib/repository-handler" -export function useRepository() { - const [repoUrl, setRepoUrl] = useState("") - const [commits, setCommits] = useState([]) +export function useRepositorySession() { 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) => { - setRepoUrl(REPOSITORY.GITTUF_URL) + const demoRepository: RepositoryInfo = demoVisualizerData.repository + + setCurrentRepository(demoRepository) + setCurrentRepositoryData(demoVisualizerData) setIsLoading(true) setError("") try { - const commitsData = await mockFetchCommits(REPOSITORY.GITTUF_URL) - setCommits(commitsData) + await repositoryHandler.setRepository(demoRepository) + 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,13 +34,13 @@ export function useRepository() { const handleRepositorySelect = async (repoInfo: RepositoryInfo, onSuccess?: () => void) => { setCurrentRepository(repoInfo) + setCurrentRepositoryData(null) setIsLoading(true) setError("") try { await repositoryHandler.setRepository(repoInfo) - const commitsData = await repositoryHandler.fetchCommits() - setCommits(commitsData) + await repositoryHandler.fetchCommits() setShowRepositorySelector(false) if (onSuccess) onSuccess() } catch (err) { @@ -57,8 +57,7 @@ export function useRepository() { setError("") try { - const commitsData = await repositoryHandler.fetchCommits() - setCommits(commitsData) + await repositoryHandler.fetchCommits() } catch (err) { setError(`Failed to refresh repository data: ${err instanceof Error ? err.message : "Unknown error"}`) } finally { @@ -66,42 +65,23 @@ export function useRepository() { } } - const handleRepoSubmit = async (e: React.FormEvent, onSuccess?: () => void) => { - e.preventDefault() - - if (!repoUrl.trim()) { - setError("Please enter a GitHub repository URL") - return - } - - setIsLoading(true) + const handleDisconnect = () => { + setIsLoading(false) setError("") - - try { - const commitsData = await mockFetchCommits(repoUrl) - setCommits(commitsData) - if (onSuccess) onSuccess() - } catch { - setError("Failed to fetch repository data. Please check the URL and try again.") - } finally { - setIsLoading(false) - } + 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, - handleRepoSubmit, } } diff --git a/frontend/hooks/visualizer/use-workspace-history.ts b/frontend/hooks/visualizer/use-workspace-history.ts new file mode 100644 index 0000000..c11d33b --- /dev/null +++ b/frontend/hooks/visualizer/use-workspace-history.ts @@ -0,0 +1,46 @@ +"use client"; + +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 [currentPage, setCurrentPage] = useState(1); + const [touchedCommitId, setTouchedCommitId] = useState(null); + const [selectedCommitId, setSelectedCommitId] = useState( + Math.max(0, commits.findIndex((commit) => commit.hash === selectedCommitHash)), + ); + + const totalPages = Math.ceil(commits.length / commitsPerPage); + const effectiveCurrentPage = Math.min(currentPage, totalPages); + const visibleCommits = useMemo( + () => + commits.slice( + (effectiveCurrentPage - 1) * commitsPerPage, + effectiveCurrentPage * commitsPerPage, + ), + [commits, effectiveCurrentPage], + ); + + return { + commitListRef, + commitsPerPage, + currentPage: effectiveCurrentPage, + setCurrentPage, + totalPages, + visibleCommits, + selectedCommitId, + setSelectedCommitId, + touchedCommitId, + setTouchedCommitId, + }; +} diff --git a/frontend/lib/demo-visualizer-fixture.ts b/frontend/lib/demo-visualizer-fixture.ts new file mode 100644 index 0000000..e9c0e56 --- /dev/null +++ b/frontend/lib/demo-visualizer-fixture.ts @@ -0,0 +1,541 @@ +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: { + 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 authorized users 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: "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 }, + ], + 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: demoHistoryCommits, + }, + compare: { + baseVersionOptions: demoCompareVersionOptions, + compareVersionOptions: demoCompareVersionOptions, + selectedBaseVersion: compareLabelByHash["c3d4e5f6"], + selectedCompareVersion: compareLabelByHash["0a1b2c3d"], + 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: demoGraphsByVersion, + + }, + 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", "authorized-users"] }, + { id: "principals", label: "Principals", items: ["Alice", "Bob", "Carol"] }, + { id: "file-rules", label: "File Rules", items: ["src/** requires authorized users", "docs/** requires authorized users"] }, + ], + }, + 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: "authorized-users" }, + }, + }, + }, + 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: "authorized-users" }, + "docs/**": { rule: "authorized-users" }, + }, + }, + }, + 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: "authorized-users" }, + "docs/**": { rule: "authorized-users" }, + }, + }, + }, + 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: "authorized-users", threshold: 1, principalids: ["alice", "carol"] }, + ], + }, + "targets.json": { + type: "targets", + expires: "2026-07-30T00:00:00Z", + targets: { + "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 new file mode 100644 index 0000000..6f4f8b5 --- /dev/null +++ b/frontend/lib/demo-visualizer.types.ts @@ -0,0 +1,193 @@ +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 +} + +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 + } + > +} diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 0000000..4938e66 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/screens/repository/repository-selector.tsx b/frontend/screens/repository/repository-selector.tsx new file mode 100644 index 0000000..d16090e --- /dev/null +++ b/frontend/screens/repository/repository-selector.tsx @@ -0,0 +1,319 @@ +"use client" + +import type React from "react" + +import Image from "next/image" +import { useMemo, useState } from "react" +import { AlertCircle, CheckCircle, Loader2 } from "lucide-react" +import clipIcon from "@/assets/clip.png" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import type { RepositoryInfo } from "@/lib/repository-handler" + +interface RepositorySelectorProps { + onRepositorySelect: (info: RepositoryInfo) => 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)" +const REMOTE_REPOSITORY_HOSTS = { + "github.com": "GitHub", + "gitlab.com": "GitLab", + "bitbucket.org": "Bitbucket", +} as const + +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()}` + let validatedHost: keyof typeof REMOTE_REPOSITORY_HOSTS | null = null + + try { + const url = new URL(normalizedUrl) + const normalizedHost = url.hostname.toLowerCase() + + if (!(normalizedHost in REMOTE_REPOSITORY_HOSTS)) { + setValidationStatus({ + isValid: false, + message: "Please enter a GitHub, GitLab, or Bitbucket repository URL.", + }) + return + } + + validatedHost = normalizedHost as keyof typeof REMOTE_REPOSITORY_HOSTS + } 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: validatedHost ? REMOTE_REPOSITORY_HOSTS[validatedHost] : undefined, + 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..3a6c8df --- /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/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 new file mode 100644 index 0000000..58a05bf --- /dev/null +++ b/frontend/screens/visualizer/detail-content.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useState } from "react"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; +import type { RepositoryInfo } from "@/lib/repository-handler"; +import { + DetailPanelCompare, + DetailPanelGraphSource, + DetailPanelHistory, + DetailPanelMetadata, + 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"; + +interface WorkspaceDetailContentProps { + activePanel: WorkspacePanelId; + repository: RepositoryInfo; + workspaceData?: DemoVisualizerData | null; + onRegenerate: () => void; + isLoading?: boolean; + 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; + comparisonResult: VisualizerComparisonResult; + hasCompared: boolean; + onBaseVersionChange: (value: string) => void; + onCompareVersionChange: (value: string) => void; + onSwapVersions: () => void; + onCompare: () => void; +} + +export function WorkspaceDetailContent({ + activePanel, + repository, + workspaceData, + onRegenerate, + isLoading = false, + historyCommits, + selectedHistoryCommitHash, + onHistoryCommitSelect, + searchQuery, + selectedHistorySort, + isHistorySortAscending, + onHistorySortChange, + onHistorySortDirectionToggle, + selectedBaseVersion, + selectedCompareVersion, + comparisonResult, + 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..9e92451 --- /dev/null +++ b/frontend/screens/visualizer/history-canvas.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; +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..b1cdafd --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-compare.tsx @@ -0,0 +1,114 @@ +"use client"; + +import Image from "next/image"; +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"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; +import { + DetailActionButton, + PanelSection, + SearchHighlightText, + SectionBulletLabel, + 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; + onSwapVersions: () => void; + onCompare: () => void; +} + +export function DetailPanelCompare({ + workspaceData, + searchQuery, + selectedBaseVersion, + selectedCompareVersion, + comparisonResult, + hasCompared, + onBaseVersionChange, + onCompareVersionChange, + onSwapVersions, + onCompare, +}: DetailPanelCompareProps) { + const [isComparing, setIsComparing] = useState(false); + const compareData = + workspaceData?.workspaceDetails.compare ?? + demoVisualizerData.workspaceDetails.compare; + const baseOptions = compareData.baseVersionOptions; + const compareOptions = compareData.compareVersionOptions; + + return ( +
+ + ({ label, icon: emptyFileIcon }))} + selectedLabel={selectedBaseVersion} + onChange={onBaseVersionChange} + fullWidth + /> + +
+ +
+ + ({ label, icon: emptyFileIcon }))} + selectedLabel={selectedCompareVersion} + onChange={onCompareVersionChange} + fullWidth + /> + +
+ { + setIsComparing(true); + window.requestAnimationFrame(() => { + onCompare(); + window.setTimeout(() => setIsComparing(false), 250); + }); + }} + /> +
+ {hasCompared ? ( +
+ +
+ {comparisonResult.changedMetadata.length > 0 ? ( + comparisonResult.changedMetadata.map((item) => ( +
+ ✓ +
+ )) + ) : ( +
— No metadata changes
+ )} +
+ +
+ ) : 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..d6eda16 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-graph-source.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; +import type { RepositoryInfo } from "@/lib/repository-handler"; +import { + DetailActionButton, + InlineSelectRow, + StaticValueRow, +} from "@/components/visualizer/detail/workspace-detail-primitives"; + +interface DetailPanelGraphSourceProps { + repository: RepositoryInfo; + workspaceData?: DemoVisualizerData | null; + onRegenerate: () => void; + isLoading?: boolean; + searchQuery?: string; +} + +export function DetailPanelGraphSource({ + repository, + workspaceData, + onRegenerate, + isLoading = false, + 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..497a920 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-history.tsx @@ -0,0 +1,191 @@ +"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 } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; +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-33" + /> + +
+
+
+ {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} + /> + ))} +
+
+ +
+ {Array.from({ length: totalPages }, (_, index) => { + const pageNumber = index + 1; + const isActive = currentPage === pageNumber; + + return ( + + ); + })} +
+ +
+
+
+ ); +} 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..86a7a27 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-metadata.tsx @@ -0,0 +1,194 @@ +"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, + SectionBulletLabel, + StatusRow, + SummaryMetricGrid, + ValueChip, +} from "@/components/visualizer/detail/workspace-detail-primitives"; + +interface DetailPanelMetadataProps { + workspaceData?: DemoVisualizerData | null; + searchQuery?: string; +} + +function MetadataCodeCard({ + label, + value, + searchQuery, +}: { + label: string; + value: string; + searchQuery?: string; +}) { + return ( +
+ +
+        
+      
+
+ ); +} + +export function DetailPanelMetadata({ + workspaceData, + searchQuery, +}: DetailPanelMetadataProps) { + 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 ( +
+ +
+ +
+ {metadataData.policyFiles.map((item, index) => ( + + ))} +
+
+
+ +
+ {metadataData.status.payloadDecoded ? ( + + ) : null} + + +
+
+
+ +
+
+ {metadataData.views.map((tab) => ( + + ))} +
+ {activeView === "Summary" ? ( + + ) : null} + {activeView === "Decoded JSON" ? ( +
+ {decodedJsonCards.map((card) => ( + + ))} +
+ ) : null} + {activeView === "Envelope" ? ( +
+ {envelopeCards.map((card) => ( + + ))} +
+ ) : null} +
+
+
+ ); +} 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..47c7204 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-policy-query.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useMemo, useState } from "react"; +import branchIcon from "@/assets/branch.png"; +import emptyFileIcon from "@/assets/empty_file.png"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; +import { + DetailActionButton, + 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 [isQuerying, setIsQuerying] = useState(false); + 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 + /> + +
+ { + setIsQuerying(true); + window.requestAnimationFrame(() => { + onQuery({ + matchedBranch: + queryScenario?.matchedBranch ?? + policyQuery.queryResult.matchedBranch ?? + selectedBranch, + matchedRule: + queryScenario?.matchedRule ?? + policyQuery.queryResult.matchedRule ?? + selectedChangedPath, + requiredApprovals: + queryScenario?.requiredApprovals ?? + policyQuery.queryResult.requiredApprovals ?? + 2, + authorizedUsers: + queryScenario?.authorizedUsers ?? + policyQuery.authorizedUsers, + }); + window.setTimeout(() => setIsQuerying(false), 250); + }); + }} + /> +
+ {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..fb50389 --- /dev/null +++ b/frontend/screens/visualizer/panel-tabs/detail-settings.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState } from "react"; +import { demoVisualizerData } from "@/lib/demo-visualizer-fixture"; +import type { DemoVisualizerData } from "@/lib/demo-visualizer.types"; +import { + CheckboxRow, + DetailActionButton, + 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 [isResetting, setIsResetting] = useState(false); + 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)} + /> + ))} +
+
+
+ { + setIsResetting(true); + window.requestAnimationFrame(() => { + setSelectedDetailLevel(settingsData.selectedDetailLevel ?? defaultDetailLevels[0]); + setSelectedLayoutDirection( + settingsData.selectedLayoutDirection ?? defaultLayoutDirections[0], + ); + setVisibleNodeTypes(defaultVisibleNodeTypes); + setLabels(defaultLabels); + setDataOptions(defaultDataOptions); + window.setTimeout(() => setIsResetting(false), 250); + }); + }} + /> +
+
+ ); +} diff --git a/frontend/screens/visualizer/policy-graph-canvas.tsx b/frontend/screens/visualizer/policy-graph-canvas.tsx new file mode 100644 index 0000000..b647e95 --- /dev/null +++ b/frontend/screens/visualizer/policy-graph-canvas.tsx @@ -0,0 +1,285 @@ +"use client"; + +import type React from "react"; +import { useState } from "react"; +import { + boundary, + branchBox, + defaultLanes, + defaultPolicyGraphVariant, + defaultPrincipalNames, + fileBox, + layoutHeight, + layoutWidth, + roleBox, + rowY, + scrollPadding, +} from "@/screens/visualizer/policy-graph.constants"; +import { + getLaneCenters, + getLaneNodeChangeTypes, + getNodeTextStyle, + getPrincipalChangeType, + getPrincipalOffsets, +} 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; + 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 ( +
+
+
+
+ + +
+ + {onDelete ? ( + + ) : null} + +
+ {repositoryLabel} +
+ + {lanes.map((lane, laneIndex) => { + const centerX = laneCenters[laneIndex]; + + return ( + + ); + })} +
+
+
+
+ ); +} 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.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..53c8506 --- /dev/null +++ b/frontend/screens/visualizer/policy-graph.types.ts @@ -0,0 +1,40 @@ +// for policy graph comparison +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..dee135c --- /dev/null +++ b/frontend/screens/visualizer/policy-graph.utils.ts @@ -0,0 +1,116 @@ +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) { + // 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); + 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]; + } + + // 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); + return boundary.x + 210 + usableWidth * ratio; + }); +} + +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; + const step = spread / (principalCount - 1); + + return Array.from({ length: principalCount }, (_, index) => start + step * index); +} 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..8c46828 --- /dev/null +++ b/frontend/screens/visualizer/use-visualizer-history-compare.ts @@ -0,0 +1,150 @@ +"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 { buildComparisonResult } from "@/screens/visualizer/compare.utils"; +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 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 comparisonResult = useMemo( + () => + buildComparisonResult( + compareData.graphsByVersion[selectedBaseVersion], + compareData.graphsByVersion[selectedCompareVersion], + selectedCompareVersion, + ), + [compareData.graphsByVersion, selectedBaseVersion, selectedCompareVersion], + ); + const compareGraph = useMemo(() => { + return { + repositoryLabel: comparisonResult.compareGraph.repositoryLabel, + branchLabel: comparisonResult.compareGraph.branchLabel, + lanes: comparisonResult.compareGraph.lanes, + showCompareLegend: comparisonResult.compareGraph.showLegend ?? true, + }; + }, [comparisonResult]); + + 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, + comparisonResult, + 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..6fe82e4 --- /dev/null +++ b/frontend/screens/visualizer/use-visualizer-tabs.ts @@ -0,0 +1,226 @@ +"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 nextCompareTabNumberRef = useRef(1); + + const activeGraphTab = + graphTabs.find((tab) => tab.id === activeGraphTabId) ?? graphTabs[0]; + const isHistoryPanel = activeGraphTabId === historyTabId; + const isComparePanel = activeGraphTabId.startsWith(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 = () => { + const nextCompareTabId = `${compareTabId}-${nextCompareTabNumberRef.current++}`; + + setGraphTabs((currentTabs) => [ + ...currentTabs, + { + id: nextCompareTabId, + label: compareTabLabel, + closable: true, + editable: false, + graphs: [], + }, + ]); + setHasCompared(true); + setActivePanel("compare"); + setActiveGraphTabId(nextCompareTabId); + }; + + 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.startsWith(compareTabId)) { + setActivePanel("compare"); + setActiveGraphTabId(tabId); + 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 new file mode 100644 index 0000000..3ee68f1 --- /dev/null +++ b/frontend/screens/visualizer/use-visualizer-workspace.ts @@ -0,0 +1,163 @@ +"use client"; + +import { useState } from "react"; +import { visualizerMenuItems } from "@/screens/visualizer/visualizer.constants"; +import type { + VisualizerWorkspaceProps, + WorkspacePanelId, +} from "@/screens/visualizer/visualizer.types"; +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 [activePanel, setActivePanel] = useState("graph-source"); + const [detailSearchQuery, setDetailSearchQuery] = useState(""); + const [graphZoom, setGraphZoom] = useState(0.75); + const [graphSearchQuery, setGraphSearchQuery] = useState(""); + const { + defaultLayout, + detailPanelRef, + detailPanelWidth, + footerLeftWidthPx, + handleDetailPanelToggle, + isDetailCollapsed, + isMenuCollapsed, + isMenuCompact, + menuPanelRef, + menuPanelWidth, + onLayoutChanged, + panelGroupRef, + setDetailPanelWidth, + setIsDetailCollapsed, + setIsMenuCollapsed, + setMenuPanelWidth, + } = useVisualizerLayout(); + const { + activeHistoryCommitId, + baseCompareGraph, + comparisonResult, + 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 activeLabel = + visualizerMenuItems.find((item) => item.id === activePanel)?.label ?? + "Graph Source"; + const activePanelIcon = + visualizerMenuItems.find((item) => item.id === activePanel)?.icon ?? + visualizerMenuItems[0].icon; + + const handleMenuItemSelect = (panelId: WorkspacePanelId) => { + setActivePanel(panelId); + if (panelId === "history") { + handleHistoryPanelSelect(); + } + }; + + return { + activeGraphTab, + activeGraphTabId, + activeHistoryCommitId, + activeLabel, + activePanel, + activePanelIcon, + baseCompareGraph, + comparisonResult, + 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, + 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..b74fb5b --- /dev/null +++ b/frontend/screens/visualizer/visualizer-workspace.tsx @@ -0,0 +1,407 @@ +"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, + comparisonResult, + 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} + comparisonResult={comparisonResult} + 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..3933ff3 --- /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.types"; + +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/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 48d6d82..d19d012 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -36,6 +36,7 @@ ".next/dev/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "archive" ] }