Skip to content

Commit 0c70745

Browse files
committed
feat: make graph panel changes apply explicitly
The webview now buffers operations, text filters, and layout values before applying them, while grouped query labels and minimap visuals were adjusted to make large graphs easier to inspect. Constraint: Must not introduce new UI dependencies or persistent layout state Rejected: Auto-apply with debounce | still couples expensive graph recomputation to typing and slider drag Confidence: medium Scope-risk: moderate Directive: Keep apply semantics isolated per section; avoid shared apply handlers across unrelated drafts Tested: pnpm test; pnpm lint; pnpm type-check; pnpm build Not-tested: Manual UX pass for every button combination in the webview
1 parent 4e0e509 commit 0c70745

12 files changed

Lines changed: 752 additions & 83 deletions

src/webview/GraphCanvas.tsx

Lines changed: 32 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ import {
3131
} from './graphUtils';
3232
import { useResizablePanels } from './hooks/useResizablePanels';
3333
import { getLayoutedElements } from './layout';
34+
import { computeProjectBubbleFrame, computeProjectGridShifts } from './projectLayout';
3435
import { cx, isDeclareActionNode } from './utils';
3536
import { vscode } from './vscode';
3637

3738
const defaultFilters: FilterState = {
3839
relation: {
39-
invalidates: true,
40+
invalidates: false,
4041
refetches: false,
4142
cancels: false,
4243
resets: false,
@@ -229,6 +230,8 @@ const PROJECT_TOP_DIVIDER_GAP = 42;
229230
const PROJECT_BAND_GAP = 8;
230231
const FILE_ACTION_PROJECT_GAP = 48;
231232
const PROJECT_COLUMN_GAP = 168;
233+
const MONOREPO_PROJECT_ROW_GAP = 96;
234+
const MONOREPO_PROJECT_MAX_COLUMNS = 4;
232235
const DECLARE_ACTION_QUERY_GAP = 56;
233236
const PROJECT_DIVIDER_TOP_MARGIN = 23;
234237
const PROJECT_DIVIDER_BOTTOM_MARGIN = 11;
@@ -439,37 +442,30 @@ function buildProjectDividerNodes(
439442
if (!keepFirstDivider && index === 0) {
440443
continue;
441444
}
442-
443-
const horizontalPadding = 72;
444-
const topPadding = 82;
445-
const bottomPadding = 52;
446-
const bubbleX = range.minX - horizontalPadding;
447-
const bubbleY = range.minY - topPadding;
448-
const bubbleWidth = Math.max(860, range.maxX - range.minX + horizontalPadding * 2);
449-
const bubbleHeight = Math.max(260, range.maxY - range.minY + topPadding + bottomPadding);
445+
const bubbleFrame = computeProjectBubbleFrame(range);
450446

451447
dividers.push({
452448
id: `divider:${index}:${range.label}`,
453449
type: 'rqvDivider',
454450
data: {
455451
label: range.label,
456-
width: bubbleWidth,
457-
height: bubbleHeight,
452+
width: bubbleFrame.width,
453+
height: bubbleFrame.height,
458454
showLabel: true,
459455
variant: 'bubble',
460456
},
461457
position: {
462-
x: bubbleX,
463-
y: bubbleY,
458+
x: bubbleFrame.x,
459+
y: bubbleFrame.y,
464460
},
465461
selectable: false,
466462
draggable: false,
467463
connectable: false,
468464
deletable: false,
469465
focusable: false,
470466
style: {
471-
width: bubbleWidth,
472-
height: bubbleHeight,
467+
width: bubbleFrame.width,
468+
height: bubbleFrame.height,
473469
zIndex: 0,
474470
pointerEvents: 'none',
475471
},
@@ -1158,50 +1154,29 @@ function arrangeProjectsHorizontally(nodes: Node[], graph: WebviewPayload['graph
11581154
return nodes;
11591155
}
11601156

1161-
const sortedProjects = [...boundsByProject.entries()]
1162-
.sort((a, b) => a[1].minX - b[1].minX || a[1].minY - b[1].minY || a[0].localeCompare(b[0]))
1163-
.map(([project]) => project);
1164-
1165-
const topBaseline = sortedProjects.reduce((minY, project) => {
1166-
const bounds = boundsByProject.get(project);
1167-
if (!bounds) {
1168-
return minY;
1169-
}
1170-
return Math.min(minY, bounds.minY);
1171-
}, Number.POSITIVE_INFINITY);
1172-
1173-
if (!Number.isFinite(topBaseline)) {
1174-
return nodes;
1175-
}
1176-
1177-
let cursorX = sortedProjects.reduce((minX, project) => {
1178-
const bounds = boundsByProject.get(project);
1179-
if (!bounds) {
1180-
return minX;
1181-
}
1182-
return Math.min(minX, bounds.minX);
1183-
}, Number.POSITIVE_INFINITY);
1184-
1185-
if (!Number.isFinite(cursorX)) {
1186-
return nodes;
1187-
}
1188-
11891157
const shiftByNodeId = new Map<string, { x: number; y: number }>();
1190-
for (const project of sortedProjects) {
1191-
const bounds = boundsByProject.get(project);
1158+
const projectShifts = computeProjectGridShifts(
1159+
[...boundsByProject.entries()].map(([project, bounds]) => ({
1160+
project,
1161+
minX: bounds.minX,
1162+
maxX: bounds.maxX,
1163+
minY: bounds.minY,
1164+
maxY: bounds.maxY,
1165+
})),
1166+
{
1167+
columnGap: PROJECT_COLUMN_GAP,
1168+
rowGap: MONOREPO_PROJECT_ROW_GAP,
1169+
maxColumns: MONOREPO_PROJECT_MAX_COLUMNS,
1170+
},
1171+
);
1172+
for (const [project, shift] of projectShifts.entries()) {
11921173
const nodeIds = nodeIdsByProject.get(project);
1193-
if (!bounds || !nodeIds || nodeIds.length === 0) {
1174+
if (!nodeIds || nodeIds.length === 0) {
11941175
continue;
11951176
}
1196-
1197-
const shiftX = cursorX - bounds.minX;
1198-
const shiftY = topBaseline - bounds.minY;
11991177
for (const nodeId of nodeIds) {
1200-
shiftByNodeId.set(nodeId, { x: shiftX, y: shiftY });
1178+
shiftByNodeId.set(nodeId, shift);
12011179
}
1202-
1203-
const width = Math.max(0, bounds.maxX - bounds.minX);
1204-
cursorX += width + PROJECT_COLUMN_GAP;
12051180
}
12061181

12071182
if (shiftByNodeId.size === 0) {
@@ -2141,14 +2116,16 @@ export function GraphCanvas({ payload }: { payload: WebviewPayload }) {
21412116
nodeColor="var(--rqv-minimap-node)"
21422117
maskColor="var(--rqv-minimap-mask)"
21432118
style={{
2144-
width: 132,
2145-
height: 88,
2119+
width: 198,
2120+
height: 132,
21462121
background: 'var(--rqv-minimap-bg)',
21472122
border: '1px solid var(--rqv-minimap-border)',
21482123
borderRadius: 10,
21492124
boxShadow: '0 8px 20px var(--rqv-minimap-shadow)',
21502125
marginRight: 10,
21512126
marginBottom: 10,
2127+
['--xy-minimap-mask-stroke-color' as string]: 'var(--rqv-minimap-mask-stroke)',
2128+
['--xy-minimap-mask-stroke-width' as string]: 2.5,
21522129
}}
21532130
/>
21542131
<Background variant={BackgroundVariant.Lines} gap={44} size={0.48} color="var(--rqv-grid-color)" />

src/webview/components/LeftPanel.tsx

Lines changed: 126 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { type Dispatch, type ReactElement, type SetStateAction, useEffect, useMemo, useState } from 'react';
22

33
import { OPERATION_RELATIONS, RELATION_COLOR, RELATION_LABEL } from '../constants';
4+
import {
5+
applyFilterDraft,
6+
buildFilterDraft,
7+
hasPendingFilterChanges,
8+
hasPendingOperationChanges,
9+
hasPendingTextFilterChanges,
10+
} from '../filterDraft';
411
import type { OperationRelation, ScannedFile } from '../model';
512
import { cx } from '../utils';
613
import type { FilterState } from '../viewTypes';
@@ -151,6 +158,9 @@ export function LeftPanel({
151158
relatedFiles: false,
152159
});
153160
const [collapsedDirectories, setCollapsedDirectories] = useState<Set<string>>(() => new Set());
161+
const [draftFilters, setDraftFilters] = useState(() => buildFilterDraft(filters));
162+
const [draftVerticalSpacing, setDraftVerticalSpacing] = useState(verticalSpacing);
163+
const [draftHorizontalSpacing, setDraftHorizontalSpacing] = useState(horizontalSpacing);
154164

155165
const filteredRelatedFiles = useMemo(() => {
156166
const unique = new Map<string, ScannedFile>();
@@ -184,8 +194,24 @@ export function LeftPanel({
184194
});
185195
}, [directoryPaths]);
186196

197+
useEffect(() => {
198+
setDraftFilters({
199+
relation: { ...filters.relation },
200+
fileQuery: filters.fileQuery,
201+
search: filters.search,
202+
});
203+
}, [filters.relation, filters.fileQuery, filters.search]);
204+
205+
useEffect(() => {
206+
setDraftVerticalSpacing(verticalSpacing);
207+
}, [verticalSpacing]);
208+
209+
useEffect(() => {
210+
setDraftHorizontalSpacing(horizontalSpacing);
211+
}, [horizontalSpacing]);
212+
187213
const toggleRelation = (relation: OperationRelation) => {
188-
setFilters((prev) => ({
214+
setDraftFilters((prev) => ({
189215
...prev,
190216
relation: {
191217
...prev.relation,
@@ -213,6 +239,29 @@ export function LeftPanel({
213239
});
214240
};
215241

242+
const applyDraftFilters = () => {
243+
if (!hasPendingFilterChanges(filters, draftFilters)) {
244+
return;
245+
}
246+
247+
setFilters((previous) => applyFilterDraft(previous, draftFilters));
248+
};
249+
250+
const hasPendingOperations = hasPendingOperationChanges(filters, draftFilters);
251+
const hasPendingTextFilters = hasPendingTextFilterChanges(filters, draftFilters);
252+
253+
const hasPendingLayoutChanges =
254+
draftVerticalSpacing !== verticalSpacing || draftHorizontalSpacing !== horizontalSpacing;
255+
256+
const applyDraftLayout = () => {
257+
if (!hasPendingLayoutChanges) {
258+
return;
259+
}
260+
261+
onVerticalSpacingChange(draftVerticalSpacing);
262+
onHorizontalSpacingChange(draftHorizontalSpacing);
263+
};
264+
216265
const sectionHeader = (title: string, section: PanelSectionKey, extra?: string) => {
217266
const collapsed = collapsedSections[section];
218267

@@ -369,21 +418,42 @@ export function LeftPanel({
369418
key={relation}
370419
className="my-1 flex items-center gap-2 rounded px-1 py-0.5 text-xs hover:bg-zinc-200/70 dark:hover:bg-zinc-800/70"
371420
>
372-
<input type="checkbox" checked={filters.relation[relation]} onChange={() => toggleRelation(relation)} />
421+
<input
422+
type="checkbox"
423+
checked={draftFilters.relation[relation]}
424+
onChange={() => toggleRelation(relation)}
425+
/>
373426
<span
374427
aria-hidden
375428
className="h-[3px] w-4 shrink-0 rounded-full"
376429
style={{ backgroundColor: RELATION_COLOR[relation] }}
377430
/>
378431
<span
379432
className={
380-
filters.relation[relation] ? 'text-zinc-800 dark:text-zinc-100' : 'text-zinc-500 dark:text-zinc-400'
433+
draftFilters.relation[relation]
434+
? 'text-zinc-800 dark:text-zinc-100'
435+
: 'text-zinc-500 dark:text-zinc-400'
381436
}
382437
>
383438
{RELATION_LABEL[relation]}
384439
</span>
385440
</label>
386-
))
441+
)).concat(
442+
<button
443+
key="apply-operations"
444+
type="button"
445+
className={cx(
446+
'mt-2 w-full rounded-[7px] border px-2 py-[7px] text-[12px] font-medium transition-colors',
447+
hasPendingOperations
448+
? 'border-zinc-500 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 dark:border-zinc-300 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
449+
: 'cursor-not-allowed border-zinc-300 bg-zinc-200/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-500',
450+
)}
451+
onClick={applyDraftFilters}
452+
disabled={!hasPendingOperations}
453+
>
454+
Apply Operations
455+
</button>,
456+
)
387457
: null}
388458
</section>
389459

@@ -393,40 +463,65 @@ export function LeftPanel({
393463
<>
394464
<input
395465
className="mb-2 w-full rounded-[7px] border border-zinc-300 bg-zinc-100 px-2 py-[7px] text-[12px] text-zinc-700 placeholder:text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-500"
396-
value={filters.fileQuery}
466+
value={draftFilters.fileQuery}
397467
placeholder="Filter files"
398-
onChange={(event) => setFilters((prev) => ({ ...prev, fileQuery: event.target.value }))}
468+
onChange={(event) => setDraftFilters((previous) => ({ ...previous, fileQuery: event.target.value }))}
469+
onKeyDown={(event) => {
470+
if (event.key === 'Enter') {
471+
applyDraftFilters();
472+
}
473+
}}
399474
/>
400475
<input
401476
className="mb-2 w-full rounded-[7px] border border-zinc-300 bg-zinc-100 px-2 py-[7px] text-[12px] text-zinc-700 placeholder:text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-500"
402-
value={filters.search}
477+
value={draftFilters.search}
403478
placeholder="Search labels"
404-
onChange={(event) => setFilters((prev) => ({ ...prev, search: event.target.value }))}
479+
onChange={(event) => setDraftFilters((previous) => ({ ...previous, search: event.target.value }))}
480+
onKeyDown={(event) => {
481+
if (event.key === 'Enter') {
482+
applyDraftFilters();
483+
}
484+
}}
405485
/>
486+
<button
487+
type="button"
488+
className={cx(
489+
'w-full rounded-[7px] border px-2 py-[7px] text-[12px] font-medium transition-colors',
490+
hasPendingTextFilters
491+
? 'border-zinc-500 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 dark:border-zinc-300 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
492+
: 'cursor-not-allowed border-zinc-300 bg-zinc-200/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-500',
493+
)}
494+
onClick={applyDraftFilters}
495+
disabled={!hasPendingTextFilters}
496+
>
497+
Apply Filters
498+
</button>
406499
</>
407500
) : null}
408501
</section>
409502

410503
<section className="mb-4 shrink-0 border-b border-zinc-300/75 pb-[10px] dark:border-zinc-700/70">
411504
{sectionHeader('Layout', 'layout')}
412505
{!collapsedSections.layout ? (
413-
<div className="space-y-2 rounded-[7px] border border-zinc-300 px-2 py-1.5 dark:border-zinc-700">
506+
<div className="space-y-2 px-2 py-1.5">
414507
<div>
415508
<label
416509
className="mb-0.5 flex items-center justify-between text-[11px] text-zinc-600 dark:text-zinc-300"
417510
htmlFor="rqv-vertical-spacing"
418511
>
419512
<span>Vertical Spacing</span>
420-
<span className="font-medium tabular-nums text-zinc-800 dark:text-zinc-100">{verticalSpacing}</span>
513+
<span className="font-medium tabular-nums text-zinc-800 dark:text-zinc-100">
514+
{draftVerticalSpacing}
515+
</span>
421516
</label>
422517
<input
423518
id="rqv-vertical-spacing"
424519
type="range"
425520
min={0}
426521
max={300}
427522
step={2}
428-
value={verticalSpacing}
429-
onChange={(event) => onVerticalSpacingChange(Number(event.target.value))}
523+
value={draftVerticalSpacing}
524+
onChange={(event) => setDraftVerticalSpacing(Number(event.target.value))}
430525
className="h-1.5 w-full cursor-pointer accent-zinc-700 dark:accent-zinc-300"
431526
/>
432527
</div>
@@ -437,19 +532,35 @@ export function LeftPanel({
437532
htmlFor="rqv-horizontal-spacing"
438533
>
439534
<span>Horizontal Spacing</span>
440-
<span className="font-medium tabular-nums text-zinc-800 dark:text-zinc-100">{horizontalSpacing}</span>
535+
<span className="font-medium tabular-nums text-zinc-800 dark:text-zinc-100">
536+
{draftHorizontalSpacing}
537+
</span>
441538
</label>
442539
<input
443540
id="rqv-horizontal-spacing"
444541
type="range"
445542
min={100}
446543
max={3000}
447544
step={25}
448-
value={horizontalSpacing}
449-
onChange={(event) => onHorizontalSpacingChange(Number(event.target.value))}
545+
value={draftHorizontalSpacing}
546+
onChange={(event) => setDraftHorizontalSpacing(Number(event.target.value))}
450547
className="h-1.5 w-full cursor-pointer accent-zinc-700 dark:accent-zinc-300"
451548
/>
452549
</div>
550+
551+
<button
552+
type="button"
553+
className={cx(
554+
'w-full rounded-[7px] border px-2 py-[7px] text-[12px] font-medium transition-colors',
555+
hasPendingLayoutChanges
556+
? 'border-zinc-500 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 dark:border-zinc-300 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200'
557+
: 'cursor-not-allowed border-zinc-300 bg-zinc-200/80 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-500',
558+
)}
559+
onClick={applyDraftLayout}
560+
disabled={!hasPendingLayoutChanges}
561+
>
562+
Apply Layout
563+
</button>
453564
</div>
454565
) : null}
455566
</section>

0 commit comments

Comments
 (0)