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
6 changes: 4 additions & 2 deletions client/src/components/BranchSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,18 @@ export default function BranchSelector({ branches, selected, onChange, disabled
style={{
background: '#0d1117',
border: '1px solid #30363d',
borderRadius: 6,
borderRadius: 8,
color: '#dfe2eb',
padding: '0.25rem 0.625rem',
padding: '0.375rem 0.75rem',
fontSize: '0.875rem',
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
opacity: disabled ? 0.6 : 1,
minWidth: 140,
height: 36,
boxSizing: 'border-box',
}}
>
<span style={{ flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Expand Down
17 changes: 14 additions & 3 deletions client/src/components/DateRangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,20 @@ export default function DateRangePicker() {
style={{
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 6,
borderRadius: 8,
color: '#dfe2eb',
padding: '0.375rem 0.625rem',
padding: '0.375rem 0.75rem',
fontSize: '0.875rem',
cursor: 'pointer',
height: 36,
boxSizing: 'border-box',
WebkitAppearance: 'none',
MozAppearance: 'none',
appearance: 'none',
paddingRight: '1.75rem',
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%238b949e' d='M2 4l4 4 4-4'/%3E%3C/svg%3E")`,
backgroundRepeat: 'no-repeat',
backgroundPosition: 'right 0.5rem center',
}}
>
{(Object.keys(TIME_RANGE_LABELS) as TimeRange[]).map((r) => (
Expand All @@ -263,7 +272,7 @@ export default function DateRangePicker() {
style={{
background: open ? 'rgba(88,166,255,0.08)' : '#161b22',
border: `1px solid ${open ? '#58a6ff' : '#30363d'}`,
borderRadius: 6,
borderRadius: 8,
color: timeRange === 'all' ? '#484f58' : '#dfe2eb',
padding: '0.375rem 0.75rem',
fontSize: '0.875rem',
Expand All @@ -273,6 +282,8 @@ export default function DateRangePicker() {
gap: '0.375rem',
whiteSpace: 'nowrap',
transition: 'border-color 0.15s, background 0.15s',
height: 36,
boxSizing: 'border-box',
}}
>
<span aria-hidden="true" style={{ fontSize: '0.875rem' }}>📅</span>
Expand Down
110 changes: 97 additions & 13 deletions client/src/components/GraphVisualization.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { useEffect, useRef, useMemo } from 'react';
import { useEffect, useRef, useMemo, useCallback } from 'react';
import * as d3 from 'd3';
import { buildDag, calcGraphWidth, calcGraphHeight, GRAPH_CONSTANTS } from '../lib/dag.js';
import type { DagNode } from '../lib/dag.js';
import type { CommitNode } from '../lib/api.js';

const { NODE_RADIUS } = GRAPH_CONSTANTS;

// Layout: commit details on LEFT, graph dots on RIGHT
const LABEL_LEFT_PAD = 16; // left padding for text labels
const SHA_WIDTH = 64; // width for abbreviated SHA
const MSG_WIDTH = 460; // width for commit message
const DATE_WIDTH = 80; // width for relative date
const LABEL_AREA_WIDTH = LABEL_LEFT_PAD + SHA_WIDTH + MSG_WIDTH + DATE_WIDTH + 24;

// Color palette for lanes
const LANE_COLORS = [
'#58a6ff', // main/master - blue
Expand Down Expand Up @@ -82,6 +89,42 @@ export default function GraphVisualization({
[commits, branchMap, defaultBranch]
);

// Re-center: zoom to latest commit (row 0), positioned at top of viewport
const recenter = useCallback(() => {
const svg = svgRef.current;
const container = containerRef.current;
const zoom = zoomBehaviorRef.current;
if (!svg || !container || !zoom || nodes.length === 0) return;

const viewWidth = container.clientWidth;

// Latest commit is row 0
const latestNode = nodes[0]!;
const latestY = latestNode.y;

// The content starts at x=0 (label left pad) and the graph dots are offset
// by LABEL_AREA_WIDTH. We want the full row visible, so anchor x=0 at
// a small left margin.
const graphWidth = Math.max(calcGraphWidth(nodes), 60);
const totalContentWidth = LABEL_AREA_WIDTH + graphWidth + 40;

// Pick scale so the full content width fits the viewport (but cap at 1)
const scale = Math.min(viewWidth / totalContentWidth, 1);

// Horizontally: center the content if it's narrower than the viewport
const contentW = totalContentWidth * scale;
const tx = Math.max((viewWidth - contentW) / 2, 8);

// Vertically: place the latest commit 40px from the top of the viewport
const ty = 40 - latestY * scale;

const t = d3.zoomIdentity.translate(tx, ty).scale(scale);

const d3svg = d3.select(svg);
d3svg.transition().duration(400).call(zoom.transform, t);
zoomTransformRef.current = t;
}, [nodes]);

useEffect(() => {
const svg = svgRef.current;
const container = containerRef.current;
Expand Down Expand Up @@ -123,6 +166,10 @@ export default function GraphVisualization({
const nodeMap = new Map<string, DagNode>();
for (const n of nodes) nodeMap.set(n.oid, n);

// Graph dots are on the RIGHT side, offset by LABEL_AREA_WIDTH
// Remap node x positions: original x is lane-based, shift right by label area
const graphOffsetX = LABEL_AREA_WIDTH;

// ─── Draw edges ───
const edgeGroup = g.append('g').attr('class', 'edges');

Expand All @@ -131,9 +178,9 @@ export default function GraphVisualization({
const parent = nodeMap.get(parentRef.oid);
if (!parent) continue;

const x1 = node.x;
const x1 = graphOffsetX + node.x;
const y1 = node.y;
const x2 = parent.x;
const x2 = graphOffsetX + parent.x;
const y2 = parent.y;
const color = getLaneColor(node.lane);

Expand All @@ -156,14 +203,12 @@ export default function GraphVisualization({
// ─── Draw nodes ───
const nodeGroup = g.append('g').attr('class', 'nodes');

// Label area starts after the graph columns
const labelX = graphWidth + 12;
const rightPanelWidth = 600;
const totalContentWidth = graphWidth + rightPanelWidth;
const totalContentWidth = LABEL_AREA_WIDTH + graphWidth + 40;

for (const node of nodes) {
const isSelected = node.oid === selectedOid;
const color = getLaneColor(node.lane);
const dotX = graphOffsetX + node.x;
const nodeG = nodeGroup
.append('g')
.attr('class', 'node')
Expand All @@ -175,7 +220,7 @@ export default function GraphVisualization({
if (isSelected) {
nodeG
.append('circle')
.attr('cx', node.x)
.attr('cx', dotX)
.attr('cy', node.y)
.attr('r', NODE_RADIUS + 4)
.attr('fill', 'none')
Expand All @@ -190,26 +235,27 @@ export default function GraphVisualization({
.append('polygon')
.attr(
'points',
`${node.x},${node.y - size} ${node.x + size},${node.y} ${node.x},${node.y + size} ${node.x - size},${node.y}`
`${dotX},${node.y - size} ${dotX + size},${node.y} ${dotX},${node.y + size} ${dotX - size},${node.y}`
)
.attr('fill', isSelected ? color : '#161b22')
.attr('stroke', color)
.attr('stroke-width', 2);
} else {
nodeG
.append('circle')
.attr('cx', node.x)
.attr('cx', dotX)
.attr('cy', node.y)
.attr('r', NODE_RADIUS)
.attr('fill', isSelected ? color : '#161b22')
.attr('stroke', color)
.attr('stroke-width', 2);
}

// ─── LEFT SIDE: commit details (SHA, message, date) ───
// SHA label
nodeG
.append('text')
.attr('x', labelX)
.attr('x', LABEL_LEFT_PAD)
.attr('y', node.y)
.attr('dy', '0.32em')
.attr('fill', isSelected ? '#dfe2eb' : '#c0c7d4')
Expand All @@ -223,7 +269,7 @@ export default function GraphVisualization({

nodeG
.append('text')
.attr('x', labelX + 64)
.attr('x', LABEL_LEFT_PAD + SHA_WIDTH)
.attr('y', node.y)
.attr('dy', '0.32em')
.attr('fill', isSelected ? '#dfe2eb' : '#c0c7d4')
Expand All @@ -233,7 +279,7 @@ export default function GraphVisualization({
// Date
nodeG
.append('text')
.attr('x', labelX + 64 + 460)
.attr('x', LABEL_LEFT_PAD + SHA_WIDTH + MSG_WIDTH)
.attr('y', node.y)
.attr('dy', '0.32em')
.attr('fill', '#8b949e')
Expand Down Expand Up @@ -360,6 +406,44 @@ export default function GraphVisualization({
ref={svgRef}
style={{ width: '100%', height: '100%', display: 'block' }}
/>
{/* Re-center button */}
<button
onClick={recenter}
title="Re-center to latest commit"
style={{
position: 'absolute',
top: 12,
right: 12,
width: 36,
height: 36,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#21262d',
border: '1px solid #30363d',
borderRadius: 8,
color: '#c0c7d4',
cursor: 'pointer',
zIndex: 20,
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#30363d';
e.currentTarget.style.borderColor = '#58a6ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#21262d';
e.currentTarget.style.borderColor = '#30363d';
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="8" />
<line x1="12" y1="2" x2="12" y2="6" />
<line x1="12" y1="18" x2="12" y2="22" />
<line x1="2" y1="12" x2="6" y2="12" />
<line x1="18" y1="12" x2="22" y2="12" />
</svg>
</button>
{/* Tooltip overlay */}
<div
ref={tooltipRef}
Expand Down
13 changes: 9 additions & 4 deletions client/src/hooks/useRepos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,19 @@ export function useOrgRepos(owner: string | null, userLogin: string | null, enab
const [accumulatedRepos, setAccumulatedRepos] = useState<UserRepo[]>([]);
const [hasNextPage, setHasNextPage] = useState(false);

// Reset when owner changes
const isPersonal = owner === null || owner === userLogin;

// Stable key for the "effective owner" so we only reset when the actual
// data source changes, not when flipping between null and userLogin
// (both of which resolve to the same "personal" repos endpoint).
const effectiveOwner = isPersonal ? '__personal__' : owner;

// Reset when the effective owner changes
useEffect(() => {
setPage(1);
setAccumulatedRepos([]);
setHasNextPage(false);
}, [owner]);

const isPersonal = owner === null || owner === userLogin;
}, [effectiveOwner]);

const { data, isLoading, error } = useQuery<ReposPage>({
queryKey: isPersonal ? ['repos-paged', page] : ['org-repos', owner, page],
Expand Down
17 changes: 14 additions & 3 deletions client/src/pages/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export default function AppLayout() {
// set selectedOwner to match so the correct org's repos are loaded
return params.owner ?? null;
});
// Track when user explicitly picks an org/Personal so the URL-sync effect
// doesn't immediately override their choice
const userSelectedOwnerRef = useRef(false);
const [showOrgDropdown, setShowOrgDropdown] = useState(false);
const [showRepoDropdown, setShowRepoDropdown] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
Expand All @@ -40,11 +43,14 @@ export default function AppLayout() {
}
}, [isAuthenticated, isLoading, navigate]);

// Sync org dropdown with URL owner param (for deep linking)
// Sync org dropdown with URL owner param (for deep linking / navigation)
// Skip if the user just explicitly selected an org (to avoid overriding their choice)
useEffect(() => {
if (userSelectedOwnerRef.current) {
userSelectedOwnerRef.current = false;
return;
}
if (params.owner && params.owner !== selectedOwner) {
// If URL owner matches logged-in user, treat as Personal (null works too, but
// setting it explicitly ensures the repos list fetches for the right owner)
setSelectedOwner(params.owner);
}
}, [params.owner]); // eslint-disable-line react-hooks/exhaustive-deps
Expand Down Expand Up @@ -79,6 +85,7 @@ export default function AppLayout() {

function selectOrg(owner: string | null) {
setShowOrgDropdown(false);
userSelectedOwnerRef.current = true;
setSelectedOwner(owner);
// If current repo doesn't belong to new owner, navigate to /app
if (params.owner && params.owner !== (owner ?? user?.login)) {
Expand Down Expand Up @@ -207,6 +214,8 @@ export default function AppLayout() {
alignItems: 'center',
gap: '0.5rem',
width: 180,
height: 36,
boxSizing: 'border-box',
}}
>
{isPersonal && user ? (
Expand Down Expand Up @@ -330,6 +339,8 @@ export default function AppLayout() {
alignItems: 'center',
gap: '0.5rem',
width: 320,
height: 36,
boxSizing: 'border-box',
}}
>
<span style={{ flexGrow: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
Expand Down
Loading