Skip to content

Commit 0ca7dd7

Browse files
committed
Merge branch 'feat/58c3-history-nav'
2 parents 3b3d5ab + 768b06d commit 0ca7dd7

12 files changed

Lines changed: 564 additions & 32 deletions

web/src/App.svelte

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
import { SelectionState } from "./lib/selection.svelte";
1616
import { DragState } from "./lib/drag.svelte";
1717
import type { DropZone } from "./lib/drag.svelte";
18-
import { provideSelection, provideDrag, provideConfirmDialog, provideEditorOrchestration } from "./lib/contexts";
18+
import { provideSelection, provideDrag, provideConfirmDialog, provideEditorOrchestration, provideHistoryNav } from "./lib/contexts";
19+
import { createHistoryNav } from "./lib/composables/useHistoryNav.svelte";
1920
import { createConfirmDialog } from "./lib/composables/useConfirmDialog.svelte";
2021
import { createEditorOrchestration } from "./lib/composables/useEditorOrchestration.svelte";
2122
import { useKeyboardShortcuts } from "./lib/composables/useKeyboardShortcuts.svelte";
@@ -47,11 +48,32 @@
4748
const prefs = new Preferences();
4849
const selection = new SelectionState();
4950
const drag = new DragState();
51+
// isBlocked reads editor/confirmDialog, created below — the closure is only
52+
// invoked at popstate time, by which point both exist. While a blocking overlay
53+
// is open, Back/Forward must not navigate the panel behind it (nibs-g1fy).
54+
const nav = createHistoryNav({
55+
selection,
56+
isBlocked: () => editor.editorOpen || editor.typePickerOpen || confirmDialog.open,
57+
});
5058
provideSelection(selection);
5159
provideDrag(drag);
60+
provideHistoryNav(nav);
61+
62+
// Wire browser history: sync selection from the initial URL, then let
63+
// Back/Forward drive selection via popstate. syncFromUrl reads/writes only
64+
// non-reactive deps (window.location/history), so this effect runs once
65+
// (no reactive reads). handlePopState is only registered as a listener
66+
// here, never invoked in the effect body.
67+
$effect(() => {
68+
nav.syncFromUrl();
69+
window.addEventListener("popstate", nav.handlePopState);
70+
return () => window.removeEventListener("popstate", nav.handlePopState);
71+
});
5272
const confirmDialog = createConfirmDialog();
5373
provideConfirmDialog(confirmDialog);
54-
const editor = createEditorOrchestration({ client, selection });
74+
// Pass `nav` so editor-save auto-select records a Back-stop (creating/opening
75+
// a nib after save routes through history, per nibs-58c3 "all open paths").
76+
const editor = createEditorOrchestration({ client, nav });
5577
provideEditorOrchestration(editor);
5678
// Collect unique tags from the query results via TreeTable callback
5779
let availableTags: string[] = $state([]);
@@ -101,9 +123,10 @@
101123
let contextMenuNib: TreeTableNib | null = $state(null);
102124
103125
function handleRowContextMenu(nibId: string, event: MouseEvent, nib: TreeTableNib) {
104-
// If the right-clicked nib is not in the selection, select it first
126+
// If the right-clicked nib is not in the selection, select it first —
127+
// route through nav so the URL/history stay in sync (nibs-58c3).
105128
if (!selection.isSelected(nibId)) {
106-
selection.select(nibId);
129+
nav.navigateToNib(nibId);
107130
}
108131
contextMenuNibId = nibId;
109132
contextMenuNib = nib;
@@ -114,6 +137,7 @@
114137
// --- Global keyboard shortcuts ---
115138
useKeyboardShortcuts({
116139
selection,
140+
nav,
117141
editor,
118142
confirmDialog,
119143
mutations,
@@ -239,15 +263,23 @@
239263
collapsible={true}
240264
collapsedSize={0}
241265
onResize={handleDetailPaneResize}
242-
onCollapse={() => { if (selection.panelOpen) requestAnimationFrame(() => selection.close()); }}
266+
onCollapse={() => {
267+
if (!selection.panelOpen) return;
268+
// Capture the open nib at schedule time; only close if it's still the one
269+
// showing when the frame fires. A different nib opened during the rAF window
270+
// (near-impossible resize-drag race) must not be closed / push a spurious
271+
// {nibId:null}. nibs-58c3.
272+
const openedId = selection.selectedNibId;
273+
requestAnimationFrame(() => { if (selection.selectedNibId === openedId) nav.closePanel(); });
274+
}}
243275
bind:this={detailPaneComponent}
244276
data-testid="detail-pane"
245277
>
246278
{#if selection.panelOpen && selection.selectedNibId}
247279
<DetailPanel
248280
nibId={selection.selectedNibId}
249-
onclose={() => selection.close()}
250-
onnibselect={(nibId) => selection.select(nibId)}
281+
onclose={() => nav.closePanel()}
282+
onnibselect={(nibId) => nav.navigateToNib(nibId)}
251283
onedit={editor.handleEditNib}
252284
onaddchild={editor.handleAddChild}
253285
/>

web/src/App.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ const mockQueryStore = vi.mocked(queryStore);
7878
describe("App", () => {
7979
beforeEach(() => {
8080
mockQueryStore.mockClear();
81+
// App now syncs selection from the URL on mount, and clicks push history.
82+
// jsdom shares window.history/location across tests in a file, so reset to a
83+
// clean URL before each test to mirror a fresh page load.
84+
window.history.replaceState(null, "", "/");
8185
});
8286

8387
it("renders with dark theme shell containing Toolbar and TreeTable", () => {

web/src/lib/components/RowContextMenu.svelte

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { STATUSES, PRIORITIES } from "../constants";
44
import { canHaveChildren } from "../typeHierarchy";
55
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
6-
import { useSelection, useConfirmDialog, useEditorOrchestration } from "$lib/contexts";
6+
import { useSelection, useConfirmDialog, useEditorOrchestration, useHistoryNav } from "$lib/contexts";
77
import { getMutationStore } from "$lib/mutations";
88
import {
99
setStatusBatch,
@@ -30,6 +30,7 @@
3030
const selection = useSelection();
3131
const confirmDialog = useConfirmDialog();
3232
const editor = useEditorOrchestration();
33+
const nav = useHistoryNav();
3334
const mutations = getMutationStore();
3435
3536
let isBulk = $derived(selectedCount > 1);
@@ -51,7 +52,8 @@
5152
5253
function handleOpen() {
5354
if (nib) {
54-
selection.select(nib.id);
55+
// Route through nav so the URL/history stay in sync (nibs-58c3).
56+
nav.navigateToNib(nib.id);
5557
}
5658
}
5759

web/src/lib/components/TreeTable.svelte

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import TreeTableRow from "./TreeTableRow.svelte";
1111
import { Plus, Minus } from "@lucide/svelte";
1212
import type { DropZone } from "../drag.svelte";
13-
import { useSelection, useDrag } from "../contexts";
13+
import { useSelection, useDrag, useHistoryNav } from "../contexts";
1414
import { useColumnResize } from "../composables/useColumnResize.svelte";
1515
import { useTreeDrag } from "../composables/useTreeDrag.svelte";
1616
import { useKeyboardNav } from "../composables/useKeyboardNav.svelte";
@@ -49,6 +49,7 @@
4949
5050
const selection = useSelection();
5151
const drag = useDrag();
52+
const nav = useHistoryNav();
5253
5354
// Resolve values: prefs takes precedence over individual props
5455
let resolvedFilter = $derived(resolveFilter(prefs, filter));
@@ -141,6 +142,15 @@
141142
let parentIds = $derived(tableData.parentIds);
142143
let visibleRowIds = $derived(rows.map(r => r.nib.id));
143144
145+
// Structural equality for two string sets (size + membership).
146+
function sameSet(a: Set<string>, b: Set<string>): boolean {
147+
if (a.size !== b.size) return false;
148+
for (const x of a) {
149+
if (!b.has(x)) return false;
150+
}
151+
return true;
152+
}
153+
144154
// --- Ensure-visible: expand collapsed ancestors and scroll into view ---
145155
$effect(() => {
146156
const nibId = selection.pendingEnsureVisibleId;
@@ -152,25 +162,44 @@
152162
nibMap.set(nib.id, nib);
153163
}
154164
155-
// If the nib doesn't exist in the dataset, clear and bail
165+
// The nib isn't in the dataset. Two distinct cases must NOT be conflated:
166+
// - Query still loading (cold deep-link fires syncFromUrl before the
167+
// GraphQL result lands, so allNibs is []): keep the pending request so
168+
// the expand/scroll runs once data arrives (nibs-58c3 AC3). Reading
169+
// $result.fetching also subscribes the effect to re-run on settle.
170+
// - Query settled and the nib is genuinely absent (archived/bad URL):
171+
// clear and bail — there is nothing to scroll to.
156172
if (!nibMap.has(nibId)) {
157-
selection.clearEnsureVisible();
173+
if (!$result.fetching) {
174+
selection.clearEnsureVisible();
175+
}
158176
return;
159177
}
160178
161-
// If the nib is not currently visible, expand collapsed ancestors
179+
// The nib is in the dataset but not currently visible. Try to expand its
180+
// collapsed ancestors so it becomes reachable.
162181
if (!visibleRowIds.includes(nibId)) {
163182
const next = new Set(collapsedIds);
164183
let current = nibMap.get(nibId);
165184
while (current?.parentId) {
166-
if (next.has(current.parentId)) {
167-
next.delete(current.parentId);
168-
}
185+
next.delete(current.parentId);
169186
current = nibMap.get(current.parentId);
170187
}
188+
// If expansion changes nothing yet the nib is still not visible, it is in
189+
// the dataset but excluded from the visible rows — by an active client
190+
// filter OR the current view level (e.g. viewLevel='milestones' drops a
191+
// top-level epic via buildViewTree), regardless of collapse state.
192+
// Ancestor-expansion can never reveal it, so clear and bail — otherwise
193+
// reassigning `collapsedIds` to a new Set every pass would loop forever
194+
// (effect_update_depth_exceeded).
195+
if (sameSet(next, collapsedIds)) {
196+
selection.clearEnsureVisible();
197+
return;
198+
}
199+
// Ancestors were collapsed — expand them and let the effect re-run once
200+
// visibleRowIds updates (either the nib appears, or the next pass hits
201+
// the filtered-out guard above and clears).
171202
collapsedIds = next;
172-
// After expanding, the nib will appear in the next reactive cycle.
173-
// We return here and let the effect re-run once visibleRowIds updates.
174203
return;
175204
}
176205
@@ -258,6 +287,7 @@
258287
toggleNode,
259288
getScrollContainer: () => scrollContainerEl ?? null,
260289
onDragKeyDown: treeDrag.onDragKeyDown,
290+
navigateToNib: nav.navigateToNib,
261291
});
262292
263293
// --- Event delegation helpers ---
@@ -296,7 +326,7 @@
296326
}
297327
298328
if (action === "title") {
299-
selection.select(nibId);
329+
nav.navigateToNib(nibId);
300330
return; // Title click selects, not row click
301331
}
302332
@@ -306,13 +336,20 @@
306336
return; // Don't fire row click for add-child
307337
}
308338
309-
// Default: row click with modifier handling
339+
// Default: row click with modifier handling.
340+
// Shift/Ctrl-Cmd are bulk-selection gestures — intentionally NOT routed
341+
// through nav, so they record no Back/Forward history entry. Note that a
342+
// collapse-to-exactly-one still opens the single-nib panel (and collapse-to-
343+
// zero closes it) without a history push, so URL/history can lag selection
344+
// after these gestures; that's accepted because multi-select is a bulk
345+
// gesture, not detail-panel navigation (nibs-58c3).
346+
// Only a plain click is treated as navigation.
310347
if (e.shiftKey) {
311348
selection.rangeSelect(nibId, visibleRowIds);
312349
} else if (e.ctrlKey || e.metaKey) {
313350
selection.toggleSelect(nibId);
314351
} else {
315-
selection.select(nibId);
352+
nav.navigateToNib(nibId);
316353
}
317354
}
318355
@@ -322,7 +359,7 @@
322359
const nibId = getNibIdFromEvent(e);
323360
if (!nibId) return;
324361
325-
selection.select(nibId);
362+
nav.navigateToNib(nibId);
326363
}
327364
328365
function handleDelegatedContextMenu(e: MouseEvent) {

web/src/lib/components/TreeTable.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,74 @@ describe("TreeTable", () => {
11741174
expect(sel.pendingEnsureVisibleId).toBeNull();
11751175
});
11761176
});
1177+
1178+
// Regression (nibs-58c3, review #1): ensureVisible for a nib that is in the
1179+
// dataset but excluded by an active client filter used to spin the effect
1180+
// forever — every pass reassigned collapsedIds to a fresh Set that could
1181+
// never make the filtered-out nib visible (effect_update_depth_exceeded).
1182+
// The effect must now detect that expansion changes nothing and clear.
1183+
it("settles (does not loop) when ensureVisible targets a filtered-out nib", async () => {
1184+
const sel = new SelectionState();
1185+
const nibs: TreeTableNib[] = [
1186+
makeTreeTableNib({ id: "nibs-m1", title: "Milestone", type: "milestone" }),
1187+
makeTreeTableNib({ id: "nibs-e1", title: "Epic", type: "epic", parentId: "nibs-m1" }),
1188+
makeTreeTableNib({ id: "nibs-bug1", title: "Matching Bug", type: "bug", parentId: "nibs-e1" }),
1189+
makeTreeTableNib({ id: "nibs-task1", title: "Filtered Task", type: "task", parentId: "nibs-e1" }),
1190+
];
1191+
1192+
// Active client filter (type: bug) drops the task from `rows` while it
1193+
// stays in `allNibs`. Expanding ancestors can never reveal it.
1194+
setupWithNibs(nibs, { filter: { type: ["bug"] } }, { selection: sel });
1195+
1196+
expect(screen.getByText("Matching Bug")).toBeInTheDocument();
1197+
expect(screen.queryByText("Filtered Task")).not.toBeInTheDocument();
1198+
1199+
sel.ensureVisible("nibs-task1");
1200+
1201+
// Must terminate by clearing, not loop forever reassigning collapsedIds.
1202+
await waitFor(() => {
1203+
expect(sel.pendingEnsureVisibleId).toBeNull();
1204+
});
1205+
1206+
// Still filtered out — expansion did not (and cannot) reveal it.
1207+
expect(screen.queryByText("Filtered Task")).not.toBeInTheDocument();
1208+
});
1209+
1210+
// Regression (nibs-58c3, review #3): a cold deep-link runs syncFromUrl on
1211+
// mount before the GraphQL query resolves (allNibs === []). The effect must
1212+
// NOT clear the pending request as "absent" while the query is still
1213+
// fetching — it must wait for data, then expand/scroll.
1214+
it("keeps the pending request while the query is fetching, then resolves it once data arrives", async () => {
1215+
const sel = new SelectionState();
1216+
const nibs: TreeTableNib[] = [
1217+
makeTreeTableNib({ id: "nibs-m1", title: "Milestone", type: "milestone" }),
1218+
makeTreeTableNib({ id: "nibs-t1", title: "Deferred Task", type: "task", parentId: "nibs-m1" }),
1219+
];
1220+
1221+
// Query in-flight, no data yet.
1222+
const store = writable<any>({ fetching: true, error: undefined, data: undefined, stale: false });
1223+
mockQueryStore.mockReturnValue(store as any);
1224+
1225+
// Deep-link request for a nib not yet in the (empty) dataset.
1226+
sel.ensureVisible("nibs-t1");
1227+
1228+
renderTreeTable(
1229+
{ filter: {}, viewLevel: "milestones" as ViewLevel },
1230+
{ selection: sel },
1231+
);
1232+
await tick();
1233+
1234+
// Still fetching → preserve the request (do NOT clear as absent).
1235+
expect(sel.pendingEnsureVisibleId).toBe("nibs-t1");
1236+
1237+
// Data arrives.
1238+
store.set({ fetching: false, error: undefined, data: { nibs }, stale: false });
1239+
1240+
// Now present and visible → effect clears after scrolling.
1241+
await waitFor(() => {
1242+
expect(sel.pendingEnsureVisibleId).toBeNull();
1243+
});
1244+
});
11771245
});
11781246

11791247
describe("reactive filter re-query", () => {

web/src/lib/composables/useEditorOrchestration.svelte.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { toast } from "svelte-sonner";
1111
import { NIB_DETAIL_QUERY } from "../queries";
1212
import { getValidChildTypes } from "../typeHierarchy";
1313
import type { NibData } from "../components/EditorModal.svelte";
14-
import type { SelectionState } from "../selection.svelte";
14+
import type { HistoryNav } from "./useHistoryNav.svelte";
1515

1616
export interface EditorOrchestrationState {
1717
readonly editorOpen: boolean;
@@ -34,9 +34,9 @@ export interface EditorOrchestrationState {
3434

3535
export function createEditorOrchestration(opts: {
3636
client: Client;
37-
selection: SelectionState;
37+
nav: HistoryNav;
3838
}): EditorOrchestrationState {
39-
const { client, selection } = opts;
39+
const { client, nav } = opts;
4040

4141
// Editor modal state
4242
let editorOpen = $state(false);
@@ -126,7 +126,10 @@ export function createEditorOrchestration(opts: {
126126
}
127127

128128
function handleEditorSave(nibId: string) {
129-
selection.select(nibId);
129+
// Route through nav so the URL/history stay in sync (nibs-58c3). This
130+
// intentionally records a Back-stop: creating/opening a nib after save is
131+
// a navigation, per "all open paths flow through nav".
132+
nav.navigateToNib(nibId);
130133
}
131134

132135
function closeTypePicker() {

0 commit comments

Comments
 (0)