mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 13:28:57 +00:00
feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157) (#962)
* feat(web): pane-navigation controller — depth/ownership state machine (TASK-2157) Turn the collection page's split pane (PLAN-2105) into a navigable mini-browser per PLAN-2154 Architecture A. Depth + session ownership are stamped in SvelteKit page.state (never raw history.state), so they follow opaque Back/Forward, survive history.go, and reconstruct on cold-load. - New pure controller ($lib/collections/paneController.ts): planPaneDrill (same-ref guard + soft depth cap + ownership INHERITANCE), planLateralOpen (first-open mints ownership / depth-0 re-target / depth>0 stack reset), planPaneClose (three-way staged unwind). Fully unit-tested (26 cases). - navigatePaneTo(target) drill added beside openItemPane; ownership created only by first-open, inherited by drills (cold-load base = unowned). - Three-way ownership-aware staged close: OWNED -> go(-(depth+1)); UNOWNED & depth>0 -> go(-depth) then afterNavigate-latched replaceState-delete; UNOWNED & depth 0 -> replaceState-delete. - R14 fence-on-continuation baked in: controllerActionSeq + a one-shot afterNavigate latch (seq-fenced, state-rechecked), schedulePaneFollow made inert at depth>0 (schedule + fired callback) and cancelled on drill/close, an in-flight guard so a rapid gesture can't stack a second history.go. - depth+ownership preserved through every ?item=-preserving nav: updateUrlFilters, the ?graph toggle (ItemDetail), and the collection rename onNavigateAway (now replaceState, not push). - navigatePaneTo exported onto the pane ItemDetail seam for TASK-2158 and reachable now via a localStorage-gated __padPaneController test hook. Tests: 26 unit + 5 Playwright e2e (open/close/j-k, drill/back/same-ref, detach j/k inertness, cold-load close, detached-row reset). Existing pane e2e suite (13) still green. Closes TASK-2157 Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): rebase pane ownership on collection rename + tie latch to its popstate Codex review of the pane controller (TASK-2157): - P1 (rename ownership): a collection rename replaceState's /old?item=X -> /new?item=X, but every predecessor history entry still points at the now- dead OLD slug — carrying paneOwned=true forward made an owned close history.go back onto a 404. Ownership means "a live pre-pane entry exists to unwind to", which is false after a rename, so onNavigateAway now stamps a fresh {paneDepth:0, paneOwned:false} base on the new slug: close drops ?item= in place, staying on the valid new route. New e2e covers it. - P2 (latch): gate the afterNavigate latch on nav.type==='popstate' so only its own history.go can consume it; a competing goto/link/form leaves it armed until the go settles. - P1 (owned close discards mid-pane filter changes): documented as the plan-mandated R8 behavior — an explicit close is now identical to the browser Back that already closed the pane in PLAN-2105 (no deviation). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): harden pane latch consumption + bypass draft guard on rename Second Codex round (TASK-2157): - Latch is no longer dropped by a competing history traversal: run() now RETURNS whether it reached its destination (depth collapsed to the base); the afterNavigate handler consumes the latch only when run() fires, so an unrelated browser Back/Forward during the go's in-flight window leaves the latch armed for its own popstate instead of clearing it against the wrong entry. - Collection rename now bypasses the unsaved-draft beforeNavigate guard (navigatePaneAfterRename): the server-side rename already committed and the route component is reused across the same-route pathname change (drafts survive), so a "Stay" prompt could otherwise strand the user on the dead old slug with a stale owned stamp. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): bound pane latch with a fallback timer + stronger reset correlation Third Codex round (TASK-2157): - P1 lockup: the "leave the latch armed until run() reaches its destination" rule could leave paneNavInFlight() stuck forever if the arming history.go was superseded (its own popstate never lands). Add a bounded fallback timer (PANE_LATCH_FALLBACK_MS) that best-effort-fires then UNCONDITIONALLY clears the latch, so the in-flight guard can never stick. clearPaneLatch() also tears down the timer (onDestroy + on consume). - P2 reset correlation: the detached-open reset now requires the landing entry to carry ?item= (the pane base), not just depth 0 — rejecting a competing browser Back that landed on the pre-pane (no-?item=) entry. - P2 rename + browser Back: documented that Back to the old-slug predecessor is an inherent rename-in-history limitation (past entries can't be rewritten), out of the controller's reach; the imperative close is fixed. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): fence every controller history.go + split rename vs move nav-away Fourth Codex round (TASK-2157): - P1 (move vs rename): onNavigateAway is fired by ItemDetail for BOTH a collection rename (/user/ws/NEWSLUG?item=X — pane preserved) AND a cross-collection item move (/user/ws/coll/slug — full-page route, no ?item=). handlePaneNavigateAway now branches on whether the target keeps the pane (?item=): rename gets the rebase-to-unowned + draft-guard bypass; a move keeps the ORIGINAL guarded push so its unsaved-draft prompt still fires (the collection page unmounts and would lose drafts). - P2 (duplicate close): the production owned-go close is a one-phase history.go(-1) that wasn't fenced, so a double-click ✕ / ESC+click could stack a second traversal and overshoot the pre-pane entry. Unify all controller traversals (owned close, cold-base close, reset) through paneHistoryGo(), which marks navigation in-flight (paneNavInFlight blocks a duplicate gesture) until the traversal's own popstate settles or a bounded fallback. New e2e asserts a double close lands exactly on the pre-pane URL. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
import { test, expect } from './fixtures';
|
||||
import { browserLogin, seedDoc } from './lib/collab-helpers';
|
||||
import type { APIRequestContext, Page } from '@playwright/test';
|
||||
import type { SuiteFixture } from './fixtures';
|
||||
|
||||
/**
|
||||
* Pane-navigation controller — depth/ownership state machine
|
||||
* (PLAN-2154 Architecture A / TASK-2157).
|
||||
*
|
||||
* The collection page's split pane (PLAN-2105) becomes a navigable
|
||||
* mini-browser: `openItemPane` handles lateral/list opens; `navigatePaneTo`
|
||||
* handles in-pane DRILLS; `closeItemPane` does a three-way, ownership-aware,
|
||||
* staged unwind. Depth + ownership are stamped in SvelteKit `page.state` so
|
||||
* they follow opaque Back/Forward, survive `history.go`, and reconstruct on
|
||||
* cold-load.
|
||||
*
|
||||
* `navigatePaneTo` has NO in-pane content-link caller yet (those land in
|
||||
* TASK-2158/2159/2160). We exercise it — and read back the depth/ownership
|
||||
* stamp — through the localStorage-gated `window.__padPaneController` test hook
|
||||
* this task ships, so the drill / reset / three-way-close arithmetic is
|
||||
* verified end-to-end in a REAL browser before the UI callers exist. jsdom has
|
||||
* no history/navigation model, so the pure arithmetic is unit-tested in
|
||||
* paneController.test.ts and the runtime wiring is verified here.
|
||||
*
|
||||
* Viewport is driven explicitly (desktop split), so one project is enough.
|
||||
*/
|
||||
|
||||
const DESKTOP = { width: 1200, height: 900 };
|
||||
|
||||
function docsUrl(fixture: SuiteFixture, query = ''): string {
|
||||
return `/${fixture.adminUsername}/${fixture.workspaceSlug}/docs${query}`;
|
||||
}
|
||||
|
||||
function openItemParam(page: Page): string | null {
|
||||
return new URL(page.url()).searchParams.get('item');
|
||||
}
|
||||
|
||||
function pathname(page: Page): string {
|
||||
return new URL(page.url()).pathname;
|
||||
}
|
||||
|
||||
interface HookState {
|
||||
paneDepth: number;
|
||||
paneOwned: boolean;
|
||||
}
|
||||
|
||||
/** Read the controller's live {paneDepth, paneOwned} via the test hook. */
|
||||
function paneState(page: Page): Promise<HookState | null> {
|
||||
return page.evaluate(() => {
|
||||
const c = (window as unknown as { __padPaneController?: { getPaneState(): HookState } })
|
||||
.__padPaneController;
|
||||
return c ? c.getPaneState() : null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Drive an in-pane DRILL (`navigatePaneTo`) via the test hook. */
|
||||
async function drillTo(page: Page, ref: string): Promise<void> {
|
||||
await page.evaluate((r) => {
|
||||
(
|
||||
window as unknown as { __padPaneController?: { navigatePaneTo(ref: string): void } }
|
||||
).__padPaneController?.navigatePaneTo(r);
|
||||
}, ref);
|
||||
}
|
||||
|
||||
/** Imperative close via the test hook (exercises the three-way close). */
|
||||
async function hookClose(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as unknown as { __padPaneController?: { closeItemPane(): void } }
|
||||
).__padPaneController?.closeItemPane();
|
||||
});
|
||||
}
|
||||
|
||||
function historyLength(page: Page): Promise<number> {
|
||||
return page.evaluate(() => history.length);
|
||||
}
|
||||
|
||||
/** Enable the controller test hook for all navigations in this context. */
|
||||
async function enableHook(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
localStorage.setItem('pad:pane-test-hook', '1');
|
||||
} catch {
|
||||
/* private mode / disabled storage — hook simply won't install */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function authHeaders(fixture: SuiteFixture) {
|
||||
return { Authorization: `Bearer ${fixture.apiToken}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** Create a fresh, test-scoped renamable collection (letters-only prefix, name
|
||||
* unique — see pane-collection-migration-race.spec.ts for the slug/prefix
|
||||
* gotchas). */
|
||||
async function seedCollection(
|
||||
fixture: SuiteFixture,
|
||||
request: APIRequestContext,
|
||||
namePrefix: string,
|
||||
itemPrefix: string,
|
||||
): Promise<{ id: string; slug: string; name: string }> {
|
||||
const name = `${namePrefix} ${Date.now()}`;
|
||||
const schema = JSON.stringify({ fields: [{ key: 'note', label: 'Note', type: 'text' }] });
|
||||
const resp = await request.post(`/api/v1/workspaces/${fixture.workspaceSlug}/collections`, {
|
||||
headers: authHeaders(fixture),
|
||||
data: { name, prefix: itemPrefix, schema },
|
||||
});
|
||||
if (!resp.ok()) throw new Error(`collection create failed (${resp.status()}): ${await resp.text()}`);
|
||||
return (await resp.json()) as { id: string; slug: string; name: string };
|
||||
}
|
||||
|
||||
async function seedItemIn(
|
||||
fixture: SuiteFixture,
|
||||
request: APIRequestContext,
|
||||
collSlug: string,
|
||||
title: string,
|
||||
): Promise<{ id: string; slug: string }> {
|
||||
const resp = await request.post(
|
||||
`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`,
|
||||
{ headers: authHeaders(fixture), data: { title, fields: JSON.stringify({}), content: '' } },
|
||||
);
|
||||
if (!resp.ok()) throw new Error(`item create failed (${resp.status()}): ${await resp.text()}`);
|
||||
return (await resp.json()) as { id: string; slug: string };
|
||||
}
|
||||
|
||||
test.describe('pane controller: depth/ownership state machine (PLAN-2154 / TASK-2157)', () => {
|
||||
// The controller is viewport-agnostic; the desktop split project is enough.
|
||||
test.beforeEach(({}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== 'desktop-chromium',
|
||||
'controller is viewport-agnostic; the desktop split project is enough',
|
||||
);
|
||||
});
|
||||
|
||||
test('no PLAN-2105 regression: click-open (owned depth 0), j/k re-target (still depth 0), close returns to pre-pane URL', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await enableHook(page);
|
||||
await browserLogin(page);
|
||||
await seedDoc(fixture, request, 'Ctrl regress alpha');
|
||||
await seedDoc(fixture, request, 'Ctrl regress bravo');
|
||||
await page.goto(docsUrl(fixture));
|
||||
|
||||
const prePaneUrl = page.url();
|
||||
const row = page.locator('.item-card', { hasText: 'Ctrl regress alpha' }).first();
|
||||
await expect(row).toBeVisible();
|
||||
await row.click();
|
||||
|
||||
const pane = page.locator('.item-pane');
|
||||
await expect(pane).toBeVisible();
|
||||
const refA = openItemParam(page);
|
||||
expect(refA).not.toBeNull();
|
||||
// First-open MINTS ownership at depth 0.
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||
|
||||
// j moves the list cursor; the pane FOLLOWS (re-target replace) — still
|
||||
// depth 0, still owned, and NO new history entry (the PLAN-2105 fix).
|
||||
const lenAfterOpen = await historyLength(page);
|
||||
await page.keyboard.press('j');
|
||||
await expect.poll(() => openItemParam(page)).not.toBe(refA);
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||
expect(await historyLength(page)).toBe(lenAfterOpen); // replace, not push
|
||||
await expect(pane).toBeVisible();
|
||||
|
||||
// Close via the pane's real ✕ (aria-label is unique across the loaded and
|
||||
// minimal headers, which are mutually exclusive) — OWNED depth 0 →
|
||||
// history.go(-1) back to the exact pre-pane URL (pane gone, `?item=` gone).
|
||||
await pane.locator('button[aria-label="Close pane"]').click();
|
||||
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||
await expect(pane).toBeHidden();
|
||||
await expect.poll(() => page.url()).toBe(prePaneUrl);
|
||||
});
|
||||
|
||||
test('a duplicate close gesture cannot stack a second history.go and overshoot the pre-pane URL', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await enableHook(page);
|
||||
await browserLogin(page);
|
||||
await seedDoc(fixture, request, 'Ctrl dblclose alpha');
|
||||
await page.goto(docsUrl(fixture));
|
||||
|
||||
const prePaneUrl = page.url();
|
||||
await page.locator('.item-card', { hasText: 'Ctrl dblclose alpha' }).first().click();
|
||||
const pane = page.locator('.item-pane');
|
||||
await expect(pane).toBeVisible();
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||
|
||||
// Fire closeItemPane TWICE synchronously (a double-click ✕ / ESC+click
|
||||
// race). The owned close is a one-phase history.go(-1); without the
|
||||
// in-flight fence the second call would read the still-stale state and
|
||||
// stack a SECOND go(-1), overshooting PAST the pre-pane entry (here, back
|
||||
// to /login). The fence must make the second call a no-op.
|
||||
await page.evaluate(() => {
|
||||
const c = (
|
||||
window as unknown as { __padPaneController?: { closeItemPane(): void } }
|
||||
).__padPaneController;
|
||||
c?.closeItemPane();
|
||||
c?.closeItemPane();
|
||||
});
|
||||
|
||||
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||
await expect(pane).toBeHidden();
|
||||
// Landed EXACTLY on the pre-pane URL — not overshot to /login.
|
||||
await expect.poll(() => page.url()).toBe(prePaneUrl);
|
||||
expect(page.url()).not.toContain('/login');
|
||||
});
|
||||
|
||||
test('drill A→B pushes a depth-1 owned entry; browser Back returns to A at depth 0', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await enableHook(page);
|
||||
await browserLogin(page);
|
||||
await seedDoc(fixture, request, 'Ctrl drill alpha');
|
||||
const b = await seedDoc(fixture, request, 'Ctrl drill bravo');
|
||||
await page.goto(docsUrl(fixture));
|
||||
|
||||
await page.locator('.item-card', { hasText: 'Ctrl drill alpha' }).first().click();
|
||||
const pane = page.locator('.item-pane');
|
||||
await expect(pane).toBeVisible();
|
||||
const refA = openItemParam(page);
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||
const lenBeforeDrill = await historyLength(page);
|
||||
|
||||
// DRILL A→B (no UI caller yet → test hook). Pushes depth 1, INHERITING
|
||||
// ownership (owned base → owned drill).
|
||||
await drillTo(page, b.slug);
|
||||
await expect.poll(() => openItemParam(page)).toBe(b.slug);
|
||||
await expect(pane.locator('.title', { hasText: /Ctrl drill bravo/ })).toBeVisible();
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||
expect(await historyLength(page)).toBe(lenBeforeDrill + 1); // push
|
||||
|
||||
// Same-ref guard (D4): re-drilling to the CURRENT item is a no-op — no
|
||||
// new history entry, depth unchanged.
|
||||
const lenAtB = await historyLength(page);
|
||||
await drillTo(page, b.slug);
|
||||
await page.waitForTimeout(50);
|
||||
expect(await historyLength(page)).toBe(lenAtB);
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||
|
||||
// Browser Back unwinds ONE hop → back to A at depth 0.
|
||||
await page.goBack();
|
||||
await expect.poll(() => openItemParam(page)).toBe(refA);
|
||||
await expect(pane.locator('.title', { hasText: /Ctrl drill alpha/ })).toBeVisible();
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||
});
|
||||
|
||||
test('detach: j/k is INERT at depth>0 (a list follow cannot re-target a drilled stack)', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await enableHook(page);
|
||||
await browserLogin(page);
|
||||
await seedDoc(fixture, request, 'Ctrl detach alpha');
|
||||
const b = await seedDoc(fixture, request, 'Ctrl detach bravo');
|
||||
await seedDoc(fixture, request, 'Ctrl detach charlie');
|
||||
await page.goto(docsUrl(fixture));
|
||||
|
||||
await page.locator('.item-card', { hasText: 'Ctrl detach alpha' }).first().click();
|
||||
const pane = page.locator('.item-pane');
|
||||
await expect(pane).toBeVisible();
|
||||
|
||||
// Drill to B → depth 1 (detached).
|
||||
await drillTo(page, b.slug);
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||
const refAtB = openItemParam(page);
|
||||
|
||||
// Move focus to the list, then press j. schedulePaneFollow must BAIL at
|
||||
// depth>0 (both at schedule time and in the fired callback), so the pane
|
||||
// stays on B — j/k does NOT laterally re-target a drilled stack.
|
||||
await page.evaluate(() => document.querySelector<HTMLElement>('.list-column')?.focus());
|
||||
await page.keyboard.press('j');
|
||||
await page.waitForTimeout(250); // > PANE_FOLLOW_DEBOUNCE_MS (140ms)
|
||||
expect(openItemParam(page)).toBe(refAtB);
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||
});
|
||||
|
||||
test('cold-load close: a cold `?item=` then drill closes back to the cold base — never go(-2) off it', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await enableHook(page);
|
||||
await browserLogin(page);
|
||||
const a = await seedDoc(fixture, request, 'Ctrl cold alpha');
|
||||
const b = await seedDoc(fixture, request, 'Ctrl cold bravo');
|
||||
|
||||
// COLD LOAD: deep-link straight into an open pane. No history stamp →
|
||||
// UNOWNED base.
|
||||
await page.goto(docsUrl(fixture, `?item=${a.slug}`));
|
||||
const pane = page.locator('.item-pane');
|
||||
await expect(pane).toBeVisible();
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: false });
|
||||
const coldBasePath = pathname(page);
|
||||
|
||||
// Drill A→B: INHERITS the cold base's unowned stamp (depth 1, UNOWNED) —
|
||||
// this is what keeps the cold-base close branch reachable.
|
||||
await drillTo(page, b.slug);
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: false });
|
||||
|
||||
// Imperative close: UNOWNED depth>0 → history.go(-1) to the cold base,
|
||||
// THEN a latched replaceState-delete of `?item=`. Must NOT go(-2) off the
|
||||
// base into whatever preceded the cold load (the /login page here).
|
||||
await hookClose(page);
|
||||
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||
await expect(pane).toBeHidden();
|
||||
// Landed on the SAME collection page (the cold base), not an earlier page.
|
||||
expect(pathname(page)).toBe(coldBasePath);
|
||||
expect(page.url()).not.toContain('/login');
|
||||
});
|
||||
|
||||
test('detached row click RESETS the stack: a new top-level open, closing cleanly to the pre-pane URL', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await enableHook(page);
|
||||
await browserLogin(page);
|
||||
await seedDoc(fixture, request, 'Ctrl reset alpha');
|
||||
const b = await seedDoc(fixture, request, 'Ctrl reset bravo');
|
||||
await seedDoc(fixture, request, 'Ctrl reset charlie');
|
||||
await page.goto(docsUrl(fixture));
|
||||
|
||||
const prePaneUrl = page.url();
|
||||
await page.locator('.item-card', { hasText: 'Ctrl reset alpha' }).first().click();
|
||||
const pane = page.locator('.item-pane');
|
||||
await expect(pane).toBeVisible();
|
||||
|
||||
// Drill A→B (depth 1).
|
||||
await drillTo(page, b.slug);
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 1, paneOwned: true });
|
||||
|
||||
// Now a DIRECT list-row click on a THIRD item (charlie) while detached:
|
||||
// resets the stack (go(-depth) to base, then a latched re-target),
|
||||
// landing at depth 0 (the base ownership preserved → owned).
|
||||
await page.locator('.item-card', { hasText: 'Ctrl reset charlie' }).first().click();
|
||||
await expect(pane.locator('.title', { hasText: /Ctrl reset charlie/ })).toBeVisible();
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||
|
||||
// The reset collapsed the stack, so closing now returns straight to the
|
||||
// pre-pane URL in a single unwind (owned go(-1)).
|
||||
await pane.locator('button[aria-label="Close pane"]').click();
|
||||
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||
await expect.poll(() => page.url()).toBe(prePaneUrl);
|
||||
});
|
||||
|
||||
test('collection rename keeps the pane on the NEW slug and closes cleanly (ownership rebased, not 404)', async ({
|
||||
page,
|
||||
fixture,
|
||||
request,
|
||||
}) => {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await enableHook(page);
|
||||
await browserLogin(page);
|
||||
const coll = await seedCollection(fixture, request, 'Ctrl rename', 'CTRN');
|
||||
await seedItemIn(fixture, request, coll.slug, 'Rename target item');
|
||||
|
||||
await page.goto(`/${fixture.adminUsername}/${fixture.workspaceSlug}/${coll.slug}`);
|
||||
await page.locator('.item-card', { hasText: 'Rename target item' }).first().click();
|
||||
const pane = page.locator('.item-pane');
|
||||
await expect(pane).toBeVisible();
|
||||
// Owned first-open at depth 0.
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: true });
|
||||
// The `?item=` value (canonical ref or slug) that must survive the rename.
|
||||
const itemParam = openItemParam(page);
|
||||
expect(itemParam).not.toBeNull();
|
||||
|
||||
// Rename the collection via the pane's Quick Actions → Manage actions →
|
||||
// Edit Collection modal (General tab). The rename changes the pathname
|
||||
// (old→new slug) while preserving `?item=` — routed through
|
||||
// onNavigateAway (must replaceState + rebase ownership).
|
||||
await pane.locator('button.trigger-btn[title="Quick actions"]').click();
|
||||
await pane.locator('button.action-item.footer-row', { hasText: 'Manage actions' }).click();
|
||||
await expect(page.locator('#edit-collection-title')).toBeVisible();
|
||||
// "Manage actions" opens the modal on the Actions tab — switch to General
|
||||
// to reach the collection-name field.
|
||||
await page.locator('button.tab', { hasText: 'General' }).click();
|
||||
const newName = `Ctrl renamed ${Date.now()}`;
|
||||
await page.locator('input.name-input').fill(newName);
|
||||
await page.locator('button.btn-save', { hasText: 'Save Changes' }).click();
|
||||
|
||||
// The pane survives on the NEW slug with `?item=` intact and the pane not
|
||||
// remounted away.
|
||||
await expect.poll(() => new URL(page.url()).pathname).not.toContain(`/${coll.slug}`);
|
||||
const newPath = new URL(page.url()).pathname;
|
||||
expect(newPath).toMatch(new RegExp(`/${fixture.workspaceSlug}/[^/]+$`));
|
||||
expect(openItemParam(page)).toBe(itemParam);
|
||||
await expect(pane).toBeVisible();
|
||||
// Ownership REBASED to a fresh unowned depth-0 base on the new slug (the
|
||||
// pre-pane entry now points at the dead old slug).
|
||||
await expect.poll(() => paneState(page)).toEqual({ paneDepth: 0, paneOwned: false });
|
||||
|
||||
// Closing must drop `?item=` IN PLACE (staying on the valid new slug),
|
||||
// never `history.go` back onto the now-404 old slug.
|
||||
await pane.locator('button[aria-label="Close pane"]').click();
|
||||
await expect.poll(() => openItemParam(page)).toBeNull();
|
||||
await expect(pane).toBeHidden();
|
||||
expect(new URL(page.url()).pathname).toBe(newPath);
|
||||
// Still a LIVE collection page — the renamed collection renders its own
|
||||
// heading, not a "Collection not found" error. (The item list re-hydrates
|
||||
// the renamed collection asynchronously via the local index, so we assert
|
||||
// page liveness via the heading rather than a specific row.)
|
||||
await expect(page.getByRole('heading', { level: 1, name: new RegExp(newName) })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Vendored
+9
-1
@@ -5,7 +5,15 @@ declare global {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// Split-pane mini-browser depth/ownership stamp (PLAN-2154 /
|
||||
// TASK-2157). Carried in SvelteKit `page.state` (NOT raw
|
||||
// `history.state`) so it follows opaque Back/Forward, survives
|
||||
// `history.go`, and reconstructs on cold-load. See
|
||||
// `$lib/collections/paneController.ts`.
|
||||
interface PageState {
|
||||
paneDepth?: number;
|
||||
paneOwned?: boolean;
|
||||
}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
readPaneState,
|
||||
planPaneDrill,
|
||||
planLateralOpen,
|
||||
planPaneClose,
|
||||
PANE_DEPTH_SOFT_CAP,
|
||||
type ResolvedPaneState,
|
||||
} from './paneController';
|
||||
|
||||
const base: ResolvedPaneState = { paneDepth: 0, paneOwned: false };
|
||||
const ownedBase: ResolvedPaneState = { paneDepth: 0, paneOwned: true };
|
||||
|
||||
describe('readPaneState', () => {
|
||||
it('defaults an unstamped (cold-loaded) entry to depth 0, UNOWNED', () => {
|
||||
expect(readPaneState(undefined)).toEqual({ paneDepth: 0, paneOwned: false });
|
||||
expect(readPaneState(null)).toEqual({ paneDepth: 0, paneOwned: false });
|
||||
expect(readPaneState({})).toEqual({ paneDepth: 0, paneOwned: false });
|
||||
});
|
||||
|
||||
it('reads a full stamp verbatim', () => {
|
||||
expect(readPaneState({ paneDepth: 3, paneOwned: true })).toEqual({
|
||||
paneDepth: 3,
|
||||
paneOwned: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces owned to a strict boolean (only literal true owns)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(readPaneState({ paneOwned: 1 as any }).paneOwned).toBe(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(readPaneState({ paneOwned: 'yes' as any }).paneOwned).toBe(false);
|
||||
expect(readPaneState({ paneOwned: true }).paneOwned).toBe(true);
|
||||
});
|
||||
|
||||
it('floors negative / fractional / non-numeric depths to 0', () => {
|
||||
expect(readPaneState({ paneDepth: -4 }).paneDepth).toBe(0);
|
||||
expect(readPaneState({ paneDepth: 2.9 }).paneDepth).toBe(2);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect(readPaneState({ paneDepth: 'x' as any }).paneDepth).toBe(0);
|
||||
expect(readPaneState({ paneDepth: NaN }).paneDepth).toBe(0);
|
||||
expect(readPaneState({ paneDepth: Infinity }).paneDepth).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPaneDrill — same-ref guard (D4)', () => {
|
||||
it('is a no-op when the target equals the currently-shown item', () => {
|
||||
expect(planPaneDrill('TASK-5', 'TASK-5', ownedBase)).toEqual({ kind: 'noop' });
|
||||
expect(planPaneDrill('TASK-5', 'TASK-5', { paneDepth: 4, paneOwned: true })).toEqual({
|
||||
kind: 'noop',
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op for a falsy / empty target', () => {
|
||||
expect(planPaneDrill('TASK-5', null, ownedBase)).toEqual({ kind: 'noop' });
|
||||
expect(planPaneDrill('TASK-5', undefined, ownedBase)).toEqual({ kind: 'noop' });
|
||||
expect(planPaneDrill('TASK-5', '', ownedBase)).toEqual({ kind: 'noop' });
|
||||
});
|
||||
|
||||
it('drills when the target differs, even if currentRef is null (cold base)', () => {
|
||||
expect(planPaneDrill(null, 'TASK-9', base)).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: 1, paneOwned: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPaneDrill — ownership INHERITANCE', () => {
|
||||
it('a drill from an OWNED base inherits owned=true', () => {
|
||||
expect(planPaneDrill('TASK-1', 'TASK-2', ownedBase)).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: 1, paneOwned: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('a drill from a COLD (unowned) base inherits owned=false — keeps the cold-close branch reachable', () => {
|
||||
expect(planPaneDrill('TASK-1', 'TASK-2', base)).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: 1, paneOwned: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('inheritance carries through multiple hops', () => {
|
||||
expect(planPaneDrill('TASK-2', 'TASK-3', { paneDepth: 1, paneOwned: false })).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: 2, paneOwned: false },
|
||||
});
|
||||
expect(planPaneDrill('TASK-3', 'TASK-4', { paneDepth: 2, paneOwned: true })).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: 3, paneOwned: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPaneDrill — soft depth cap (D4)', () => {
|
||||
it('pushes below the cap', () => {
|
||||
const belowCap = { paneDepth: PANE_DEPTH_SOFT_CAP - 1, paneOwned: true };
|
||||
expect(planPaneDrill('A', 'B', belowCap)).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: PANE_DEPTH_SOFT_CAP, paneOwned: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('REPLACES at the cap — holds depth + ownership steady', () => {
|
||||
const atCap = { paneDepth: PANE_DEPTH_SOFT_CAP, paneOwned: true };
|
||||
expect(planPaneDrill('A', 'B', atCap)).toEqual({
|
||||
kind: 'replace',
|
||||
state: { paneDepth: PANE_DEPTH_SOFT_CAP, paneOwned: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('REPLACES past the cap too (no unbounded growth)', () => {
|
||||
const pastCap = { paneDepth: PANE_DEPTH_SOFT_CAP + 5, paneOwned: false };
|
||||
expect(planPaneDrill('A', 'B', pastCap)).toEqual({
|
||||
kind: 'replace',
|
||||
state: { paneDepth: PANE_DEPTH_SOFT_CAP + 5, paneOwned: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('honours a custom cap', () => {
|
||||
expect(planPaneDrill('A', 'B', { paneDepth: 2, paneOwned: true }, 2)).toEqual({
|
||||
kind: 'replace',
|
||||
state: { paneDepth: 2, paneOwned: true },
|
||||
});
|
||||
expect(planPaneDrill('A', 'B', { paneDepth: 1, paneOwned: true }, 2)).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: 2, paneOwned: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('planLateralOpen', () => {
|
||||
it('first-open (pane closed) PUSHES and MINTS ownership', () => {
|
||||
expect(planLateralOpen(false, base)).toEqual({
|
||||
kind: 'push',
|
||||
state: { paneDepth: 0, paneOwned: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('re-target at depth 0 from an OWNED base REPLACES, keeping owned=true', () => {
|
||||
expect(planLateralOpen(true, ownedBase)).toEqual({
|
||||
kind: 'replace',
|
||||
state: { paneDepth: 0, paneOwned: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('re-target at depth 0 from a COLD base REPLACES, keeping owned=false', () => {
|
||||
expect(planLateralOpen(true, base)).toEqual({
|
||||
kind: 'replace',
|
||||
state: { paneDepth: 0, paneOwned: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('a direct row click at depth>0 (detached) RESETS the stack, preserving base ownership', () => {
|
||||
expect(planLateralOpen(true, { paneDepth: 3, paneOwned: true })).toEqual({
|
||||
kind: 'reset',
|
||||
goDelta: -3,
|
||||
resetState: { paneDepth: 0, paneOwned: true },
|
||||
});
|
||||
expect(planLateralOpen(true, { paneDepth: 2, paneOwned: false })).toEqual({
|
||||
kind: 'reset',
|
||||
goDelta: -2,
|
||||
resetState: { paneDepth: 0, paneOwned: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('planPaneClose — three-way staged unwind (R8)', () => {
|
||||
it('OWNED depth 0 (click-opened, no drill) → go(-1) back to the pre-pane URL', () => {
|
||||
expect(planPaneClose(ownedBase)).toEqual({ kind: 'owned-go', goDelta: -1 });
|
||||
});
|
||||
|
||||
it('OWNED depth N → go(-(N+1)) unwinds base + every drill', () => {
|
||||
expect(planPaneClose({ paneDepth: 3, paneOwned: true })).toEqual({
|
||||
kind: 'owned-go',
|
||||
goDelta: -4,
|
||||
});
|
||||
});
|
||||
|
||||
it('UNOWNED depth 0 (cold load) → replaceState-delete in place (never go off the base)', () => {
|
||||
expect(planPaneClose(base)).toEqual({ kind: 'replace-delete' });
|
||||
});
|
||||
|
||||
it('UNOWNED depth>0 (cold base then drilled) → go(-depth) to the cold base, then latched delete', () => {
|
||||
expect(planPaneClose({ paneDepth: 2, paneOwned: false })).toEqual({
|
||||
kind: 'cold-base-go',
|
||||
goDelta: -2,
|
||||
});
|
||||
expect(planPaneClose({ paneDepth: 1, paneOwned: false })).toEqual({
|
||||
kind: 'cold-base-go',
|
||||
goDelta: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration — full open/drill/close round-trips', () => {
|
||||
it('click-open → close returns to the pre-pane URL (owned go(-1))', () => {
|
||||
const open = planLateralOpen(false, base); // push {0,true}
|
||||
expect(open).toMatchObject({ kind: 'push', state: { paneDepth: 0, paneOwned: true } });
|
||||
const close = planPaneClose({ paneDepth: 0, paneOwned: true });
|
||||
expect(close).toEqual({ kind: 'owned-go', goDelta: -1 });
|
||||
});
|
||||
|
||||
it('click-open → drill A→B→C → close unwinds all four entries', () => {
|
||||
// first-open A: {0,true}
|
||||
let s = readPaneState({ paneDepth: 0, paneOwned: true });
|
||||
// drill A→B
|
||||
const b = planPaneDrill('A', 'B', s);
|
||||
expect(b).toMatchObject({ kind: 'push', state: { paneDepth: 1, paneOwned: true } });
|
||||
s = readPaneState((b as { state: ResolvedPaneState }).state);
|
||||
// drill B→C
|
||||
const c = planPaneDrill('B', 'C', s);
|
||||
expect(c).toMatchObject({ kind: 'push', state: { paneDepth: 2, paneOwned: true } });
|
||||
s = readPaneState((c as { state: ResolvedPaneState }).state);
|
||||
// close from depth 2 owned → go(-3)
|
||||
expect(planPaneClose(s)).toEqual({ kind: 'owned-go', goDelta: -3 });
|
||||
});
|
||||
|
||||
it('cold-load A → drill A→B → close goes to the cold base then deletes (NOT go(-2) off the base)', () => {
|
||||
// cold load: unstamped → {0,false}
|
||||
let s = readPaneState(undefined);
|
||||
expect(s).toEqual({ paneDepth: 0, paneOwned: false });
|
||||
const b = planPaneDrill('A', 'B', s);
|
||||
expect(b).toMatchObject({ kind: 'push', state: { paneDepth: 1, paneOwned: false } });
|
||||
s = readPaneState((b as { state: ResolvedPaneState }).state);
|
||||
// close from depth 1 UNOWNED → go(-1) to cold base, then latched delete
|
||||
expect(planPaneClose(s)).toEqual({ kind: 'cold-base-go', goDelta: -1 });
|
||||
});
|
||||
|
||||
it('detached row click resets, and the reset base then closes correctly', () => {
|
||||
// owned, drilled to depth 2
|
||||
const reset = planLateralOpen(true, { paneDepth: 2, paneOwned: true });
|
||||
expect(reset).toEqual({
|
||||
kind: 'reset',
|
||||
goDelta: -2,
|
||||
resetState: { paneDepth: 0, paneOwned: true },
|
||||
});
|
||||
// after reset the base is {0,true}; closing it is a clean go(-1)
|
||||
expect(planPaneClose((reset as { resetState: ResolvedPaneState }).resetState)).toEqual({
|
||||
kind: 'owned-go',
|
||||
goDelta: -1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
// Pane-navigation controller — the depth/ownership state machine that turns
|
||||
// the collection page's split-pane detail view (PLAN-2105) into a navigable
|
||||
// mini-browser with a back stack (PLAN-2154 / IDEA-2153, Architecture A).
|
||||
//
|
||||
// This module holds the PURE decision logic, framework-agnostic and free of
|
||||
// `$state`/`page`/`goto` so it's exhaustively unit-testable without mounting
|
||||
// the 4200-line route. `+page.svelte` reads the current `{paneDepth,
|
||||
// paneOwned}` off SvelteKit's `page.state` (NEVER raw `history.state` — Kit
|
||||
// nests app state under `sveltekit:states`), passes it here, and EXECUTES the
|
||||
// returned plan via `goto(..., { state })` / `history.go`. Keeping the
|
||||
// arithmetic pure is what makes the three-way close and the ownership model
|
||||
// reviewable in isolation — the exact class of bug (opaque Back/Forward,
|
||||
// cold-load close off the base, late-async clobbers) this controller lives in.
|
||||
//
|
||||
// The vocabulary:
|
||||
// • depth — how many in-pane drill hops deep we are. 0 = first-open or a
|
||||
// cold-loaded shared `?item=` URL (the "base").
|
||||
// • ownership — whether THIS session pushed the pane's base entry, so there
|
||||
// is a pre-pane history entry to unwind to on close. Created ONLY by a
|
||||
// first-open (`openItemPane` on a closed pane); INHERITED by every drill
|
||||
// (`navigatePaneTo`). A cold-loaded `?item=` has no stamp → unowned, which
|
||||
// is what keeps the UNOWNED-close branches reachable (a cold A→B drill
|
||||
// must NOT `go(-2)` off the base).
|
||||
|
||||
/**
|
||||
* Depth + ownership stamp carried in SvelteKit `page.state` for the split-pane
|
||||
* mini-browser. Both fields are optional at the type level because a
|
||||
* cold-loaded / never-stamped history entry has no `page.state` of its own;
|
||||
* {@link readPaneState} normalizes those absences to the base defaults.
|
||||
*/
|
||||
export interface PaneHistoryState {
|
||||
/** In-pane drill depth. 0 = first-open / cold-load base. */
|
||||
paneDepth?: number;
|
||||
/**
|
||||
* True when this session minted the pane's base entry (first-open), so a
|
||||
* pre-pane history entry exists to unwind to. False for a cold-loaded
|
||||
* shared `?item=` URL. Drills copy (inherit) this from the current entry.
|
||||
*/
|
||||
paneOwned?: boolean;
|
||||
}
|
||||
|
||||
/** The normalized, always-present form after {@link readPaneState}. */
|
||||
export interface ResolvedPaneState {
|
||||
paneDepth: number;
|
||||
paneOwned: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft cap on drill depth (D4). Past it a drill REPLACES instead of pushing,
|
||||
* so a pathological very-deep distinct chain can't grow the browser history
|
||||
* unbounded. Chosen in the plan's ~15–20 band; the exact value isn't
|
||||
* load-bearing — it only bounds an extreme.
|
||||
*/
|
||||
export const PANE_DEPTH_SOFT_CAP = 20;
|
||||
|
||||
/**
|
||||
* Normalize an opaque `page.state`-shaped value into a definite
|
||||
* `{paneDepth, paneOwned}`. A cold-loaded or never-stamped entry (`undefined`,
|
||||
* `null`, `{}`) resolves to the base: depth 0, UNOWNED — the invariant that
|
||||
* keeps a cold-load A→B drill from wrongly unwinding off the base on close.
|
||||
* Negative / non-integer / non-number depths are floored to a safe 0.
|
||||
*/
|
||||
export function readPaneState(state: PaneHistoryState | null | undefined): ResolvedPaneState {
|
||||
const s = state ?? {};
|
||||
const rawDepth = s.paneDepth;
|
||||
const paneDepth =
|
||||
typeof rawDepth === 'number' && Number.isFinite(rawDepth) && rawDepth > 0
|
||||
? Math.floor(rawDepth)
|
||||
: 0;
|
||||
return { paneDepth, paneOwned: s.paneOwned === true };
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-entry history write: `push` mints a new entry, `replace` rewrites
|
||||
* the current one. Both carry the `page.state` stamp to emit.
|
||||
*/
|
||||
export type PaneWritePlan = { kind: 'push' | 'replace'; state: ResolvedPaneState };
|
||||
|
||||
/**
|
||||
* Detached lateral open (a direct list-row click at depth>0): a NEW top-level
|
||||
* open, not a replace of the drilled entry. Unwind the drill stack back to the
|
||||
* base with `history.go(goDelta)`, THEN (from a one-shot `afterNavigate` latch
|
||||
* — `history.go` has no completion promise) re-target the base to the new item,
|
||||
* PRESERVING the base's ownership (`resetState`).
|
||||
*/
|
||||
export type PaneResetPlan = { kind: 'reset'; goDelta: number; resetState: ResolvedPaneState };
|
||||
|
||||
/** What a DRILL (`navigatePaneTo`) decides: a same-ref/cycle no-op, or a write. */
|
||||
export type PaneDrillPlan = { kind: 'noop' } | PaneWritePlan;
|
||||
|
||||
/** What a LATERAL open (`openItemPane`) decides: a write, or a detached reset. */
|
||||
export type PaneLateralPlan = PaneWritePlan | PaneResetPlan;
|
||||
|
||||
/** The staged close plan (R8). Three ownership-aware branches. */
|
||||
export type PaneClosePlan =
|
||||
/** UNOWNED & depth 0 (cold-loaded base, no pre-pane entry): drop `?item=`
|
||||
* in place with `replaceState`. Never `go` off the base. */
|
||||
| { kind: 'replace-delete' }
|
||||
/** OWNED: unwind the pushed base entry + every drill back to the pre-pane
|
||||
* URL in one `history.go(goDelta)`. No follow-up write needed — the
|
||||
* destination has no `?item=`, so the pane closes naturally. */
|
||||
| { kind: 'owned-go'; goDelta: number }
|
||||
/** UNOWNED & depth>0 (cold base, then drilled): `history.go(goDelta)` back
|
||||
* to the cold base, THEN a latched `replaceState`-delete of `?item=` (the
|
||||
* base still carries it). Two-phase because `history.go` can't be awaited. */
|
||||
| { kind: 'cold-base-go'; goDelta: number };
|
||||
|
||||
/**
|
||||
* Plan a DRILL (`navigatePaneTo`) — re-target the pane in place, deeper into
|
||||
* the stack. Ownership is INHERITED from the current entry (drills copy it),
|
||||
* so a cold-loaded base stays unowned all the way down.
|
||||
*
|
||||
* - same-ref guard (D4): a drill to the item already shown is a no-op — kills
|
||||
* the common `A→B→A` oscillation with zero stack;
|
||||
* - soft depth cap (D4): at/above the cap, REPLACE (bound history growth);
|
||||
* - otherwise PUSH at depth+1, carrying the inherited ownership.
|
||||
*
|
||||
* `targetRef` is the already-resolved canonical `?item=` value; a falsy target
|
||||
* is a no-op (nothing to open).
|
||||
*/
|
||||
export function planPaneDrill(
|
||||
currentRef: string | null,
|
||||
targetRef: string | null | undefined,
|
||||
current: ResolvedPaneState,
|
||||
softCap: number = PANE_DEPTH_SOFT_CAP,
|
||||
): PaneDrillPlan {
|
||||
if (!targetRef) return { kind: 'noop' };
|
||||
// D4 same-ref guard — skip the push when re-targeting the current item.
|
||||
if (currentRef !== null && targetRef === currentRef) return { kind: 'noop' };
|
||||
if (current.paneDepth >= softCap) {
|
||||
// At the cap: replace, holding depth + inherited ownership steady.
|
||||
return { kind: 'replace', state: { paneDepth: current.paneDepth, paneOwned: current.paneOwned } };
|
||||
}
|
||||
// Drill: push one level deeper, INHERITING ownership from the current entry.
|
||||
return { kind: 'push', state: { paneDepth: current.paneDepth + 1, paneOwned: current.paneOwned } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan a LATERAL open (`openItemPane` — a list/board/table row click, Enter,
|
||||
* or a j/k pane-follow settle). Three cases:
|
||||
*
|
||||
* - pane CLOSED → first-open: PUSH `{depth:0, owned:true}`. Only a first-open
|
||||
* mints ownership (there's now a pre-pane entry to unwind to);
|
||||
* - pane open at depth 0 → re-target: REPLACE, preserving the current
|
||||
* ownership (an owned first-open stays owned; a cold-loaded base stays
|
||||
* unowned) — the PLAN-2105 "hold j/k settles with one history entry" fix;
|
||||
* - pane open at depth>0 (detached) → RESET: a direct row click is a NEW
|
||||
* top-level open, not a replace of the drilled entry — collapse the stack
|
||||
* (`go(-depth)`) then re-target the base, preserving the base's ownership.
|
||||
* (`j`/`k` never reaches here: it's gated inert at depth>0.)
|
||||
*/
|
||||
export function planLateralOpen(paneOpen: boolean, current: ResolvedPaneState): PaneLateralPlan {
|
||||
if (!paneOpen) {
|
||||
// First-open mints ownership.
|
||||
return { kind: 'push', state: { paneDepth: 0, paneOwned: true } };
|
||||
}
|
||||
if (current.paneDepth === 0) {
|
||||
// Re-target at the base: replace, preserving ownership.
|
||||
return { kind: 'replace', state: { paneDepth: 0, paneOwned: current.paneOwned } };
|
||||
}
|
||||
// Detached: collapse the stack, then re-open the base (ownership preserved —
|
||||
// drills inherited it from the base, so current.paneOwned === base ownership).
|
||||
return {
|
||||
kind: 'reset',
|
||||
goDelta: -current.paneDepth,
|
||||
resetState: { paneDepth: 0, paneOwned: current.paneOwned },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the three-way, ownership-aware, staged CLOSE (R8). See
|
||||
* {@link PaneClosePlan} for the branch semantics.
|
||||
*/
|
||||
export function planPaneClose(current: ResolvedPaneState): PaneClosePlan {
|
||||
if (current.paneOwned) {
|
||||
// Unwind the pushed base + every drill back to the pre-pane URL.
|
||||
return { kind: 'owned-go', goDelta: -(current.paneDepth + 1) };
|
||||
}
|
||||
if (current.paneDepth > 0) {
|
||||
// Cold base, then drilled: go back to the cold base, latch a delete.
|
||||
return { kind: 'cold-base-go', goDelta: -current.paneDepth };
|
||||
}
|
||||
// Cold-loaded base with no drills: drop `?item=` in place.
|
||||
return { kind: 'replace-delete' };
|
||||
}
|
||||
@@ -343,7 +343,12 @@
|
||||
const url = new URL(page.url);
|
||||
if (open) url.searchParams.set('graph', '1');
|
||||
else url.searchParams.delete('graph');
|
||||
goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
// This same-page toggle can PRESERVE an open pane's `?item=` (on the
|
||||
// full-page host, Q1/Phase 2), so re-emit the current `page.state` to
|
||||
// keep the pane depth+ownership stamp intact — a bare replaceState would
|
||||
// blank it and desync the close arithmetic (PLAN-2154 R13). A no-op when
|
||||
// there's no pane state to carry.
|
||||
goto(url, { replaceState: true, noScroll: true, keepFocus: true, state: page.state });
|
||||
}
|
||||
function openGraph() {
|
||||
setGraphParam(true);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, beforeNavigate } from '$app/navigation';
|
||||
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { api, PadApiError, isPlanLimitError, planLimitMessage } from '$lib/api/client';
|
||||
import type { BulkItemsRequest, Collection, Item, QuickAction, View, ViewConfig } from '$lib/types';
|
||||
import { parseSettings, parseFields, parseSchema, parseTags, getStatusOptions, itemUrlId, formatItemRef } from '$lib/types';
|
||||
@@ -44,6 +44,13 @@
|
||||
viewHasUnparentedFilter,
|
||||
} from '$lib/collections/unparentedFilter';
|
||||
import { KNOWN_COLLECTION_URL_PARAMS, buildCollectionUrlParams } from '$lib/collections/paneUrlParams';
|
||||
import {
|
||||
readPaneState,
|
||||
planPaneDrill,
|
||||
planLateralOpen,
|
||||
planPaneClose,
|
||||
type ResolvedPaneState,
|
||||
} from '$lib/collections/paneController';
|
||||
import { paneFocusables, nextTrapTarget, resolvePaneReturnTarget } from '$lib/collections/paneFocus';
|
||||
import { pushEscapeHandler, runTopEscape, topEscapePriority, ESCAPE_PRIORITY } from '$lib/stores/escapeStack';
|
||||
import { boardKeyNav, type BoardNavColumn, type BoardNavDirection } from '$lib/collections/boardNav';
|
||||
@@ -453,7 +460,17 @@
|
||||
);
|
||||
const qs = params.toString();
|
||||
const newUrl = `/${username}/${wsSlug}/${collSlug}${qs ? '?' + qs : ''}`;
|
||||
goto(newUrl, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
// This same-page `goto` PRESERVES `?item=` (via buildCollectionUrlParams),
|
||||
// so it MUST re-emit the pane depth+ownership stamp too — a bare
|
||||
// replaceState would blank `page.state` and desync the close arithmetic
|
||||
// (an owned pane would lose ownership and close via replaceState-delete
|
||||
// instead of go(-1)). PLAN-2154 R13. Harmless no-op when no pane is open.
|
||||
goto(newUrl, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
state: currentPaneState(),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Split-pane open/close (PLAN-2105 Phase 2) ──────────────────────
|
||||
@@ -478,11 +495,107 @@
|
||||
// a deep-linked item that isn't in the current filtered list.
|
||||
let paneReturnFocusEl: HTMLElement | null = null;
|
||||
|
||||
// ── Pane-navigation controller: depth/ownership state machine ──────────
|
||||
// (PLAN-2154 Architecture A / TASK-2157). The pane is a navigable
|
||||
// mini-browser: `openItemPane` handles lateral/list opens (first-open + at-
|
||||
// depth-0 re-target + at-depth>0 stack RESET) and `navigatePaneTo` handles
|
||||
// in-pane DRILLS. Depth + ownership live in SvelteKit `page.state` (NOT raw
|
||||
// `history.state` — Kit nests app state under `sveltekit:states`), so they
|
||||
// follow opaque Back/Forward, survive `history.go`, and reconstruct on
|
||||
// cold-load. The pure decision logic is in `$lib/collections/paneController`
|
||||
// (unit-tested); this wiring EXECUTES the returned plans.
|
||||
//
|
||||
// NOTE (TASK-2157 scope): `navigatePaneTo` has no in-pane content-link
|
||||
// caller yet — those land in TASK-2158/2159/2160, which resolve a
|
||||
// `PaneTarget` (ref/slug/href/collection) to a canonical `?item=` value and
|
||||
// then call `navigatePaneTo(resolvedRef)`. It's exported onto the pane's
|
||||
// ItemDetail seam below and reachable now only via the depth>0 test hook
|
||||
// (`__padPaneController`) so the controller's drill/reset/close arithmetic
|
||||
// is exercisable end-to-end before the UI callers exist.
|
||||
|
||||
/** Current pane depth+ownership, read from SvelteKit `page.state`. */
|
||||
function currentPaneState(): ResolvedPaneState {
|
||||
return readPaneState(page.state);
|
||||
}
|
||||
|
||||
// R14 fence: a monotonically-increasing sequence bumped at the START of every
|
||||
// controller action (open/drill/close). A two-phase `history.go` continuation
|
||||
// captures it and BAILS if a newer action superseded it (belt-and-suspenders
|
||||
// now that `paneNavInFlight()` blocks a fresh gesture mid-traversal).
|
||||
let controllerActionSeq = 0;
|
||||
// EVERY controller `history.go` (owned close, cold-base close, detached-open
|
||||
// reset) marks navigation in-flight from the moment it's issued until its
|
||||
// traversal settles (its own popstate) or a bounded fallback. `history.go`
|
||||
// has no completion promise, so without this a duplicate gesture — a
|
||||
// double-click ✕, an ESC racing a click-away — could stack a SECOND traversal
|
||||
// before the first's popstate lands and OVERSHOOT the intended entry. Blocks
|
||||
// re-entrancy; bounded so it can never stick (a stuck flag would freeze every
|
||||
// pane gesture). Steady-state depth-0 opens/re-targets issue no `history.go`,
|
||||
// so this only arms on a close/reset (R14; Codex review).
|
||||
const PANE_GO_SETTLE_MS = 500;
|
||||
let paneGoInFlight = false;
|
||||
let paneGoTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// The "then write" continuation of a STAGED go (cold-base close / reset).
|
||||
// `run()` RETURNS whether it fired: false means "not at the destination yet"
|
||||
// (a competing traversal landed us elsewhere), so it stays armed for this
|
||||
// go's own popstate rather than firing against the wrong entry. Null for a
|
||||
// one-phase owned close (nothing to write after the traversal).
|
||||
let pendingPaneLatch: { seq: number; run: () => boolean } | null = null;
|
||||
|
||||
function paneNavInFlight(): boolean {
|
||||
return paneGoInFlight;
|
||||
}
|
||||
|
||||
function clearPaneGo() {
|
||||
paneGoInFlight = false;
|
||||
pendingPaneLatch = null;
|
||||
if (paneGoTimer) {
|
||||
clearTimeout(paneGoTimer);
|
||||
paneGoTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Issue a controller traversal, fencing against a duplicate gesture stacking
|
||||
// a second one. `latch` is the optional post-traversal write (cold-base /
|
||||
// reset), fired from the settling popstate.
|
||||
function paneHistoryGo(delta: number, latch?: () => boolean) {
|
||||
paneGoInFlight = true;
|
||||
pendingPaneLatch = latch ? { seq: controllerActionSeq, run: latch } : null;
|
||||
if (paneGoTimer) clearTimeout(paneGoTimer);
|
||||
paneGoTimer = setTimeout(() => {
|
||||
paneGoTimer = null;
|
||||
// Gave up waiting for the settling popstate (the traversal was
|
||||
// superseded): best-effort fire the latch (its own preconditions no-op
|
||||
// it off-target), then release the guard UNCONDITIONALLY so gestures
|
||||
// can't stay blocked.
|
||||
const l = pendingPaneLatch;
|
||||
paneGoInFlight = false;
|
||||
pendingPaneLatch = null;
|
||||
l?.run();
|
||||
}, PANE_GO_SETTLE_MS);
|
||||
history.go(delta);
|
||||
}
|
||||
|
||||
// Settle the in-flight traversal on its own POPSTATE. `popstate` also covers
|
||||
// an unrelated browser Back/Forward, so a STAGED latch is not consumed
|
||||
// eagerly: `run()` verifies it reached its destination (the pane base — depth
|
||||
// 0 with `?item=` present) and reports whether it fired; if not, we stay
|
||||
// in-flight for this go's own popstate (bounded by the fallback timer). A
|
||||
// one-phase owned close has no latch and simply releases the guard.
|
||||
afterNavigate((nav) => {
|
||||
if (!paneGoInFlight) return;
|
||||
if (nav.type !== 'popstate') return; // wait for the traversal's own popstate
|
||||
const latch = pendingPaneLatch;
|
||||
// Staged latch not yet at its destination → stay in-flight.
|
||||
if (latch && latch.seq === controllerActionSeq && !latch.run()) return;
|
||||
clearPaneGo();
|
||||
});
|
||||
|
||||
function openItemPane(item: Item) {
|
||||
if (paneNavInFlight()) return;
|
||||
controllerActionSeq++;
|
||||
const targetRef = itemUrlId(item);
|
||||
const url = new URL(page.url);
|
||||
// Whether a pane is ALREADY open decides push (first open) vs replace
|
||||
// (re-target). Read off the live URL — the same source openItemRef
|
||||
// derives from.
|
||||
const alreadyOpen = url.searchParams.has('item');
|
||||
// Capture the trigger on the FIRST open only — re-targeting (j/k follow /
|
||||
// row re-click on an open pane) keeps the ORIGINAL trigger as the
|
||||
@@ -491,27 +604,172 @@
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
paneReturnFocusEl = active && active !== document.body ? active : null;
|
||||
}
|
||||
url.searchParams.set('item', itemUrlId(item));
|
||||
const plan = planLateralOpen(alreadyOpen, currentPaneState());
|
||||
if (plan.kind === 'reset') {
|
||||
// Detached (depth>0) direct row click → NEW top-level open: collapse
|
||||
// the drill stack back to the base, then re-target the base to this
|
||||
// item from a one-shot latch (`history.go` can't be awaited). Base
|
||||
// ownership is preserved so the subsequent close still unwinds
|
||||
// correctly. A pending j/k follow must not fire mid-reset.
|
||||
cancelPaneFollow();
|
||||
const resetState = plan.resetState;
|
||||
paneHistoryGo(plan.goDelta, () => {
|
||||
if (!browser) return false;
|
||||
// Only re-target once the stack has actually collapsed to the pane
|
||||
// BASE — a depth-0 entry that still carries `?item=`. Requiring
|
||||
// `?item=` (not just depth 0) rejects a competing browser Back that
|
||||
// landed on the pre-pane entry (which has no `?item=`), so the reset
|
||||
// can't be written onto the wrong entry; it stays armed for this go's
|
||||
// own popstate (Codex review).
|
||||
if (!page.url.searchParams.has('item')) return false;
|
||||
if (currentPaneState().paneDepth !== 0) return false;
|
||||
const u = new URL(page.url);
|
||||
u.searchParams.set('item', targetRef);
|
||||
goto(`${u.pathname}${u.search}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
state: resetState,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
url.searchParams.set('item', targetRef);
|
||||
goto(`${url.pathname}${url.search}`, {
|
||||
replaceState: alreadyOpen,
|
||||
replaceState: plan.kind === 'replace',
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
state: plan.state,
|
||||
});
|
||||
}
|
||||
|
||||
// In-pane DRILL (Architecture A). `target` is an already-resolved canonical
|
||||
// `?item=` ref (TASK-2158 supplies the PaneTarget→ref resolution in front of
|
||||
// this). Same-ref guard + soft depth cap + ownership INHERITED from the
|
||||
// current entry, all decided by `planPaneDrill`.
|
||||
function navigatePaneTo(target: string) {
|
||||
if (paneNavInFlight()) return;
|
||||
controllerActionSeq++;
|
||||
// A pending j/k follow scheduled at a shallower depth must not fire after
|
||||
// this drill and clobber it (R3 / R14).
|
||||
cancelPaneFollow();
|
||||
const plan = planPaneDrill(openItemRef, target, currentPaneState());
|
||||
if (plan.kind === 'noop') return;
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.set('item', target);
|
||||
goto(`${url.pathname}${url.search}`, {
|
||||
replaceState: plan.kind === 'replace',
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
state: plan.state,
|
||||
});
|
||||
}
|
||||
|
||||
// The pane's ItemDetail fires `onNavigateAway` for TWO distinct cases, which
|
||||
// we tell apart by whether the target URL still carries `?item=` (i.e. keeps
|
||||
// the pane open):
|
||||
//
|
||||
// • COLLECTION RENAME (`/user/ws/NEWSLUG?item=X`) — the pathname changed
|
||||
// (old→new slug) but the pane stays open. This needs the rename-specific
|
||||
// handling: replaceState (not push) so it adds no uncounted history entry
|
||||
// that would desync the close-depth arithmetic (R8); a FRESH UNOWNED
|
||||
// depth-0 stamp (every predecessor entry — pre-pane base + any drills —
|
||||
// still points at the now-dead OLD slug, so carrying ownership forward
|
||||
// would make an owned close `history.go` onto a 404; an unowned base
|
||||
// closes by dropping `?item=` in place on the valid new route); and a
|
||||
// bypass of the unsaved-draft guard (the rename already committed
|
||||
// server-side and this route component is REUSED across the same-route
|
||||
// pathname change, so its quick-create drafts survive — a "Stay" prompt
|
||||
// would only strand the user on the dead old slug). This fixes the
|
||||
// IMPERATIVE close; browser Back still traverses to the old-slug
|
||||
// predecessor (an inherent rename-in-history limitation — past entries
|
||||
// can't be rewritten — outside the controller's reach).
|
||||
//
|
||||
// • ITEM MOVE to a different collection (`/user/ws/COLL/SLUG`, a full-page
|
||||
// item route with NO `?item=`) — this leaves the collection page entirely
|
||||
// (the pane closes, the component unmounts, its drafts WOULD be lost), so
|
||||
// it must retain the ORIGINAL guarded push: the unsaved-draft prompt has
|
||||
// to fire, and there is no pane to stamp (Codex review).
|
||||
function handlePaneNavigateAway(url: string) {
|
||||
let keepsPane = false;
|
||||
try {
|
||||
keepsPane = new URL(url, 'http://pad.invalid').searchParams.has('item');
|
||||
} catch {
|
||||
keepsPane = false;
|
||||
}
|
||||
if (!keepsPane) {
|
||||
// Item move (or any away-nav that drops the pane) — original behavior:
|
||||
// a guarded navigation so the unsaved-draft prompt still fires.
|
||||
void goto(url);
|
||||
return;
|
||||
}
|
||||
// Collection rename — rebase ownership + bypass the (now-spurious) draft
|
||||
// guard for the committed, component-reusing pathname change.
|
||||
bypassNavGuard = true;
|
||||
void goto(url, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
state: { paneDepth: 0, paneOwned: false },
|
||||
}).finally(() => {
|
||||
bypassNavGuard = false;
|
||||
});
|
||||
}
|
||||
|
||||
function closeItemPane() {
|
||||
if (paneNavInFlight()) return;
|
||||
controllerActionSeq++;
|
||||
// A pending j/k pane-follow must not re-open the pane after an explicit
|
||||
// close (e.g. ESC while a follow debounce is in flight).
|
||||
cancelPaneFollow();
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('item');
|
||||
// Focus return to the originating row is handled by the close-detection
|
||||
// effect below (keyed off `openItemRef` going truthy→null), so it covers
|
||||
// browser Back / delete alike — not just this imperative close path.
|
||||
goto(`${url.pathname}${url.search}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
const plan = planPaneClose(currentPaneState());
|
||||
if (plan.kind === 'replace-delete') {
|
||||
// Cold-loaded base with no drills: drop `?item=` in place. No pre-pane
|
||||
// history entry to unwind to.
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('item');
|
||||
goto(`${url.pathname}${url.search}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (plan.kind === 'owned-go') {
|
||||
// Owned: unwind the pushed base + every drill back to the pre-pane URL
|
||||
// (which carries no `?item=`, so the pane closes on arrival). This is
|
||||
// PLAN-2154 R8's mandated close — and it makes an explicit ✕/ESC close
|
||||
// IDENTICAL to the browser Back that already closed the pane in
|
||||
// PLAN-2105 (a single Back pops the first-open push). Consequence: a
|
||||
// list filter/view/search change made WHILE the pane was open — which
|
||||
// `updateUrlFilters` replaceState'd onto the pane entry — is not
|
||||
// carried to the pre-pane URL, exactly as browser Back already
|
||||
// behaves. A one-phase traversal (no latch); the in-flight fence stops
|
||||
// a duplicate ✕/ESC from stacking a second go(-1) that would overshoot.
|
||||
paneHistoryGo(plan.goDelta);
|
||||
return;
|
||||
}
|
||||
// cold-base-go: go back to the cold base (still carries `?item=`), then
|
||||
// delete it from a latch fired on the settling popstate.
|
||||
paneHistoryGo(plan.goDelta, () => {
|
||||
if (!browser) return false;
|
||||
// Only delete once we've reached the cold base that still shows the
|
||||
// pane (depth 0, `?item=` present); a competing traversal landing
|
||||
// elsewhere leaves it armed (R14 fence-on-continuation).
|
||||
if (!page.url.searchParams.has('item')) return false;
|
||||
if (currentPaneState().paneDepth !== 0) return false;
|
||||
const u = new URL(page.url);
|
||||
u.searchParams.delete('item');
|
||||
goto(`${u.pathname}${u.search}`, {
|
||||
replaceState: true,
|
||||
noScroll: true,
|
||||
keepFocus: true,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1089,7 +1347,36 @@
|
||||
// Sync coordinator — handle tab-resume data refresh efficiently
|
||||
let unsubscribeSync: (() => void) | null = null;
|
||||
|
||||
// Test-only hook (PLAN-2154 / TASK-2157): exposes the pane controller so e2e
|
||||
// can drive `navigatePaneTo` — which has NO in-pane UI caller until
|
||||
// TASK-2158/2159/2160 wire content links — and read back the depth/ownership
|
||||
// stamp. Gated on an opt-in localStorage flag so it adds ZERO surface to
|
||||
// production; the e2e harness sets `pad:pane-test-hook=1` before navigating.
|
||||
interface PaneTestHook {
|
||||
navigatePaneTo: (ref: string) => void;
|
||||
closeItemPane: () => void;
|
||||
getPaneState: () => ResolvedPaneState;
|
||||
}
|
||||
function installPaneTestHook() {
|
||||
if (!browser) return;
|
||||
try {
|
||||
if (localStorage.getItem('pad:pane-test-hook') !== '1') return;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
(window as unknown as { __padPaneController?: PaneTestHook }).__padPaneController = {
|
||||
navigatePaneTo: (ref: string) => navigatePaneTo(ref),
|
||||
closeItemPane: () => closeItemPane(),
|
||||
getPaneState: () => currentPaneState(),
|
||||
};
|
||||
}
|
||||
function removePaneTestHook() {
|
||||
if (!browser) return;
|
||||
delete (window as unknown as { __padPaneController?: PaneTestHook }).__padPaneController;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
installPaneTestHook();
|
||||
unsubscribeSync = syncService.onSync(async (result) => {
|
||||
if (!wsSlug || !collSlug) return;
|
||||
|
||||
@@ -1113,9 +1400,13 @@
|
||||
onDestroy(() => {
|
||||
unsubscribeSSE?.();
|
||||
unsubscribeSync?.();
|
||||
removePaneTestHook();
|
||||
// Drop any pending j/k pane-follow so a late timer can't call goto on
|
||||
// the unmounted page (PLAN-2105 / TASK-2119).
|
||||
cancelPaneFollow();
|
||||
// Drop any in-flight `history.go` continuation (and its fallback timer) so
|
||||
// a late afterNavigate latch can't write to the unmounted page.
|
||||
clearPaneGo();
|
||||
// Scroll save/restore cleanup is owned by createScrollRestoration
|
||||
// (snapshot.capture fires on navigate-away; the helper's $effect
|
||||
// teardown cancels any in-flight RAF).
|
||||
@@ -2127,11 +2418,20 @@
|
||||
function schedulePaneFollow() {
|
||||
if (!browser) return;
|
||||
if (!openItemRef) return; // pane closed → keyboard nav moves focus only
|
||||
// DETACH (PLAN-2154 D-detach / R3): once the pane has drilled past the
|
||||
// base (depth>0) it's an independent viewer — j/k must go INERT so a
|
||||
// list follow can't laterally re-target (and RESET) the drilled stack.
|
||||
// Guard at SCHEDULE time here AND re-check inside the fired callback
|
||||
// below, since a timer armed at depth 0 can otherwise fire AFTER a drill
|
||||
// (the R14 late-async-continuation clobber).
|
||||
if (currentPaneState().paneDepth > 0) return;
|
||||
cancelPaneFollow();
|
||||
paneFollowTimer = setTimeout(() => {
|
||||
paneFollowTimer = null;
|
||||
// Re-check: the pane may have closed during the debounce window.
|
||||
// Re-check: the pane may have closed OR drilled during the debounce
|
||||
// window (R14 fence-on-continuation).
|
||||
if (!openItemRef) return;
|
||||
if (currentPaneState().paneDepth > 0) return;
|
||||
if (focusedIndex < 0 || focusedIndex >= filteredItems.length) return;
|
||||
const item = filteredItems[focusedIndex];
|
||||
// Skip if the focused row is already the paned item — avoids a
|
||||
@@ -3348,7 +3648,7 @@
|
||||
{collSlug}
|
||||
onClose={closeItemPane}
|
||||
onGone={closeItemPane}
|
||||
onNavigateAway={(url) => goto(url)}
|
||||
onNavigateAway={handlePaneNavigateAway}
|
||||
/>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user