|
10 | 10 | import TreeTableRow from "./TreeTableRow.svelte"; |
11 | 11 | import { Plus, Minus } from "@lucide/svelte"; |
12 | 12 | import type { DropZone } from "../drag.svelte"; |
13 | | - import { useSelection, useDrag } from "../contexts"; |
| 13 | + import { useSelection, useDrag, useHistoryNav } from "../contexts"; |
14 | 14 | import { useColumnResize } from "../composables/useColumnResize.svelte"; |
15 | 15 | import { useTreeDrag } from "../composables/useTreeDrag.svelte"; |
16 | 16 | import { useKeyboardNav } from "../composables/useKeyboardNav.svelte"; |
|
49 | 49 |
|
50 | 50 | const selection = useSelection(); |
51 | 51 | const drag = useDrag(); |
| 52 | + const nav = useHistoryNav(); |
52 | 53 |
|
53 | 54 | // Resolve values: prefs takes precedence over individual props |
54 | 55 | let resolvedFilter = $derived(resolveFilter(prefs, filter)); |
|
141 | 142 | let parentIds = $derived(tableData.parentIds); |
142 | 143 | let visibleRowIds = $derived(rows.map(r => r.nib.id)); |
143 | 144 |
|
| 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 | +
|
144 | 154 | // --- Ensure-visible: expand collapsed ancestors and scroll into view --- |
145 | 155 | $effect(() => { |
146 | 156 | const nibId = selection.pendingEnsureVisibleId; |
|
152 | 162 | nibMap.set(nib.id, nib); |
153 | 163 | } |
154 | 164 |
|
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. |
156 | 172 | if (!nibMap.has(nibId)) { |
157 | | - selection.clearEnsureVisible(); |
| 173 | + if (!$result.fetching) { |
| 174 | + selection.clearEnsureVisible(); |
| 175 | + } |
158 | 176 | return; |
159 | 177 | } |
160 | 178 |
|
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. |
162 | 181 | if (!visibleRowIds.includes(nibId)) { |
163 | 182 | const next = new Set(collapsedIds); |
164 | 183 | let current = nibMap.get(nibId); |
165 | 184 | while (current?.parentId) { |
166 | | - if (next.has(current.parentId)) { |
167 | | - next.delete(current.parentId); |
168 | | - } |
| 185 | + next.delete(current.parentId); |
169 | 186 | current = nibMap.get(current.parentId); |
170 | 187 | } |
| 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). |
171 | 202 | 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. |
174 | 203 | return; |
175 | 204 | } |
176 | 205 |
|
|
258 | 287 | toggleNode, |
259 | 288 | getScrollContainer: () => scrollContainerEl ?? null, |
260 | 289 | onDragKeyDown: treeDrag.onDragKeyDown, |
| 290 | + navigateToNib: nav.navigateToNib, |
261 | 291 | }); |
262 | 292 |
|
263 | 293 | // --- Event delegation helpers --- |
|
296 | 326 | } |
297 | 327 |
|
298 | 328 | if (action === "title") { |
299 | | - selection.select(nibId); |
| 329 | + nav.navigateToNib(nibId); |
300 | 330 | return; // Title click selects, not row click |
301 | 331 | } |
302 | 332 |
|
|
306 | 336 | return; // Don't fire row click for add-child |
307 | 337 | } |
308 | 338 |
|
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. |
310 | 347 | if (e.shiftKey) { |
311 | 348 | selection.rangeSelect(nibId, visibleRowIds); |
312 | 349 | } else if (e.ctrlKey || e.metaKey) { |
313 | 350 | selection.toggleSelect(nibId); |
314 | 351 | } else { |
315 | | - selection.select(nibId); |
| 352 | + nav.navigateToNib(nibId); |
316 | 353 | } |
317 | 354 | } |
318 | 355 |
|
|
322 | 359 | const nibId = getNibIdFromEvent(e); |
323 | 360 | if (!nibId) return; |
324 | 361 |
|
325 | | - selection.select(nibId); |
| 362 | + nav.navigateToNib(nibId); |
326 | 363 | } |
327 | 364 |
|
328 | 365 | function handleDelegatedContextMenu(e: MouseEvent) { |
|
0 commit comments