diff --git a/client/src/components/BranchSelector.tsx b/client/src/components/BranchSelector.tsx index a8dff1f..a87a6f4 100644 --- a/client/src/components/BranchSelector.tsx +++ b/client/src/components/BranchSelector.tsx @@ -58,9 +58,9 @@ 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', @@ -68,6 +68,8 @@ export default function BranchSelector({ branches, selected, onChange, disabled gap: '0.375rem', opacity: disabled ? 0.6 : 1, minWidth: 140, + height: 36, + boxSizing: 'border-box', }} > diff --git a/client/src/components/DateRangePicker.tsx b/client/src/components/DateRangePicker.tsx index 45c1b5c..ab947ca 100644 --- a/client/src/components/DateRangePicker.tsx +++ b/client/src/components/DateRangePicker.tsx @@ -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) => ( @@ -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', @@ -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', }} > diff --git a/client/src/components/GraphVisualization.tsx b/client/src/components/GraphVisualization.tsx index 002df0f..e507138 100644 --- a/client/src/components/GraphVisualization.tsx +++ b/client/src/components/GraphVisualization.tsx @@ -1,4 +1,4 @@ -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'; @@ -6,6 +6,13 @@ 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 @@ -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; @@ -123,6 +166,10 @@ export default function GraphVisualization({ const nodeMap = new Map(); 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'); @@ -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); @@ -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') @@ -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') @@ -190,7 +235,7 @@ 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) @@ -198,7 +243,7 @@ export default function GraphVisualization({ } else { nodeG .append('circle') - .attr('cx', node.x) + .attr('cx', dotX) .attr('cy', node.y) .attr('r', NODE_RADIUS) .attr('fill', isSelected ? color : '#161b22') @@ -206,10 +251,11 @@ export default function GraphVisualization({ .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') @@ -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') @@ -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') @@ -360,6 +406,44 @@ export default function GraphVisualization({ ref={svgRef} style={{ width: '100%', height: '100%', display: 'block' }} /> + {/* Re-center button */} + {/* Tooltip overlay */}
([]); 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({ queryKey: isPersonal ? ['repos-paged', page] : ['org-repos', owner, page], diff --git a/client/src/pages/AppLayout.tsx b/client/src/pages/AppLayout.tsx index 28d81f2..8fdcf81 100644 --- a/client/src/pages/AppLayout.tsx +++ b/client/src/pages/AppLayout.tsx @@ -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(''); @@ -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 @@ -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)) { @@ -207,6 +214,8 @@ export default function AppLayout() { alignItems: 'center', gap: '0.5rem', width: 180, + height: 36, + boxSizing: 'border-box', }} > {isPersonal && user ? ( @@ -330,6 +339,8 @@ export default function AppLayout() { alignItems: 'center', gap: '0.5rem', width: 320, + height: 36, + boxSizing: 'border-box', }} >