Skip to content
Open
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
9 changes: 8 additions & 1 deletion frontend/src/components/ProjectSessionList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useCallback, useRef, useId } from 'react';
import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare } from 'lucide-react';
import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare, Network } from 'lucide-react';
import { SessionDetailTooltip } from './SessionDetailTooltip';
import { useSessionStore } from '../stores/sessionStore';
import { useNavigationStore } from '../stores/navigationStore';
Expand Down Expand Up @@ -81,6 +81,7 @@ export function ProjectSessionList({
const navigateToPaneChat = useNavigationStore(s => s.navigateToPaneChat);
const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID);
const navigateToProject = useNavigationStore(s => s.navigateToProject);
const navigateToGitGraph = useNavigationStore(s => s.navigateToGitGraph);
const setSidebarNavigationScope = useNavigationStore(s => s.setSidebarNavigationScope);
// Expansion state lives in the navigation store so the always-mounted
// session hotkeys (useSessionNavigationHotkeys) see the same visible ordering
Expand Down Expand Up @@ -413,6 +414,12 @@ export function ProjectSessionList({
icon: GitBranch,
onClick: () => navigateToProject(project.id),
},
{
id: 'commit-graph',
label: 'Commit graph',
icon: Network,
onClick: () => navigateToGitGraph(project.id),
},
{
id: 'delete',
label: 'Delete Project',
Expand Down
12 changes: 10 additions & 2 deletions frontend/src/components/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { CommitMessageDialog } from './session/CommitMessageDialog';
import { FolderArchiveDialog } from './session/FolderArchiveDialog';
import { ConfirmDialog } from './ConfirmDialog';
import { ProjectView } from './ProjectView';
import { GitGraphView } from './gitGraph/GitGraphView';
import { API } from '../utils/api';
import { useResizable } from '../hooks/useResizable';
import { useResizableHeight } from '../hooks/useResizableHeight';
Expand Down Expand Up @@ -1217,9 +1218,11 @@ export const SessionView = memo(() => {
return () => { cancelled = true; };
}, [activeSession?.id, activeSession?.isMainRepo]);

// Load project data when activeProjectId changes
// Load project data when activeProjectId changes. The commit graph needs it
// too: without a name in its header there is nothing on screen saying which
// repository is being graphed.
useEffect(() => {
if (activeView === 'project' && activeProjectId) {
if ((activeView === 'project' || activeView === 'git-graph') && activeProjectId) {
const loadProjectData = async () => {
setIsProjectLoading(true);
try {
Expand Down Expand Up @@ -1613,6 +1616,11 @@ export const SessionView = memo(() => {

// Removed unused variables - now handled by panels

// Repository-wide commit graph — project-scoped, not session-scoped.
if (activeView === 'git-graph' && activeProjectId) {
return <GitGraphView key={activeProjectId} projectId={activeProjectId} projectName={projectData?.name} />;
}

// Show project view if navigation is set to project
if (activeView === 'project' && activeProjectId) {
if (isProjectLoading || !projectData) {
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom';
import { CreateSessionDialog } from './CreateSessionDialog';
import { ProjectSessionList, ArchivedSessions } from './ProjectSessionList';
import { ArchiveProgress } from './ArchiveProgress';
import { Archive, ArrowUpDown, ChevronDown, ChevronRight, Cpu, FolderGit2, Home, Monitor, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Pin, Settings as SettingsIcon, Plus, RefreshCw, MessageSquare, SquareTerminal } from 'lucide-react';
import { Archive, ArrowUpDown, ChevronDown, ChevronRight, Cpu, FolderGit2, Home, Monitor, MoreHorizontal, Network, PanelLeftClose, PanelLeftOpen, Pin, Settings as SettingsIcon, Plus, RefreshCw, MessageSquare, SquareTerminal } from 'lucide-react';
import { SessionDetailTooltip } from './SessionDetailTooltip';
import { usePaneLogo } from '../hooks/usePaneLogo';
import { IconButton } from './ui/Button';
Expand Down Expand Up @@ -388,6 +388,7 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
const activeView = useNavigationStore((state) => state.activeView);
const expandedProjects = useNavigationStore((state) => state.expandedProjects);
const navigateToProject = useNavigationStore((state) => state.navigateToProject);
const navigateToGitGraph = useNavigationStore((state) => state.navigateToGitGraph);
const navigateToSessions = useNavigationStore((state) => state.navigateToSessions);
const navigateToPaneChat = useNavigationStore((state) => state.navigateToPaneChat);
const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID);
Expand Down Expand Up @@ -645,6 +646,19 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick,
</button>
</Tooltip>

<Tooltip content={`Commit graph for ${project.name}`} side="right">
<button
type="button"
data-testid={`compact-repository-graph-${project.id}`}
data-compact-rail-item
onClick={() => navigateToGitGraph(project.id)}
aria-label={`Open commit graph for ${project.name}`}
className={`${COMPACT_RAIL_BUTTON} ${activeProjectId === project.id && activeView === 'git-graph' ? COMPACT_RAIL_ACTIVE : COMPACT_RAIL_IDLE}`}
>
<Network className="h-4 w-4" aria-hidden="true" />
</button>
</Tooltip>

{expandedProjects.has(project.id) && projectSessions.map((session) => (
<Tooltip
key={session.id}
Expand Down
103 changes: 103 additions & 0 deletions frontend/src/components/gitGraph/GitGraphCanvas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { memo } from 'react';
import type { GitGraphRow } from '../../../../shared/types/gitGraph';
import { LANE_WIDTH, ROW_HEIGHT, laneColor } from './graphColors';

const DOT_RADIUS = 4;
/** Length of the cap drawn above a branch tip's dot. */
const TIP_CAP = 5;

/**
* Path for one edge within a row's band.
*
* Every row draws its own slice, so an edge has to cover exactly the part of
* the band it owns: a pass-through spans the full height, a straight or merge
* edge leaves the dot half-way down, and a join arrives at the dot from above.
* Getting this wrong is what turned continuing branches into dashed lines.
*/
function edgePath(kind: string, x1: number, x2: number, centerY: number, rowHeight: number): string {
switch (kind) {
case 'passthrough':
return `M ${x1} 0 L ${x2} ${rowHeight}`;
case 'straight':
return `M ${x1} ${centerY} L ${x2} ${rowHeight}`;
case 'join':
// Mirror of the merge curve: comes down its own lane, then bends in.
return `M ${x1} 0 C ${x1} ${centerY * 0.5}, ${x2} ${centerY * 0.75}, ${x2} ${centerY}`;
default:
return `M ${x1} ${centerY} C ${x1} ${centerY + rowHeight * 0.35}, ${x2} ${centerY + rowHeight * 0.25}, ${x2} ${rowHeight}`;
}
}

interface GitGraphCanvasProps {
row: GitGraphRow;
laneCount: number;
/** Highlights the dot when this commit is selected in the list. */
isSelected: boolean;
}

/**
* Draws one row's slice of the commit graph: the commit dot plus every edge
* descending from this row into the next.
*
* Purely decorative — `aria-hidden`, zero interactivity. The clickable target
* is the row button in {@link GitGraphView}, which keeps the graph free of
* `jsx-a11y/no-static-element-interactions` violations.
*/
export const GitGraphCanvas = memo(function GitGraphCanvas({ row, laneCount, isSelected }: GitGraphCanvasProps) {
const width = Math.max(laneCount, 1) * LANE_WIDTH;
const centerY = ROW_HEIGHT / 2;
const laneX = (lane: number) => lane * LANE_WIDTH + LANE_WIDTH / 2;
const isMerge = row.node.parents.length > 1;

return (
<svg
width={width}
height={ROW_HEIGHT}
viewBox={`0 0 ${width} ${ROW_HEIGHT}`}
className="flex-shrink-0"
aria-hidden="true"
focusable="false"
>
{row.edges.map((edge, index) => (
<path
key={`${edge.fromLane}-${edge.toLane}-${edge.kind}-${index}`}
d={edgePath(edge.kind, laneX(edge.fromLane), laneX(edge.toLane), centerY, ROW_HEIGHT)}
fill="none"
stroke={laneColor(edge.colorIndex)}
strokeWidth={1.5}
strokeLinecap="round"
opacity={edge.danglesBelow ? 0.35 : 0.85}
/>
))}

{/*
Above the dot: the continuation of this lane from the row above, or —
when the branch starts here — a short rounded cap that reads as a
beginning instead of a line arriving from nowhere.
*/}
<path
d={row.isBranchTip
? `M ${laneX(row.lane)} ${centerY - TIP_CAP} L ${laneX(row.lane)} ${centerY}`
: `M ${laneX(row.lane)} 0 L ${laneX(row.lane)} ${centerY}`}
fill="none"
stroke={laneColor(row.colorIndex)}
strokeWidth={1.5}
strokeLinecap="round"
opacity={0.85}
/>

{/*
A merge is drawn hollow — the standard cue, and the only thing that
tells two histories joining here apart from an ordinary commit.
*/}
<circle
cx={laneX(row.lane)}
cy={centerY}
r={isSelected ? DOT_RADIUS + 1.5 : DOT_RADIUS}
fill={isMerge ? 'var(--color-bg-primary, #101014)' : laneColor(row.colorIndex)}
stroke={isMerge ? laneColor(row.colorIndex) : 'var(--color-bg-primary, #101014)'}
strokeWidth={isMerge ? 2 : 1.5}
/>
</svg>
);
});
123 changes: 123 additions & 0 deletions frontend/src/components/gitGraph/GitGraphCommitDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useEffect, useMemo, useState } from 'react';
import { Loader2, User, Clock, Hash, GitFork } from 'lucide-react';
import { API } from '../../utils/api';
import { parseUnifiedDiffToFiles } from '../../utils/parseUnifiedDiff';
import DiffViewer from '../panels/diff/DiffViewer';
import { CopyableField } from '../ui/CopyableField';
import { Badge } from '../ui/Badge';
import type { GitGraphNode } from '../../../../shared/types/gitGraph';
import type { GitDiffResult } from '../../types/diff';

interface GitGraphCommitDetailProps {
projectId: number;
node: GitGraphNode;
}

/**
* Right-hand pane of the graph view: metadata for the selected commit plus its
* full patch, rendered with the same {@link DiffViewer} the diff panel uses so
* expand/collapse, split view and syntax highlighting behave identically.
*/
export function GitGraphCommitDetail({ projectId, node }: GitGraphCommitDetailProps) {
const [diff, setDiff] = useState<GitDiffResult | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
setDiff(null);

API.projects.getCommitDetail(projectId, node.hash)
.then(response => {
if (cancelled) return;
if (!response.success || !response.data) {
throw new Error(response.error || 'Failed to load commit');
}
setDiff(response.data);
})
.catch(err => {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load commit');
})
.finally(() => {
if (!cancelled) setLoading(false);
});

return () => { cancelled = true; };
}, [projectId, node.hash]);

const files = useMemo(() => (diff ? parseUnifiedDiffToFiles(diff.diff) : []), [diff]);

const fullDate = useMemo(() => {
const date = new Date(node.authorDate);
return Number.isNaN(date.getTime())
? node.authorDate
: date.toLocaleString(undefined, {
weekday: 'short', year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}, [node.authorDate]);

return (
<div className="flex h-full min-w-0 flex-col">
<header className="flex-shrink-0 border-b border-border-primary bg-surface-secondary px-4 py-3">
<h2 className="mb-2 whitespace-pre-wrap break-words text-sm font-medium leading-snug text-text-primary">
{node.subject}
</h2>

{node.refs.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1">
{node.refs.map(ref => (
<Badge
key={`${ref.kind}-${ref.name}`}
size="sm"
variant={ref.kind === 'tag' ? 'warning' : ref.isCurrent ? 'primary' : 'default'}
>
{ref.name}
</Badge>
))}
</div>
)}

<div className="space-y-0.5 text-[11px]">
<CopyableField icon={User} value={`${node.authorName} <${node.authorEmail}>`} />
<CopyableField icon={Hash} value={node.hash} mono />
<CopyableField icon={Clock} value={fullDate} />
{node.parents.length > 0 && (
<CopyableField icon={GitFork} value={node.parents.join(', ')} mono />
)}
</div>

{diff && (
<div className="mt-2 flex items-center gap-2 text-xs">
<span className="font-semibold text-status-success">+{diff.stats.additions}</span>
<span className="font-semibold text-status-error">-{diff.stats.deletions}</span>
<span className="text-text-muted">
{diff.stats.filesChanged} {diff.stats.filesChanged === 1 ? 'file' : 'files'}
</span>
</div>
)}
</header>

<div className="min-h-0 flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-xs text-text-tertiary">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading commit…
</div>
) : error ? (
<div className="m-4 rounded border border-status-error/30 bg-status-error/10 p-4 text-sm text-status-error">
{error}
</div>
) : files.length === 0 ? (
<div className="p-4 text-center text-sm text-text-secondary">
This commit has no file changes.
</div>
) : (
<DiffViewer files={files} className="h-full" />
)}
</div>
</div>
);
}
Loading