From 245f8a124232db77a1bbb14fb6f4ff1d330adbc2 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:21:04 -0500 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20flip=20graph=20layout=20=E2=80=94?= =?UTF-8?q?=20commit=20details=20left,=20dots=20right=20+=20re-center=20bu?= =?UTF-8?q?tton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moved SHA, commit message, and date labels to the LEFT side - Graph dots/edges now render on the RIGHT side (offset by label area width) - Added re-center button (⊙) in top-right corner that smoothly animates to the latest commit at scale 1, positioned near top-center - Button uses Obsidian dark theme styling with hover effects --- client/src/components/GraphVisualization.tsx | 95 +++++++++++++++++--- 1 file changed, 82 insertions(+), 13 deletions(-) diff --git a/client/src/components/GraphVisualization.tsx b/client/src/components/GraphVisualization.tsx index 002df0f..34add70 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,32 @@ export default function GraphVisualization({ [commits, branchMap, defaultBranch] ); + // Re-center: zoom to latest commit (row 0), centered horizontally, scale 1 + 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; + const viewHeight = container.clientHeight || 600; + + // Latest commit is row 0 — its y is ROW_HEIGHT / 2 + const latestNode = nodes[0]!; + const targetX = latestNode.x; + const targetY = latestNode.y; + + // Zoom scale 1, center the latest commit near the top-center of the viewport + const scale = 1; + const tx = viewWidth / 2 - targetX * scale; + const ty = viewHeight * 0.15 - targetY * scale; // 15% from top + 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 +156,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 +168,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 +193,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 +210,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 +225,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 +233,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 +241,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 +259,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 +269,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 +396,39 @@ export default function GraphVisualization({ ref={svgRef} style={{ width: '100%', height: '100%', display: 'block' }} /> + {/* Re-center button */} + {/* Tooltip overlay */}
Date: Wed, 15 Apr 2026 11:34:57 -0500 Subject: [PATCH 2/5] fix: re-center button now accurately snaps to latest commit - Fixed coordinate calculation: accounts for LABEL_AREA_WIDTH offset so the full row (labels + graph dot) is visible - Scale fits content width to viewport (capped at 1x) - Latest commit positioned 40px from the top of the viewport - Content centered horizontally when narrower than viewport --- client/src/components/GraphVisualization.tsx | 28 +++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/client/src/components/GraphVisualization.tsx b/client/src/components/GraphVisualization.tsx index 34add70..f4c0414 100644 --- a/client/src/components/GraphVisualization.tsx +++ b/client/src/components/GraphVisualization.tsx @@ -89,7 +89,7 @@ export default function GraphVisualization({ [commits, branchMap, defaultBranch] ); - // Re-center: zoom to latest commit (row 0), centered horizontally, scale 1 + // Re-center: zoom to latest commit (row 0), positioned at top of viewport const recenter = useCallback(() => { const svg = svgRef.current; const container = containerRef.current; @@ -97,17 +97,27 @@ export default function GraphVisualization({ if (!svg || !container || !zoom || nodes.length === 0) return; const viewWidth = container.clientWidth; - const viewHeight = container.clientHeight || 600; - // Latest commit is row 0 — its y is ROW_HEIGHT / 2 + // Latest commit is row 0 const latestNode = nodes[0]!; - const targetX = latestNode.x; - const targetY = latestNode.y; + 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; - // Zoom scale 1, center the latest commit near the top-center of the viewport - const scale = 1; - const tx = viewWidth / 2 - targetX * scale; - const ty = viewHeight * 0.15 - targetY * scale; // 15% from top const t = d3.zoomIdentity.translate(tx, ty).scale(scale); const d3svg = d3.select(svg); From dae429036b3be69f2beae0a316460ca184f68df6 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:42:42 -0500 Subject: [PATCH 3/5] fix: org/repo dropdown reset when selecting Personal Two bugs fixed: 1. AppLayout URL-sync effect was overriding user's explicit org selection. When clicking 'Personal', selectedOwner was set to null, but the sync effect immediately saw params.owner !== null and reverted it. Added a ref flag (userSelectedOwnerRef) to skip the sync effect after intentional selections. 2. useOrgRepos was resetting accumulated repos when switching between null and userLogin (both resolve to Personal). The reset effect keyed on raw owner, but both values hit the same API endpoint. Now uses an effectiveOwner key ('__personal__' for both null and userLogin) so it only resets when the actual data source changes. --- client/src/hooks/useRepos.ts | 13 +++++++++---- client/src/pages/AppLayout.tsx | 13 ++++++++++--- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/client/src/hooks/useRepos.ts b/client/src/hooks/useRepos.ts index 53f3ebf..3572b59 100644 --- a/client/src/hooks/useRepos.ts +++ b/client/src/hooks/useRepos.ts @@ -40,14 +40,19 @@ export function useOrgRepos(owner: string | null, userLogin: string | null, enab const [accumulatedRepos, setAccumulatedRepos] = useState([]); 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..ddd4e3b 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)) { From 9f89c9b58dc221f95fcc1d9a43ca66575ba325a7 Mon Sep 17 00:00:00 2001 From: Lupita Bot <263059171+lupita-hom@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:46:58 -0500 Subject: [PATCH 4/5] fix: replace re-center icon with crosshair/target SVG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swapped the ambiguous ⊙ character for a proper crosshair icon (circle + 4 tick marks) — unmistakably a 're-center' button. --- client/src/components/GraphVisualization.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/components/GraphVisualization.tsx b/client/src/components/GraphVisualization.tsx index f4c0414..e507138 100644 --- a/client/src/components/GraphVisualization.tsx +++ b/client/src/components/GraphVisualization.tsx @@ -423,7 +423,6 @@ export default function GraphVisualization({ border: '1px solid #30363d', borderRadius: 8, color: '#c0c7d4', - fontSize: '16px', cursor: 'pointer', zIndex: 20, transition: 'background 0.15s, border-color 0.15s', @@ -437,7 +436,13 @@ export default function GraphVisualization({ e.currentTarget.style.borderColor = '#30363d'; }} > - ⊙ + + + + + + + {/* Tooltip overlay */}
Date: Wed, 15 Apr 2026 12:58:01 -0500 Subject: [PATCH 5/5] fix: standardize all toolbar control heights and border-radius All dropdowns, buttons, and pickers now share: - height: 36px (explicit) - border-radius: 8px - padding: 0.375rem 0.75rem - box-sizing: border-box Components standardized: - Org dropdown (AppLayout header) - Repo dropdown (AppLayout header) - BranchSelector button - DateRangePicker preset select (also removed native appearance for consistent cross-browser rendering + custom dropdown arrow) - DateRangePicker date range button --- client/src/components/BranchSelector.tsx | 6 ++++-- client/src/components/DateRangePicker.tsx | 17 ++++++++++++++--- client/src/pages/AppLayout.tsx | 4 ++++ 3 files changed, 22 insertions(+), 5 deletions(-) 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/pages/AppLayout.tsx b/client/src/pages/AppLayout.tsx index ddd4e3b..8fdcf81 100644 --- a/client/src/pages/AppLayout.tsx +++ b/client/src/pages/AppLayout.tsx @@ -214,6 +214,8 @@ export default function AppLayout() { alignItems: 'center', gap: '0.5rem', width: 180, + height: 36, + boxSizing: 'border-box', }} > {isPersonal && user ? ( @@ -337,6 +339,8 @@ export default function AppLayout() { alignItems: 'center', gap: '0.5rem', width: 320, + height: 36, + boxSizing: 'border-box', }} >