Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion app/src/app/api/repos/[id]/git/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ import {
push,
commit,
diffFile,
diffCommitsFile,
getCommitDiffFiles,
restoreFile,
log,
withToken,
isGithubHost,
} from "@/lib/gitops";
import { redactCredentials } from "@/lib/indexer";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -26,7 +29,10 @@ function hasStderr(e: unknown): e is { stderr: string } {
function err(e: unknown, status = 500) {
let msg = e instanceof Error ? e.message : String(e);
if (hasStderr(e) && e.stderr.trim()) msg = e.stderr.trim();
return NextResponse.json({ error: msg }, { status });
// A failed push/clone-adjacent op embeds the token-bearing remote URL
// (https://x-access-token:<PAT>@github.com/...) in execFile's error message
// and stderr; strip it before it reaches the client (matches F004/F017).
return NextResponse.json({ error: redactCredentials(msg) }, { status });
}

// GET /api/repos/:id/git?op=status|branches|log|diff&path=&limit=
Expand All @@ -49,6 +55,19 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
if (!p) return NextResponse.json({ error: "Missing path" }, { status: 400 });
return NextResponse.json({ diff: await diffFile(ws.dir, p) });
}
if (op === "diffFiles") {
const base = searchParams.get("base");
const head = searchParams.get("head");
if (!base || !head) return NextResponse.json({ error: "Missing base or head" }, { status: 400 });
return NextResponse.json({ files: await getCommitDiffFiles(ws.dir, base, head) });
}
if (op === "diffCommits") {
const base = searchParams.get("base");
const head = searchParams.get("head");
const p = searchParams.get("path");
if (!base || !head || !p) return NextResponse.json({ error: "Missing base, head, or path" }, { status: 400 });
return NextResponse.json({ diff: await diffCommitsFile(ws.dir, base, head, p) });
}
if (op === "saveMode") return NextResponse.json({ saveMode: getSaveMode(id) });
return NextResponse.json({ error: "Unknown op" }, { status: 400 });
} catch (e) {
Expand Down
251 changes: 211 additions & 40 deletions app/src/components/TimelineView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useEffect, useState, useRef } from "react";
import { Play, Pause, Loader2, ChevronLeft, ChevronRight, Info } from "lucide-react";
import { timelineMetadata, timelineSnapshot, timelineBuild, timelineCompare } from "@/lib/api";
import { Play, Pause, Loader2, ChevronLeft, ChevronRight, Info, FileText, X } from "lucide-react";
import { timelineMetadata, timelineSnapshot, timelineBuild, timelineCompare, gitDiffFiles, gitDiffCommits } from "@/lib/api";
import type { TimelineSnapshot, ArchitectureSnapshot, ArchitectureEvolution } from "@/lib/gitops/timelineApi";
import { CirclePackView } from "@/components/CirclePackView";

Expand All @@ -20,6 +20,10 @@ export function TimelineView({ repoId }: { repoId: string }) {
const [compareHead, setCompareHead] = useState<string>("");
const [comparisonEvolution, setComparisonEvolution] = useState<ArchitectureEvolution | null>(null);
const [comparing, setComparing] = useState(false);
const [changedFiles, setChangedFiles] = useState<Array<{ status: string, path: string }>>([]);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [fileDiffText, setFileDiffText] = useState<string | null>(null);
const [loadingDiff, setLoadingDiff] = useState(false);
const playRef = useRef<NodeJS.Timeout | undefined>(undefined);

useEffect(() => {
Expand Down Expand Up @@ -122,7 +126,8 @@ export function TimelineView({ repoId }: { repoId: string }) {
const currentMeta = snapshots[currentIndex];

return (
<div className="flex flex-col gap-6">
<>
<div className="flex flex-col gap-6">
{/* Metrics Row */}
<div className="grid grid-cols-6 gap-4">
<MetricCard label="Date" value={new Date(currentMeta.timestamp * 1000).toLocaleDateString()} />
Expand Down Expand Up @@ -227,6 +232,18 @@ export function TimelineView({ repoId }: { repoId: string }) {
</div>
</div>

{/* Main View Issues */}
{currentGraph?.result?.issues && currentGraph.result.issues.length > 0 && (
<div className="bg-[#111] border border-white/10 rounded-xl p-6 mt-2">
<h3 className="text-sm font-semibold text-white mb-4">Issues in this Snapshot ({currentGraph.result.issues.length})</h3>
<div className="max-h-[300px] overflow-y-auto pr-2">
{currentGraph.result.issues.map((iss: any, i: number) => (
<IssueItem key={i} issue={iss} />
))}
</div>
</div>
)}

{/* Ad-Hoc Compare Section */}
<div className="bg-[#111] border border-white/10 rounded-xl p-6 mt-4">
<h3 className="text-lg font-semibold text-white mb-4">Ad-Hoc Architecture Diff</h3>
Expand Down Expand Up @@ -262,8 +279,12 @@ export function TimelineView({ repoId }: { repoId: string }) {
if (!compareBase || !compareHead) return;
setComparing(true);
try {
const evo = await timelineCompare(repoId, compareBase, compareHead);
const [evo, files] = await Promise.all([
timelineCompare(repoId, compareBase, compareHead),
gitDiffFiles(repoId, compareBase, compareHead)
]);
setComparisonEvolution(evo);
setChangedFiles(files);
} catch (err) {
console.error(err);
} finally {
Expand All @@ -278,50 +299,178 @@ export function TimelineView({ repoId }: { repoId: string }) {
</div>

{comparisonEvolution && (
<div className="grid grid-cols-2 gap-6 bg-[#0a0a0a] border border-white/5 rounded-xl p-4">
<div className="flex flex-col gap-2 max-h-[400px] overflow-y-auto pr-2">
<h4 className="text-xs text-gray-500 uppercase tracking-wider sticky top-0 bg-[#0a0a0a] py-2 z-10">
Evolution Events ({compareBase.substring(0,7)} → {compareHead.substring(0,7)})
</h4>
{comparisonEvolution.events.map((e, i) => (
<div key={i} className="bg-white/5 p-3 rounded-lg border border-white/5">
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded text-gray-300 mb-2 inline-block">{e.category}</span>
<div className="text-sm text-gray-200 font-medium">{e.title}</div>
<div className="text-xs text-gray-400 mt-1">{e.description}</div>
</div>
))}
</div>
<div className="flex flex-col gap-4">
<div>
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Metrics Shift</h4>
<div className="grid grid-cols-2 gap-2">
<MetricCard
label="Arch Score"
value={comparisonEvolution.metrics.architectureScore.toFixed(0)}
sub={comparisonEvolution.baselineMetrics ? `${comparisonEvolution.baselineMetrics.architectureScore.toFixed(0)} → ${comparisonEvolution.metrics.architectureScore.toFixed(0)}` : undefined}
info="A 0-100 health score weighted by issue severity, blast radius (fan-in), and normalized by codebase size."
/>
<MetricCard
label="Coupling"
value={`${(comparisonEvolution.metrics.coupling * 100).toFixed(0)}%`}
sub={comparisonEvolution.baselineMetrics ? `${(comparisonEvolution.baselineMetrics.coupling * 100).toFixed(0)}% → ${(comparisonEvolution.metrics.coupling * 100).toFixed(0)}%` : undefined}
info="The percentage of import statements that cross top-level directory boundaries. Lower is better (highly modular)."
/>
</div>
<div className="flex flex-col gap-6 mt-6">
<div className="grid grid-cols-3 gap-6 bg-[#0a0a0a] border border-white/5 rounded-xl p-4">
<div className="flex flex-col gap-2 max-h-[400px] overflow-y-auto pr-2">
<h4 className="text-xs text-gray-500 uppercase tracking-wider sticky top-0 bg-[#0a0a0a] py-2 z-10">
Evolution Events ({compareBase.substring(0,7)} → {compareHead.substring(0,7)})
</h4>
{comparisonEvolution.events.map((e, i) => (
<div key={i} className="bg-white/5 p-3 rounded-lg border border-white/5">
<span className="text-[10px] bg-white/10 px-2 py-0.5 rounded text-gray-300 mb-2 inline-block">{e.category}</span>
<div className="text-sm text-gray-200 font-medium">{e.title}</div>
<div className="text-xs text-gray-400 mt-1">{e.description}</div>
</div>
))}
</div>

{/* Changed Files Column */}
<div className="flex flex-col gap-2 max-h-[400px] overflow-y-auto pr-2">
<h4 className="text-xs text-gray-500 uppercase tracking-wider sticky top-0 bg-[#0a0a0a] py-2 z-10">
Changed Files ({changedFiles.length})
</h4>
{changedFiles.length === 0 ? (
<div className="text-xs text-gray-500 italic">No files changed.</div>
) : (
changedFiles.map((file, i) => {
const parts = file.path.split('/');
const fileName = parts.pop() || file.path;
return (
<button
key={i}
onClick={async () => {
setSelectedFile(file.path);
setLoadingDiff(true);
try {
const text = await gitDiffCommits(repoId, compareBase, compareHead, file.path);
setFileDiffText(text);
} catch (err) {
setFileDiffText("Failed to load diff.");
} finally {
setLoadingDiff(false);
}
}}
className="flex items-center gap-3 bg-white/5 hover:bg-white/10 p-2 rounded-lg border border-transparent hover:border-white/10 text-left transition-colors w-full"
>
<span className={`text-[10px] font-mono shrink-0 px-1.5 py-0.5 rounded ${file.status === 'A' ? 'bg-green-500/20 text-green-400' : file.status === 'D' ? 'bg-red-500/20 text-red-400' : 'bg-blue-500/20 text-blue-400'}`}>
{file.status}
</span>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium text-gray-200 truncate">{fileName}</span>
<span className="text-[10px] text-gray-500 truncate font-mono mt-0.5">{file.path}</span>
</div>
</button>
);
})
)}
</div>

{comparisonEvolution.aiNarrative && (
<div className="bg-purple-500/10 border border-purple-500/20 p-3 rounded-lg">
<h4 className="text-xs text-purple-400 uppercase tracking-wider mb-2">AI Summary</h4>
<p className="text-xs text-purple-200 mb-2">{comparisonEvolution.aiNarrative.reason}</p>
<p className="text-xs text-purple-300 font-medium">💡 {comparisonEvolution.aiNarrative.recommendation}</p>
<div className="flex flex-col gap-4">
<div>
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Metrics Shift</h4>
<div className="grid grid-cols-2 gap-2">
<MetricCard
label="Arch Score"
value={comparisonEvolution.metrics.architectureScore.toFixed(0)}
sub={comparisonEvolution.baselineMetrics ? `${comparisonEvolution.baselineMetrics.architectureScore.toFixed(0)} → ${comparisonEvolution.metrics.architectureScore.toFixed(0)}` : undefined}
info="A 0-100 health score weighted by issue severity, blast radius (fan-in), and normalized by codebase size."
/>
<MetricCard
label="Coupling"
value={`${(comparisonEvolution.metrics.coupling * 100).toFixed(0)}%`}
sub={comparisonEvolution.baselineMetrics ? `${(comparisonEvolution.baselineMetrics.coupling * 100).toFixed(0)}% → ${(comparisonEvolution.metrics.coupling * 100).toFixed(0)}%` : undefined}
info="The percentage of import statements that cross top-level directory boundaries. Lower is better (highly modular)."
/>
</div>
</div>
)}

{comparisonEvolution.aiNarrative && (
<div className="bg-purple-500/10 border border-purple-500/20 p-3 rounded-lg">
<h4 className="text-xs text-purple-400 uppercase tracking-wider mb-2">AI Summary</h4>
<p className="text-xs text-purple-200 mb-2">{comparisonEvolution.aiNarrative.reason}</p>
<p className="text-xs text-purple-300 font-medium">💡 {comparisonEvolution.aiNarrative.recommendation}</p>
</div>
)}
</div>
</div>

{/* Issues Shift */}
{(comparisonEvolution.issueDiff?.introduced?.length > 0 || comparisonEvolution.issueDiff?.resolved?.length > 0) && (
<div className="grid grid-cols-2 gap-6 bg-[#0a0a0a] border border-white/5 rounded-xl p-4">
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
<h4 className="text-xs text-gray-500 uppercase tracking-wider sticky top-0 bg-[#0a0a0a] py-2 z-10">
Issues Introduced ({comparisonEvolution.issueDiff.introduced.length})
</h4>
{comparisonEvolution.issueDiff.introduced.length === 0 ? (
<div className="text-xs text-gray-500 italic">No new issues introduced.</div>
) : (
comparisonEvolution.issueDiff.introduced.map((iss: any, i: number) => (
<IssueItem key={i} issue={iss} />
))
)}
</div>
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-auto pr-2">
<h4 className="text-xs text-gray-500 uppercase tracking-wider sticky top-0 bg-[#0a0a0a] py-2 z-10">
Issues Resolved ({comparisonEvolution.issueDiff.resolved.length})
</h4>
{comparisonEvolution.issueDiff.resolved.length === 0 ? (
<div className="text-xs text-gray-500 italic">No issues resolved.</div>
) : (
comparisonEvolution.issueDiff.resolved.map((iss: any, i: number) => (
<IssueItem key={i} issue={iss} />
))
)}
</div>
</div>
)}
</div>
)}
</div>
</div>

{/* File Diff Modal */}
{selectedFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-6">
<div className="bg-[#0a0a0a] border border-white/10 rounded-xl w-full max-w-5xl max-h-[90vh] flex flex-col overflow-hidden shadow-2xl">
<div className="flex items-center justify-between p-4 border-b border-white/5 bg-[#111]">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-purple-400" />
<h3 className="text-sm font-mono text-gray-200">{selectedFile}</h3>
</div>
<button
onClick={() => { setSelectedFile(null); setFileDiffText(null); }}
className="p-1 hover:bg-white/10 rounded text-gray-400 hover:text-white transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-auto p-4">
{loadingDiff ? (
<div className="flex items-center justify-center h-full text-gray-500 gap-2">
<Loader2 className="w-4 h-4 animate-spin"/> Loading diff...
</div>
) : (
<div className="text-xs font-mono text-gray-300 whitespace-pre-wrap flex flex-col w-full bg-[#050505] p-2 rounded-lg border border-white/5">
{!fileDiffText ? (
<span className="text-gray-500 italic">No changes visible.</span>
) : (
fileDiffText.split("\n").map((line, i) => {
let lineClass = "px-4 py-0.5 w-full ";
if (line.startsWith("+") && !line.startsWith("+++")) {
lineClass += "bg-green-500/10 text-green-400";
} else if (line.startsWith("-") && !line.startsWith("---")) {
lineClass += "bg-red-500/10 text-red-400";
} else if (line.startsWith("@@")) {
lineClass += "bg-cyan-500/10 text-cyan-400 mt-2 mb-1 rounded";
} else if (line.startsWith("+++") || line.startsWith("---")) {
lineClass += "text-gray-500 font-bold bg-white/5";
} else {
lineClass += "text-gray-400";
}
return (
<span key={i} className={lineClass}>
{line || " "}
</span>
);
})
)}
</div>
)}
</div>
</div>
</div>
)}
</>
);
}

Expand All @@ -344,3 +493,25 @@ function MetricCard({ label, value, sub, info }: { label: string, value: string
</div>
);
}

const SEV_COLOR: Record<number, string> = {
5: "text-rose-400 bg-rose-500/10 border-rose-500/20",
4: "text-orange-400 bg-orange-500/10 border-orange-500/20",
3: "text-amber-400 bg-amber-500/10 border-amber-500/20",
2: "text-yellow-300 bg-yellow-500/10 border-yellow-500/20",
1: "text-gray-400 bg-white/5 border-white/10",
};

function IssueItem({ issue }: { issue: any }) {
return (
<div className="py-2 flex items-start gap-3 border-b border-white/5 last:border-0">
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded border shrink-0 mt-0.5 ${SEV_COLOR[issue.severity] || SEV_COLOR[1]}`}>
S{issue.severity}
</span>
<div className="min-w-0 flex-1">
<div className="text-sm text-gray-200">{issue.title}</div>
<div className="text-xs text-gray-500 font-mono truncate">{issue.file}:{issue.line}</div>
</div>
</div>
);
}
Loading
Loading