diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index c28c058..3898db6 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,324 +1,71 @@
"use client"
-import type React from "react"
-import { useState, useEffect } from "react"
-import dynamic from "next/dynamic"
-import { Loader2, Github, GitCommit, FileJson, GitCompare, BarChart3, Sparkles } from "lucide-react"
+import { GitCommit, GitCompare, BarChart3, FileJson, Github, Sparkles } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
-import { Badge } from "@/components/ui/badge"
-import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
-import CommitList from "@/components/commit-list"
-import CommitCompare from "@/components/commit-compare"
-import CommitAnalysis from "@/components/commit-analysis"
-import QuickStartGuide from "@/components/quick-start-guide"
-import EnhancedViewModeToggle from "@/components/enhanced-view-mode-toggle"
-import ProgressIndicator from "@/components/progress-indicator"
-import { mockFetchCommits, mockFetchMetadata } from "@/lib/mock-api"
-import type { Commit } from "@/lib/types"
-import JsonTreeView from "@/components/json-tree-view"
-import { getHiddenFieldsCount, type ViewMode } from "@/lib/view-mode-utils"
+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 RepositoryStatus from "@/components/repository-status"
-import { RepositoryHandler, type RepositoryInfo } from "@/lib/repository-handler"
-import RepositorySelector from "@/components/repository-selector"
+import RepositoryStatus from "@/components/features/repository/repository-status"
+import RepositorySelector from "@/components/features/repository/repository-selector"
-// Dynamically import the JsonTreeVisualization component to avoid SSR issues with ReactFlow
-const JsonTreeVisualization = dynamic(() => import("@/components/json-tree-visualization"), {
- ssr: false,
- loading: () => (
-
-
- Loading visualization...
-
- ),
-})
+// 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"
-// Dynamically import the JsonDiffVisualization component
-const JsonDiffVisualization = dynamic(() => import("@/components/json-diff-visualization"), {
- ssr: false,
- loading: () => (
-
-
- Loading diff visualization...
-
- ),
-})
+// Custom Hook
+import { useGittufExplorer } from "@/hooks/use-gittuf-explorer"
export default function Home() {
- const [repoUrl, setRepoUrl] = useState("")
- const [isLoading, setIsLoading] = useState(false)
- const [commits, setCommits] = useState([])
- const [selectedCommit, setSelectedCommit] = useState(null)
- const [compareCommits, setCompareCommits] = useState<{
- base: Commit | null
- compare: Commit | null
- }>({ base: null, compare: null })
- const [jsonData, setJsonData] = useState(null)
- const [compareData, setCompareData] = useState<{
- base: any | null
- compare: any | null
- }>({ base: null, compare: null })
- const [activeTab, setActiveTab] = useState("commits")
- const [error, setError] = useState("")
- const [selectedFile, setSelectedFile] = useState("root.json")
- const [selectedCommits, setSelectedCommits] = useState([])
- const [globalViewMode, setGlobalViewMode] = useState("normal")
- const [currentStep, setCurrentStep] = useState(0)
- const [repositoryHandler] = useState(() => new RepositoryHandler())
- const [currentRepository, setCurrentRepository] = useState(null)
- const [showRepositorySelector, setShowRepositorySelector] = useState(true)
-
- const steps = ["Repository", "Commits", "Visualization", "Analysis"]
-
- const handleTryDemo = async () => {
- setRepoUrl("https://github.com/gittuf/gittuf")
- setCurrentStep(1)
-
- setIsLoading(true)
- setError("")
-
- try {
- const commitsData = await mockFetchCommits("https://github.com/gittuf/gittuf")
- setCommits(commitsData)
- setSelectedCommit(null)
- setCompareCommits({ base: null, compare: null })
- setJsonData(null)
- setCompareData({ base: null, compare: null })
- setSelectedCommits([])
- setActiveTab("commits")
- setCurrentStep(2)
- } catch (err) {
- setError("Failed to load demo data. Please try again.")
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleRepositorySelect = async (repoInfo: RepositoryInfo) => {
- setCurrentRepository(repoInfo)
- setCurrentStep(1)
- setIsLoading(true)
- setError("")
-
- try {
- await repositoryHandler.setRepository(repoInfo)
- const commitsData = await repositoryHandler.fetchCommits()
- setCommits(commitsData)
- setSelectedCommit(null)
- setCompareCommits({ base: null, compare: null })
- setJsonData(null)
- setCompareData({ base: null, compare: null })
- setSelectedCommits([])
- setActiveTab("commits")
- setCurrentStep(2)
- setShowRepositorySelector(false)
- } catch (err) {
- setError(`Failed to connect to repository: ${err instanceof Error ? err.message : "Unknown error"}`)
- setCurrentStep(0)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleRepositoryRefresh = async () => {
- if (!currentRepository) return
-
- setIsLoading(true)
- setError("")
-
- try {
- const commitsData = await repositoryHandler.fetchCommits()
- setCommits(commitsData)
- } catch (err) {
- setError(`Failed to refresh repository data: ${err instanceof Error ? err.message : "Unknown error"}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleRepoSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
-
- if (!repoUrl.trim()) {
- setError("Please enter a GitHub repository URL")
- return
- }
-
- setCurrentStep(1)
- setIsLoading(true)
- setError("")
-
- try {
- const commitsData = await mockFetchCommits(repoUrl)
- setCommits(commitsData)
- setSelectedCommit(null)
- setCompareCommits({ base: null, compare: null })
- setJsonData(null)
- setCompareData({ base: null, compare: null })
- setSelectedCommits([])
- setActiveTab("commits")
- setCurrentStep(2)
- } catch (err) {
- setError("Failed to fetch repository data. Please check the URL and try again.")
- setCurrentStep(0)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleCommitSelect = async (commit: Commit) => {
- setSelectedCommit(commit)
- setIsLoading(true)
- setActiveTab("visualization")
- setCurrentStep(3)
- setError("")
-
- try {
- // Use the mock API directly with proper fallback URL
- const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf"
- const metadata = await mockFetchMetadata(fallbackUrl, commit.hash, selectedFile)
- setJsonData(metadata)
- } 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 {
- setIsLoading(false)
- }
- }
-
- const handleCompareSelect = async (base: Commit, compare: Commit) => {
- setCompareCommits({ base, compare })
- setIsLoading(true)
- setActiveTab("compare")
- setCurrentStep(3)
- setError("")
-
- try {
- const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf"
- const [baseData, compareData] = await Promise.all([
- mockFetchMetadata(fallbackUrl, base.hash, selectedFile),
- mockFetchMetadata(fallbackUrl, compare.hash, selectedFile),
- ])
-
- setCompareData({ base: baseData, compare: compareData })
- } catch (err) {
- console.error("Failed to fetch comparison data:", err)
- setError(`Failed to fetch comparison data: ${err instanceof Error ? err.message : "Unknown error"}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- const handleFileChange = async (file: string) => {
- setSelectedFile(file)
-
- if (selectedCommit && (activeTab === "visualization" || activeTab === "tree")) {
- setIsLoading(true)
- setError("")
- try {
- const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf"
- const metadata = await mockFetchMetadata(fallbackUrl, selectedCommit.hash, file)
- setJsonData(metadata)
- } 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 {
- setIsLoading(false)
- }
- }
-
- if (compareCommits.base && compareCommits.compare && activeTab === "compare") {
- setIsLoading(true)
- setError("")
- try {
- const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf"
- const [baseData, compareData] = await Promise.all([
- mockFetchMetadata(fallbackUrl, compareCommits.base.hash, file),
- mockFetchMetadata(fallbackUrl, compareCommits.compare.hash, file),
- ])
-
- setCompareData({ base: baseData, compare: compareData })
- } 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 {
- setIsLoading(false)
- }
- }
- }
-
- const handleCommitRangeSelect = (commits: Commit[]) => {
- setSelectedCommits(commits)
- setActiveTab("analysis")
- setCurrentStep(4)
- }
-
- useEffect(() => {
- if (activeTab === "analysis" && selectedCommits.length > 0) {
- const loadAnalysisData = async () => {
- setIsLoading(true)
- setError("")
-
- try {
- const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf"
- 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],
- }))
-
- 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 {
- setIsLoading(false)
- }
- }
-
- loadAnalysisData()
- }
- }, [activeTab, selectedCommits.length, selectedFile, currentRepository, repoUrl])
-
- const hiddenCount = globalViewMode === "normal" && jsonData ? getHiddenFieldsCount(jsonData) : 0
+ const {
+ repoUrl,
+ setRepoUrl,
+ isLoading,
+ commits,
+ selectedCommit,
+ compareCommits,
+ jsonData,
+ compareData,
+ activeTab,
+ setActiveTab,
+ error,
+ selectedFile,
+ selectedCommits,
+ globalViewMode,
+ setGlobalViewMode,
+ currentStep,
+ currentRepository,
+ showRepositorySelector,
+ setShowRepositorySelector,
+ steps,
+ handleTryDemo,
+ handleRepositorySelect,
+ handleRepositoryRefresh,
+ handleRepoSubmit,
+ handleCommitSelect,
+ handleCompareSelect,
+ handleFileChange,
+ handleCommitRangeSelect,
+ hiddenCount,
+ } = useGittufExplorer()
return (
)
diff --git a/frontend/app/simulator/page.tsx b/frontend/app/simulator/page.tsx
index 3fdfee4..9d18bca 100644
--- a/frontend/app/simulator/page.tsx
+++ b/frontend/app/simulator/page.tsx
@@ -1,6 +1,5 @@
"use client"
-import { useState, useEffect, useCallback, useMemo } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -39,436 +38,43 @@ import {
ShieldPlus,
} from "lucide-react"
-import { StoryModal } from "@/components/story-modal"
-import { StatusCard } from "@/components/status-card"
-import { TrustGraph } from "@/components/trust-graph"
-import type { SimulatorResponse, ApprovalRequirement, EligibleSigner } from "@/lib/simulator-types"
-
-// Import fixtures
-import fixtureAllowed from "@/fixtures/fixture-allowed.json"
-import fixtureBlocked from "@/fixtures/fixture-blocked.json"
-
-// Types
-interface CustomPerson {
- id: string
- display_name: string
- keyid: string
- key_type: "ssh" | "gpg" | "sigstore"
- has_signed: boolean
-}
-
-interface CustomRole {
- id: string
- display_name: string
- threshold: number
- file_globs: string[]
- assigned_people: string[]
-}
-
-interface CustomConfig {
- people: CustomPerson[]
- roles: CustomRole[]
-}
-
-// Default configuration
-const DEFAULT_CONFIG: CustomConfig = {
- people: [
- {
- id: "alice",
- display_name: "Alice Johnson",
- keyid: "ssh-rsa-abc123",
- key_type: "ssh",
- has_signed: false,
- },
- {
- id: "bob",
- display_name: "Bob Smith",
- keyid: "gpg-def456",
- key_type: "gpg",
- has_signed: false,
- },
- {
- id: "charlie",
- display_name: "Charlie Brown",
- keyid: "sigstore-ghi789",
- key_type: "sigstore",
- has_signed: false,
- },
- ],
- roles: [
- {
- id: "maintainer",
- display_name: "Maintainer",
- threshold: 2,
- file_globs: ["src/**", "docs/**"],
- assigned_people: ["alice", "bob", "charlie"],
- },
- {
- id: "reviewer",
- display_name: "Reviewer",
- threshold: 1,
- file_globs: ["tests/**"],
- assigned_people: ["alice", "bob"],
- },
- ],
-}
+import { StoryModal } from "@/components/shared/story-modal"
+import { StatusCard } from "@/components/shared/status-card"
+import { TrustGraph } from "@/components/features/visualization/trust-graph"
+import { useGittufSimulator } from "@/hooks/use-gittuf-simulator"
export default function SimulatorPage() {
- // Core UI State
- const [darkMode, setDarkMode] = useState(false)
- const [showStory, setShowStory] = useState(false)
- const [showSimulator, setShowSimulator] = useState(false)
- const [isProcessing, setIsProcessing] = useState(false)
-
- // Simulator State
- const [currentFixture, setCurrentFixture] = useState<"blocked" | "allowed" | "custom">("blocked")
- const [whatIfMode, setWhatIfMode] = useState(false)
- const [simulatedSigners, setSimulatedSigners] = useState>(new Set())
-
- // UI Layout State
- const [expandedGraph, setExpandedGraph] = useState(false)
- const [showControls, setShowControls] = useState(true)
- const [showDetails, setShowDetails] = useState(false)
-
- // Custom Config State
- const [showCustomConfig, setShowCustomConfig] = useState(false)
- const [customConfig, setCustomConfig] = useState(DEFAULT_CONFIG)
-
- // Form States (isolated to prevent re-renders)
- const [newPersonForm, setNewPersonForm] = useState({
- id: "",
- display_name: "",
- keyid: "",
- key_type: "ssh" as const,
- has_signed: false,
- })
-
- const [newRoleForm, setNewRoleForm] = useState({
- id: "",
- display_name: "",
- threshold: 1,
- file_globs: ["src/**"],
- assigned_people: [] as string[],
- })
-
- const [editingPerson, setEditingPerson] = useState(null)
- const [editingRole, setEditingRole] = useState(null)
-
- // Generate custom fixture from config
- const customFixture = useMemo((): SimulatorResponse => {
- const approval_requirements: ApprovalRequirement[] = customConfig.roles.map((role) => {
- const eligible_signers: EligibleSigner[] = role.assigned_people
- .map((personId) => {
- const person = customConfig.people.find((p) => p.id === personId)
- return person
- ? {
- id: person.id,
- display_name: person.display_name,
- keyid: person.keyid,
- key_type: person.key_type,
- }
- : null
- })
- .filter(Boolean) as EligibleSigner[]
-
- const satisfiers = eligible_signers
- .filter((signer) => {
- const person = customConfig.people.find((p) => p.id === signer.id)
- return person?.has_signed
- })
- .map((signer) => ({
- who: signer.id,
- keyid: signer.keyid,
- signature_valid: true,
- signature_time: new Date().toISOString(),
- signature_verification_reason: `Valid ${signer.key_type.toUpperCase()} signature`,
- }))
-
- return {
- role: role.id,
- role_metadata_version: 1,
- threshold: role.threshold,
- file_globs: role.file_globs,
- eligible_signers,
- satisfied: satisfiers.length,
- satisfiers,
- }
- })
-
- const allRequirementsMet = approval_requirements.every((req) => req.satisfied >= req.threshold)
-
- const visualization_hint = {
- nodes: [
- ...customConfig.roles.map((role) => ({
- id: role.id,
- type: "role" as const,
- label: `${role.display_name} (${
- approval_requirements.find((req) => req.role === role.id)?.satisfied || 0
- }/${role.threshold})`,
- meta: {
- satisfied: (approval_requirements.find((req) => req.role === role.id)?.satisfied || 0) >= role.threshold,
- threshold: role.threshold,
- current: approval_requirements.find((req) => req.role === role.id)?.satisfied || 0,
- },
- })),
- ...customConfig.people.map((person) => ({
- id: person.id,
- type: "person" as const,
- label: person.display_name,
- meta: {
- signed: person.has_signed,
- keyType: person.key_type,
- },
- })),
- ],
- edges: customConfig.roles.flatMap((role) =>
- role.assigned_people.map((personId) => {
- const person = customConfig.people.find((p) => p.id === personId)
- return {
- from: personId,
- to: role.id,
- label: person?.has_signed ? "Approved" : "Eligible",
- satisfied: person?.has_signed || false,
- }
- }),
- ),
- }
-
- return {
- result: allRequirementsMet ? "allowed" : "blocked",
- reasons: allRequirementsMet
- ? ["All approval requirements satisfied"]
- : approval_requirements
- .filter((req) => req.satisfied < req.threshold)
- .map((req) => `Missing ${req.threshold - req.satisfied} ${req.role} approval(s)`),
- approval_requirements,
- signature_verification: approval_requirements.flatMap((req) =>
- req.satisfiers.map((satisfier, index) => ({
- signature_id: `sig-${req.role}-${index + 1}`,
- keyid: satisfier.keyid,
- sig_ok: satisfier.signature_valid,
- verified_at: satisfier.signature_time,
- reason: satisfier.signature_verification_reason,
- })),
- ),
- attestation_matches: [
- {
- attestation_id: "att-custom-001",
- rsl_index: 42,
- maps_to_proposal: true,
- from_revision_ok: true,
- target_tree_hash_match: true,
- signature_valid: true,
- },
- ],
- visualization_hint,
- }
- }, [customConfig])
-
- // Get current fixture
- const getCurrentFixture = useCallback((): SimulatorResponse => {
- switch (currentFixture) {
- case "allowed":
- return fixtureAllowed as SimulatorResponse
- case "custom":
- return customFixture
- default:
- return fixtureBlocked as SimulatorResponse
- }
- }, [currentFixture, customFixture])
-
- const fixture = getCurrentFixture()
-
- // Calculate what-if result
- const displayResult = useMemo((): SimulatorResponse => {
- if (!whatIfMode || simulatedSigners.size === 0) return fixture
-
- const whatIfResult = JSON.parse(JSON.stringify(fixture)) as SimulatorResponse
-
- whatIfResult.approval_requirements = whatIfResult.approval_requirements.map((req) => {
- const additionalSatisfiers = req.eligible_signers
- .filter((signer) => simulatedSigners.has(signer.id) && !req.satisfiers.some((s) => s.who === signer.id))
- .map((signer) => ({
- who: signer.id,
- keyid: signer.keyid,
- signature_valid: true,
- signature_time: new Date().toISOString(),
- signature_verification_reason: "Simulated signature",
- }))
-
- return {
- ...req,
- satisfied: req.satisfied + additionalSatisfiers.length,
- satisfiers: [...req.satisfiers, ...additionalSatisfiers],
- }
- })
-
- const allRequirementsMet = whatIfResult.approval_requirements.every((req) => req.satisfied >= req.threshold)
- whatIfResult.result = allRequirementsMet ? "allowed" : "blocked"
-
- if (allRequirementsMet && fixture.result === "blocked") {
- whatIfResult.reasons = ["All approval requirements satisfied (with simulated signatures)"]
- }
-
- return whatIfResult
- }, [whatIfMode, simulatedSigners, fixture])
-
- // Event Handlers
- const handleRunSimulation = useCallback(async () => {
- setIsProcessing(true)
- setShowSimulator(true)
- await new Promise((resolve) => setTimeout(resolve, 1000))
- setIsProcessing(false)
- }, [])
-
- const handleSimulatedSignerToggle = useCallback((signerId: string, checked: boolean) => {
- setSimulatedSigners((prev) => {
- const newSet = new Set(prev)
- if (checked) {
- newSet.add(signerId)
- } else {
- newSet.delete(signerId)
- }
- return newSet
- })
- }, [])
-
- const handleExportJson = useCallback(() => {
- const dataStr = JSON.stringify(displayResult, null, 2)
- const dataBlob = new Blob([dataStr], { type: "application/json" })
- const url = URL.createObjectURL(dataBlob)
- const link = document.createElement("a")
- link.href = url
- link.download = `gittuf-simulation-${Date.now()}.json`
- link.click()
- URL.revokeObjectURL(url)
- }, [displayResult])
-
- // Custom Config Handlers
- const addPerson = useCallback(() => {
- if (!newPersonForm.id || !newPersonForm.display_name) return
-
- const newPerson = {
- ...newPersonForm,
- keyid: newPersonForm.keyid || `${newPersonForm.key_type}-${Date.now()}`,
- }
-
- setCustomConfig((prev) => ({
- ...prev,
- people: [...prev.people, newPerson],
- }))
-
- setNewPersonForm({
- id: "",
- display_name: "",
- keyid: "",
- key_type: "ssh",
- has_signed: false,
- })
- }, [newPersonForm])
-
- const addRole = useCallback(() => {
- if (!newRoleForm.id || !newRoleForm.display_name) return
-
- setCustomConfig((prev) => ({
- ...prev,
- roles: [...prev.roles, { ...newRoleForm }],
- }))
-
- setNewRoleForm({
- id: "",
- display_name: "",
- threshold: 1,
- file_globs: ["src/**"],
- assigned_people: [],
- })
- }, [newRoleForm])
-
- const deletePerson = useCallback((id: string) => {
- setCustomConfig((prev) => ({
- ...prev,
- people: prev.people.filter((p) => p.id !== id),
- roles: prev.roles.map((role) => ({
- ...role,
- assigned_people: role.assigned_people.filter((pid) => pid !== id),
- })),
- }))
- }, [])
-
- const deleteRole = useCallback((id: string) => {
- setCustomConfig((prev) => ({
- ...prev,
- roles: prev.roles.filter((r) => r.id !== id),
- }))
- }, [])
-
- const updatePerson = useCallback((person: CustomPerson) => {
- setCustomConfig((prev) => ({
- ...prev,
- people: prev.people.map((p) => (p.id === person.id ? person : p)),
- }))
- setEditingPerson(null)
- }, [])
-
- const updateRole = useCallback((role: CustomRole) => {
- setCustomConfig((prev) => ({
- ...prev,
- roles: prev.roles.map((r) => (r.id === role.id ? role : r)),
- }))
- setEditingRole(null)
- }, [])
-
- const togglePersonSigned = useCallback((personId: string) => {
- setCustomConfig((prev) => ({
- ...prev,
- people: prev.people.map((p) => (p.id === personId ? { ...p, has_signed: !p.has_signed } : p)),
- }))
- }, [])
-
- // Keyboard shortcuts with proper error handling
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- // Safety checks
- if (!e || !e.key || typeof e.key !== "string") return
- if (e.ctrlKey || e.metaKey) return
-
- try {
- const key = e.key.toLowerCase()
-
- switch (key) {
- case "r":
- e.preventDefault()
- handleRunSimulation()
- break
- case "w":
- e.preventDefault()
- setWhatIfMode(!whatIfMode)
- break
- case "s":
- e.preventDefault()
- setShowStory(true)
- break
- case "e":
- e.preventDefault()
- handleExportJson()
- break
- case "f":
- e.preventDefault()
- setExpandedGraph(!expandedGraph)
- break
- case "c":
- e.preventDefault()
- setShowCustomConfig(!showCustomConfig)
- break
- }
- } catch (error) {
- console.warn("Keyboard shortcut error:", error)
- }
- }
+ const {
+ darkMode, setDarkMode,
+ showStory, setShowStory,
+ showSimulator, setShowSimulator,
+ isProcessing,
+ currentFixture, setCurrentFixture,
+ whatIfMode, setWhatIfMode,
+ simulatedSigners,
+ expandedGraph, setExpandedGraph,
+ showControls, setShowControls,
+ showDetails, setShowDetails,
+ showCustomConfig, setShowCustomConfig,
+ customConfig,
+ newPersonForm, setNewPersonForm,
+ newRoleForm, setNewRoleForm,
+ editingPerson, setEditingPerson,
+ editingRole, setEditingRole,
+ fixture,
+ displayResult,
+ handleRunSimulation,
+ handleSimulatedSignerToggle,
+ handleExportJson,
+ addPerson,
+ addRole,
+ deletePerson,
+ deleteRole,
+ updatePerson,
+ updateRole,
+ togglePersonSigned
+ } = useGittufSimulator()
- window.addEventListener("keydown", handleKeyDown)
- return () => window.removeEventListener("keydown", handleKeyDown)
- }, [handleRunSimulation, handleExportJson, whatIfMode, expandedGraph, showCustomConfig])
return (
([])
const [securityTrends, setSecurityTrends] = useState
([])
- const [overallScore, setOverallScore] = useState(0)
const [error, setError] = useState(null)
// Process commits data for comprehensive analysis
@@ -65,7 +52,6 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
const events: SecurityEvent[] = []
const trends: SecurityTrend[] = []
- let totalSecurityScore = 0
// Analyze each commit transition
for (let i = 1; i < sortedCommits.length; i++) {
@@ -80,9 +66,7 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
const commitEvents = analyzeSecurityEvents(diff, currentCommit)
events.push(...commitEvents)
- // Calculate security score for this commit
- const commitScore = calculateSecurityScore(currentCommit.data, diff)
- totalSecurityScore += commitScore
+
}
}
@@ -90,12 +74,8 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
const calculatedTrends = calculateSecurityTrends(sortedCommits)
trends.push(...calculatedTrends)
- // Calculate overall security score
- const avgScore = Math.round(totalSecurityScore / (sortedCommits.length - 1))
-
setSecurityEvents(events)
setSecurityTrends(trends)
- setOverallScore(avgScore)
setError(null)
} catch (err) {
console.error("Error processing analysis data:", err)
@@ -103,13 +83,16 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
}
}, [commits])
- const analyzeSecurityEvents = (diff: any, commit: Commit): SecurityEvent[] => {
+ const analyzeSecurityEvents = (diff: DiffResult | DiffEntry | null, commit: Commit): SecurityEvent[] => {
const events: SecurityEvent[] = []
- const traverseChanges = (obj: any, path = "") => {
+ // If diff is null or singular DiffEntry (root change), we might need to handle it.
+ // Assuming structure is mostly Record for traversal.
+
+ const traverseChanges = (obj: Record | undefined, path = "") => {
if (!obj) return
- Object.entries(obj).forEach(([key, value]: [string, any]) => {
+ Object.entries(obj).forEach(([key, value]) => {
const currentPath = path ? `${path}.${key}` : key
const pathLower = currentPath.toLowerCase()
@@ -119,8 +102,8 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
// Expiration changes
if (pathLower.includes("expires")) {
if (value.status === "changed") {
- const oldDate = new Date(value.oldValue)
- const newDate = new Date(value.value)
+ const oldDate = new Date(String(value.oldValue))
+ const newDate = new Date(String(value.value))
const extended = newDate > oldDate
event = {
@@ -141,7 +124,7 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
// Threshold changes
else if (pathLower.includes("threshold")) {
- if (value.status === "changed") {
+ if (value.status === "changed" && typeof value.value === 'number' && typeof value.oldValue === 'number') {
const increased = value.value > value.oldValue
event = {
commit: commit.hash.substring(0, 8),
@@ -230,48 +213,17 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
})
}
- traverseChanges(diff)
+ if (diff && !('status' in diff)) {
+ traverseChanges(diff as DiffResult)
+ } else if (diff && 'status' in diff && (diff as DiffEntry).children) {
+ // If root is a DiffEntry with children
+ traverseChanges((diff as DiffEntry).children)
+ }
+
return events
}
- const calculateSecurityScore = (data: any, diff: any): number => {
- let score = 50 // Base score
-
- // Positive factors
- if (data.expires) {
- const expiryDate = new Date(data.expires)
- const now = new Date()
- const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
-
- if (daysUntilExpiry > 90) score += 20
- else if (daysUntilExpiry > 30) score += 10
- else if (daysUntilExpiry < 0) score -= 30
- }
-
- // Threshold analysis
- if (data.roles) {
- Object.values(data.roles).forEach((role: any) => {
- if (role.threshold) {
- if (role.threshold >= 2) score += 15
- else if (role.threshold === 1) score += 5
- }
- })
- }
-
- // Rules analysis
- if (data.rules) {
- const ruleCount = Object.keys(data.rules).length
- score += Math.min(ruleCount * 5, 25) // Max 25 points for rules
- }
- // Principal diversity
- if (data.principals) {
- const principalCount = Object.keys(data.principals).length
- score += Math.min(principalCount * 3, 15) // Max 15 points for principals
- }
-
- return Math.max(0, Math.min(100, score))
- }
const calculateSecurityTrends = (commits: Commit[]): SecurityTrend[] => {
const trends: SecurityTrend[] = []
@@ -282,8 +234,8 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
const lastCommit = commits[commits.length - 1]
// Principal count trend
- const firstPrincipals = firstCommit.data?.principals ? Object.keys(firstCommit.data.principals).length : 0
- const lastPrincipals = lastCommit.data?.principals ? Object.keys(lastCommit.data.principals).length : 0
+ const firstPrincipals = firstCommit.data?.principals && typeof firstCommit.data.principals === 'object' && !Array.isArray(firstCommit.data.principals) ? Object.keys(firstCommit.data.principals).length : 0
+ const lastPrincipals = lastCommit.data?.principals && typeof lastCommit.data.principals === 'object' && !Array.isArray(lastCommit.data.principals) ? Object.keys(lastCommit.data.principals).length : 0
trends.push({
metric: "Security Principals",
@@ -294,8 +246,8 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
})
// Rules count trend
- const firstRules = firstCommit.data?.rules ? Object.keys(firstCommit.data.rules).length : 0
- const lastRules = lastCommit.data?.rules ? Object.keys(lastCommit.data.rules).length : 0
+ const firstRules = firstCommit.data?.rules && typeof firstCommit.data.rules === 'object' && !Array.isArray(firstCommit.data.rules) ? Object.keys(firstCommit.data.rules).length : 0
+ const lastRules = lastCommit.data?.rules && typeof lastCommit.data.rules === 'object' && !Array.isArray(lastCommit.data.rules) ? Object.keys(lastCommit.data.rules).length : 0
trends.push({
metric: "Security Rules",
@@ -306,7 +258,7 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
})
// Expiration health
- if (lastCommit.data?.expires) {
+ if (lastCommit.data?.expires && typeof lastCommit.data.expires === 'string') {
const expiryDate = new Date(lastCommit.data.expires)
const now = new Date()
const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
@@ -323,18 +275,7 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
return trends
}
- const getScoreColor = (score: number) => {
- if (score >= 80) return "text-green-600"
- if (score >= 60) return "text-yellow-600"
- return "text-red-600"
- }
- const getScoreDescription = (score: number) => {
- if (score >= 80) return "Excellent security posture"
- if (score >= 60) return "Good security with room for improvement"
- if (score >= 40) return "Moderate security, needs attention"
- return "Poor security, immediate action required"
- }
return (
@@ -353,10 +294,6 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
-
-
{overallScore}/100
-
{getScoreDescription(overallScore)}
-
@@ -570,7 +507,6 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
@@ -580,7 +516,6 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
commits={commits}
securityEvents={securityEvents}
securityTrends={securityTrends}
- overallScore={overallScore}
isLoading={isLoading}
/>
@@ -593,12 +528,10 @@ export default function CommitAnalysis({ commits, isLoading, selectedFile }: Com
function SecurityInsights({
commits,
securityEvents,
- overallScore,
isLoading,
}: {
commits: Commit[]
securityEvents: SecurityEvent[]
- overallScore: number
isLoading: boolean
}) {
const getInsights = () => {
@@ -652,20 +585,7 @@ function SecurityInsights({
})
}
- // Overall health
- if (overallScore >= 80) {
- insights.push({
- title: "Strong Security Posture",
- description: "Your repository maintains excellent security practices",
- type: "success" as const,
- })
- } else if (overallScore < 50) {
- insights.push({
- title: "Security Concerns",
- description: "Multiple security issues detected that need addressing",
- type: "error" as const,
- })
- }
+
return insights
}
@@ -695,13 +615,11 @@ function SecurityInsights({
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
className={`p-4 rounded-lg border-l-4 ${
- insight.type === "success"
- ? "border-green-500 bg-green-50"
- : insight.type === "warning"
- ? "border-yellow-500 bg-yellow-50"
- : insight.type === "error"
- ? "border-red-500 bg-red-50"
- : "border-blue-500 bg-blue-50"
+ insight.type === "warning"
+ ? "border-yellow-500 bg-yellow-50"
+ : insight.type === "error"
+ ? "border-red-500 bg-red-50"
+ : "border-blue-500 bg-blue-50"
}`}
>
{insight.title}
@@ -720,28 +638,33 @@ function SecurityRecommendations({
commits,
securityEvents,
securityTrends,
- overallScore,
isLoading,
}: {
commits: Commit[]
securityEvents: SecurityEvent[]
securityTrends: SecurityTrend[]
- overallScore: number
isLoading: boolean
}) {
- const getRecommendations = () => {
- const recommendations = []
+ interface Recommendation {
+ priority: "critical" | "high" | "medium" | "low"
+ title: string
+ description: string
+ action: string
+ }
+
+ const getRecommendations = (): Recommendation[] => {
+ const recommendations: Recommendation[] = []
// Check expiration
const latestCommit = commits[commits.length - 1]
- if (latestCommit?.data?.expires) {
+ if (latestCommit?.data?.expires && typeof latestCommit.data.expires === 'string') {
const expiryDate = new Date(latestCommit.data.expires)
const now = new Date()
const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (daysUntilExpiry < 30) {
recommendations.push({
- priority: "high" as const,
+ priority: "high",
title: "Renew Security Metadata",
description: `Security metadata expires in ${daysUntilExpiry} days. Plan renewal soon.`,
action: "Update expiration date and refresh security keys",
@@ -750,11 +673,11 @@ function SecurityRecommendations({
}
// Check thresholds
- if (latestCommit?.data?.roles) {
+ if (latestCommit?.data?.roles && typeof latestCommit.data.roles === 'object') {
Object.entries(latestCommit.data.roles).forEach(([role, config]: [string, any]) => {
- if (config.threshold === 1) {
+ if (config && typeof config === 'object' && config.threshold === 1) {
recommendations.push({
- priority: "medium" as const,
+ priority: "medium",
title: "Increase Security Threshold",
description: `Role "${role}" only requires 1 signature. Consider increasing for better security.`,
action: "Increase threshold to 2 or more signatures",
@@ -767,7 +690,7 @@ function SecurityRecommendations({
securityTrends.forEach((trend) => {
if (trend.trend === "declining") {
recommendations.push({
- priority: "medium" as const,
+ priority: "medium",
title: `Address Declining ${trend.metric}`,
description: `${trend.metric} has decreased from ${trend.previous} to ${trend.current}.`,
action: "Review and restore security measures",
@@ -775,21 +698,13 @@ function SecurityRecommendations({
}
})
- // Overall score recommendations
- if (overallScore < 60) {
- recommendations.push({
- priority: "high" as const,
- title: "Improve Overall Security",
- description: "Security score is below recommended levels.",
- action: "Review all security policies and implement missing protections",
- })
- }
+
// Critical events
const criticalEvents = securityEvents.filter((e) => e.severity === "critical")
if (criticalEvents.length > 0) {
recommendations.push({
- priority: "critical" as const,
+ priority: "critical",
title: "Address Critical Security Events",
description: `${criticalEvents.length} critical security events detected.`,
action: "Review and remediate all critical security changes",
diff --git a/frontend/components/commit-compare.tsx b/frontend/components/features/commit/commit-compare.tsx
similarity index 99%
rename from frontend/components/commit-compare.tsx
rename to frontend/components/features/commit/commit-compare.tsx
index 657565b..cb4ccdd 100644
--- a/frontend/components/commit-compare.tsx
+++ b/frontend/components/features/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 "./json-diff-visualization"
-import JsonDiffStats from "./json-diff-stats"
+import JsonDiffVisualization from "@/components/features/json/json-diff-visualization"
+import JsonDiffStats from "@/components/features/json/json-diff-stats"
import { Button } from "@/components/ui/button"
import type { Commit } from "@/lib/types"
import { useState } from "react"
-import JsonTreeView from "./json-tree-view"
+import JsonTreeView from "@/components/features/json/json-tree-view"
import { compareJsonObjects, countChanges } from "@/lib/json-diff"
import type { ViewMode } from "@/lib/view-mode-utils"
import { motion } from "framer-motion"
diff --git a/frontend/components/commit-list.tsx b/frontend/components/features/commit/commit-list.tsx
similarity index 100%
rename from frontend/components/commit-list.tsx
rename to frontend/components/features/commit/commit-list.tsx
diff --git a/frontend/components/json-diff-stats.tsx b/frontend/components/features/json/json-diff-stats.tsx
similarity index 100%
rename from frontend/components/json-diff-stats.tsx
rename to frontend/components/features/json/json-diff-stats.tsx
diff --git a/frontend/components/json-diff-visualization.tsx b/frontend/components/features/json/json-diff-visualization.tsx
similarity index 64%
rename from frontend/components/json-diff-visualization.tsx
rename to frontend/components/features/json/json-diff-visualization.tsx
index 6a9c250..78db715 100644
--- a/frontend/components/json-diff-visualization.tsx
+++ b/frontend/components/features/json/json-diff-visualization.tsx
@@ -18,11 +18,13 @@ import ReactFlow, {
import "reactflow/dist/style.css"
import dagre from "dagre"
import { motion } from "framer-motion"
-import { CollapsibleCard } from "./collapsible-card"
-import { compareJsonObjects } from "@/lib/json-diff"
+import { CollapsibleCard } from "@/components/shared/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"
import { formatJsonValue, getNodeTypeDescription } from "@/lib/json-utils"
+import type { JsonValue, JsonObject } from "@/lib/types"
+import type { ViewMode } from "@/lib/view-mode-utils"
// Node dimensions for layout
const NODE_WIDTH = 220
@@ -37,8 +39,20 @@ const AnimatedNode = ({ children }: { children: React.ReactNode }) => {
)
}
+interface DiffNodeData {
+ label?: string
+ value?: JsonValue
+ oldValue?: JsonValue
+ newValue?: JsonValue
+ path?: string
+ isExpanded?: boolean
+ onToggle?: () => void
+ metadata?: Record
+ diffDetails?: string
+}
+
// Node tooltip wrapper
-const DiffNodeTooltip = ({ children, data, type }: { children: React.ReactNode; data: any; type: string }) => {
+const DiffNodeTooltip = ({ children, data, type }: { children: React.ReactNode; data: DiffNodeData; type: string }) => {
return (
@@ -134,127 +148,122 @@ const DiffNodeTooltip = ({ children, data, type }: { children: React.ReactNode;
}
// Node types
-function DiffRootNode({ data, isConnectable }: any) {
+function DiffRootNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) {
return (
-
-
-
-
- {typeof data.value === "object" && data.value !== null
- ? `Object with ${Object.keys(data.value).length} properties`
- : data.value === null
- ? "null"
- : data.value === undefined
- ? "undefined"
- : String(data.value)}
-
-
-
+
+
+
)
}
-function DiffAddedNode({ data, isConnectable }: any) {
+function DiffAddedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) {
return (
-
-
-
-
-
- {typeof data.value === "object" && data.value !== null
- ? `Object with ${Object.keys(data.value).length} properties`
- : data.value === null
- ? "null"
- : data.value === undefined
- ? "undefined"
- : String(data.value)}
-
-
-
+
+
+
+ {formatJsonValue(data.value)}
+
+
+
)
}
-function DiffRemovedNode({ data, isConnectable }: any) {
+function DiffRemovedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) {
return (
-
-
-
-
-
- {typeof data.value === "object" && data.value !== null
- ? `Object with ${Object.keys(data.value).length} properties`
- : data.value === null
- ? "null"
- : data.value === undefined
- ? "undefined"
- : String(data.value)}
+
+
+
+
{data.label}
+
+ {formatJsonValue(data.value)}
-
+
+
)
}
-function DiffChangedNode({ data, isConnectable }: any) {
+function DiffChangedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) {
return (
-
-
-
-
-
-
{String(data.oldValue)}
-
{String(data.newValue)}
+
+
+
+
{data.label}
+
+
+
Old
+
{formatJsonValue(data.oldValue)}
+
+
+
New
+
{formatJsonValue(data.newValue)}
+
-
+
+
)
}
-function DiffUnchangedNode({ data, isConnectable }: any) {
+function DiffUnchangedNode({ data, isConnectable }: { data: DiffNodeData; isConnectable: boolean }) {
return (
@@ -262,7 +271,7 @@ function DiffUnchangedNode({ data, isConnectable }: any) {
@@ -326,20 +335,26 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[], direction = "TB") =>
}
// Main component
+export interface JsonDiffVisualizationProps {
+ baseData: JsonObject | null
+ compareData: JsonObject | null
+ className?: string
+ viewMode?: ViewMode
+}
+
export default function JsonDiffVisualization({
baseData,
compareData,
-}: {
- baseData: any
- compareData: any
-}) {
+ className,
+ viewMode,
+}: JsonDiffVisualizationProps) {
const [expandedNodes, setExpandedNodes] = useState>({})
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [showUnchanged, setShowUnchanged] = useState(true)
const [error, setError] = useState(null)
- const onConnect = useCallback((params: any) => setEdges((eds) => addEdge(params, eds)), [setEdges])
+ const onConnect = useCallback((params: Edge | any) => setEdges((eds) => addEdge(params, eds)), [setEdges])
const toggleNodeExpansion = useCallback((nodeId: string) => {
setExpandedNodes((prev) => ({
@@ -370,7 +385,7 @@ export default function JsonDiffVisualization({
path: "$",
metadata: {
type: typeof compareData,
- schemaVersion: compareData?.schemaVersion || "N/A",
+ schemaVersion: (compareData as any)?.schemaVersion || "N/A",
},
},
})
@@ -378,11 +393,95 @@ export default function JsonDiffVisualization({
// Compare the two JSON objects
const diff = compareJsonObjects(baseData, compareData)
- // Process diff recursively
- const processDiff = (parentId: string, diffObj: any, path = "$", level = 1) => {
- if (!diffObj) return
+ if (!diff) {
+ setNodes(getLayoutedElements(newNodes, newEdges, "TB").nodes)
+ setEdges(newEdges)
+ return
+ }
- Object.entries(diffObj).forEach(([key, value]: [string, any]) => {
+ // Helper function to process added objects recursively
+ const processAddedObject = (parentId: string, obj: JsonObject | any[], path: string, level: number) => {
+ Object.entries(obj).forEach(([childKey, childValue]) => {
+ const childId = `node-${nodeId++}`
+ const childPath = `${path}.${childKey}`
+
+ newNodes.push({
+ id: childId,
+ type: "diffAdded",
+ position: { x: 0, y: level * 100 },
+ data: {
+ label: childKey,
+ value: childValue as JsonValue,
+ isExpanded: false,
+ path: childPath,
+ metadata: {
+ type:
+ typeof childValue === "object"
+ ? Array.isArray(childValue)
+ ? "array"
+ : "object"
+ : typeof childValue,
+ },
+ },
+ })
+
+ newEdges.push({
+ id: `edge-${parentId}-${childId}`,
+ source: parentId,
+ target: childId,
+ animated: false,
+ style: { stroke: "#22c55e" },
+ })
+
+ if (typeof childValue === "object" && childValue !== null) {
+ processAddedObject(childId, childValue, childPath, level + 1)
+ }
+ })
+ }
+
+ // Helper function to process removed objects recursively
+ const processRemovedObject = (parentId: string, obj: JsonObject | any[], path: string, level: number) => {
+ Object.entries(obj).forEach(([childKey, childValue]) => {
+ const childId = `node-${nodeId++}`
+ const childPath = `${path}.${childKey}`
+
+ newNodes.push({
+ id: childId,
+ type: "diffRemoved",
+ position: { x: 0, y: level * 100 },
+ data: {
+ label: childKey,
+ value: childValue as JsonValue,
+ isExpanded: false,
+ path: childPath,
+ metadata: {
+ type:
+ typeof childValue === "object"
+ ? Array.isArray(childValue)
+ ? "array"
+ : "object"
+ : typeof childValue,
+ },
+ },
+ })
+
+ newEdges.push({
+ id: `edge-${parentId}-${childId}`,
+ source: parentId,
+ target: childId,
+ animated: false,
+ style: { stroke: "#ef4444" },
+ })
+
+ if (typeof childValue === "object" && childValue !== null) {
+ processRemovedObject(childId, childValue, childPath, level + 1)
+ }
+ })
+ }
+
+ // Process diff recursively
+ const processDiff = (parentId: string, diffObj: DiffResult, path = "$", level = 1) => {
+ Object.entries(diffObj).forEach(([key, value]) => {
const currentId = `node-${nodeId++}`
const currentPath = path === "$" ? `${path}.${key}` : `${path}.${key}`
const isExpanded = expandedNodes[currentId] !== false
@@ -420,46 +519,7 @@ export default function JsonDiffVisualization({
if (isExpanded && typeof value.value === "object" && value.value !== null) {
// For added objects, create nodes for their properties
- const addedObj = value.value
- const processAddedObject = (parentId: string, obj: any, path: string, level: number) => {
- Object.entries(obj).forEach(([childKey, childValue]: [string, any]) => {
- const childId = `node-${nodeId++}`
- const childPath = `${path}.${childKey}`
-
- newNodes.push({
- id: childId,
- type: "diffAdded",
- position: { x: 0, y: level * 100 },
- data: {
- label: childKey,
- value: childValue,
- isExpanded: false,
- path: childPath,
- metadata: {
- type:
- typeof childValue === "object"
- ? Array.isArray(childValue)
- ? "array"
- : "object"
- : typeof childValue,
- },
- },
- })
-
- newEdges.push({
- id: `edge-${parentId}-${childId}`,
- source: parentId,
- target: childId,
- animated: false,
- style: { stroke: "#22c55e" },
- })
-
- if (typeof childValue === "object" && childValue !== null) {
- processAddedObject(childId, childValue, childPath, level + 1)
- }
- })
- }
-
+ const addedObj = value.value as JsonObject | any[]
processAddedObject(currentId, addedObj, currentPath, level + 1)
}
} else if (value.status === "removed") {
@@ -495,46 +555,7 @@ export default function JsonDiffVisualization({
if (isExpanded && typeof value.value === "object" && value.value !== null) {
// For removed objects, create nodes for their properties
- const removedObj = value.value
- const processRemovedObject = (parentId: string, obj: any, path: string, level: number) => {
- Object.entries(obj).forEach(([childKey, childValue]: [string, any]) => {
- const childId = `node-${nodeId++}`
- const childPath = `${path}.${childKey}`
-
- newNodes.push({
- id: childId,
- type: "diffRemoved",
- position: { x: 0, y: level * 100 },
- data: {
- label: childKey,
- value: childValue,
- isExpanded: false,
- path: childPath,
- metadata: {
- type:
- typeof childValue === "object"
- ? Array.isArray(childValue)
- ? "array"
- : "object"
- : typeof childValue,
- },
- },
- })
-
- newEdges.push({
- id: `edge-${parentId}-${childId}`,
- source: parentId,
- target: childId,
- animated: false,
- style: { stroke: "#ef4444" },
- })
-
- if (typeof childValue === "object" && childValue !== null) {
- processRemovedObject(childId, childValue, childPath, level + 1)
- }
- })
- }
-
+ const removedObj = value.value as JsonObject | any[]
processRemovedObject(currentId, removedObj, currentPath, level + 1)
}
} else if (value.status === "changed") {
@@ -609,9 +630,18 @@ export default function JsonDiffVisualization({
processDiff(currentId, value.children, currentPath, level + 1)
}
} else if (value.children) {
- // This is a nested object with changes inside
- const nodeType =
- value.status === "added" ? "diffAdded" : value.status === "removed" ? "diffRemoved" : "diffUnchanged"
+ // This is a nested object with changes inside, but the node itself is "unchanged"
+ // (checked above). If we are here, it means showUnchanged is false (otherwise caught above),
+ // OR the status was not added/removed/changed.
+ // Since we previously checked added/removed/changed, status is strictly "unchanged".
+
+ // We usually want to show nodes that contain changes even if "showUnchanged" is false?
+ // If showUnchanged is false, the previous block skipped it.
+ // So this block executes for unchanged nodes with children.
+ // But if we want to hide unchanged nodes, we shouldn't render this node?
+ // However, if children have changes, we probably MUST render this node to maintain the tree.
+
+ const nodeType = "diffUnchanged"
newNodes.push({
id: currentId,
@@ -635,13 +665,13 @@ export default function JsonDiffVisualization({
},
})
- const edgeColor = value.status === "added" ? "#22c55e" : value.status === "removed" ? "#ef4444" : "#94a3b8"
+ const edgeColor = "#94a3b8"
newEdges.push({
id: `edge-${parentId}-${currentId}`,
source: parentId,
target: currentId,
- animated: value.status !== "unchanged",
+ animated: false,
style: { stroke: edgeColor },
})
@@ -652,8 +682,20 @@ export default function JsonDiffVisualization({
})
}
- // Start processing from root
- processDiff(rootId, diff, "$")
+ // Check if diff is a single entry (e.g., entire object added/removed) or a Result Record
+ if ('status' in diff && typeof (diff as DiffEntry).status === 'string') {
+ const diffEntry = diff as DiffEntry;
+ // If the whole thing is added, we iterate its value if it's an object
+ if (diffEntry.status === "added" && typeof diffEntry.value === "object" && diffEntry.value !== null) {
+ processAddedObject(rootId, diffEntry.value as JsonObject | any[], "$", 1);
+ } else if (diffEntry.status === "removed" && typeof diffEntry.value === "object" && diffEntry.value !== null) {
+ processRemovedObject(rootId, diffEntry.value as JsonObject | any[], "$", 1);
+ }
+ // Handle other cases if necessary (e.g. root changed type)
+ } else {
+ // Start processing from root as a DiffResult
+ processDiff(rootId, diff as DiffResult, "$")
+ }
// Apply layout
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
diff --git a/frontend/components/json-tree-view.tsx b/frontend/components/features/json/json-tree-view.tsx
similarity index 100%
rename from frontend/components/json-tree-view.tsx
rename to frontend/components/features/json/json-tree-view.tsx
diff --git a/frontend/components/json-tree-visualization.tsx b/frontend/components/features/json/json-tree-visualization.tsx
similarity index 99%
rename from frontend/components/json-tree-visualization.tsx
rename to frontend/components/features/json/json-tree-visualization.tsx
index 932e557..7ec73ed 100644
--- a/frontend/components/json-tree-visualization.tsx
+++ b/frontend/components/features/json/json-tree-visualization.tsx
@@ -17,7 +17,7 @@ import ReactFlow, {
} from "reactflow"
import "reactflow/dist/style.css"
import dagre from "dagre"
-import { CollapsibleCard } from "./collapsible-card"
+import { CollapsibleCard } from "@/components/shared/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/components/features/json/tree-view-tab.tsx
new file mode 100644
index 0000000..9076658
--- /dev/null
+++ b/frontend/components/features/json/tree-view-tab.tsx
@@ -0,0 +1,87 @@
+"use client"
+
+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 type { Commit } from "@/lib/types"
+import type { ViewMode } from "@/lib/view-mode-utils"
+
+interface TreeViewTabProps {
+ selectedCommit: Commit | null
+ selectedFile: string
+ isLoading: boolean
+ error: string
+ jsonData: any
+ viewMode: ViewMode
+ onRetry: () => void
+}
+
+export default function TreeViewTab({
+ selectedCommit,
+ selectedFile,
+ isLoading,
+ error,
+ jsonData,
+ viewMode,
+ onRetry,
+}: TreeViewTabProps) {
+ return (
+ <>
+ {selectedCommit && (
+
+
+
+
+
+
+
+ Structured Tree View: {selectedFile}
+
+
+
+ {selectedCommit.hash.substring(0, 8)}
+
+
{selectedCommit.message}
+
+
+
+ {new Date(selectedCommit.date).toLocaleDateString()} by {selectedCommit.author}
+
+
+
+
+
+ )}
+
+
+ {isLoading ? (
+
+
+ Loading tree structure...
+
+ ) : error ? (
+
+
+
Error Loading Tree View
+
{error}
+
+
+ ) : jsonData && selectedCommit ? (
+
+ ) : (
+
+
+
Tree View Ready!
+
+ Select a commit to explore the security metadata in a familiar tree structure - perfect for beginners!
+
+
+ )}
+
+ >
+ )
+}
diff --git a/frontend/components/repository-selector.tsx b/frontend/components/features/repository/repository-selector.tsx
similarity index 100%
rename from frontend/components/repository-selector.tsx
rename to frontend/components/features/repository/repository-selector.tsx
diff --git a/frontend/components/repository-status.tsx b/frontend/components/features/repository/repository-status.tsx
similarity index 100%
rename from frontend/components/repository-status.tsx
rename to frontend/components/features/repository/repository-status.tsx
diff --git a/frontend/components/features/visualization/file-selector.tsx b/frontend/components/features/visualization/file-selector.tsx
new file mode 100644
index 0000000..7326255
--- /dev/null
+++ b/frontend/components/features/visualization/file-selector.tsx
@@ -0,0 +1,88 @@
+"use client"
+
+import { FileJson } from "lucide-react"
+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 type { ViewMode } from "@/lib/view-mode-utils"
+
+interface FileSelectorProps {
+ selectedFile: string
+ onFileChange: (file: string) => void
+ viewMode: ViewMode
+ onViewModeChange: (mode: ViewMode) => void
+ hiddenCount: number
+ showViewToggle: boolean
+}
+
+export default function FileSelector({
+ selectedFile,
+ onFileChange,
+ viewMode,
+ onViewModeChange,
+ hiddenCount,
+ showViewToggle,
+}: FileSelectorProps) {
+ return (
+
+
+
+
+
+ Security Files:
+
+
+
+
+
+
+
+
+
Root Security Policy
+
+ Contains trust anchors, keys, and role definitions that form the foundation of repository security.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Target Security Rules
+
+ Contains specific security policies and rules that control who can modify different parts of the
+ repository.
+
+
+
+
+
+
+
+
+ {showViewToggle && (
+
+ )}
+
+ )
+}
diff --git a/frontend/components/trust-graph.tsx b/frontend/components/features/visualization/trust-graph.tsx
similarity index 100%
rename from frontend/components/trust-graph.tsx
rename to frontend/components/features/visualization/trust-graph.tsx
diff --git a/frontend/components/features/visualization/visualization-tab.tsx b/frontend/components/features/visualization/visualization-tab.tsx
new file mode 100644
index 0000000..a12e848
--- /dev/null
+++ b/frontend/components/features/visualization/visualization-tab.tsx
@@ -0,0 +1,97 @@
+"use client"
+
+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 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"), {
+ ssr: false,
+ loading: () => (
+
+
+ Loading visualization...
+
+ ),
+})
+
+interface VisualizationTabProps {
+ selectedCommit: Commit | null
+ selectedFile: string
+ isLoading: boolean
+ error: string
+ jsonData: any
+ viewMode: ViewMode
+ onRetry: () => void
+}
+
+export default function VisualizationTab({
+ selectedCommit,
+ selectedFile,
+ isLoading,
+ error,
+ jsonData,
+ viewMode,
+ onRetry,
+}: VisualizationTabProps) {
+ return (
+ <>
+ {selectedCommit && (
+
+
+
+
+
+
+
+ Interactive Graph View: {selectedFile}
+
+
+
+ {selectedCommit.hash.substring(0, 8)}
+
+
{selectedCommit.message}
+
+
+
+ {new Date(selectedCommit.date).toLocaleDateString()} by {selectedCommit.author}
+
+
+
+
+
+ )}
+
+
+ {isLoading ? (
+
+
+ Loading security metadata visualization...
+
+ ) : error ? (
+
+
+
Error Loading Visualization
+
{error}
+
+
+ ) : jsonData && selectedCommit ? (
+
+ ) : (
+
+
+
Ready to Visualize!
+
+ Select a commit from the "Browse Commits" tab to see an interactive graph of the security metadata
+
+
+ )}
+
+ >
+ )
+}
diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx
new file mode 100644
index 0000000..8da638c
--- /dev/null
+++ b/frontend/components/layout/header.tsx
@@ -0,0 +1,49 @@
+"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 (
+
+ )
+}
diff --git a/frontend/components/collapsible-card.tsx b/frontend/components/shared/collapsible-card.tsx
similarity index 91%
rename from frontend/components/collapsible-card.tsx
rename to frontend/components/shared/collapsible-card.tsx
index 9d2ab2e..208923e 100644
--- a/frontend/components/collapsible-card.tsx
+++ b/frontend/components/shared/collapsible-card.tsx
@@ -14,6 +14,8 @@ interface CollapsibleCardProps {
isExpanded?: boolean
badgeText?: string
badgeColor?: string
+ className?: string
+ headerClassName?: string
}
export const CollapsibleCard: React.FC = ({
@@ -25,6 +27,8 @@ export const CollapsibleCard: React.FC = ({
isExpanded,
badgeText,
badgeColor,
+ className = "",
+ headerClassName = "",
}) => {
const colorMap: Record = {
"border-blue-500": "text-blue-600",
@@ -43,9 +47,9 @@ export const CollapsibleCard: React.FC = ({
-
+
{title}
{badgeText && (
diff --git a/frontend/components/enhanced-view-mode-toggle.tsx b/frontend/components/shared/enhanced-view-mode-toggle.tsx
similarity index 100%
rename from frontend/components/enhanced-view-mode-toggle.tsx
rename to frontend/components/shared/enhanced-view-mode-toggle.tsx
diff --git a/frontend/components/progress-indicator.tsx b/frontend/components/shared/progress-indicator.tsx
similarity index 100%
rename from frontend/components/progress-indicator.tsx
rename to frontend/components/shared/progress-indicator.tsx
diff --git a/frontend/components/quick-start-guide.tsx b/frontend/components/shared/quick-start-guide.tsx
similarity index 100%
rename from frontend/components/quick-start-guide.tsx
rename to frontend/components/shared/quick-start-guide.tsx
diff --git a/frontend/components/status-card.tsx b/frontend/components/shared/status-card.tsx
similarity index 100%
rename from frontend/components/status-card.tsx
rename to frontend/components/shared/status-card.tsx
diff --git a/frontend/components/story-modal.tsx b/frontend/components/shared/story-modal.tsx
similarity index 99%
rename from frontend/components/story-modal.tsx
rename to frontend/components/shared/story-modal.tsx
index c473de1..1df3425 100644
--- a/frontend/components/story-modal.tsx
+++ b/frontend/components/shared/story-modal.tsx
@@ -19,7 +19,7 @@ import {
Key,
FileText,
} from "lucide-react"
-import { TrustGraph } from "./trust-graph"
+import { TrustGraph } from "@/components/features/visualization/trust-graph"
import type { SimulatorResponse } from "@/lib/simulator-types"
interface StoryModalProps {
diff --git a/frontend/components/view-mode-toggle.tsx b/frontend/components/shared/view-mode-toggle.tsx
similarity index 100%
rename from frontend/components/view-mode-toggle.tsx
rename to frontend/components/shared/view-mode-toggle.tsx
diff --git a/frontend/components/shared/welcome-screen.tsx b/frontend/components/shared/welcome-screen.tsx
new file mode 100644
index 0000000..5688740
--- /dev/null
+++ b/frontend/components/shared/welcome-screen.tsx
@@ -0,0 +1,24 @@
+"use client"
+
+import { Github, Sparkles } from "lucide-react"
+import { Button } from "@/components/ui/button"
+
+interface WelcomeScreenProps {
+ onTryDemo: () => void
+}
+
+export default function WelcomeScreen({ onTryDemo }: WelcomeScreenProps) {
+ return (
+
+
+
Ready to Explore Security Metadata
+
+ Enter a GitHub repository URL above or try our interactive demo to start learning about gittuf security policies
+
+
+
+ )
+}
diff --git a/frontend/components/welcome-section.tsx b/frontend/components/shared/welcome-section.tsx
similarity index 100%
rename from frontend/components/welcome-section.tsx
rename to frontend/components/shared/welcome-section.tsx
diff --git a/frontend/hooks/explorer/use-commit-analysis.ts b/frontend/hooks/explorer/use-commit-analysis.ts
new file mode 100644
index 0000000..3518913
--- /dev/null
+++ b/frontend/hooks/explorer/use-commit-analysis.ts
@@ -0,0 +1,64 @@
+"use client"
+
+import { useState, useEffect } from "react"
+import type { Commit } from "@/lib/types"
+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(() => {
+ if (activeTab === "analysis" && selectedCommits.length > 0) {
+ const loadAnalysisData = async () => {
+ setLoading(true)
+ setError("")
+
+ try {
+ const fallbackUrl = currentRepository?.path || repoUrl || "https://github.com/gittuf/gittuf"
+ 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],
+ }))
+
+ 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()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [activeTab, selectedCommits.length, selectedFile, currentRepository?.path, repoUrl])
+ // Dependency management: verify if this covers all needed updates without infinite loops.
+ // selectedCommits.length is safer than selectedCommits to avoid loops if deep equality isn't checked,
+ // but here we are updating selectedCommits inside the effect, so we MUST correspond to the pattern.
+
+ 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
new file mode 100644
index 0000000..08a1837
--- /dev/null
+++ b/frontend/hooks/explorer/use-commit-comparison.ts
@@ -0,0 +1,89 @@
+"use client"
+
+import { useState } from "react"
+import type { Commit } 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: any | null
+ compare: any | 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, compareData] = await Promise.all([
+ mockFetchMetadata(fallbackUrl, base.hash, selectedFile),
+ mockFetchMetadata(fallbackUrl, compare.hash, selectedFile),
+ ])
+
+ setCompareData({ base: baseData, compare: compareData })
+ 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 || "https://github.com/gittuf/gittuf"
+ const [baseData, compareData] = await Promise.all([
+ mockFetchMetadata(fallbackUrl, compareCommits.base.hash, file),
+ mockFetchMetadata(fallbackUrl, compareCommits.compare.hash, file),
+ ])
+
+ setCompareData({ base: baseData, compare: compareData })
+ } 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
new file mode 100644
index 0000000..37c9052
--- /dev/null
+++ b/frontend/hooks/explorer/use-commit-selection.ts
@@ -0,0 +1,80 @@
+"use client"
+
+import { useState } from "react"
+import type { Commit } from "@/lib/types"
+import { mockFetchMetadata } from "@/lib/mock-api"
+import type { ViewMode } from "@/lib/view-mode-utils"
+import type { RepositoryInfo } from "@/lib/repository-handler"
+
+export function useCommitSelection() {
+ const [selectedCommit, setSelectedCommit] = useState(null)
+ const [jsonData, setJsonData] = useState(null)
+ const [selectedFile, setSelectedFile] = useState("root.json")
+ 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)
+ 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 || "https://github.com/gittuf/gittuf"
+ const metadata = await mockFetchMetadata(fallbackUrl, selectedCommit.hash, file)
+ setJsonData(metadata)
+ } 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/explorer/use-repository.ts b/frontend/hooks/explorer/use-repository.ts
new file mode 100644
index 0000000..f8c494e
--- /dev/null
+++ b/frontend/hooks/explorer/use-repository.ts
@@ -0,0 +1,106 @@
+"use client"
+
+import type React from "react"
+import { useState } from "react"
+import { mockFetchCommits } from "@/lib/mock-api"
+import type { Commit } from "@/lib/types"
+import { RepositoryHandler, type RepositoryInfo } from "@/lib/repository-handler"
+
+export function useRepository() {
+ const [repoUrl, setRepoUrl] = useState("")
+ const [commits, setCommits] = useState([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState("")
+ const [currentRepository, setCurrentRepository] = useState(null)
+ const [showRepositorySelector, setShowRepositorySelector] = useState(true)
+ const [repositoryHandler] = useState(() => new RepositoryHandler())
+
+ const handleTryDemo = async (onSuccess?: () => void) => {
+ setRepoUrl("https://github.com/gittuf/gittuf")
+ setIsLoading(true)
+ setError("")
+
+ try {
+ const commitsData = await mockFetchCommits("https://github.com/gittuf/gittuf")
+ setCommits(commitsData)
+ if (onSuccess) onSuccess()
+ } catch (err) {
+ setError("Failed to load demo data. Please try again.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleRepositorySelect = async (repoInfo: RepositoryInfo, onSuccess?: () => void) => {
+ setCurrentRepository(repoInfo)
+ setIsLoading(true)
+ setError("")
+
+ try {
+ await repositoryHandler.setRepository(repoInfo)
+ const commitsData = await repositoryHandler.fetchCommits()
+ setCommits(commitsData)
+ setShowRepositorySelector(false)
+ if (onSuccess) onSuccess()
+ } catch (err) {
+ setError(`Failed to connect to repository: ${err instanceof Error ? err.message : "Unknown error"}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleRepositoryRefresh = async () => {
+ if (!currentRepository) return
+
+ setIsLoading(true)
+ setError("")
+
+ try {
+ const commitsData = await repositoryHandler.fetchCommits()
+ setCommits(commitsData)
+ } catch (err) {
+ setError(`Failed to refresh repository data: ${err instanceof Error ? err.message : "Unknown error"}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleRepoSubmit = async (e: React.FormEvent, onSuccess?: () => void) => {
+ e.preventDefault()
+
+ if (!repoUrl.trim()) {
+ setError("Please enter a GitHub repository URL")
+ return
+ }
+
+ setIsLoading(true)
+ setError("")
+
+ try {
+ const commitsData = await mockFetchCommits(repoUrl)
+ setCommits(commitsData)
+ if (onSuccess) onSuccess()
+ } catch (err) {
+ setError("Failed to fetch repository data. Please check the URL and try again.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return {
+ repoUrl,
+ setRepoUrl,
+ commits,
+ setCommits,
+ isLoading,
+ error,
+ setError, // Exposed to allow other hooks to set error if needed, or clear it
+ currentRepository,
+ showRepositorySelector,
+ setShowRepositorySelector,
+ handleTryDemo,
+ handleRepositorySelect,
+ handleRepositoryRefresh,
+ handleRepoSubmit,
+ }
+}
diff --git a/frontend/hooks/use-gittuf-explorer.ts b/frontend/hooks/use-gittuf-explorer.ts
new file mode 100644
index 0000000..2bb5c4c
--- /dev/null
+++ b/frontend/hooks/use-gittuf-explorer.ts
@@ -0,0 +1,179 @@
+"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/use-gittuf-simulator.ts b/frontend/hooks/use-gittuf-simulator.ts
new file mode 100644
index 0000000..731d9e5
--- /dev/null
+++ b/frontend/hooks/use-gittuf-simulator.ts
@@ -0,0 +1,436 @@
+import { useState, useCallback, useMemo, useEffect } from "react"
+import type { SimulatorResponse, ApprovalRequirement, EligibleSigner, CustomConfig, CustomPerson, CustomRole } from "@/lib/simulator-types"
+import fixtureAllowed from "@/fixtures/fixture-allowed.json"
+import fixtureBlocked from "@/fixtures/fixture-blocked.json"
+
+const DEFAULT_CONFIG: CustomConfig = {
+ people: [
+ {
+ id: "alice",
+ display_name: "Alice Johnson",
+ keyid: "ssh-rsa-abc123",
+ key_type: "ssh",
+ has_signed: false,
+ },
+ {
+ id: "bob",
+ display_name: "Bob Smith",
+ keyid: "gpg-def456",
+ key_type: "gpg",
+ has_signed: false,
+ },
+ {
+ id: "charlie",
+ display_name: "Charlie Brown",
+ keyid: "sigstore-ghi789",
+ key_type: "sigstore",
+ has_signed: false,
+ },
+ ],
+ roles: [
+ {
+ id: "maintainer",
+ display_name: "Maintainer",
+ threshold: 2,
+ file_globs: ["src/**", "docs/**"],
+ assigned_people: ["alice", "bob", "charlie"],
+ },
+ {
+ id: "reviewer",
+ display_name: "Reviewer",
+ threshold: 1,
+ file_globs: ["tests/**"],
+ assigned_people: ["alice", "bob"],
+ },
+ ],
+}
+
+export function useGittufSimulator() {
+ // Core UI State
+ const [darkMode, setDarkMode] = useState(false)
+ const [showStory, setShowStory] = useState(false)
+ const [showSimulator, setShowSimulator] = useState(false)
+ const [isProcessing, setIsProcessing] = useState(false)
+
+ // Simulator State
+ const [currentFixture, setCurrentFixture] = useState<"blocked" | "allowed" | "custom">("blocked")
+ const [whatIfMode, setWhatIfMode] = useState(false)
+ const [simulatedSigners, setSimulatedSigners] = useState>(new Set())
+
+ // UI Layout State
+ const [expandedGraph, setExpandedGraph] = useState(false)
+ const [showControls, setShowControls] = useState(true)
+ const [showDetails, setShowDetails] = useState(false)
+
+ // Custom Config State
+ const [showCustomConfig, setShowCustomConfig] = useState(false)
+ const [customConfig, setCustomConfig] = useState(DEFAULT_CONFIG)
+
+ // Form States
+ const [newPersonForm, setNewPersonForm] = useState({
+ id: "",
+ display_name: "",
+ keyid: "",
+ key_type: "ssh" as const,
+ has_signed: false,
+ })
+
+ const [newRoleForm, setNewRoleForm] = useState({
+ id: "",
+ display_name: "",
+ threshold: 1,
+ file_globs: ["src/**"],
+ assigned_people: [] as string[],
+ })
+
+ const [editingPerson, setEditingPerson] = useState(null)
+ const [editingRole, setEditingRole] = useState(null)
+
+ // Generate custom fixture from config
+ const customFixture = useMemo((): SimulatorResponse => {
+ const approval_requirements: ApprovalRequirement[] = customConfig.roles.map((role) => {
+ const eligible_signers: EligibleSigner[] = role.assigned_people
+ .map((personId) => {
+ const person = customConfig.people.find((p) => p.id === personId)
+ return person
+ ? {
+ id: person.id,
+ display_name: person.display_name,
+ keyid: person.keyid,
+ key_type: person.key_type,
+ }
+ : null
+ })
+ .filter(Boolean) as EligibleSigner[]
+
+ const satisfiers = eligible_signers
+ .filter((signer) => {
+ const person = customConfig.people.find((p) => p.id === signer.id)
+ return person?.has_signed
+ })
+ .map((signer) => ({
+ who: signer.id,
+ keyid: signer.keyid,
+ signature_valid: true,
+ signature_time: new Date().toISOString(),
+ signature_verification_reason: `Valid ${signer.key_type.toUpperCase()} signature`,
+ }))
+
+ return {
+ role: role.id,
+ role_metadata_version: 1,
+ threshold: role.threshold,
+ file_globs: role.file_globs,
+ eligible_signers,
+ satisfied: satisfiers.length,
+ satisfiers,
+ }
+ })
+
+ const allRequirementsMet = approval_requirements.every((req) => req.satisfied >= req.threshold)
+
+ const visualization_hint = {
+ nodes: [
+ ...customConfig.roles.map((role) => ({
+ id: role.id,
+ type: "role" as const,
+ label: `${role.display_name} (${
+ approval_requirements.find((req) => req.role === role.id)?.satisfied || 0
+ }/${role.threshold})`,
+ meta: {
+ satisfied: (approval_requirements.find((req) => req.role === role.id)?.satisfied || 0) >= role.threshold,
+ threshold: role.threshold,
+ current: approval_requirements.find((req) => req.role === role.id)?.satisfied || 0,
+ },
+ })),
+ ...customConfig.people.map((person) => ({
+ id: person.id,
+ type: "person" as const,
+ label: person.display_name,
+ meta: {
+ signed: person.has_signed,
+ keyType: person.key_type,
+ },
+ })),
+ ],
+ edges: customConfig.roles.flatMap((role) =>
+ role.assigned_people.map((personId) => {
+ const person = customConfig.people.find((p) => p.id === personId)
+ return {
+ from: personId,
+ to: role.id,
+ label: person?.has_signed ? "Approved" : "Eligible",
+ satisfied: person?.has_signed || false,
+ }
+ }),
+ ),
+ }
+
+ return {
+ result: allRequirementsMet ? "allowed" : "blocked",
+ reasons: allRequirementsMet
+ ? ["All approval requirements satisfied"]
+ : approval_requirements
+ .filter((req) => req.satisfied < req.threshold)
+ .map((req) => `Missing ${req.threshold - req.satisfied} ${req.role} approval(s)`),
+ approval_requirements,
+ signature_verification: approval_requirements.flatMap((req) =>
+ req.satisfiers.map((satisfier, index) => ({
+ signature_id: `sig-${req.role}-${index + 1}`,
+ keyid: satisfier.keyid,
+ sig_ok: satisfier.signature_valid,
+ verified_at: satisfier.signature_time,
+ reason: satisfier.signature_verification_reason,
+ })),
+ ),
+ attestation_matches: [
+ {
+ attestation_id: "att-custom-001",
+ rsl_index: 42,
+ maps_to_proposal: true,
+ from_revision_ok: true,
+ target_tree_hash_match: true,
+ signature_valid: true,
+ },
+ ],
+ visualization_hint,
+ }
+ }, [customConfig])
+
+ // Get current fixture
+ const getCurrentFixture = useCallback((): SimulatorResponse => {
+ switch (currentFixture) {
+ case "allowed":
+ return fixtureAllowed as SimulatorResponse
+ case "custom":
+ return customFixture
+ default:
+ return fixtureBlocked as SimulatorResponse
+ }
+ }, [currentFixture, customFixture])
+
+ const fixture = getCurrentFixture()
+
+ // Calculate what-if result
+ const displayResult = useMemo((): SimulatorResponse => {
+ if (!whatIfMode || simulatedSigners.size === 0) return fixture
+
+ const whatIfResult = JSON.parse(JSON.stringify(fixture)) as SimulatorResponse
+
+ whatIfResult.approval_requirements = whatIfResult.approval_requirements.map((req) => {
+ const additionalSatisfiers = req.eligible_signers
+ .filter((signer) => simulatedSigners.has(signer.id) && !req.satisfiers.some((s) => s.who === signer.id))
+ .map((signer) => ({
+ who: signer.id,
+ keyid: signer.keyid,
+ signature_valid: true,
+ signature_time: new Date().toISOString(),
+ signature_verification_reason: "Simulated signature",
+ }))
+
+ return {
+ ...req,
+ satisfied: req.satisfied + additionalSatisfiers.length,
+ satisfiers: [...req.satisfiers, ...additionalSatisfiers],
+ }
+ })
+
+ const allRequirementsMet = whatIfResult.approval_requirements.every((req) => req.satisfied >= req.threshold)
+ whatIfResult.result = allRequirementsMet ? "allowed" : "blocked"
+
+ if (allRequirementsMet && fixture.result === "blocked") {
+ whatIfResult.reasons = ["All approval requirements satisfied (with simulated signatures)"]
+ }
+
+ return whatIfResult
+ }, [whatIfMode, simulatedSigners, fixture])
+
+ // Event Handlers
+ const handleRunSimulation = useCallback(async () => {
+ setIsProcessing(true)
+ setShowSimulator(true)
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ setIsProcessing(false)
+ }, [])
+
+ const handleSimulatedSignerToggle = useCallback((signerId: string, checked: boolean) => {
+ setSimulatedSigners((prev) => {
+ const newSet = new Set(prev)
+ if (checked) {
+ newSet.add(signerId)
+ } else {
+ newSet.delete(signerId)
+ }
+ return newSet
+ })
+ }, [])
+
+ const handleExportJson = useCallback(() => {
+ const dataStr = JSON.stringify(displayResult, null, 2)
+ const dataBlob = new Blob([dataStr], { type: "application/json" })
+ const url = URL.createObjectURL(dataBlob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = `gittuf-simulation-${Date.now()}.json`
+ link.click()
+ URL.revokeObjectURL(url)
+ }, [displayResult])
+
+ const addPerson = useCallback(() => {
+ if (!newPersonForm.id || !newPersonForm.display_name) return
+
+ const newPerson = {
+ ...newPersonForm,
+ keyid: newPersonForm.keyid || `${newPersonForm.key_type}-${Date.now()}`,
+ }
+
+ setCustomConfig((prev) => ({
+ ...prev,
+ people: [...prev.people, newPerson],
+ }))
+
+ setNewPersonForm({
+ id: "",
+ display_name: "",
+ keyid: "",
+ key_type: "ssh",
+ has_signed: false,
+ })
+ }, [newPersonForm])
+
+ const addRole = useCallback(() => {
+ if (!newRoleForm.id || !newRoleForm.display_name) return
+
+ setCustomConfig((prev) => ({
+ ...prev,
+ roles: [...prev.roles, { ...newRoleForm }],
+ }))
+
+ setNewRoleForm({
+ id: "",
+ display_name: "",
+ threshold: 1,
+ file_globs: ["src/**"],
+ assigned_people: [],
+ })
+ }, [newRoleForm])
+
+ const deletePerson = useCallback((id: string) => {
+ setCustomConfig((prev) => ({
+ ...prev,
+ people: prev.people.filter((p) => p.id !== id),
+ roles: prev.roles.map((role) => ({
+ ...role,
+ assigned_people: role.assigned_people.filter((pid) => pid !== id),
+ })),
+ }))
+ }, [])
+
+ const deleteRole = useCallback((id: string) => {
+ setCustomConfig((prev) => ({
+ ...prev,
+ roles: prev.roles.filter((r) => r.id !== id),
+ }))
+ }, [])
+
+ const updatePerson = useCallback((person: CustomPerson) => {
+ setCustomConfig((prev) => ({
+ ...prev,
+ people: prev.people.map((p) => (p.id === person.id ? person : p)),
+ }))
+ setEditingPerson(null)
+ }, [])
+
+ const updateRole = useCallback((role: CustomRole) => {
+ setCustomConfig((prev) => ({
+ ...prev,
+ roles: prev.roles.map((r) => (r.id === role.id ? role : r)),
+ }))
+ setEditingRole(null)
+ }, [])
+
+ const togglePersonSigned = useCallback((personId: string) => {
+ setCustomConfig((prev) => ({
+ ...prev,
+ people: prev.people.map((p) => (p.id === personId ? { ...p, has_signed: !p.has_signed } : p)),
+ }))
+ }, [])
+
+ // Keyboard shortcuts
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (!e || !e.key || typeof e.key !== "string") return
+ if (e.ctrlKey || e.metaKey) return
+
+ try {
+ const key = e.key.toLowerCase()
+
+ switch (key) {
+ case "r":
+ e.preventDefault()
+ handleRunSimulation()
+ break
+ case "w":
+ e.preventDefault()
+ setWhatIfMode(!whatIfMode)
+ break
+ case "s":
+ e.preventDefault()
+ setShowStory(true)
+ break
+ case "e":
+ e.preventDefault()
+ handleExportJson()
+ break
+ case "f":
+ e.preventDefault()
+ setExpandedGraph(!expandedGraph)
+ break
+ case "c":
+ e.preventDefault()
+ setShowCustomConfig(!showCustomConfig)
+ break
+ }
+ } catch (error) {
+ console.warn("Keyboard shortcut error:", error)
+ }
+ }
+
+ // Only attach listener if no modal is open (simplified check)
+ // In a real app we might want more robust context
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [handleRunSimulation, handleExportJson, whatIfMode, expandedGraph, showCustomConfig, setShowStory, setWhatIfMode, setExpandedGraph, setShowCustomConfig])
+
+ return {
+ darkMode, setDarkMode,
+ showStory, setShowStory,
+ showSimulator, setShowSimulator,
+ isProcessing,
+ currentFixture, setCurrentFixture,
+ whatIfMode, setWhatIfMode,
+ simulatedSigners,
+ expandedGraph, setExpandedGraph,
+ showControls, setShowControls,
+ showDetails, setShowDetails,
+ showCustomConfig, setShowCustomConfig,
+ customConfig,
+ newPersonForm, setNewPersonForm,
+ newRoleForm, setNewRoleForm,
+ editingPerson, setEditingPerson,
+ editingRole, setEditingRole,
+ fixture,
+ displayResult,
+ handleRunSimulation,
+ handleSimulatedSignerToggle,
+ handleExportJson,
+ addPerson,
+ addRole,
+ deletePerson,
+ deleteRole,
+ updatePerson,
+ updateRole,
+ togglePersonSigned,
+ customFixture // Exporting just in case, though handled internally
+ }
+}
diff --git a/frontend/json-diff.ts b/frontend/json-diff.ts
deleted file mode 100644
index 61a6b78..0000000
--- a/frontend/json-diff.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-// Function to compare two JSON objects and identify differences
-export function compareJsonObjects(oldObj: any, newObj: any) {
- try {
- const result: Record = {}
-
- // Handle null or undefined objects
- if (!oldObj && !newObj) return null
- if (!oldObj) return { status: "added", value: newObj }
- if (!newObj) return { status: "removed", value: oldObj }
-
- // Get all keys from both objects
- const oldKeys = typeof oldObj === "object" && oldObj !== null ? Object.keys(oldObj) : []
- const newKeys = typeof newObj === "object" && newObj !== null ? Object.keys(newObj) : []
- const allKeys = new Set([...oldKeys, ...newKeys])
-
- allKeys.forEach((key) => {
- const oldValue = oldObj?.[key]
- const newValue = newObj?.[key]
-
- // Key exists in both objects
- if (key in oldObj && key in newObj) {
- // Both values are objects - recursively compare
- if (typeof oldValue === "object" && oldValue !== null && typeof newValue === "object" && newValue !== null) {
- const childDiff = compareJsonObjects(oldValue, newValue)
-
- // If there are differences in the child objects
- if (childDiff && Object.keys(childDiff).length > 0) {
- result[key] = {
- status: "unchanged",
- value: newValue,
- children: childDiff,
- }
- } else {
- result[key] = { status: "unchanged", value: newValue }
- }
- }
- // Values are different
- else if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
- result[key] = {
- status: "changed",
- oldValue: oldValue,
- value: newValue,
- }
- }
- // Values are the same
- else {
- result[key] = { status: "unchanged", value: newValue }
- }
- }
- // Key only exists in the new object
- else if (key in newObj) {
- result[key] = { status: "added", value: newValue }
- }
- // Key only exists in the old object
- else {
- result[key] = { status: "removed", value: oldValue }
- }
- })
-
- return result
- } catch (error) {
- console.error("Error comparing JSON objects:", error)
- throw new Error("Failed to compare JSON objects. Please check the data format.")
- }
-}
-
-// Function to count the number of changes in a diff object
-export function countChanges(diff: Record) {
- let added = 0
- let removed = 0
- let changed = 0
- let unchanged = 0
-
- const countRecursive = (obj: Record) => {
- if (!obj) return
-
- Object.values(obj).forEach((value: any) => {
- if (value.status === "added") {
- added++
- } else if (value.status === "removed") {
- removed++
- } else if (value.status === "changed") {
- changed++
- } else if (value.status === "unchanged") {
- unchanged++
- }
-
- if (value.children) {
- countRecursive(value.children)
- }
- })
- }
-
- countRecursive(diff)
- return { added, removed, changed, unchanged }
-}
diff --git a/frontend/json-utils.ts b/frontend/json-utils.ts
deleted file mode 100644
index 4a4334c..0000000
--- a/frontend/json-utils.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Format JSON value for display in tooltips
- */
-export function formatJsonValue(value: any): string {
- if (value === undefined) return "undefined"
- if (value === null) return "null"
-
- if (typeof value === "object") {
- try {
- return JSON.stringify(value, null, 2)
- } catch (error) {
- return "[Complex Object]"
- }
- }
-
- return String(value)
-}
-
-/**
- * Get a human-readable description of a node type
- */
-export function getNodeTypeDescription(type: string): string {
- switch (type) {
- case "rootNode":
- return "Root Object"
- case "jsonNode":
- return "Object"
- case "arrayNode":
- return "Array"
- case "valueNode":
- return "Value"
- case "diffRoot":
- return "Root Object"
- case "diffAdded":
- return "Added"
- case "diffRemoved":
- return "Removed"
- case "diffChanged":
- return "Changed"
- case "diffUnchanged":
- return "Unchanged"
- default:
- return type
- }
-}
-
-/**
- * Get a description of the security implications of a node
- */
-export function getSecurityImplication(path: string, value: any): string | null {
- // Check for security-related paths
- if (path.includes("principals") || path.includes("keyval")) {
- return "Contains security principal information"
- }
-
- if (path.includes("threshold")) {
- return `Requires ${value} signature(s) for validation`
- }
-
- if (path.includes("expires")) {
- const expiryDate = new Date(value)
- const now = new Date()
- if (expiryDate < now) {
- return "EXPIRED! This metadata has passed its expiration date"
- }
-
- // Calculate days until expiry
- const daysUntilExpiry = Math.floor((expiryDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
- if (daysUntilExpiry < 30) {
- return `Expiring soon! Only ${daysUntilExpiry} days remaining`
- }
-
- return `Valid for ${daysUntilExpiry} more days`
- }
-
- if (path.includes("trusted") && value === true) {
- return "Trusted security component"
- }
-
- if (path.includes("rules") || path.includes("pattern") || path.includes("action")) {
- return "Security policy rule component"
- }
-
- return null
-}
-
-/**
- * Determine if a node contains sensitive security information
- */
-export function isSensitiveNode(path: string): boolean {
- const sensitivePatterns = ["private", "secret", "password", "token", "key", "keyval.private", "auth"]
-
- return sensitivePatterns.some((pattern) => path.toLowerCase().includes(pattern))
-}
diff --git a/frontend/lib/json-diff.ts b/frontend/lib/json-diff.ts
index 61a6b78..b69d714 100644
--- a/frontend/lib/json-diff.ts
+++ b/frontend/lib/json-diff.ts
@@ -1,37 +1,69 @@
+import { JsonValue, JsonObject } from "./types"
+
+export interface DiffEntry {
+ status: "added" | "removed" | "changed" | "unchanged"
+ value?: JsonValue
+ oldValue?: JsonValue
+ children?: Record
+}
+
+export type DiffResult = Record
+
// Function to compare two JSON objects and identify differences
-export function compareJsonObjects(oldObj: any, newObj: any) {
+export function compareJsonObjects(
+ oldObj: JsonObject | null | undefined,
+ newObj: JsonObject | null | undefined,
+): DiffResult | DiffEntry | null {
try {
- const result: Record = {}
+ const result: DiffResult = {}
// Handle null or undefined objects
if (!oldObj && !newObj) return null
- if (!oldObj) return { status: "added", value: newObj }
- if (!newObj) return { status: "removed", value: oldObj }
+ if (!oldObj) return { status: "added", value: newObj as JsonValue }
+ if (!newObj) return { status: "removed", value: oldObj as JsonValue }
// Get all keys from both objects
- const oldKeys = typeof oldObj === "object" && oldObj !== null ? Object.keys(oldObj) : []
- const newKeys = typeof newObj === "object" && newObj !== null ? Object.keys(newObj) : []
+ const oldKeys = oldObj ? Object.keys(oldObj) : []
+ const newKeys = newObj ? Object.keys(newObj) : []
const allKeys = new Set([...oldKeys, ...newKeys])
allKeys.forEach((key) => {
- const oldValue = oldObj?.[key]
- const newValue = newObj?.[key]
+ const oldValue = oldObj ? oldObj[key] : undefined
+ const newValue = newObj ? newObj[key] : undefined
// Key exists in both objects
- if (key in oldObj && key in newObj) {
+ if (oldObj && key in oldObj && newObj && key in newObj) {
// Both values are objects - recursively compare
- if (typeof oldValue === "object" && oldValue !== null && typeof newValue === "object" && newValue !== null) {
- const childDiff = compareJsonObjects(oldValue, newValue)
+ if (
+ typeof oldValue === "object" &&
+ oldValue !== null &&
+ !Array.isArray(oldValue) &&
+ typeof newValue === "object" &&
+ newValue !== null &&
+ !Array.isArray(newValue)
+ ) {
+ const childDiff = compareJsonObjects(oldValue as JsonObject, newValue as JsonObject)
// If there are differences in the child objects
- if (childDiff && Object.keys(childDiff).length > 0) {
- result[key] = {
- status: "unchanged",
- value: newValue,
- children: childDiff,
+ if (childDiff) {
+ // If childDiff is a Record, use it as children
+ // If it's a single DiffEntry (from null check shortcut), what do we do?
+ // The recursive call with two objects should return a DiffResult (Record)
+ // unless one was null, but we checked type===object && !== null.
+ // So childDiff should be DiffResult here.
+
+ const hasChanges = Object.keys(childDiff).length > 0
+ if (hasChanges) {
+ result[key] = {
+ status: "unchanged",
+ value: newValue,
+ children: childDiff as Record,
+ }
+ } else {
+ result[key] = { status: "unchanged", value: newValue }
}
} else {
- result[key] = { status: "unchanged", value: newValue }
+ result[key] = { status: "unchanged", value: newValue }
}
}
// Values are different
@@ -48,7 +80,7 @@ export function compareJsonObjects(oldObj: any, newObj: any) {
}
}
// Key only exists in the new object
- else if (key in newObj) {
+ else if (newObj && key in newObj) {
result[key] = { status: "added", value: newValue }
}
// Key only exists in the old object
@@ -65,16 +97,36 @@ export function compareJsonObjects(oldObj: any, newObj: any) {
}
// Function to count the number of changes in a diff object
-export function countChanges(diff: Record) {
+export function countChanges(diff: DiffResult | DiffEntry | null) {
let added = 0
let removed = 0
let changed = 0
let unchanged = 0
- const countRecursive = (obj: Record) => {
+ if (!diff) return { added, removed, changed, unchanged }
+
+ // Handle edge case where diff is a single DiffEntry
+ if ('status' in diff && typeof diff.status === 'string') {
+ const entry = diff as DiffEntry;
+ if (entry.status === 'added') added++;
+ else if (entry.status === 'removed') removed++;
+ else if (entry.status === 'changed') changed++;
+ else if (entry.status === 'unchanged') unchanged++;
+
+ if (entry.children) {
+ const childrenCounts = countChanges(entry.children);
+ added += childrenCounts.added;
+ removed += childrenCounts.removed;
+ changed += childrenCounts.changed;
+ unchanged += childrenCounts.unchanged;
+ }
+ return { added, removed, changed, unchanged };
+ }
+
+ const countRecursive = (obj: Record) => {
if (!obj) return
- Object.values(obj).forEach((value: any) => {
+ Object.values(obj).forEach((value: DiffEntry) => {
if (value.status === "added") {
added++
} else if (value.status === "removed") {
@@ -91,6 +143,6 @@ export function countChanges(diff: Record) {
})
}
- countRecursive(diff)
+ countRecursive(diff as Record)
return { added, removed, changed, unchanged }
}
diff --git a/frontend/lib/simulator-types.ts b/frontend/lib/simulator-types.ts
index aab6503..7edab03 100644
--- a/frontend/lib/simulator-types.ts
+++ b/frontend/lib/simulator-types.ts
@@ -76,3 +76,24 @@ export interface ProposedChange {
commit?: string
pr_json?: string
}
+
+export interface CustomPerson {
+ id: string
+ display_name: string
+ keyid: string
+ key_type: "ssh" | "gpg" | "sigstore"
+ has_signed: boolean
+}
+
+export interface CustomRole {
+ id: string
+ display_name: string
+ threshold: number
+ file_globs: string[]
+ assigned_people: string[]
+}
+
+export interface CustomConfig {
+ people: CustomPerson[]
+ roles: CustomRole[]
+}
diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts
index 8e4a189..cce7b2e 100644
--- a/frontend/lib/types.ts
+++ b/frontend/lib/types.ts
@@ -3,6 +3,7 @@ export interface Commit {
message: string
author: string
date: string
+ data?: JsonObject
}
export interface MetadataRequest {
@@ -14,3 +15,21 @@ export interface MetadataRequest {
export interface CommitsRequest {
url: string
}
+
+export type JsonValue = string | number | boolean | null | JsonObject | JsonArray
+export type JsonArray = JsonValue[]
+export interface JsonObject {
+ [key: string]: JsonValue
+}
+
+export interface SecurityEvent {
+ commit: string
+ date: string
+ author: string
+ message: string
+ type: "security_enhancement" | "security_degradation" | "policy_change" | "principal_change" | "expiration_change"
+ severity: "critical" | "high" | "medium" | "low"
+ description: string
+ details: string
+ impact: string
+}
diff --git a/frontend/mock-api.ts b/frontend/mock-api.ts
deleted file mode 100644
index f71da38..0000000
--- a/frontend/mock-api.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { Commit } from "./types"
-
-export async function mockFetchCommits(url: string): Promise {
- const response = await fetch("http://localhost:5000/commits", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ url }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || "Failed to fetch commits");
- }
-
- const data = await response.json();
- return data;
-}
-
-export async function mockFetchMetadata(
- url: string,
- commit: string,
- file: string
-): Promise {
- const response = await fetch("http://localhost:5000/metadata", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ url, commit, file }),
- });
-
- if (!response.ok) {
- const error = await response.json();
- throw new Error(error.error || "Failed to fetch metadata");
- }
-
- const data = await response.json();
- return data;
-}
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9219663..f22a766 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,7 +8,7 @@
"name": "my-v0-project",
"version": "0.1.0",
"dependencies": {
- "@emotion/is-prop-valid": "*",
+ "@emotion/is-prop-valid": "latest",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "1.2.12",
"@radix-ui/react-alert-dialog": "1.1.15",
@@ -37,16 +37,16 @@
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@radix-ui/react-tooltip": "1.2.8",
- "autoprefixer": "^10.4.23",
- "chart.js": "*",
+ "autoprefixer": "^10.4.22",
+ "chart.js": "latest",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.1.1",
"cytoscape": "^3.33.1",
- "dagre": "*",
+ "dagre": "latest",
"date-fns": "4.1.0",
"embla-carousel-react": "8.6.0",
- "framer-motion": "*",
+ "framer-motion": "latest",
"input-otp": "1.4.2",
"lucide-react": "^0.562.0",
"next": "16.1.1",
@@ -66,6 +66,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
+ "@types/dagre": "^0.7.53",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25",
"@types/react": "^19",
@@ -109,7 +110,6 @@
"resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz",
"integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@emotion/memoize": "^0.9.0"
}
@@ -3164,6 +3164,13 @@
"@types/d3-selection": "*"
}
},
+ "node_modules/@types/dagre": {
+ "version": "0.7.53",
+ "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
+ "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -3186,7 +3193,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3197,7 +3203,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3284,7 +3289,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3324,7 +3328,6 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -3500,7 +3503,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -3643,8 +3645,7 @@
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
@@ -3777,7 +3778,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
"license": "MIT",
- "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -4253,7 +4253,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -4274,7 +4273,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4315,7 +4313,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4328,7 +4325,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.70.0.tgz",
"integrity": "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -4352,7 +4348,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -4502,8 +4497,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
diff --git a/frontend/package.json b/frontend/package.json
index bd2f12b..cdc232f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -67,6 +67,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
+ "@types/dagre": "^0.7.53",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25",
"@types/react": "^19",
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 4b2dc7b..48d6d82 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,6 +1,10 @@
{
"compilerOptions": {
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
- "@/*": ["./*"]
+ "@/*": [
+ "./*"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}
diff --git a/frontend/types.ts b/frontend/types.ts
deleted file mode 100644
index 8e4a189..0000000
--- a/frontend/types.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export interface Commit {
- hash: string
- message: string
- author: string
- date: string
-}
-
-export interface MetadataRequest {
- url: string
- commit: string
- file: string
-}
-
-export interface CommitsRequest {
- url: string
-}
diff --git a/frontend/utils.ts b/frontend/utils.ts
deleted file mode 100644
index bd0c391..0000000
--- a/frontend/utils.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { clsx, type ClassValue } from "clsx"
-import { twMerge } from "tailwind-merge"
-
-export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
-}
diff --git a/frontend/view-mode-utils.ts b/frontend/view-mode-utils.ts
deleted file mode 100644
index 13b6191..0000000
--- a/frontend/view-mode-utils.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-// Utility functions for determining what to show in Normal vs Advanced mode
-
-export type ViewMode = "normal" | "advanced"
-
-export interface NodeImportance {
- level: "critical" | "important" | "normal" | "hidden"
- reason?: string
-}
-
-// Define which fields are important for normal mode
-export const getNodeImportance = (key: string, value: any, level: number, parentKey?: string): NodeImportance => {
- const keyLower = key.toLowerCase()
-
- // Always show root level containers
- if (level === 0) return { level: "critical", reason: "Root level" }
-
- // CRITICAL - Always show these security essentials
- if (keyLower.includes("expire")) return { level: "critical", reason: "Security expiration" }
- if (keyLower === "trusted") return { level: "critical", reason: "Trust status" }
- if (keyLower === "threshold") return { level: "critical", reason: "Security threshold" }
-
- // IMPORTANT - Key security containers and policies
- if (keyLower === "principals") return { level: "important", reason: "Security principals" }
- if (keyLower === "roles") return { level: "important", reason: "Access control roles" }
- if (keyLower === "rules") return { level: "important", reason: "Security policies" }
- if (keyLower === "githubapps") return { level: "important", reason: "GitHub integrations" }
- if (keyLower === "requirements") return { level: "important", reason: "Policy requirements" }
- if (keyLower === "authorizedprincipals") return { level: "important", reason: "Authorized users" }
- if (keyLower === "principalids") return { level: "important", reason: "Principal references" }
-
- // IMPORTANT - Policy definition fields
- if (keyLower === "pattern") return { level: "important", reason: "Rule pattern" }
- if (keyLower === "action") return { level: "important", reason: "Rule action" }
-
- // IMPORTANT - Identity fields
- if (keyLower === "identity") return { level: "important", reason: "User identity" }
- if (keyLower === "issuer") return { level: "important", reason: "Identity provider" }
- if (keyLower === "keytype") return { level: "important", reason: "Key algorithm" }
-
- // HIDDEN - Technical implementation details that clutter the view
- if (keyLower === "type") return { level: "hidden", reason: "Technical metadata type" }
- if (keyLower === "schemaversion") return { level: "hidden", reason: "Technical schema version" }
- if (keyLower === "keyid_hash_algorithms") return { level: "hidden", reason: "Technical key details" }
- if (keyLower === "keyid") return { level: "hidden", reason: "Technical key identifier" }
- if (keyLower === "scheme") return { level: "hidden", reason: "Technical signature scheme" }
- if (keyLower === "public") return { level: "hidden", reason: "Raw key material" }
- if (keyLower === "keyval") return { level: "hidden", reason: "Raw key data" }
-
- // NORMAL - Everything else
- return { level: "normal", reason: "Standard field" }
-}
-
-export const shouldShowInNormalMode = (key: string, value: any, level: number, parentKey?: string): boolean => {
- const importance = getNodeImportance(key, value, level, parentKey)
- return importance.level === "critical" || importance.level === "important"
-}
-
-export const getHiddenFieldsCount = (data: any, level = 0, parentKey?: string): number => {
- if (!data || typeof data !== "object") return 0
-
- let hiddenCount = 0
- const keys = Array.isArray(data) ? data.map((_, i) => i.toString()) : Object.keys(data)
-
- for (const key of keys) {
- const importance = getNodeImportance(key, data[key], level, parentKey)
- if (importance.level === "hidden" || importance.level === "normal") {
- hiddenCount++
- }
-
- // Recursively count hidden fields in nested objects
- if (typeof data[key] === "object" && data[key] !== null) {
- hiddenCount += getHiddenFieldsCount(data[key], level + 1, key)
- }
- }
-
- return hiddenCount
-}