diff --git a/.wiki/Architecture.md b/.wiki/Architecture.md index 8e4e3c72..daa35f76 100644 --- a/.wiki/Architecture.md +++ b/.wiki/Architecture.md @@ -15,9 +15,19 @@ main.js (bootstrap + concrete adapters) ui/ → net/state/core net/ → core core/ → nothing ``` -`src/main.js` is the composition root. `createApp(env)` receives browser and -service dependencies. Render modules receive the returned `app` controller and -must not import `app.js`, which prevents cycles. +`src/main.js` bootstraps the app; `createApp(env)` in `src/ui/app.js` is the +composition root, receiving browser and service dependencies and returning the +`app` controller every render module addresses. Render modules must not import +`app.js`, which prevents cycles. `createApp` builds `app` via one typed object +literal with no `as App` cast — a member missing from construction is a `tsc` +error, not a runtime hole (#588). Four responsibilities that used to live +entirely inside `createApp` are now their own modules the composition root +wires up: workspace persistence/cross-tab sync +(`src/application/workspace-session.js`), `/sql` routing and main-surface +navigation (`src/application/surface-navigation.js`), the Workbench variable +strip (`src/ui/workbench/variable-strip.js`), and the save/conflict cluster +(`src/ui/workbench/save-controller.js`) — `src/application/*` may never import +`src/ui/`, mechanically enforced by `build/check-boundaries.mjs`. ## Side-effect seams diff --git a/.wiki/Source-Map.md b/.wiki/Source-Map.md index c515510c..3f2f7ad1 100644 --- a/.wiki/Source-Map.md +++ b/.wiki/Source-Map.md @@ -7,7 +7,12 @@ Back to [[Home]]. Related: [[Architecture]], [[Product-and-Features]]. | Path | Role | |---|---| | `src/main.js` | browser bootstrap and concrete adapter injection | -| `src/ui/app.js` | controller, actions, orchestration, render entry | +| `src/ui/app.js` | controller, actions, orchestration, render entry (composition root; shrunk by #588 — see below) | +| `src/application/workspace-session.js` | workspace write queue, cross-tab BroadcastChannel sync, refresh scheduling, `beforeunload` guard (#588) | +| `src/application/surface-navigation.js` | `/sql` routing, main-surface (Query↔Dashboard) navigation (#588) | +| `src/ui/workbench/variable-strip.js` | Workbench variable strip render + run-button sync (#588) | +| `src/ui/workbench/save-controller.js` | saved-query save/conflict/reload cluster (#588) | +| `src/ui/keyboard-owner.js` | shared keyboard-owner acquire/release channel (#588) | | `src/state.js` | signals-backed state model and persistence operations | | `src/net/ch-client.js` | ClickHouse HTTP execution and schema calls | | `src/net/oauth.js` | OAuth flow/token exchange | diff --git a/CHANGELOG.md b/CHANGELOG.md index 667858b9..f4d22972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,44 @@ auto-generated per-PR notes; this file is the curated, human-readable history. of a wrong number. No behavior change for well-formed persisted data; no change to `editorPct`/`sideSplitPct`/`cellDrawerPx`/`docPanePx` numeric clamping, `clamp` itself, or `decodeStoredSavedQueries`. +- **Decomposed the `createApp` composition root along four extraction seams, + plus typed staged construction** (#588, phase 4 of the #593 refactor + umbrella). `src/ui/app.ts` (`createApp`) shrank from ~3,246 to ~2,226 lines + and the flat `App` interface from 112 to 105 required members, via five + sequential, gate-green extractions: (1) `renderVarStrip`/`setRunBtn` → + `src/ui/workbench/variable-strip.ts` (a new sibling controller, not + `variable-bar.ts` — that module's `VariableBarApp` port is deliberately + adapter-facing with neutral names, and the Workbench's own state doesn't + fit it); (2) `anchoredPopover` promoted into `src/ui/popover.ts` beside the + existing modal `openAnchoredDialog`, the save cluster + (`updateSaveBtn`/`saveActiveQuery`/`openConflictChooser`/…) into + `src/ui/workbench/save-controller.ts`, and the three copy-pasted + `keyboardOwnerChannel` implementations (`file-menu.ts`/ + `library-assign-menu.ts`/`dashboard.ts`) hoisted into one + `src/ui/keyboard-owner.ts`; (3) workspace persistence, cross-tab + BroadcastChannel sync, refresh scheduling, and the `beforeunload` guard + into `src/application/workspace-session.ts` (queueing/tokens/broadcast/ + listeners only — `applyCommittedWorkspace` stays in `app.ts` since it does + real UI orchestration, not "zero DOM" as originally scoped); (4) routing + and main-surface navigation into `src/application/surface-navigation.ts`, + with `SurfaceCommandPort`/`DashboardFocusOutcome`/`WorkspaceRouteStatus` + relocated to `src/application/main-surface.ts` so the new + `src/application/*` modules never import `src/ui/` (mechanically checked + by `check:arch`, including type-only imports); (5) the `appBase: + Partial` + `as App` cast replaced by one late-bound object literal — + a forgotten member assignment is now a `tsc` compile error instead of a + runtime hole (caught one for real: `App.editingLibrary` had never been + initialized and was silently reading `undefined`). All four extractions + are pure refactors — one pre-existing defect is deliberately **not** + fixed: `anchoredPopover`'s stale `close()` can clobber a newer popover + sharing the same `dom` ref slot (documented in `popover.ts` and pinned by + a characterization test; tracked separately, not part of this phase). + `openSavePopover`, `handleSqlPopState`, `focusDashboardMember`, + `syncSqlRoute`, `rewriteWorkspaceRoute`, `sourceTabId`, `documentVisible`, + `getLastCommittedToken`, `serializeWrite`, `flushWorkspaceWrites`, and + `refreshWorkspaceFromStore` are gone from the flat `App` bag (repointed to + `app.nav.*`/`app.workspaceSession.*` at every production consumer); `App` + gains `nav`/`workspaceSession`. ### Changed - **The project wiki moved in-repo, as tracked `.wiki/`.** The maintainer/agent diff --git a/src/application/main-surface.ts b/src/application/main-surface.ts index 8bdc5ba5..5d08a260 100644 --- a/src/application/main-surface.ts +++ b/src/application/main-surface.ts @@ -31,6 +31,52 @@ export type DashboardFocusTarget = * authorization boundary (ADR-0003). */ export type DashboardSurfaceMode = 'view' | 'edit'; +/** `App['workspaceRouteStatus']`'s canonical declaration (#588 phase 4 §3-T #3 + * — moved here from `src/ui/shortcuts.ts`'s inline union so wave 3's + * `workspace-session.ts` and wave 4's `surface-navigation.ts` both have one + * real import instead of independently-copied inline unions). `app.types.ts` + * and `shortcuts.ts` both import this now; `workspace-session.ts` (wave 3) + * rewires its local placeholder copy onto this import in the same change. */ +export type WorkspaceRouteStatus = 'loading' | 'ready' | 'not-found' | 'error'; + +/** Local copy (#588 phase 4 §3-T #2 — moved here from `src/ui/shortcuts.ts`), + * not an import of the canonical `dashboard-viewer-session.ts` one: this + * module may not import `src/dashboard/**` (the reverse would be the wrong + * dependency direction), and `shortcuts.ts`'s own pre-move copy made the same + * trade — filed as a later unification candidate (#588 phase 4 plan §9-5). */ +type DashboardStyle = 'grid' | 'full' | 'report' | 'columns-2' | 'columns-3'; + +/** + * What an IN-PLACE member navigation could do (#426). Three outcomes, because + * two of them are not failures: + * - `ok` — delivered against the live surface; no rebuild happened. + * - `pending` — not deliverable in place *right now* (the opening wave has not + * settled, so a curated filter's control is about to be replaced; + * or this port has been superseded). The caller falls back to the + * normal render transition, which delivers focus at the + * deterministic point the node exists. NOT a diagnostic. + * - `missing` — the member is genuinely not on this Dashboard any more. The + * caller reports it non-destructively and changes nothing. + * + * (#588 phase 4 §3-T #2 — moved here from `src/ui/shortcuts.ts`, which now + * re-exports it so existing importers keep compiling unchanged.) + */ +export type DashboardFocusOutcome = 'ok' | 'pending' | 'missing'; + +/** (#588 phase 4 §3-T #2 — moved here from `src/ui/shortcuts.ts`, alongside + * `DashboardFocusOutcome`; see that type's own doc comment.) */ +export interface SurfaceCommandPort { + surface: 'dashboard'; + generation: number; + refresh(): void; + setDashboardStyle(style: DashboardStyle): void; + /** #426 — scroll/focus/highlight one already-rendered tile or curated filter + * WITHOUT rebuilding or re-running the Dashboard. Repeated same-Dashboard + * member navigation is a normal tree operation, so it must not cost a render + * or a history entry. */ + focusMember(member: DashboardFocusTarget): DashboardFocusOutcome; +} + /** * #426 splits what #425 carried as one `focus` field into two independent facts, * because the Dashboard tree needs to distinguish them: diff --git a/src/application/surface-navigation.ts b/src/application/surface-navigation.ts new file mode 100644 index 00000000..b19a7549 --- /dev/null +++ b/src/application/surface-navigation.ts @@ -0,0 +1,645 @@ +// The main-surface / `/sql` route navigation session (#588 phase 4 wave 4). +// Owns: the surface-generation guard cluster, `/sql` route writes, boot/ +// popstate/programmatic-navigation loading, and every main-surface transition +// (open a Dashboard, return to Query, open a saved query/panel/variable tab). +// +// Deliberately NOT here — all DOM-owning, and reached only through +// `deps.hooks`, an injected callback bag this module calls but never +// implements (same layering discipline as `workspace-session.ts`, #588 phase 4 +// wave 3 — this module imports `../workspace/*`, `../state.ts` (types only), +// `../core/*`, and this directory's own siblings, never `../ui/*` or +// `../editor/*`; build/check-boundaries.mjs enforces the direction, type-only +// imports included): +// - `ensureShell`/`disposeShell` and `dashboardRenderTarget` (persistent-shell +// mount/dispose and the Dashboard render-target projection); +// - `beginSurfaceTransition`/`disposeCurrentSurface` (what a transition tears +// down before this module's own route/surface write lands); +// - `renderWorkspaceNotFound`/`renderWorkspaceLoading` (the two placeholder +// DOM states — reached through `hooks.renderWorkspaceNotFound`/ +// `hooks.renderWorkspaceLoading`); +// - `app.renderDashboard`/`app.renderApp` (reached through `hooks.renderDashboard`/ +// `hooks.renderApp`); +// - `resetCorruptWorkspace` (drives `app.workspace.delete` alongside +// `session.resolveImplicitOrProvision` — reached through +// `hooks.onCorruptWorkspace`, and itself calls back into this module's own +// `rewriteWorkspaceRoute`/`loadGeneration`). +// +// `app.sqlRoute`/`app.mainSurface`/`app.currentWorkspace`/ +// `app.workspaceRouteStatus`/`app.surfaceCommands` remain App DATA PROPERTIES +// (read by dashboard.ts, dashboard-tree.ts, file-menu.ts, app-shell.ts, +// shortcuts.ts, saved-history.ts) — this module never owns them directly. It +// receives the live `app` object, narrowed structurally to `SurfaceStatePort`, +// through a `surface: () => SurfaceStatePort` thunk (per the plan's exact +// wording: "Decision: `surface: () => app`") and mutates through that SAME +// object identity, so none of those consumers needs to change. +// +// Two escape hatches beyond the plan's "Nav exposes" list, both needed by +// `applyCommittedWorkspace` (app.ts, stays there — real UI orchestration, not +// "zero DOM"), which the plan's frozen interface did not anticipate because +// its line-number survey predates waves 1-3's repeated shifts (see the wave 4 +// worker prompt's own warning to re-verify, not trust, cited line numbers): +// - `writeRoute` — `applyCommittedWorkspace`'s lostSelection fallback forces +// the URL to the QUERY surface's route (`mainSurfaceRoute(QUERY_SURFACE, +// key)`) with 'replace', REGARDLESS of the route's current surface. Neither +// `rewriteWorkspaceRoute` (preserves the CURRENT surface — wrong when the +// current surface is 'dashboard', which is exactly when this fallback +// fires) nor `showQuerySurface`/`applyMainSurface` (stamp Dashboard +// history, invalidate the tree again, and — worse — use 'push' when +// leaving a Dashboard route, adding a history entry this projection-time +// fallback must not create) is behavior-identical to the pre-extraction +// inline `writeRoute(...)` call this branch made directly. Exposing the +// primitive itself was the only way to keep behavior byte-identical. +// - `currentRouteSearch` — `app.consumeLegacyShared` (app.ts, stays) rebuilds +// the URL after stripping a one-shot share/OAuth payload using the LIVE +// cached search string (it used to read the module-local `routeSearch` +// directly, which reflects `loadWorkspaceOnBoot`'s own canonicalization by +// the time `consumeLegacyShared` runs). + +import type { StoredWorkspaceV5, SavedQueryV2 } from '../generated/json-schema.types.js'; +import { activeTab } from '../state.js'; +import type { AppState } from '../state.js'; +import type { WorkspaceRepository, WorkspaceLoadResult } from '../workspace/workspace-repository.js'; +import type { WorkspaceSession } from './workspace-session.js'; +import { + replaceDashboard, resolveCompatibilityDashboard, withCompatibilityDashboard, +} from '../workspace/workspace-dashboards.js'; +import { + QUERY_SURFACE, isSameDashboardSelection, mainSurfaceRoute, reconcileMainSurface, + carryCurrentMember, resolveOpenDashboard, selectedDashboardId, withCurrentMember, + dashboardHistorySnapshot, readDashboardHistorySnapshot, restoreDashboardSurface, +} from './main-surface.js'; +import type { + DashboardFocusOutcome, DashboardFocusTarget, DashboardSurfaceMode, MainSurfaceState, + OpenDashboardRequest, SurfaceCommandPort, WorkspaceRouteStatus, +} from './main-surface.js'; +import { dashboardVariables } from './dashboard-tree-model.js'; +import { queryView } from '../core/saved-query.js'; +import { + buildSqlRouteSearch, normalizeSqlRouteSearch, parseSqlRoute, routeForWorkspace, +} from '../core/sql-route.js'; +import type { SqlRoute } from '../core/sql-route.js'; + +/** The live `app` slice this module reads/mutates through the `surface` thunk + * — structurally `App`'s own data properties (`src/ui/app.types.ts`), never + * imported from there (this module names no UI type). */ +export interface SurfaceStatePort { + sqlRoute: SqlRoute; + mainSurface: MainSurfaceState; + currentWorkspace: StoredWorkspaceV5 | null; + workspaceRouteStatus: WorkspaceRouteStatus; + surfaceCommands: SurfaceCommandPort | null; +} + +export interface SurfaceNavigationDeps { + state: AppState; + /** Returns the live `app` object, narrowed structurally. Mutations through + * this thunk's return value land on the real `app` — there is no copy. */ + surface: () => SurfaceStatePort; + repository: Pick; + session: Pick; + history: Pick & { state?: unknown }; + basePath(): string; + locationHash(): string; + locationSearch(): string; + hooks: { + applyCommittedWorkspace(ws: StoredWorkspaceV5): void; + renderApp(): void; + renderDashboard(): void; + renderWorkspaceLoading(): void; + renderWorkspaceNotFound(): void; + onCorruptWorkspace(id: string): void; + retryPendingOAuthDocumentRecovery(): void; + closeShortcutDialog(): void; + resetShortcutChord(): void; + isSignedIn(): boolean; + invalidateDashboardTree(): void; + /** Extended beyond the plan's `toast(message: string): void` — the corrupt- + * workspace toast (`loadWorkspaceOnBoot`) needs the SAME recovery-action + * button (`flashToast`'s own `action` option) the pre-extraction inline + * code passed; every other call site here passes no `opts` at all, so + * the optional second parameter is additive, not a narrowing. */ + toast(message: string, opts?: { action?: { label: string; onClick: () => void } }): void; + revealAssignedPanel(dashboardId: string, tileId: string): void; + loadIntoNewTab(query: SavedQueryV2): void; + openVariableTabUi(binding: { dashboardId: string; variableName: string }, sql: string): void; + toEditorOnMobile(): void; + runAction(opts: { view?: string }): void; + dashboardScrollTop(): number | null; + isAutoRunnableSql(sql: string): boolean; + /** + * Four "self-dispatch" hooks NOT in the plan's frozen list, added because + * the pre-extraction code called these SAME four members (all of which + * keep a flat `App` delegate) through `app.foo()` property access from + * OTHER moved functions (`navigateSqlRoute`/`handleSqlPopState` calling + * `app.loadWorkspaceOnBoot()`/`app.renderCurrentSurface()`; + * `applyMainSurface`/`showDashboardSurface` calling + * `app.renderCurrentSurface()`/`app.openDashboard()`; + * `openQueryDocument`/`openVariableTab` calling `app.showQuerySurface()`). + * That property access is exactly what let a test override e.g. + * `app.renderCurrentSurface = vi.fn()` and have it observed by EVERY + * caller — a real, test-exercised behavior (17 `app.renderCurrentSurface + * = vi.fn()` fixtures in app.test.ts). A private nav-internal local + * reference does not have that property, so preserving it requires + * reading back through `app.*` here — wired in app.ts as + * `() => app.renderCurrentSurface()` etc., which resolves to nav's own + * real implementation by DEFAULT (the flat delegate assignment runs + * immediately after construction) and to a test's stub once one is + * installed, exactly like the pre-extraction code. + */ + dispatchCurrentSurface(): void; + dispatchLoadWorkspaceOnBoot(): Promise; + dispatchShowQuerySurface(): void; + dispatchOpenDashboard(request: OpenDashboardRequest): void; + }; +} + +export interface SurfaceNavigation { + navigateSqlRoute(route: SqlRoute, method: 'push' | 'replace'): Promise; + handleSqlPopState(): Promise; + syncSqlRoute(search: string): void; + rewriteWorkspaceRoute(workspaceKey: string): void; + /** Escape hatch — see this module's header comment. */ + writeRoute(route: SqlRoute, method: 'push' | 'replace'): void; + /** Escape hatch — see this module's header comment. */ + currentRouteSearch(): string; + renderCurrentSurface(): void; + loadWorkspaceOnBoot(): Promise; + reloadDashboardRoute(): void; + openDashboard(request: OpenDashboardRequest): void; + showQuerySurface(): void; + showDashboardSurface(mode: DashboardSurfaceMode): void; + openSavedQuery(queryId: string): void; + openPanelQuery(target: { dashboardId: string; tileId: string; queryId: string }): void; + openVariableTab(dashboardId: string, variableName: string): void; + focusDashboardMember(member: DashboardFocusTarget): DashboardFocusOutcome; + captureSurfaceGeneration(): number; + isSurfaceGenerationCurrent(generation: number): boolean; + refreshCurrentSurfaceAfterStale(generation: number, committed?: boolean): boolean; + advanceSurfaceGeneration(): void; + loadGeneration(): number; +} + +export function createSurfaceNavigation(deps: SurfaceNavigationDeps): SurfaceNavigation { + // #407 — both application surfaces live on `/sql`; the URL query string is + // cached here (not re-read from `location` on every write) so a canonicalize/ + // stamp can build on the LAST write this module made, exactly as app.ts's own + // pre-extraction `routeSearch` local did. + let routeSearch = deps.locationSearch(); + let routeLoadGeneration = 0; + // Every surface transition — mount, teardown, or sign-out — advances the + // renderer generation so an obsolete async callback (a late Dashboard wave, a + // pending focus target) can finish its durable work without settling against + // a replacement renderer. Bumped on the TRANSITION, not as a side effect of a + // mount, because a mount can be skipped when the host is already live (#425's + // preserved Query surface). + let surfaceGeneration = 0; + + const advanceSurfaceGeneration = (): void => { + surfaceGeneration += 1; + deps.surface().surfaceCommands = null; + }; + const captureSurfaceGeneration = (): number => surfaceGeneration; + const isSurfaceGenerationCurrent = (generation: number): boolean => generation === surfaceGeneration; + const refreshCurrentSurfaceAfterStale = (generation: number, committed = false): boolean => { + if (generation === surfaceGeneration) return true; + const app = deps.surface(); + const routeKey = app.sqlRoute.workspaceKey; + // #425: `isSignedIn()` is load-bearing, not defensive. Sign-out now advances + // the surface generation (so a late Dashboard callback can't settle against + // a replacement renderer) but deliberately leaves the projected workspace in + // place for the next sign-in — which would otherwise let a write that + // resolves just after sign-out re-mount the whole signed-in shell OVER the + // login screen, with no credentials. + if (committed && deps.hooks.isSignedIn() && app.workspaceRouteStatus === 'ready' + && app.currentWorkspace && (routeKey === null || routeKey === app.currentWorkspace.key)) { + deps.hooks.dispatchCurrentSurface(); + } + return false; + }; + const loadGeneration = (): number => routeLoadGeneration; + const currentRouteSearch = (): string => routeSearch; + + const writeRoute = (route: SqlRoute, method: 'push' | 'replace'): void => { + deps.surface().sqlRoute = route; + routeSearch = buildSqlRouteSearch(route, routeSearch); + deps.history[method === 'push' ? 'pushState' : 'replaceState']( + null, '', deps.basePath() + routeSearch + (deps.locationHash() || ''), + ); + }; + + const loadWorkspaceOnBoot = async (): Promise => { + const app = deps.surface(); + const generation = ++routeLoadGeneration; + const explicitKey = app.sqlRoute.workspaceKey; + const result = explicitKey !== null + ? await deps.repository.loadByKey(explicitKey) + : await deps.session.resolveImplicitOrProvision(); + if (generation !== routeLoadGeneration) return null; + if (result.status === 'corrupt') { + app.currentWorkspace = null; + app.workspaceRouteStatus = 'error'; + deps.hooks.toast( + 'Saved workspace could not be read. Other local workspaces remain unaffected.', + { action: { label: 'Reset workspace', onClick: () => { deps.hooks.onCorruptWorkspace(result.id); } } }, + ); + return null; + } + if (result.status !== 'ok') { + app.currentWorkspace = null; + app.workspaceRouteStatus = explicitKey !== null ? 'not-found' : 'error'; + const normalized = normalizeSqlRouteSearch(routeSearch); + app.sqlRoute = normalized.route; + if (normalized.search !== routeSearch) { + routeSearch = normalized.search; + deps.history.replaceState(null, '', deps.basePath() + routeSearch + (deps.locationHash() || '')); + } + return null; + } + const workspace = result.workspace; + // #588 I-9 boundary ②: an external commit/reload landing here must not let + // this stale load project or write the route. + await deps.session.recordOpened(workspace); + if (generation !== routeLoadGeneration) return null; + deps.hooks.applyCommittedWorkspace(workspace); + const canonicalRoute = routeForWorkspace(app.sqlRoute, workspace.key); + const canonicalSearch = buildSqlRouteSearch(canonicalRoute, routeSearch); + app.sqlRoute = canonicalRoute; + if (canonicalSearch !== routeSearch) { + routeSearch = canonicalSearch; + deps.history.replaceState(null, '', deps.basePath() + routeSearch + (deps.locationHash() || '')); + } + // #425: this is a URL-driven open (boot, a deep link, or a workspace + // switch), so the ROUTE decides the surface — including which Dashboard, + // resolved through the compatibility selector because the URL carries no id. + adoptRouteMainSurface(); + return workspace; + }; + + const renderCurrentSurface = (): void => { + const app = deps.surface(); + if (app.workspaceRouteStatus === 'loading') { + deps.hooks.renderWorkspaceLoading(); + return; + } + if (app.workspaceRouteStatus !== 'ready' || !app.currentWorkspace) { + deps.hooks.renderWorkspaceNotFound(); + return; + } + if (app.sqlRoute.surface === 'dashboard') deps.hooks.renderDashboard(); + else deps.hooks.renderApp(); + }; + + const navigateSqlRoute = async (route: SqlRoute, method: 'push' | 'replace'): Promise => { + deps.hooks.closeShortcutDialog(); + deps.hooks.resetShortcutChord(); + const app = deps.surface(); + const workspaceChanged = route.workspaceKey !== app.sqlRoute.workspaceKey; + const needsWorkspaceLoad = workspaceChanged || app.currentWorkspace === null; + writeRoute(route, method); + if (needsWorkspaceLoad) { + app.workspaceRouteStatus = 'loading'; + app.currentWorkspace = null; + deps.hooks.renderWorkspaceLoading(); + const expectedGeneration = routeLoadGeneration + 1; + // #588 I-9 boundary ③: a newer navigation/popstate landing while this + // await is pending must leave this stale wave's project/render unrun. + const workspace = await deps.hooks.dispatchLoadWorkspaceOnBoot(); + if (routeLoadGeneration !== expectedGeneration) return; + if (workspace) deps.hooks.retryPendingOAuthDocumentRecovery(); + } else { + adoptRouteMainSurface(); + if (app.currentWorkspace) deps.hooks.retryPendingOAuthDocumentRecovery(); + } + deps.hooks.dispatchCurrentSurface(); + }; + + const handleSqlPopState = async (): Promise => { + deps.hooks.closeShortcutDialog(); + deps.hooks.resetShortcutChord(); + const app = deps.surface(); + const previousKey = app.sqlRoute.workspaceKey; + routeSearch = deps.locationSearch(); + app.sqlRoute = parseSqlRoute(routeSearch); + if (app.sqlRoute.workspaceKey === previousKey && app.currentWorkspace !== null) { + // #425: Back/Forward between surfaces of the SAME workspace is a surface + // transition, not a teardown — the shell and the query column stay + // mounted so the editor state survives it. + adoptRouteMainSurface(); + if (app.currentWorkspace) deps.hooks.retryPendingOAuthDocumentRecovery(); + deps.hooks.dispatchCurrentSurface(); + return; + } + app.workspaceRouteStatus = 'loading'; + app.currentWorkspace = null; + deps.hooks.renderWorkspaceLoading(); + const expectedGeneration = routeLoadGeneration + 1; + // #588 I-9 boundary ④: same reasoning as boundary ③, for the popstate path. + const workspace = await deps.hooks.dispatchLoadWorkspaceOnBoot(); + if (routeLoadGeneration !== expectedGeneration) return; + if (workspace) deps.hooks.retryPendingOAuthDocumentRecovery(); + deps.hooks.dispatchCurrentSurface(); + }; + + const syncSqlRoute = (search: string): void => { + routeSearch = search; + deps.surface().sqlRoute = parseSqlRoute(search); + }; + + const rewriteWorkspaceRoute = (workspaceKey: string): void => { + writeRoute(routeForWorkspace(deps.surface().sqlRoute, workspaceKey), 'replace'); + }; + + // #425 — the main-surface navigation API. Every surface transition goes + // through these functions, so `app.mainSurface` is the ONE writer of the + // route: the URL is always derived from the session surface, never the + // other way round, and the two can never disagree. + const surfaceRouteKey = (): string | null => { + const app = deps.surface(); + return app.currentWorkspace?.key ?? deps.state.workspaceKey; + }; + + // Surface changes stay in this tab and create one useful history entry; a + // View/Edit mode change replaces so presentation toggles do not pollute Back + // (ADR-0003). + // #471 — write the Dashboard the CURRENT history entry is showing onto that + // entry, with the scroll offset the DOM has right now. It has to run BEFORE + // the transition, because `pushState` leaves the outgoing entry's state + // exactly as it was last written — and again after writing a Dashboard + // route, so a freshly created entry carries its id immediately. + const stampDashboardHistoryEntry = (): void => { + const app = deps.surface(); + const snapshot = dashboardHistorySnapshot( + app.mainSurface, app.sqlRoute.workspaceKey, deps.hooks.dashboardScrollTop() ?? 0, + ); + // `null` (Query mode) is written too: it clears a snapshot this entry may + // carry from an earlier surface, so a Query entry never restores a + // Dashboard. Unguarded, exactly like `writeRoute` — a platform with no + // history API fails there on the same transition either way. + deps.history.replaceState({ dash: snapshot }, '', deps.basePath() + routeSearch + (deps.locationHash() || '')); + }; + + const applyMainSurface = (surface: MainSurfaceState, method: 'push' | 'replace'): void => { + stampDashboardHistoryEntry(); + const app = deps.surface(); + app.mainSurface = surface; + writeRoute(mainSurfaceRoute(surface, surfaceRouteKey()), method); + if (surface.kind === 'dashboard') stampDashboardHistoryEntry(); + // #426: the tree lives in the PERSISTENT shell, so a surface transition + // does not repaint it as a side effect of re-rendering the work area — it + // needs telling. Current Dashboard/member styling is derived from this + // state. + deps.hooks.invalidateDashboardTree(); + deps.hooks.dispatchCurrentSurface(); + }; + + // #426 — deliver focus to one member of the ALREADY-RENDERED Dashboard + // through the route-local surface command port. `null`/wrong-surface/ + // superseded ports all report `pending`, which means "not deliverable in + // place" rather than "gone" — the caller then takes the normal render + // transition. + const focusDashboardMember = (member: DashboardFocusTarget): DashboardFocusOutcome => { + const port = deps.surface().surfaceCommands; + if (!port || port.surface !== 'dashboard') return 'pending'; + return port.focusMember(member); + }; + + const openDashboard = (request: OpenDashboardRequest): void => { + const app = deps.surface(); + const resolution = resolveOpenDashboard(app.currentWorkspace, request); + if (resolution.status !== 'ok') { + // Reported, never repaired: an ambiguous id must not be resolved by a + // guess, and a deleted one must not silently retarget another Dashboard. + deps.hooks.toast(resolution.status === 'duplicate' + ? 'This workspace has more than one dashboard with that id — resolve the duplicate before opening it.' + : 'That dashboard is no longer part of this workspace.'); + return; + } + const sameSelection = isSameDashboardSelection(app.mainSurface, request) + && app.sqlRoute.surface === 'dashboard'; + if (sameSelection && resolution.surface.kind === 'dashboard') { + // A repeated open of the SAME id in the SAME mode with NO member is a + // no-op on the surface itself — but it still CLEARS the current member + // (opening a Dashboard row deselects whatever member was marked), so the + // tree repaints. + if (resolution.surface.pendingFocus === null) { + app.mainSurface = resolution.surface; + deps.hooks.invalidateDashboardTree(); + return; + } + // #426 — IN-PLACE member navigation. The tree makes repeated + // same-Dashboard focusing a normal operation, so it must not rebuild the + // viewer, re-run the Dashboard, or push another history entry (#425 + // re-rendered here, which did all three). + const member = resolution.surface.pendingFocus; + const outcome = focusDashboardMember(member); + if (outcome === 'ok') { + app.mainSurface = withCurrentMember(app.mainSurface, member); + deps.hooks.invalidateDashboardTree(); + return; + } + if (outcome === 'missing') { + // Non-destructive: the Dashboard stays open and unchanged, and the + // member is deliberately NOT marked current — nothing there to mark. + deps.hooks.toast(member.kind === 'tile' + ? 'That panel is no longer on this dashboard.' + : 'That variable is no longer on this dashboard.'); + return; + } + // `pending` — a curated filter whose control the opening wave is about + // to replace, or a superseded port. Fall through to the normal + // transition, which delivers focus at the deterministic point the node + // is stable. + } + // #426: reaching here with the SAME Dashboard id means the MODE changed + // (the same-id/same-mode cases all returned above), and a View/Edit switch + // must preserve the member the user navigated to — `resolveOpenDashboard` + // builds the surface from the request alone and cannot know one was + // current. + applyMainSurface( + carryCurrentMember(app.mainSurface, resolution.surface), + app.sqlRoute.surface === 'dashboard' ? 'replace' : 'push', + ); + }; + + const showQuerySurface = (): void => { + const app = deps.surface(); + if (app.mainSurface.kind === 'query' && app.sqlRoute.surface === 'workspace') return; + applyMainSurface(QUERY_SURFACE, app.sqlRoute.surface === 'dashboard' ? 'push' : 'replace'); + }; + + // The Dashboard entry points that name no Dashboard themselves: the header + // surface switch, the Workbench "Dashboard →" nav, the `g d`/`g v`/`g e` + // shortcuts, and the View/Edit switch. An ALREADY-selected Dashboard wins — + // so a mode change retains the same document rather than retargeting the + // collection's first entry — and only an unselected surface falls back to + // the ONE compatibility Dashboard. Either way the open is addressed BY ID. + // An empty collection still reaches the Dashboard surface so its "Create + // dashboard" state remains available. + const showDashboardSurface = (mode: DashboardSurfaceMode): void => { + const app = deps.surface(); + const selectedId = app.mainSurface.kind === 'dashboard' + ? app.mainSurface.dashboardId + : app.currentWorkspace ? resolveCompatibilityDashboard(app.currentWorkspace).selectedId : null; + if (selectedId !== null) { + deps.hooks.dispatchOpenDashboard({ dashboardId: selectedId, mode }); + return; + } + const method = app.sqlRoute.surface === 'dashboard' ? 'replace' : 'push'; + app.mainSurface = QUERY_SURFACE; + writeRoute({ surface: 'dashboard', workspaceKey: surfaceRouteKey(), mode }, method); + // The one surface transition that does not go through `applyMainSurface`, + // so it has to tell the tree itself. + deps.hooks.invalidateDashboardTree(); + deps.hooks.dispatchCurrentSurface(); + }; + + // #443 — RESOLVE BEFORE NAVIGATING. The shared pre-flight: nothing moves + // until the id resolves. + const savedQueryToOpen = (queryId: string): SavedQueryV2 | null => { + const query = deps.state.savedQueries.find((saved) => saved.id === queryId); + if (query) return query; + deps.hooks.toast('That query is no longer part of this workspace.'); + return null; + }; + + /** Switch to Query mode and put `query` in a tab (re-selecting the tab + * already open on it). Spread, like saved-history.ts's own two call sites. */ + const openQueryDocument = (query: SavedQueryV2): void => { + deps.hooks.dispatchShowQuerySurface(); + deps.hooks.loadIntoNewTab({ ...query }); + deps.hooks.toEditorOnMobile(); + }; + + const openSavedQuery = (queryId: string): void => { + const query = savedQueryToOpen(queryId); + if (query) openQueryDocument(query); + }; + + // #535 — the tile's expand action. Order matters: the tree is revealed + // FIRST, exactly as the Library-drop settlement does it, so the row is + // expanded and armed as the tree's position and then the query load moves + // focus on to the editor. Revealing afterwards would steal focus back out of + // the editor the user was just sent to. + const openPanelQuery = (target: { dashboardId: string; tileId: string; queryId: string }): void => { + const query = savedQueryToOpen(target.queryId); + if (!query) return; + deps.hooks.revealAssignedPanel(target.dashboardId, target.tileId); + openQueryDocument(query); + // The tile was showing a rendered result, so the editor should too — and + // on the query's OWN saved view, or a chart panel would arrive as a raw + // table. A queryless (text) panel never exposes this action, so there is + // no run-less view-restore branch to mirror from saved-history.ts here. + // + // Gated on the tab that ACTUALLY opened, not on `query.sql`: `loadIntoNewTab` + // (inside `openQueryDocument`) re-selects an existing tab for the same + // `savedId`, and that tab may hold an unsaved draft the saved document + // knows nothing about — including a DDL statement, which must never + // auto-run. A Spec-mode tab is skipped too, since Run silently does + // nothing there. + const tab = activeTab(deps.state); + if (tab.editorMode !== 'spec' && deps.hooks.isAutoRunnableSql(tab.sqlDraft)) { + deps.hooks.runAction({ view: queryView(query) }); + } + }; + + // #457 — opening a variable's option SQL is a Query-mode act for exactly the + // same reason opening a saved query is, and routes the same way. The + // variable is resolved through `dashboardVariables`, the SAME projection the + // Dashboards tree paints its rows from, so what opens always matches what + // was clicked. + const openVariableTab = (dashboardId: string, variableName: string): void => { + const app = deps.surface(); + const variable = dashboardVariables(app.currentWorkspace, dashboardId) + .find((candidate) => candidate.name === variableName); + if (variable === undefined) return; + deps.hooks.dispatchShowQuerySurface(); + // A newly inferred variable opens EMPTY; a configured one opens on its + // stored SQL. An orphan is configured by definition, so it opens on its + // SQL. + deps.hooks.openVariableTabUi({ dashboardId, variableName }, variable.sql ?? ''); + deps.hooks.toEditorOnMobile(); + }; + + // Adopt the surface the ROUTE describes. Used at boot, on Back/Forward, and + // after a workspace switch — the three moments the URL, not a click, decides + // the surface. Back/Forward INSIDE the Dashboard surface keeps whatever is + // explicitly selected: the URL carries no Dashboard id, so re-deriving one + // here would silently retarget the surface to the collection's first entry. + const adoptRouteMainSurface = (): void => { + const app = deps.surface(); + const workspace = app.currentWorkspace; + if (app.sqlRoute.surface !== 'dashboard') { app.mainSurface = QUERY_SURFACE; return; } + const mode: DashboardSurfaceMode = app.sqlRoute.mode; + if (app.mainSurface.kind === 'dashboard') { + // #426: the mode change owes no new delivery, but the member the user + // navigated to survives a View/Edit switch. The spread carries + // `currentMember`; `reconcileMainSurface` then drops it if committed + // truth no longer contains it. + app.mainSurface = reconcileMainSurface({ ...app.mainSurface, mode, pendingFocus: null }, workspace); + return; + } + // #471: the route says "a Dashboard" but carries no id, and the session no + // longer holds one (we are arriving from Query — typically Back out of a + // tile's Open-in-Workbench). The history ENTRY is the only thing that knows + // WHICH Dashboard this was, so it is consulted before the compatibility + // fallback. + const snapshot = readDashboardHistorySnapshot(deps.history.state, app.sqlRoute.workspaceKey); + if (snapshot) { + const restored = restoreDashboardSurface(snapshot, mode, workspace); + // A snapshot whose Dashboard is gone reconciles to Query; fall through to + // the compatibility entry only then, exactly as a boot with no snapshot + // does. + if (restored.kind === 'dashboard') { app.mainSurface = restored; return; } + } + const selectedId = workspace ? resolveCompatibilityDashboard(workspace).selectedId : null; + app.mainSurface = selectedId === null + ? QUERY_SURFACE + : { + kind: 'dashboard', dashboardId: selectedId, mode, + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + }; + + const reloadDashboardRoute = (): void => { + const app = deps.surface(); + // #424: fold the projected Dashboard back into the COLLECTION, preserving + // every other entry. A null projection means "this workspace has no + // Dashboard", which can only happen when the collection is already empty + // — never a reason to drop a stored Dashboard, so the array is left alone. + // #425: fold it back into the SELECTED entry, addressed by id. + const selectedId = selectedDashboardId(app.mainSurface); + const foldProjection = (workspace: StoredWorkspaceV5): StoredWorkspaceV5 => { + if (!deps.state.dashboard) return workspace; + if (selectedId === null) return withCompatibilityDashboard(workspace, deps.state.dashboard); + return replaceDashboard(workspace, selectedId, deps.state.dashboard) ?? workspace; + }; + app.currentWorkspace = app.currentWorkspace + ? { ...foldProjection(app.currentWorkspace), queries: deps.state.savedQueries } + : null; + deps.hooks.renderDashboard(); + }; + + return { + navigateSqlRoute, + handleSqlPopState, + syncSqlRoute, + rewriteWorkspaceRoute, + writeRoute, + currentRouteSearch, + renderCurrentSurface, + loadWorkspaceOnBoot, + reloadDashboardRoute, + openDashboard, + showQuerySurface, + showDashboardSurface, + openSavedQuery, + openPanelQuery, + openVariableTab, + focusDashboardMember, + captureSurfaceGeneration, + isSurfaceGenerationCurrent, + refreshCurrentSurfaceAfterStale, + advanceSurfaceGeneration, + loadGeneration, + }; +} diff --git a/src/application/workbench-parameter-session.ts b/src/application/workbench-parameter-session.ts index f8efae7e..4bf1d708 100644 --- a/src/application/workbench-parameter-session.ts +++ b/src/application/workbench-parameter-session.ts @@ -7,12 +7,13 @@ // `workbench-session.ts`/`schema-catalog-service.ts` before it. // // Deliberately NOT included (plan-review rulings): `renderVarStrip` (the DOM -// view) stays in app.ts wholesale, calling this session's methods directly — -// the full `analyze() -> ParameterViewModel[]` view-model API the issue -// sketches is deferred, not built here. `setRunBtn` (DOM) also stays in -// app.ts. `sessionParams`/`needsSession`/`sessionParamsFor` stay app.ts-local -// (they're `tab.chSession`/transport material — Phase 4C's concern, not this -// session's). +// view — #588 W1 moved it, verbatim, into `ui/workbench/variable-strip.ts`) +// calls this session's methods directly — the full +// `analyze() -> ParameterViewModel[]` view-model API the issue sketches is +// deferred, not built here. `setRunBtn` (DOM, same module) likewise just +// calls in. `sessionParams`/`needsSession`/`sessionParamsFor` stay +// app.ts-local (they're `tab.chSession`/transport material — Phase 4C's +// concern, not this session's). // // Every state field this session reads/writes (`varValues`/`filterActive`/ // `varRecent`/`varRecentDisabled`) stays a LIVE `AppState` field, never a diff --git a/src/application/workspace-session.ts b/src/application/workspace-session.ts new file mode 100644 index 00000000..a39c2959 --- /dev/null +++ b/src/application/workspace-session.ts @@ -0,0 +1,397 @@ +// The workspace write/refresh/cross-tab session (#588 phase 4 wave 3). +// Owns: serialized writes, the read-at-dequeue `mutateWorkspace` primitive, +// this tab's snapshot-identity token bookkeeping, the BroadcastChannel +// invalidation wire + focus/visibility fallback, the coalesced refresh +// scheduler, the `beforeunload` dirty guard (incl. the OAuth-redirect +// generation-tokened bypass), and initial-workspace provisioning. +// +// Deliberately NOT here: `applyCommittedWorkspace` (src/ui/app.ts) — it +// renders tabs, cancels deferred Dashboard-tree clicks, rewrites the route on +// a lost selection, and invalidates the Dashboard tree. That is real UI +// orchestration, not "zero DOM" (the #588 issue text notwithstanding — a +// plan-stage review caught this and corrected it), so it stays in app.ts and +// is reached here only through `hooks.applyCommittedWorkspace`, an INJECTED +// callback this module calls but never implements. This keeps the dependency +// direction `workspace <- application <- UI` intact: this module imports +// `../workspace/*` and `../state.ts`, never `../ui/*`. +// +// `lastCommittedToken`'s bookkeeping half lives here too (`recordProjection`/ +// `getLastCommittedToken`): `applyCommittedWorkspace` calls +// `session.recordProjection(workspace)` at the point it used to assign +// `lastCommittedToken` directly, so the one-token-per-projection invariant +// (#343 §2 — EVERY projection funnels through `applyCommittedWorkspace`, which +// makes it the one place this has to be recorded) still holds even though the +// funnel itself did not move. + +import type { StoredWorkspaceV5 } from '../generated/json-schema.types.js'; +import type { + WorkspaceRepository, WorkspaceLoadResult, +} from '../workspace/workspace-repository.js'; +import { createNewWorkspace, DEFAULT_WORKSPACE_NAME } from '../workspace/workspace-operations.js'; +import { deriveWorkspaceKey } from '../core/workspace-key.js'; +import { workspaceToken, queriesChanged } from '../workspace/workspace-sync.js'; +import { + reconcileLinkedTabsToLatest, tabSaveDirty, +} from '../state.js'; +import type { + AppState, MutateWorkspace, WorkspaceExternallyChangedInfo, +} from '../state.js'; +import type { BroadcastChannelPort } from '../env.types.js'; +import type { WorkspaceRouteStatus } from './main-surface.js'; + +/** The cross-tab invalidation signal (#343 §5) — a small "reload the record" + * poke, never the workspace body. `sourceTabId` lets a tab ignore its own + * broadcast; `workspaceId` scopes it to a specific aggregate. Moved here from + * `src/ui/app.types.ts` (#588 phase 4 §3-T #1) — this module is now the one + * place the wire shape is declared; `app.types.ts` re-exports it so every + * existing importer keeps compiling unchanged. */ +export interface WorkspaceChangedMessage { + type: 'workspace-changed'; + sourceTabId: string; + workspaceId: string; +} + +export interface WorkspaceSessionDeps { + repository: WorkspaceRepository; + state: AppState; + uid(prefix: string): string; + genId(): string; + broadcastChannelFactory(name: string): BroadcastChannelPort | null; + documentVisible(): boolean; + windowSeam: { addEventListener?: Window['addEventListener']; removeEventListener?: Window['removeEventListener'] }; + documentSeam: { addEventListener?: Document['addEventListener'] }; + /** Route-currency reads. THIS wave wires these as thunks reading app.ts's + * raw closures/fields directly (`() => app.sqlRoute.workspaceKey`, etc.) — + * wave 4 (`src/application/surface-navigation.ts`) rewires the THUNK + * BODIES onto its own accessors; this session's own interface is frozen + * and does not change then. */ + routeCurrency: { + routeWorkspaceKey(): string | null; + routeStatus(): WorkspaceRouteStatus; + loadGeneration(): number; + }; + hooks: { + applyCommittedWorkspace(ws: StoredWorkspaceV5): void; + onWorkspaceMissing(): void; + isWorkbenchSurface(): boolean; + refreshWorkbenchUi(): void; + notifyExternallyChanged(info: WorkspaceExternallyChangedInfo): void; + onExternalInvalidation(msg: WorkspaceChangedMessage): void; + warnRefreshFailed(): void; + warnMarkOpenedFailed(): void; + }; +} + +export interface WorkspaceSession { + serializeWrite(op: () => Promise): Promise; + flushWorkspaceWrites(): Promise; + mutateWorkspace: MutateWorkspace; + refreshWorkspaceFromStore(): Promise; + scheduleRefresh(): void; + sourceTabId: string; + getLastCommittedToken(): string; + recordProjection(ws: StoredWorkspaceV5): void; + syncBeforeUnload(): void; + armOAuthRedirectUnloadBypass(): () => void; + resolveImplicitOrProvision(): Promise; + recordOpened(ws: StoredWorkspaceV5): Promise; +} + +export function createWorkspaceSession(deps: WorkspaceSessionDeps): WorkspaceSession { + // #287 review fix: serialize saved-query writes so overlapping async CRUD + // commits can't interleave. Without this, a delete and a star toggle fired in + // rapid succession each build a candidate from the same stale + // `state.savedQueries` snapshot, and whichever commits LAST wins — resurrecting + // a just-deleted query (or clobbering a concurrent edit). Chaining each op + // after the previous one fully resolves means the next op reads the freshest + // projected state. The chain swallows rejections so one failed op never + // wedges the queue; the op's own result/rejection still reaches its caller. + let writeChain: Promise = Promise.resolve(); + const serializeWrite = (op: () => Promise): Promise => { + const run = writeChain.then(op, op); + writeChain = run.then(() => undefined, () => undefined); + return run; + }; + // #341: resolve once every write accepted BEFORE this call has settled (export + // waits on this so a bundle is built from the latest committed workspace, never + // mid-flight state). Writes queued AFTER this call are intentionally not awaited. + // `writeChain` itself is always rejection-swallowed by `serializeWrite`, so + // awaiting it is sufficient; callers still observe their own operation's + // rejection through the separately returned `run` promise. + const flushWorkspaceWrites = async (): Promise => { await writeChain; }; + // #343 §5: this tab's random per-session id (crypto seam, like `uid`), stamped + // on every outgoing invalidation so a tab ignores its OWN broadcast. + const sourceTabId = deps.uid('tab-'); + // #343 §2: snapshot-identity of the workspace this tab last committed. Only + // used to detect whether a later reload actually changed anything (not CAS). + let lastCommittedToken = ''; + const getLastCommittedToken = (): string => lastCommittedToken; + // #343: EVERY projection funnels through `applyCommittedWorkspace` (app.ts), + // which calls this at the point it used to assign `lastCommittedToken` + // directly — the token stays consistent with what's on screen without this + // module implementing the projection itself. + const recordProjection = (ws: StoredWorkspaceV5): void => { lastCommittedToken = workspaceToken(ws); }; + + // #343 §5: open the invalidation channel and route inbound pokes (that aren't + // our own) to the hook. Never carries the workspace body — only a signal. + const workspaceChannel = deps.broadcastChannelFactory('asb:workspace'); + if (workspaceChannel) { + workspaceChannel.onmessage = (event) => { + const msg = event.data as WorkspaceChangedMessage | null; + if (!msg || msg.type !== 'workspace-changed' || msg.sourceTabId === sourceTabId + || msg.workspaceId !== deps.state.workspaceId) return; + deps.hooks.onExternalInvalidation(msg); + }; + } + + const routeStillMatches = (requestedWorkspaceKey: string): boolean => ( + deps.routeCurrency.routeWorkspaceKey() === null + || deps.routeCurrency.routeWorkspaceKey() === requestedWorkspaceKey + ); + + // Build every mutation from this tab's active workspace, reloaded by + // immutable id INSIDE the queue. Repository commits can never create, so an + // externally deleted active workspace aborts rather than resurrecting it. + // #343 §2: on a SUCCESSFUL commit the primitive itself owns the projection + // (`applyCommittedWorkspace`, exactly once), records the snapshot token, and + // broadcasts ONE invalidation — callers no longer project. An aborted + // transform (null / null candidate) commits nothing and notifies no one; a + // failed commit surfaces its diagnostics without projecting or notifying. + const mutateWorkspace: MutateWorkspace = (transform) => { + const requestedWorkspaceId = deps.state.workspaceId; + const requestedWorkspaceKey = deps.state.workspaceKey; + const requestedRouteGeneration = deps.routeCurrency.loadGeneration(); + if (deps.routeCurrency.routeStatus() !== 'ready' + || !routeStillMatches(requestedWorkspaceKey)) { + return Promise.resolve({ ok: false as const, aborted: true as const }); + } + return serializeWrite(async () => { + if (deps.routeCurrency.routeStatus() !== 'ready' + || deps.routeCurrency.loadGeneration() !== requestedRouteGeneration + || deps.state.workspaceId !== requestedWorkspaceId + || !routeStillMatches(requestedWorkspaceKey)) { + return { ok: false as const, aborted: true as const }; + } + const loaded = await deps.repository.loadById(requestedWorkspaceId); + if (loaded.status === 'corrupt') { + return { ok: false as const, diagnostics: loaded.diagnostics }; + } + if (loaded.status !== 'ok') { + deps.hooks.onWorkspaceMissing(); + return { ok: false as const, aborted: true as const }; + } + const latest = loaded.workspace; + const input = await transform(latest); + if (!input || !input.candidate) { + return { ok: false as const, aborted: true as const, data: input ? input.data : undefined }; + } + // #588 I-28: the stale-route fence is RE-CHECKED here, between the async + // `transform` and the durable commit boundary below — distinct from both + // the pre-transform check above and the post-commit re-check further + // down. A route/workspace switch that lands while `transform` awaited + // (a user dialog, a Spec evaluation) must not commit its stale candidate. + if (deps.routeCurrency.routeStatus() !== 'ready' + || deps.routeCurrency.loadGeneration() !== requestedRouteGeneration + || deps.state.workspaceId !== requestedWorkspaceId + || !routeStillMatches(requestedWorkspaceKey)) { + return { ok: false as const, aborted: true as const, data: input.data }; + } + const result = await deps.repository.commit(input.candidate); + if (!result.ok) return { ok: false as const, diagnostics: result.diagnostics, data: input.data }; + const routeIsStillCurrent = deps.routeCurrency.routeStatus() === 'ready' + && deps.routeCurrency.loadGeneration() === requestedRouteGeneration + && deps.state.workspaceId === requestedWorkspaceId + && routeStillMatches(requestedWorkspaceKey); + if (routeIsStillCurrent) { + deps.hooks.applyCommittedWorkspace(result.workspace); // #343: also records lastCommittedToken + } + if (workspaceChannel) { + workspaceChannel.postMessage({ + type: 'workspace-changed', sourceTabId, workspaceId: result.workspace.id, + }); + } + // The persistence operation may already have crossed its commit boundary + // when navigation began. Keep that durable write, but do not let its + // route-local caller repaint/toast against the new URL. + if (!routeIsStillCurrent) { + return { ok: false as const, aborted: true as const, data: input.data }; + } + return { + ok: true as const, workspace: result.workspace, + dashboardRevision: result.dashboardRevision, data: input.data, + }; + }); + }; + + // #343 steps 4/7/8: reload the committed workspace and, if it changed under + // us, project it + reconcile linked tabs. Runs INSIDE `serializeWrite` so it + // orders behind any pending local mutation and a token compare stops it + // projecting an older read over a newer local commit. A failed load keeps the + // projection and warns; it never rejects the queued op (no wedge). + const runWorkspaceRefresh = async (): Promise => { + const requestedWorkspaceId = deps.state.workspaceId; + const requestedRouteGeneration = deps.routeCurrency.loadGeneration(); + let loaded: StoredWorkspaceV5 | null; + try { + const result = await deps.repository.loadById(requestedWorkspaceId); + if (result.status === 'corrupt') { deps.hooks.warnRefreshFailed(); return; } + loaded = result.status === 'ok' ? result.workspace : null; + } catch { + deps.hooks.warnRefreshFailed(); + return; + } + if (deps.state.workspaceId !== requestedWorkspaceId + || deps.routeCurrency.loadGeneration() !== requestedRouteGeneration) return; + // Unchanged since this tab's last projection ⇒ cheap no-op (the common case + // for an activation refresh that raced no real external write). + if (workspaceToken(loaded) === lastCommittedToken) return; + if (!loaded) { + deps.hooks.onWorkspaceMissing(); + return; + } + // #588 I-27: reconcile linked tabs from the CURRENT (pre-projection) + // snapshots so the orphan/detach distinction survives, THEN project + // committed truth (which reconciles tab links + fills tokens + records + // lastCommittedToken via `recordProjection`). `queriesDidChange` is + // likewise computed against the PRE-projection `state.savedQueries` — + // projecting first would compare the new collection against itself. + const queriesDidChange = queriesChanged(deps.state.savedQueries, loaded.queries); + reconcileLinkedTabsToLatest(deps.state, loaded); + deps.hooks.applyCommittedWorkspace(loaded); + // Workbench surface repaint. Dashboard reacts through the + // `notifyExternallyChanged` hook instead. + if (deps.hooks.isWorkbenchSurface()) { + deps.hooks.refreshWorkbenchUi(); + } + deps.hooks.notifyExternallyChanged({ workspace: loaded, queriesChanged: queriesDidChange }); + }; + // Public entry point (#343): a single refresh ordered through the write queue. + const refreshWorkspaceFromStore = (): Promise => serializeWrite(runWorkspaceRefresh); + + // #343 steps 4/6/7: coalesce every invalidation source (channel poke, window + // focus, tab becoming visible) into ONE queued refresh. `refreshPending` gates + // duplicates: pokes arriving while a refresh is already scheduled/in-flight + // collapse into that one; it clears the instant the queued op dequeues (#588 + // I-26 — NOT at read-completion), so a poke landing during the actual store + // read schedules a fresh follow-up. The refresh is queued through + // `serializeWrite`, so a notification received mid local-write reloads only + // after that write settles (marks stale now, reloads in queue order). + let refreshPending = false; + const scheduleRefresh = (): void => { + if (refreshPending) return; + refreshPending = true; + void serializeWrite(async () => { + refreshPending = false; + await runWorkspaceRefresh(); + }); + }; + // #343 §6: focus/visibility fallback — required even with BroadcastChannel, + // because a poke can be missed while a tab is created/restored/suspended (or + // on a platform without the API). Activation ALWAYS schedules a refresh; the + // token compare inside makes an unchanged store a no-op. Works when + // `broadcastChannelFactory` returned null (channel absent) too. + // Guarded so a stub `window`/`document` (some tests inject a minimal object + // without `addEventListener`) doesn't fault at construction — the seams stay + // optional, exactly like the BroadcastChannel "capability or null" default. + if (typeof deps.windowSeam.addEventListener === 'function') { + deps.windowSeam.addEventListener('focus', () => scheduleRefresh()); + } + if (typeof deps.documentSeam.addEventListener === 'function') { + deps.documentSeam.addEventListener('visibilitychange', () => { if (deps.documentVisible()) scheduleRefresh(); }); + } + + // #466/#501-review: warn on a whole-page reload/close too, not just a + // tab-strip close — the same `tabSaveDirty` predicate the tab strip's dirty + // dot and its own close-confirm (tabs.ts's `requestCloseTab`) already read. + // + // The listener itself is installed/removed as the aggregate dirty state + // flips, rather than registered once and left checking inside — an earlier + // version of this comment argued a permanent listener "costs nothing" and + // that this app has no bfcache-restore path to give up. Both were wrong: + // Firefox (and older Chromium) disqualify a page from bfcache merely for + // HAVING a `beforeunload` listener attached, independent of what the + // callback does or whether it ever calls `preventDefault()`; bfcache + // restoration itself needs no `pageshow`/`event.persisted` handling on this + // app's part — the browser thaws the whole in-memory page, `bootstrap()` + // and all, without a reload ever happening. `returnValue` must be a TRUTHY + // value (lib.dom.d.ts's own doc comment: "when set to a truthy value, + // triggers a browser-generated confirmation dialog") — its own default is + // the empty string, so assigning that back would be a no-op for the legacy + // UAs that key off it rather than `preventDefault()`. + // A successful OAuth checkpoint authorizes precisely one intentional + // navigation. The listener remains attached (so all ordinary unloads retain + // their warning); ownership tokens ensure an older failed redirect cannot + // disarm a newer arm (#588 I-13). + let nextUnloadBypassGeneration = 0; + let armedUnloadBypassGeneration: number | null = null; + const beforeUnload = (e: BeforeUnloadEvent): void => { + if (armedUnloadBypassGeneration !== null) { + armedUnloadBypassGeneration = null; + return; + } + e.preventDefault(); + e.returnValue = true; + }; + const armOAuthRedirectUnloadBypass = (): (() => void) => { + const generation = ++nextUnloadBypassGeneration; + armedUnloadBypassGeneration = generation; + return () => { + if (armedUnloadBypassGeneration === generation) armedUnloadBypassGeneration = null; + }; + }; + let beforeUnloadInstalled = false; + const canToggleBeforeUnload = typeof deps.windowSeam.addEventListener === 'function' + && typeof deps.windowSeam.removeEventListener === 'function'; + // Called from every place that can change the aggregate dirty state: the + // tab-list reactive effect (`workbench-shell.ts`, for a new/closed/switched + // tab — anything that touches the `tabs` SIGNAL's own identity) and + // `actions.rerenderTabs` (for an in-place `dirtySql`/`dirtySpec` mutation, + // which never touches that signal at all — the SQL editor's `onDocChange` + // already calls `rerenderTabs()` right after setting `dirtySql = true`, so + // this reuses that existing repaint path rather than a new aggregate + // signal). Idempotent: a redundant call when the aggregate hasn't actually + // flipped is a no-op, never a duplicate registration. + const syncBeforeUnload = (): void => { + if (!canToggleBeforeUnload) return; + const needed = deps.state.tabs.value.some(tabSaveDirty); + if (needed === beforeUnloadInstalled) return; + beforeUnloadInstalled = needed; + if (needed) deps.windowSeam.addEventListener!('beforeunload', beforeUnload); + else deps.windowSeam.removeEventListener!('beforeunload', beforeUnload); + }; + + const provisionInitialWorkspace = async (): Promise => { + const listed = await deps.repository.list(); + const key = deriveWorkspaceKey(DEFAULT_WORKSPACE_NAME, listed.summaries.map((item) => item.key)); + const created = await deps.repository.create(createNewWorkspace(deps.genId, key, DEFAULT_WORKSPACE_NAME)); + if (created.ok) return { status: 'ok', workspace: created.workspace }; + // A different tab may have provisioned the collection after our empty + // resolution. Re-resolve instead of creating a second fallback workspace. + return deps.repository.resolveImplicit(); + }; + + const resolveImplicitOrProvision = async (): Promise => { + const resolved = await deps.repository.resolveImplicit(); + return resolved.status === 'empty' ? provisionInitialWorkspace() : resolved; + }; + + const recordOpened = async (workspace: StoredWorkspaceV5): Promise => { + const result = await deps.repository.markOpened(workspace.key); + if (!result.ok) deps.hooks.warnMarkOpenedFailed(); + }; + + return { + serializeWrite, + flushWorkspaceWrites, + mutateWorkspace, + refreshWorkspaceFromStore, + scheduleRefresh, + sourceTabId, + getLastCommittedToken, + recordProjection, + syncBeforeUnload, + armOAuthRedirectUnloadBypass, + resolveImplicitOrProvision, + recordOpened, + }; +} diff --git a/src/main.ts b/src/main.ts index 74e0e542..96580de2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,7 +38,10 @@ export interface BootstrapApp { conn: Pick; renderCurrentSurface(): void; - syncSqlRoute(search: string): void; + /** #588 phase 4 wave 4: `syncSqlRoute` moved off the flat `App` contract + * onto `app.nav` (`src/application/surface-navigation.ts`) — it has no + * production consumer besides this call, repointed in the same change. */ + nav: { syncSqlRoute(search: string): void }; /** The real `App.showLogin` is `(msg?: string) => void` — every other real * caller (ui/login.ts) always passes a string. `callbackError` below is * main.ts's own `string | null` sentinel (`null` means "no callback @@ -86,7 +89,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ : { route: parseSqlRoute(loc.search), search: loc.search }; if (normalizedRoute.search !== loc.search) { hist.replaceState(null, '', loc.origin + loc.pathname + normalizedRoute.search + loc.hash); - app.syncSqlRoute(normalizedRoute.search); + app.nav.syncSqlRoute(normalizedRoute.search); } let dash = normalizedRoute.route.surface === 'dashboard'; const u = new URL(loc.href); @@ -158,7 +161,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ ? normalizeSqlRouteSearch(callbackSearch).search : callbackSearch; hist.replaceState(null, '', loc.origin + loc.pathname + cleanedSearch + loc.hash); - app.syncSqlRoute(cleanedSearch); + app.nav.syncSqlRoute(cleanedSearch); dash = parseSqlRoute(cleanedSearch).surface === 'dashboard'; } diff --git a/src/ui/app.ts b/src/ui/app.ts index bcaa7922..175c49d9 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -4,24 +4,23 @@ // window, location, fetch, crypto, sessionStorage) is injected so the whole // controller is testable under happy-dom with stubs. -import { h, fixedAnchor } from './dom.js'; +import { h } from './dom.js'; import { Icon } from './icons.js'; import { createState, activeTab, - savedForTab, tabPanel, tabSaveDirty, variableDoc, + variableDoc, normalizeRowLimit, detachWorkspaceBoundTabs, reconcileTabsWithSavedQueries, - adoptSavedIntoTab, reconcileLinkedTabsToLatest, setTabSpecDraft, SAVED_VIEWS, + setTabSpecDraft, SAVED_VIEWS, } from '../state.js'; import type { QueryTab, AppState, SpecValidationService } from '../state.js'; import { - findDashboard, replaceDashboard, resolveCompatibilityDashboard, withCompatibilityDashboard, + findDashboard, resolveCompatibilityDashboard, } from '../workspace/workspace-dashboards.js'; -import type { SavedQueryV2, StoredWorkspaceV5 } from '../generated/json-schema.types.js'; +import type { StoredWorkspaceV5 } from '../generated/json-schema.types.js'; import { isAutoRunnable, splitStatements } from '../core/sql-split.js'; -import { analysisView, fieldControls, fieldControlKind } from '../core/param-pipeline.js'; import { hasOptionalBlocks } from '../core/optional-blocks.js'; import { saveJSON, saveStr } from '../core/storage.js'; -import { sqlString, inferQueryName, shortVersion, withStatementBreak, formatBytes } from '../core/format.js'; +import { sqlString, shortVersion, withStatementBreak, formatBytes } from '../core/format.js'; import { toTSV } from '../core/export.js'; import { newResult, parseErrorPos } from '../core/stream.js'; import { @@ -40,13 +39,10 @@ import type { EditorPort } from '../editor/editor-port.types.js'; import { createNoopSpecEditor } from '../editor/spec-editor.js'; import { createSpecCompletionSources } from '../editor/spec-completion-adapter.js'; import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab, openVariableTab } from './tabs.js'; -import type { QueryOrName } from './tabs.js'; import { commitVariableConfig } from '../application/dashboard-variable-config.js'; -import { dashboardVariables } from '../application/dashboard-tree-model.js'; -import { normalizeVariableSql } from '../core/dashboard-variables.js'; import { batch } from '@preact/signals-core'; import { renderResults } from './results.js'; -import type { Result, QueryResult, ScriptResult, ScriptEntry } from './results.js'; +import type { QueryResult } from './results.js'; import { dashboardScrollTop, disposeDashboardSurface, renderDashboard } from './dashboard.js'; import type { DashboardRenderTarget } from './dashboard.js'; import { toggleThemeDom } from './theme-toggle.js'; @@ -56,18 +52,8 @@ import { openDetailPane } from './schema-detail.js'; import type { NodeDetail, DetailNode } from './schema-detail.js'; import { openDocEntry, openDocDisambiguation, closeDocPane, isDocPaneOpen } from './doc-pane.js'; import { closeInspector } from './inspector-host.js'; +import { createAnchoredPopovers } from './popover.js'; import { renderSavedHistory } from './saved-history.js'; -import { applyFieldState, applyFieldWidth } from './var-field.js'; -import { buildRelativeTimeField } from './relative-time-field.js'; -import type { RelativeTimeField } from './relative-time-field.js'; -import { buildRecentField } from './recent-field.js'; -import type { RecentField } from './recent-field.js'; -import { buildEnumField } from './enum-field.js'; -import type { EnumField } from './enum-field.js'; -import { wireComboInput } from './combobox.js'; -import type { ComboField } from './combobox.js'; -import { recentOptions } from '../core/recent-values.js'; -import { paramComparisonColumns } from '../core/param-comparison.js'; import type { SchemaDb } from '../core/from-scope.js'; import { mountInlineLogin, renderLogin } from './login.js'; import type { InlineLoginHandle } from './login.js'; @@ -76,11 +62,14 @@ import { startDrag } from './splitters.js'; import { flashToast } from './toast.js'; import type { App, ActionsRegistry, KeyboardOwner, OAuthDocumentRecoveryApplyResult, - SchemaFocus, WorkspaceChangedMessage, + SchemaFocus, } from './app.types.js'; import type { CreateAppEnv, BroadcastChannelPort } from '../env.types.js'; import { createQueryExecutionService } from '../application/query-execution-service.js'; import { createConnectionSession } from '../application/connection-session.js'; +import { createWorkspaceSession } from '../application/workspace-session.js'; +import type { WorkspaceSession } from '../application/workspace-session.js'; +import { createSurfaceNavigation } from '../application/surface-navigation.js'; import { createOAuthDocumentRecoverySession, type OAuthDocumentRecoveryRestoreResult, @@ -97,25 +86,16 @@ import type { ExportSink, FileHandleLike, DirectoryHandleLike } from '../applica import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../application/schema-graph-session.js'; import { createAppPreferences } from '../application/app-preferences.js'; import { - QUERY_SURFACE, isSameDashboardSelection, mainSurfaceRoute, reconcileMainSurface, - carryCurrentMember, resolveOpenDashboard, selectedDashboardId, withCurrentMember, - withoutPendingFocus, dashboardHistorySnapshot, readDashboardHistorySnapshot, - restoreDashboardSurface, + QUERY_SURFACE, mainSurfaceRoute, reconcileMainSurface, selectedDashboardId, withoutPendingFocus, } from '../application/main-surface.js'; -import type { DashboardSurfaceMode, MainSurfaceState } from '../application/main-surface.js'; import { createWorkspaceRepository } from '../workspace/workspace-repository.js'; -import type { WorkspaceLoadResult } from '../workspace/workspace-repository.js'; import { createIndexedDbWorkspaceStore } from '../workspace/indexeddb-workspace-store.js'; -import { createNewWorkspace, DEFAULT_WORKSPACE_NAME } from '../workspace/workspace-operations.js'; -import { deriveWorkspaceKey } from '../core/workspace-key.js'; -import { workspaceToken, queryToken, queriesChanged } from '../workspace/workspace-sync.js'; -import { buildConflictChooser } from './conflict-resolution.js'; -import { - buildSqlRouteSearch, normalizeSqlRouteSearch, parseSqlRoute, routeForWorkspace, -} from '../core/sql-route.js'; -import type { SqlRoute } from '../core/sql-route.js'; +import { queryToken } from '../workspace/workspace-sync.js'; +import { parseSqlRoute } from '../core/sql-route.js'; import { disposeFileMenuOverlays } from './file-menu.js'; import { createWorkbenchSession } from './workbench/workbench-session.js'; +import { createVariableStrip } from './workbench/variable-strip.js'; +import { createSaveController } from './workbench/save-controller.js'; import { createQueryDocumentSession } from '../application/query-document-session.js'; import { createSavedQueryService } from '../application/saved-query-service.js'; import { mountWorkbenchShell } from './workbench/workbench-shell.js'; @@ -131,13 +111,6 @@ import { buildAppHeader } from './app-header.js'; * supplies `Chart`/`Dagre` (imported packages) via `env` directly. These are * only the env-absent fallback reads below (`win.Chart`, `win.dagre`, …), kept * narrow and all-optional so a plain `Window` still satisfies this widened type. */ -/** The var-strip's combobox-based field controller — whichever of - * `buildEnumField`/`buildRelativeTimeField`/`buildRecentField` `ctl.kind` - * picks. Only `RelativeTimeField` actually declares `previewEl` (the #169 - * live date preview `applyFieldState` points `aria-describedby` at); the - * intersection makes reading it a safe optional no-op for the other two - * control kinds, which never populate it. */ -type VarStripCombo = (EnumField | RecentField | RelativeTimeField) & { previewEl?: HTMLElement }; interface WindowExtras { Chart?: unknown; @@ -184,70 +157,34 @@ export function createApp(env: CreateAppEnv = {}): App { // Epoch clock shared by persistence metadata and parameter execution. const wallNow = (): number => (env.wallNow || (() => Date.now()))(); - // Built up as a `Partial` first (every field below has a real, - // App-typed value already — `Partial` just lets this literal typecheck - // without every OTHER `App` member also being present yet), then widened to - // `App` in one step: every member this function doesn't assign inline below - // is attached via a later `app.foo = …` statement (the closures those - // values need aren't defined until further down this function), exactly - // like tests/unit/dashboard.test.ts's own `asApp` helper reinterprets a real - // `createApp(env)` object as `App` without copying it. - const appBase: Partial = { - state: createState(), - dom: {}, - root: env.root || doc.getElementById('root'), - document: doc, - // Charting seam: the Chart.js constructor (injected so tests stub it) and a - // CSS-custom-property reader (canvas needs real colors, not `var(--x)`). - Chart: env.Chart || win.Chart, - cssVar: env.cssVar || ((name: string) => win.getComputedStyle(doc.documentElement).getPropertyValue(name)), - // Pipeline-graph layout seam: dagre (injected like Chart). The DOT parser and - // SVG drawer are ours; dagre only computes node positions + edge bend points. - Dagre: env.Dagre || win.dagre, - // The schema graph opens in a real browser tab driven by this window. All - // three are injected seams: openWindow so tests can stub window.open, - // stylesText/faviconHref so the child tab can inline the page's CSS and - // favicon (about:blank ships neither). - openWindow: env.openWindow || ((...a: Parameters) => win.open(...a)), - stylesText: env.stylesText || (doc.querySelector('style') ? doc.querySelector('style')!.textContent || '' : ''), - faviconHref: env.faviconHref - || (doc.querySelector('link[rel~="icon"]') ? doc.querySelector('link[rel~="icon"]')!.getAttribute('href') || '' : ''), - // Streaming Export (issue #87) needs the File System Access API and a - // secure context; both are injected seams (like openWindow) so tests can - // stub them without a real browser. Fixed for the session (browser + - // origin don't change), so this is computed once rather than as a signal. - showSaveFilePicker: env.showSaveFilePicker - || (typeof win.showSaveFilePicker === 'function' ? win.showSaveFilePicker.bind(win) : null), - // Script export (issue #99) needs a whole directory, not one file — same - // File System Access family as showSaveFilePicker (every browser that has - // one has the other), so this is the same seam pattern. - showDirectoryPicker: env.showDirectoryPicker - || (typeof win.showDirectoryPicker === 'function' ? win.showDirectoryPicker.bind(win) : null), - isSecureContext: env.isSecureContext != null ? env.isSecureContext : !!win.isSecureContext, - // Build stamp ("v0.1.4 (abc1234)") injected at build time via main.js; shown - // in the user menu so a bug report can be tied to a build. 'dev' in tests / - // an un-built run where the placeholder was never replaced. - build: env.build || 'dev', - // Mobile-breakpoint seam (#126): matchMedia, injected so tests can drive the - // breakpoint. renderApp uses it to seed + track `state.isMobile` against - // MOBILE_BREAKPOINT_PX. null when the platform has no matchMedia (treated as - // always-desktop — the mobile CSS still applies, just no JS branching). - matchMedia: env.matchMedia || (typeof win.matchMedia === 'function' ? win.matchMedia.bind(win) : null), - }; - const app = appBase as App; + // #588 phase 4 wave 5: `app` is a LATE-BOUND `App` -- declared here with no + // value yet, assigned exactly once near the end of this function as a + // single object literal (no `as App` cast anywhere in this file -- a + // member missing from that literal is a compile-time `TS2739`, an extra + // one is `TS2353`). Every closure defined below that reads `app.*` (or is + // itself stored as a property VALUE assigned later, like the `hooks` + // objects passed to the various `create*Session` calls) captures this + // BINDING, not a value: nothing invokes one of those closures until this + // function has returned a fully-built `app`, so a premature (non-deferred) + // dereference would throw a loud TDZ ReferenceError rather than silently + // reading through a `Partial` cast. The few places below that need a + // real value SYNCHRONOUSLY, before the literal exists (not through a + // closure -- e.g. a plain `state: app.state` config property a + // `create*Session` call evaluates immediately), read the `state`/ + // `workspaceRepo` locals declared alongside them instead of `app.state`/ + // `app.workspace` -- see those two locals below. + let app: App; + const state = createState(); // #587: null until `ensureShell()`'s first call, and null again after // `disposeShell()` — see both for the mirroring. Reachable as `app.shell` // from controller-construction time (this line) onward, including every // wiring point below that runs before any shell exists. - app.shell = null; // Chromium (+ a secure context) only — Firefox/Safari and plain-HTTP have no // File System Access API. The Export button feature-detects this at build // time and renders aria-disabled + a tooltip rather than hiding outright. - app.canExport = () => !!app.showSaveFilePicker && app.isSecureContext; // The script-export path additionally needs a directory picker (defensive — // the button's own enabled/tooltip state stays gated on canExport, since every // browser with showSaveFilePicker also has showDirectoryPicker). - app.canExportScript = () => !!app.showDirectoryPicker && app.isSecureContext; // --- persistence ------------------------------------------------------- // The true-preference persist service (#276 Phase 4D) — theme/sidebarPx/ @@ -256,10 +193,7 @@ export function createApp(env: CreateAppEnv = {}): App { // value)` directly (#276 Phase 5 deleted the flat `App.savePref` delegate); // `toggleTheme` below composes `prefs.toggleTheme()` (the state-flip + // persist) with its own DOM half. - const prefs = createAppPreferences({ saveStr, state: app.state }); - app.prefs = prefs; - app.saveJSON = saveJSON; - app.saveStr = saveStr; + const prefs = createAppPreferences({ saveStr, state }); // Atomic StoredWorkspaceV5 persistence: the injected IndexedDB factory seam // (mirrors crypto/sessionStorage) backs the workspace collection, behind // which the pure WorkspaceRepository validates create/replace commits. @@ -268,71 +202,25 @@ export function createApp(env: CreateAppEnv = {}): App { // bootstrap. The favorites-driven Dashboard render still reads legacy keys in // this phase; wiring reads onto the aggregate is Phases 3-6 of #280. const workspaceStore = createIndexedDbWorkspaceStore(env.indexedDB || win.indexedDB); - app.workspace = createWorkspaceRepository({ store: workspaceStore, now: wallNow }); + const workspaceRepo = createWorkspaceRepository({ store: workspaceStore, now: wallNow }); // #407 — both application surfaces live on `/sql`; URL query parameters are // parsed once here and reparsed on Back/Forward. The resolved live workspace - // is shared by Workbench and Dashboard. - let routeSearch = loc.search; - let routeLoadGeneration = 0; - let surfaceGeneration = 0; - app.sqlRoute = parseSqlRoute(routeSearch); - app.currentWorkspace = null; - app.workspaceRouteStatus = 'ready'; - app.keyboardOwner = null; - app.resetShortcutChord = () => resetShortcutChord(app); + // is shared by Workbench and Dashboard. (#588 phase 4 wave 4: the + // route-search cache and the surface-generation counter this used to seed + // now live in `app.nav`, constructed further below — this initial parse is a + // one-time DATA-PROPERTY seed, same as `currentWorkspace`/`workspaceRouteStatus` + // right below it, and does not need the cache that comes later.) const keyboardOwners: KeyboardOwner[] = []; - app.acquireKeyboardOwner = (kind) => { - const owner = { kind }; - keyboardOwners.push(owner); - app.keyboardOwner = owner; - resetShortcutChord(app); - let released = false; - return () => { - if (released) return; - released = true; - const index = keyboardOwners.indexOf(owner); - if (index >= 0) keyboardOwners.splice(index, 1); - app.keyboardOwner = keyboardOwners.at(-1) ?? null; - resetShortcutChord(app); - }; - }; - app.shortcutDialog = null; - app.closeShortcutDialog = () => { - const dialog = app.shortcutDialog; - app.shortcutDialog = null; - dialog?.close(); - }; - app.surfaceCommands = null; // #425: the main work surface's SESSION state — Query, or one Dashboard // selected by stable id. Never persisted (see application/main-surface.ts). - app.mainSurface = QUERY_SURFACE; - // Every surface transition — mount, teardown, or sign-out — advances the - // renderer generation so an obsolete async callback (a late Dashboard wave, - // a pending focus target) can finish its durable work without settling - // against a replacement renderer. Bumped on the TRANSITION, not as a side - // effect of a mount, because a mount can be skipped when the host is already - // live (#425's preserved Query surface). - const advanceSurfaceGeneration = (): void => { - surfaceGeneration += 1; - app.surfaceCommands = null; - }; - app.captureSurfaceGeneration = () => surfaceGeneration; - app.isSurfaceGenerationCurrent = (generation) => generation === surfaceGeneration; - app.refreshCurrentSurfaceAfterStale = (generation, committed = false) => { - if (generation === surfaceGeneration) return true; - const routeKey = app.sqlRoute.workspaceKey; - // #425: `conn.isSignedIn()` is load-bearing, not defensive. Sign-out now - // advances the surface generation (so a late Dashboard callback can't settle - // against a replacement renderer) but deliberately leaves the projected - // workspace in place for the next sign-in — which would otherwise let a write - // that resolves just after sign-out re-mount the whole signed-in shell OVER - // the login screen, with no credentials. - if (committed && app.conn.isSignedIn() && app.workspaceRouteStatus === 'ready' - && app.currentWorkspace && (routeKey === null || routeKey === app.currentWorkspace.key)) { - app.renderCurrentSurface(); - } - return false; - }; + // #588 phase 4 wave 4: the surface-generation guard cluster + // (`advanceSurfaceGeneration`/`captureSurfaceGeneration`/ + // `isSurfaceGenerationCurrent`/`refreshCurrentSurfaceAfterStale` — every + // surface transition advances the renderer generation so an obsolete async + // callback can finish its durable work without settling against a + // replacement renderer) now lives in `app.nav`, constructed further below; + // `app.captureSurfaceGeneration`/`isSurfaceGenerationCurrent`/ + // `refreshCurrentSurfaceAfterStale` are assigned as flat delegates there. // The `{name:Type}` var-value/filter-active/recent-value persistence // wrappers (saveVarValues/saveFilterActive/saveVarRecent/ // saveVarRecentDisabled) + the recent-value policy that sits on top of them @@ -341,16 +229,13 @@ export function createApp(env: CreateAppEnv = {}): App { // block below. No flat `App` delegates for these (#276 Phase 5 deleted // them) except `app.saveVarRecent`, the one deliberate survivor (see its // own doc comment below). - app.FileReader = (env.FileReader || win.FileReader) as typeof FileReader; // Exposed seam for the header File menu (file-menu.js): the file-download // helper (defined below). The library title (name + dirty dot) repaints via a // libraryName/libraryDirty effect, so callers just mutate those signals. - app.downloadFile = downloadFile; // --- identity ------------------------------------------------------------ // Identity/auth reads (host/email/isSignedIn/…) live on `app.conn` itself // (assigned below, once `conn` is constructed) — no flat `App` delegate. - app.activeTab = () => activeTab(app.state); // --- independent SQL + Spec editor seams (#143/#212) --------------------- const Editor = env.Editor || createNoopPort; @@ -367,34 +252,14 @@ export function createApp(env: CreateAppEnv = {}): App { const specValidators: AppSpecValidators = hasValidate(env.specValidators) ? env.specValidators : createSpecValidatorRegistry((env.specValidators as readonly SpecValidatorEntry[] | undefined) || CORE_SPEC_VALIDATORS); - app.specValidators = specValidators; - app.specCompletionSources = env.specCompletionSources || createSpecCompletionSources(); - app.CodeViewer = env.CodeViewer || (() => ({ - setText() {}, setLanguage() {}, setWrap() {}, focus() {}, destroy() {}, - })); // #313: the editor adapter opens the reference pane through this injected // action (never by importing ui/doc-pane itself — the editor stays a leaf // layer, enforced by build/check-boundaries.mjs). Bound before Editor(app) // only for tidiness; the adapter reads it lazily at click/F1 time. - app.openDocEntry = (target) => { - if (!app.requireAuthenticatedExecution()) return; - openDocEntry(app, target); - }; // #60 — the global Escape shortcut closes the pane from anywhere (layered // before cancel-query in shortcuts.ts's handleKeydown). - app.closeDocPane = () => { - if (!isDocPaneOpen(app)) return false; - closeDocPane(app); - return true; - }; // #315 — the F1 name-only disambiguation fallback's injected action, bound // the same way and for the same "editor never imports UI" reason. - app.openDocDisambiguation = (name) => { - if (!app.requireAuthenticatedExecution()) return; - openDocDisambiguation(app, name); - }; - app.sqlEditor = Editor(app); - app.specEditor = SpecEditor(app); // The Spec-evaluation/document lifecycle (#276 Phase 4C) — // applySpecEvaluation/evaluateSpecDraft/revalidateSpecDrafts/ // revealFirstSpecError/registerSpecValidator, plus the editor-mode POLICY @@ -409,7 +274,7 @@ export function createApp(env: CreateAppEnv = {}): App { // inline code guarded itself), the session itself never imports `src/ui/**` // or `src/editor/**`. const queryDoc = createQueryDocumentSession({ - state: app.state, + state, activeTab: () => app.activeTab(), specValidators, hooks: { @@ -420,7 +285,6 @@ export function createApp(env: CreateAppEnv = {}): App { updateEditorModeUi: () => { if (app.updateEditorModeUi) app.updateEditorModeUi(); }, }, }); - app.queryDoc = queryDoc; // The persisted OAuth checkpoint is deliberately below this shell: it can // replace authored tab state, but does not know how the mounted document // service rebuilds parsed Spec/diagnostic transients or owns the dirty-page @@ -429,7 +293,7 @@ export function createApp(env: CreateAppEnv = {}): App { const oauthDocumentRecovery = createOAuthDocumentRecoverySession({ storage: ss, now: wallNow, - state: app.state, + state, specValidators, }); const finalizeOAuthDocumentRecovery = ( @@ -484,113 +348,6 @@ export function createApp(env: CreateAppEnv = {}): App { } return { kind: 'retry-deferred-retained' }; }; - app.restoreOAuthDocumentRecovery = (callbackState: string): OAuthDocumentRecoveryApplyResult => { - // A fresh validated callback starts a new authority decision; a later - // deferred retry deserves its own single safe notice. - deferredRecoveryWarningShown = false; - try { - const restored = oauthDocumentRecovery.restore(callbackState, app.currentWorkspace); - if (restored.kind === 'retry-deferred-retained') { - return deferOAuthDocumentRecovery(); - } - return finalizeOAuthDocumentRecovery(restored); - } catch { - // The session normally converts storage failures into explicit retained - // outcomes. Keep this boundary defensive: an unexpected pre-publication - // failure must not abort the signed-in shell or expose backend details. - return deferOAuthDocumentRecovery(); - } - }; - app.retryPendingOAuthDocumentRecovery = (): OAuthDocumentRecoveryApplyResult => { - let pending: OAuthDocumentRecoveryRestoreResult; - try { - pending = oauthDocumentRecovery.retryPending(app.currentWorkspace); - } catch { - return deferOAuthDocumentRecovery(); - } - if (pending.kind === 'retry-deferred-retained') { - // Nothing was published: do not arm the dirty guard, revalidate, consume, - // or replace the current workspace. The retained recovery nevertheless - // owns callback precedence, so callers discard the legacy share handoff. - return deferOAuthDocumentRecovery(); - } - if (pending.kind === 'document-session-changed-retained') { - flashToast( - 'Recovered drafts were kept because this document session changed.', - { - document: doc, - action: { - label: 'Restore drafts', - onClick: () => { - const forced = oauthDocumentRecovery.retryPending( - app.currentWorkspace, - { allowChangedDocumentSession: true }, - ); - finalizeOAuthDocumentRecovery(forced); - app.renderCurrentSurface(); - }, - }, - }, - ); - return pending; - } - deferredRecoveryWarningShown = false; - return finalizeOAuthDocumentRecovery(pending); - }; - app.consumeLegacyShared = (allowRestore: boolean, consumedHandoff?: string | null): boolean => { - let encoded: string | null; - try { - encoded = consumedHandoff === undefined - ? ss.getItem('oauth_shared') - : consumedHandoff; - } catch { - return false; - } - if (encoded === null) return false; - // In-page Basic login owns the storage handoff here. Bootstrap passes its - // already-consumed value so the same parser/application path is reused. - if (consumedHandoff === undefined) { - try { - ss.removeItem('oauth_shared'); - } catch { - // Handoff cleanup is best-effort. Recovery precedence still suppresses - // the payload, and a storage backend failure must not abort rendering. - } - } - // The handoff is one-shot regardless of whether recovery suppresses it, - // its payload is malformed, or the current route has no Query surface. - if (!allowRestore || app.sqlRoute.surface !== 'workspace') return false; - - let shared; - try { - const raw = JSON.parse(encoded) as Record; - // Pre-#166 OAuth handoffs stored `{sql, chart}` directly; the normal - // upgrader preserves that compatibility while current v2 payloads pass - // through with their authored Spec intact. - shared = upgradeSavedQuery(raw.specVersion == null - ? { name: 'Shared query', ...raw } - : raw); - } catch { - return false; - } - const panel = queryPanel(shared); - if (!shared.sql && !panel) return false; - - const tab = app.state.tabs.value[0]; - tab.sqlDraft = shared.sql; - tab.name = queryName(shared); - tab.specVersion = shared.specVersion; - setTabSpecDraft(tab, cloneJson(shared.spec)); - const launchView = queryView(shared); - const normalized = launchView === 'chart' ? 'panel' : launchView; - if (SAVED_VIEWS.has(normalized ?? '')) { - app.state.resultView.value = normalized as App['state']['resultView']['value']; - } else if (!shared.sql && isQuerylessPanel(panel)) { - app.state.resultView.value = 'panel'; - } - win.history.replaceState(null, '', loc.pathname + routeSearch); - return true; - }; // The saved-query create/commit policy, history recording, and share-URL // building (#276 Phase 4C) now live in `application/saved-query-service.ts`, // constructible without App/AppState/DOM — this shell sequences Spec @@ -600,7 +357,7 @@ export function createApp(env: CreateAppEnv = {}): App { // unrelated clocks), matching `createSavedQuery`'s own pre-extraction // inline `Date.now()` call exactly. const saved = createSavedQueryService({ - state: app.state, + state, saveJSON, now: () => Date.now(), specValidators, @@ -609,27 +366,6 @@ export function createApp(env: CreateAppEnv = {}): App { // defined below), so defer resolution to call time when it's defined. mutateWorkspace: (transform) => app.mutateWorkspace(transform), }); - app.saved = saved; - app.sqlEditor.onDocChange((value) => { - const tab = app.activeTab(); - tab.sqlDraft = value; - tab.dirtySql = true; - // #447: no re-evaluation of the Spec on a SQL keystroke any more. The ONLY - // validator whose diagnostics depended on the SQL text was the Filter role's - // (its source SQL had to be a single row-returning statement), and that role - // no longer exists — every surviving rule reads the Spec alone, so - // re-running the whole validator graph per keystroke is pure waste. - if (app.actions) app.actions.rerenderTabs(); - if (app.updateSaveBtn) app.updateSaveBtn(); - if (app.renderVarStrip) app.renderVarStrip(); - }); - // No flat `App` delegates for `evaluateSpecDraft`/`revalidateSpecDrafts`/ - // `revealFirstSpecError`/`registerSpecValidator` (#276 Phase 5 deleted - // them) — every consumer (including this file's own call sites further - // down) reads `queryDoc.*` directly. - app.specEditor.onDocChange((value) => { - queryDoc.evaluateSpecDraft(app.activeTab(), value); - }); // login.ts's `LoginApp.root` is narrowed to a non-null `Element` (vs. // `App.root`'s `Element | null`) — deliberate there (that module always // writes through it unconditionally); every real renderLogin() call below @@ -656,7 +392,7 @@ export function createApp(env: CreateAppEnv = {}): App { // loss does not call this path; it retains the Dashboard/document shell and // exposes the inline authentication host instead. disposeDashboardSurface(); - advanceSurfaceGeneration(); + app.nav.advanceSurfaceGeneration(); app.mainSurface = QUERY_SURFACE; disposeShell(); renderLogin(app as App & { root: Element }, msg); @@ -681,10 +417,14 @@ export function createApp(env: CreateAppEnv = {}): App { // constructible without App/AppState/DOM; this module wires it to the real // browser env and to `renderLoginApp` (the one piece that IS this shell's // job — the session only ever calls `onAuthLost`, never renders). - // Assigned below beside the single beforeunload listener. ConnectionSession - // invokes this only after createApp has completed, so this closure can keep - // its lifecycle wiring near the listener it controls. - let armOAuthRedirectUnloadBypass: () => () => void; + // #588 phase 4 wave 3: `session` (createWorkspaceSession) owns the + // beforeunload listener + its OAuth-redirect bypass generation tokens now, + // but it is constructed further below (it needs `applyCommittedWorkspace`, + // defined further down still). ConnectionSession invokes this thunk only + // after createApp has completed, so the forward reference (exactly the + // existing `mutateWorkspace`/`saved` thunk-forwarding pattern this function + // already uses below) resolves to the real implementation by then. + let session: WorkspaceSession; const conn = createConnectionSession({ fetch: fetchFn, storage: ss, location: loc, crypto: cryptoObj, queryJson: ch.queryJson, @@ -696,48 +436,8 @@ export function createApp(env: CreateAppEnv = {}): App { }, prepareOAuthRedirect: (state) => oauthDocumentRecovery.prepareTransaction(state), clearOAuthDocumentRecovery: () => oauthDocumentRecovery.clear(), - armOAuthRedirectUnloadBypass: () => armOAuthRedirectUnloadBypass(), + armOAuthRedirectUnloadBypass: () => session.armOAuthRedirectUnloadBypass(), }); - app.conn = conn; - app.executionScope = () => activeExecutionScope; - app.resumeAuthenticatedExecution = () => { - const epoch = conn.connection.value.epoch; - if (activeExecutionScope?.epoch === epoch && activeExecutionScope.isOpen()) { - hideAuthenticationRequired(); - return; - } - activeExecutionScope?.close(); - const scope = createAuthenticatedExecutionScope({ - epoch, - cancelRemote: (lease, queryId) => ch.killQueryWithLease(lease, queryId, sqlString), - }); - activeExecutionScope = scope; - // Connection-scoped caches/panes are owners even when they have no live - // server query id. Their own invalidation/generation guards make late - // completion inert; query-bearing owners register their current ids. - scope.register({ name: 'schema catalog', abort: () => catalog.invalidate() }); - scope.register({ name: 'schema graph', abort: () => graph.suspend() }); - // #586: whatever currently occupies the shared docked inspector (Cell, - // Rows, or Reference) — not just Reference — must not survive a - // connection-scope abort; `closeInspector` closes the current occupant - // generically, calling its own SurfaceLifecycle teardown. - scope.register({ name: 'docked inspector', abort: () => closeInspector(app) }); - hideAuthenticationRequired(); - }; - app.requireAuthenticatedExecution = () => { - let scope = activeExecutionScope; - // Production bootstrap establishes the first scope explicitly, but - // controller entry points are also valid before a surface is mounted - // (and tests exercise that contract). An already-authenticated session can - // therefore materialize its scope lazily; an auth-required session cannot. - if (!scope && conn.isSignedIn()) { - app.resumeAuthenticatedExecution(); - scope = activeExecutionScope; - } - if (scope?.isOpen()) return scope; - revealAuthenticationRequired(conn.connection.value.detail); - return null; - }; // THE single live ClickHouse context — owned by the session, aliased locally // so every existing ch.* call site below keeps referencing the same mutated // object (chCtx.origin/authConfirmed are mutated in place, never replaced). @@ -758,29 +458,6 @@ export function createApp(env: CreateAppEnv = {}): App { // different server) never sees stale schema/reference caches. The // workbench session stays reusable after destroy(): the next renderApp // re-attaches its shell effects. - app.signOut = () => { - app.closeShortcutDialog(); - resetShortcutChord(app); - const closing = activeExecutionScope; - activeExecutionScope = null; - closing?.close(conn.captureCancellationLease()); - workbench.destroy(); - // Plain abort (no clearResult settle) — the login render replaces the - // whole DOM next, so settling the visible result would be a wasted paint. - graph.cancel(); - exportService.cancelExport(); - exportService.cancelExportScript(); - catalog.invalidate(); - // #313/#586: docked inspector content (Cell, Rows, or Reference — not - // just Reference) must never survive a connection change — closed - // alongside the catalog reset, before the login screen renders. - closeInspector(app); - conn.signOut(); - // #425: explicit logout owns Dashboard teardown, the surface-generation - // bump, and the main-surface reset through the full-screen login renderer. - renderLoginApp(); - }; - app.showLogin = (msg) => renderLoginApp(msg); // --- data loaders -------------------------------------------------------- // The server-metadata/reference lifecycle (#276 Phase 4A) — server-version @@ -810,14 +487,13 @@ export function createApp(env: CreateAppEnv = {}): App { ctx: () => chCtx, ensureConfig, sqlString, - state: app.state, + state, hooks: { onServerVersionLoaded: updateOpenServerVersion, renderVarStrip: () => app.renderVarStrip(), refreshEditorReference: () => app.sqlEditor.refreshReference(), }, }); - app.catalog = catalog; // `loadVersion`/`loadSchema`/`loadReference`/`rebuildCompletions`/ // `docSummary`/`docEntry`/`refData`/`completions` all live on `catalog` // itself now (#276 Phase 5 deleted the flat `App` delegates) — @@ -844,7 +520,6 @@ export function createApp(env: CreateAppEnv = {}): App { }, '×'), ); } - app.updateBanner = updateBanner; // Lazily load a table's columns (#26/#172 v2) — actions.loadColumns' target // below delegates to the service; kept as a local function (rather than // inlining `catalog.loadColumns` at the actions-registry call site) so that @@ -860,7 +535,6 @@ export function createApp(env: CreateAppEnv = {}): App { // wrong for epoch-relative values (#169's `now-1h`). Callers resolve one // wallNow() per execution wave and thread it through every prepare of that // wave; debounce/coalescing also live in the callers, never in the pipeline. - app.wallNow = wallNow; // A unique id for a query_id / session_id. Prefer crypto.randomUUID; its // fallback (non-secure context, where randomUUID is undefined) must still be // unique across tabs sharing one time origin — so mix in Math.random, not just @@ -879,20 +553,17 @@ export function createApp(env: CreateAppEnv = {}): App { const exec = createQueryExecutionService({ runQuery: ch.runQuery, killQuery: ch.killQuery, ctx: () => chCtx, now, uid, retryMs, sleep, sqlString, }); - app.exec = exec; // #457 removed `app.runOptionQuery` (#447 phase 2's per-variable option-query // transport): it existed only for the variable DRAWER's Test action. A variable // tab runs through the ordinary Run action and paints into the ordinary result // area, so there is no second transport to wire. // Exposed so results.js can compute a script-export row's live elapsed time // (now() - e.startedAt) with the same injected clock as exportScript itself. - app.now = now; // Update only the live elapsed-ms readout (no table re-render). Driven by an // interval while running so it ticks even for queries that emit no rows (sleep). function tickElapsed(): void { if (app.dom.runElapsedEl) app.dom.runElapsedEl.textContent = app.elapsedMs().toFixed(0) + ' ms'; } - app.tickElapsed = tickElapsed; // The ClickHouse HTTP `session_id` policy (#276 Phase 5 final home) — // `sessionParams`/`needsSession`/`sessionParamsFor` now live in @@ -906,8 +577,9 @@ export function createApp(env: CreateAppEnv = {}): App { // suggestion inference, and the #171 recent-value + persistence policy — // now lives in `application/workbench-parameter-session.ts` (#276 Phase // 4B1), constructible without App/AppState/DOM. `renderVarStrip` (the DOM - // view, below) and the workbench-session hooks + export block (further - // down) call its methods directly; `app.params.hardenedVars` reads this + // view — #588 W1 extracted it into `ui/workbench/variable-strip.ts`) and + // the workbench-session hooks + export block (further down) call its + // methods directly; `app.params.hardenedVars` reads this // session's own `Set` directly (#276 Phase 5 deleted the flat // `App.hardenedVars` alias). `sessionParamsFor` above is `ch-session-params.ts`'s // `tab.chSession`/transport material, not parameter policy — Phase 4C's @@ -936,14 +608,12 @@ export function createApp(env: CreateAppEnv = {}): App { saveVarRecent: () => app.saveVarRecent(), }, }); - app.params = params; // The single deliberate delegate survivor (#276 Phase 5 — see its own doc // comment on app.types.ts's `App.saveVarRecent`): every other params-group // member (`saveVarValues`/`saveFilterActive`/`saveVarRecentDisabled`/ // `recordBoundParams`/`clearVarRecent`/`clearAllVarRecent`/`hardenedVars`) // has no flat `App` delegate — every consumer reads `app.params.*` / // `params.*` directly. - app.saveVarRecent = () => params.saveVarRecent(); // The streaming single-file export (issue #87) + multi-statement script // export (issue #99) POLICY (#276 Phase 4B2) now lives in @@ -967,7 +637,7 @@ export function createApp(env: CreateAppEnv = {}): App { executionScope: () => app.executionScope(), canExport: () => app.canExport(), canExportScript: () => app.canExportScript(), sink: exportSink, - state: app.state, // AppState structurally satisfies ExportStateSlice + state, // AppState structurally satisfies ExportStateSlice activeTab: () => app.activeTab(), params: { prepareTabSource: params.prepareTabSource, varGateBlocked: params.varGateBlocked, execStatementSql: params.execStatementSql }, sessionParamsFor, @@ -978,7 +648,6 @@ export function createApp(env: CreateAppEnv = {}): App { loadSchema: () => { void catalog.loadSchema(); }, }, }); - app.exports = exportService; // The run/runScript/runEntry/cancel orchestration (#276 Phase 3a) now lives // in ui/workbench/workbench-session.ts — a route-scoped session that owns @@ -992,7 +661,7 @@ export function createApp(env: CreateAppEnv = {}): App { const workbench = createWorkbenchSession({ exec, ensureConfig, getToken, now, wallNow, uid, executionScope: () => app.executionScope(), - state: app.state, // AppState structurally satisfies WorkbenchStateSlice + state, // AppState structurally satisfies WorkbenchStateSlice activeTab: () => app.activeTab(), hooks: { renderResults: () => renderResults(app), @@ -1014,227 +683,25 @@ export function createApp(env: CreateAppEnv = {}): App { onAuthFailed: chCtx.onSignedOut, }, }); - app.workbench = workbench; // Milliseconds since the running query started (0 when idle) — delegates to // the session's own private runT0 bookkeeping. - app.elapsedMs = () => workbench.elapsedMs(); - // hardenVar/inputGate (#170 review bookkeeping) now live on `params` (see - // its construction above) — setRunBtn's fallback and renderVarStrip's tail - // call `params.inputGate`/`params.hardenVar` directly. - function setRunBtn(running: boolean, gate?: { missing: string[]; invalid: string[]; errors: string[] }): void { - if (!app.dom.runBtn) return; - // Disabled while running, or while any detected {name:Type} query variable - // is missing, invalid (#170), or fails to serialize (#170 review finding: - // the button's visible disabled state must match varGateBlocked's actual - // gate, which already blocks on missing+invalid+errors) — with a tooltip - // so the greyed-out button explains itself. Execution paths (run/ - // runScript) enforce the same gate via varGateBlocked. A caller that - // already has the prepared source (renderVarStrip) passes its - // {missing, invalid, errors} to avoid re-preparing; otherwise we compute - // it here via inputGate — a merely 'incomplete' value (#170) stays - // display-only and doesn't grey out the button while still focused. - const tab = app.activeTab(); - if (gate == null) { - // #465 review: a dashboard-variable tab's text is option SQL, not an - // ordinary parameterised query — the {name:Type} gate never applies to - // it (optionSqlDiagnostics, surfaced on Run, is its complete policy). - gate = running || !tab || variableDoc(tab) !== null - ? { missing: [], invalid: [], errors: [] } - : params.inputGate(params.tabAnalysis(tab.sqlDraft)); - } - const blockers = gate.missing.concat(gate.invalid); - app.dom.runBtn!.disabled = running || blockers.length > 0 || gate.errors.length > 0; - app.dom.runBtn!.title = blockers.length - ? 'Enter a value for: ' + blockers.join(', ') - : gate.errors.length ? gate.errors[0] : ''; - // "Run selection" while the editor has a non-empty selection (so the mode is - // discoverable); plain "Run" otherwise. Build the children and drop the null - // (replaceChildren would coerce a null arg into a "null" text node). - const label = running ? 'Running…' : (app.state.hasSelection.value ? 'Run selection' : 'Run'); - app.dom.runBtn!.replaceChildren( - ...[Icon.play(), h('span', null, label), - running ? null : h('kbd', null, '⌘↵')].filter((c): c is SVGElement | HTMLElement => c != null)); - } - app.setRunBtn = setRunBtn; - // Repaint the query-variable strip (#134) for the active tab. Values live in - // the shared, persisted `state.varValues` (keyed by variable name), so a value - // typed once is reused by every query that references the same variable and is - // restored on reload. The listed set comes from the all-active analysis view - // (#165): a param confined to /*[ ]*/ optional blocks stays listed — marked - // optional (blank allowed; blank keeps its blocks inactive) — while a param - // outside blocks stays required. Typing keeps `state.filterActive` in sync - // (blank ⇒ inactive, typed ⇒ active). Inputs rebuild only when the detected - // {name:Type} set changes (signature guard) — so typing in the SQL editor - // doesn't thrash the row or steal focus, and switching between tabs with the - // same variables keeps the (already-correct, shared) values in place. Always - // re-syncs the Run button's disabled/tooltip state. - // - // #172 v2 (schema-cache inference — the SUGGESTION tier) now lives on - // `params.inferredEnumOptions` (see its construction above) — pure over - // schema + analysis, no DOM. - function renderVarStrip(): void { - const strip = app.dom.varStrip; - if (!strip) return; - const tab = app.activeTab(); - // #465 review: a dashboard-variable tab's own text is option SQL, not an - // ordinary parameterised query — the {name:Type} strip/gate never applies - // to it. A `{name:Type}` inside it is optionSqlDiagnostics' story to tell - // (surfaced in the results pane on Run), not an input field to fill in. - if (tab && variableDoc(tab) !== null) { - app.dom.varStripSig = ''; - strip.replaceChildren(); - strip.style.display = 'none'; - setRunBtn(app.state.running.value); - return; - } - // One analysis per repaint (review F9): fieldControls, the #172 v2 - // comparison scan, a rebuild's initial field paint, and the tail's Run- - // button gate all feed off this single pass instead of re-analyzing the - // same SQL a second time per editor keystroke. - const analysis = tab ? params.tabAnalysis(tab.sqlDraft) : null; - const vars = analysis ? fieldControls(analysis) : []; - // #172 v2 scans the tab SQL's ANALYSIS materialization (review F2): in - // the raw text a comparison inside a /*[ ]*/ optional block is one opaque - // comment span and could never match. `resolveComparisonColumnType` - // resolves each match's position against this same text. (Workbench-only - // — the Dashboard has no schema cache and gets v1 straight from the type.) - const scanSql = tab ? analysisView(tab.sqlDraft) : ''; - const comparisonColumns = tab ? paramComparisonColumns(scanSql) : {}; - // Each field's control kind + member list (shared enum > date-like > text - // priority; a type-conflicted field degrades to text — fieldControlKind). - const controls = vars.map((v) => fieldControlKind(v, params.inferredEnumOptions(v, scanSql, comparisonColumns))); - // The signature folds in each var's control kind and resolved enum - // options — not just name/type/optional — so a column landing on the - // idle-tick loader (loadColumns calls renderVarStrip on completion) - // upgrades a v2 field from plain input to the dropdown, and a type - // conflict appearing or resolving restyles the field, even though the - // {name:Type} set itself never changed. - const sig = vars.map((v, i) => { - const c = controls[i]; - return v.name + ':' + v.type + (v.optional ? '?' : '') + (v.conflict ? '!' : '') - + ':' + c.kind + (c.enumOptions ? c.enumOptions.length : ''); - }).join(','); - // The Run button's gate from this SAME analysis (review F9: setRunBtn's - // gate-less fallback would re-analyze the identical SQL). Lazy so the - // running / tab-less states (whose gate setRunBtn hard-empties anyway) - // skip the prepare entirely. - const runGate = () => (analysis && !app.state.running.value ? params.inputGate(analysis) : undefined); - if (sig !== app.dom.varStripSig) { - // A signature change while the user is focused INSIDE the strip would - // replaceChildren() every field out from under them — a background - // column load (loadColumns → renderVarStrip, the #172 v2 upgrade path) - // completing mid-typing would steal focus, wipe the in-progress text - // repaint, and destroy any open dropdown. Defer the rebuild until focus - // leaves the strip: the upgrade only matters on the NEXT interaction - // anyway. (Typing in the SQL editor also lands here on every keystroke, - // but then focus is in the editor, not the strip — no deferral.) - const active = doc.activeElement; - if (active && strip.contains(active)) { - app.dom.varStripRerenderPending = true; - if (!app.dom.varStripDeferHooked) { - app.dom.varStripDeferHooked = true; - // One listener for the strip's lifetime (the strip node itself is - // never replaced, only its children). `focusout` bubbles; when - // focus merely moves BETWEEN fields of the strip, relatedTarget is - // still inside it and the deferral holds. - strip.addEventListener('focusout', (e: FocusEvent) => { - if (!app.dom.varStripRerenderPending) return; - if (e.relatedTarget && strip.contains(e.relatedTarget as Node)) return; - app.dom.varStripRerenderPending = false; - renderVarStrip(); - }); - } - setRunBtn(app.state.running.value, runGate()); - return; - } - app.dom.varStripRerenderPending = false; - app.dom.varStripSig = sig; - if (!vars.length) { - strip.replaceChildren(); - strip.style.display = 'none'; - } else { - strip.style.display = ''; - // The freshly-(re)built strip paints each field's already-committed - // state ('execute' mode — no field is mid-typing right after a - // rebuild, e.g. a tab switch restoring a previously-invalid value). - const initialFields = params.prepareAnalyzedBatch(analysis!, wallNow(), 'execute').fields; - strip.replaceChildren(...vars.map((v, i) => { - // controls[i] (fieldControlKind above) picks the field's control: - // #172 enum members (v1 declared or v2 inferred) > #169 date-like - // preset combobox + live preview > plain text with recents (#171). - // The field stays free-text in every case (absolute values / non- - // members keep working); persistence/#170 validation stays exactly - // the shared logic below — the combobox only adds its own focus/ - // keydown-nav/composition hooks, called first from the same - // handlers (wireComboInput; see relative-time-field.js's header - // comment on why this beats two independent listeners). - const ctl = controls[i]; - // #173 acceptance (review F1): a type-conflicted field degrades to - // the plain text control (ctl.kind above) and says so visibly — a - // warning style distinct from is-invalid (the VALUE isn't wrong; - // the declarations disagree) plus a tooltip listing them. - const conflictNote = v.conflict - ? 'Conflicting type declarations: ' + v.conflict.join(' vs ') : null; - const baseTitle = v.name + ': ' + v.type - + (v.optional ? ' — optional: blank leaves its filter block out' : '') - + (conflictNote ? ' — ' + conflictNote : ''); - let combo: VarStripCombo; - let input: HTMLInputElement; - const onValueInput = (): void => { - app.state.varValues[v.name] = input.value; - // Text controls sync activation with the value (#165). - app.state.filterActive[v.name] = input.value !== ''; - params.saveVarValues(); - params.saveFilterActive(); - // Editing the value un-hardens it (#170 review): back to - // neutral, lenient behavior until it's committed again. - params.hardenedVars.delete(v.name); - // 'input' mode (#170): a plausible prefix stays neutral while - // the field is focused — only a value that's already certainly - // wrong shows the inline error here. - const inputBatch = params.prepareTabBatch(tab.sqlDraft, wallNow(), 'input'); - applyFieldState(input, inputBatch.fields[v.name], baseTitle, combo?.previewEl); - setRunBtn(app.state.running.value, inputBatch.sources[0]); - }; - const onCommitHard = (): void => { - // Hardens 'incomplete' → 'invalid' on commit (#170). - const commitBatch = params.prepareTabBatch(tab.sqlDraft, wallNow(), 'execute'); - params.hardenVar(v.name, commitBatch.fields[v.name]); - applyFieldState(input, commitBatch.fields[v.name], baseTitle, combo?.previewEl); - setRunBtn(app.state.running.value, commitBatch.sources[0]); - }; - // #171: live-filtered recents for this field (type + typed text), - // called fresh on every dropdown open/keystroke — never a snapshot - // — so a value recorded by a run that completes without changing - // the strip's {name:Type} signature is never stale. (#160's - // curated-param opt-out hook: nothing to check yet — no curated - // param exists before #160 lands.) - const getRecents = (text: string): string[] => recentOptions(app.state.varRecent, v.name, v.type, text); - const onClearRecent = (): void => params.clearVarRecent(v.name); - const fieldOpts = { - document: doc, name: v.name, type: v.type, value: app.state.varValues[v.name] || '', - baseTitle, onValueInput, onCommit: onCommitHard, getRecents, onClearRecent, - }; - if (ctl.kind === 'enum') combo = buildEnumField({ ...fieldOpts, values: ctl.enumOptions! }); - else if (ctl.kind === 'date') combo = buildRelativeTimeField({ ...fieldOpts, wallNow }); - else combo = buildRecentField(fieldOpts); - input = combo.input; - // #345: a stable, type-appropriate width — set once per field - // build (never on keystroke), same rule the Dashboard/detached-view - // variable bar uses (variable-bar.js). - applyFieldWidth(input, v.type, ctl.kind === 'enum'); - wireComboInput(combo, { onValueInput, onCommit: onCommitHard }); - if (conflictNote) input.classList.add('is-conflict'); - params.hardenVar(v.name, initialFields[v.name]); - applyFieldState(input, initialFields[v.name], baseTitle, combo?.previewEl); - return h('label', { class: 'var-field' + (v.optional ? ' is-optional' : '') }, - h('span', { class: 'var-name' }, v.name), combo.el); - })); - } - } - setRunBtn(app.state.running.value, runGate()); - } - app.renderVarStrip = renderVarStrip; + // The Workbench `{name:Type}` query-variable STRIP — `setRunBtn` (the Run + // button's disabled/tooltip/label sync) and `renderVarStrip` (the strip's + // DOM view) — now lives in `ui/workbench/variable-strip.ts` (#588 W1), a + // pure extraction: every line of the two functions moved verbatim, only + // `app.*`/`doc`/`params.*` reads rewritten onto the `deps` thunks below. + // `app.renderVarStrip`/`app.setRunBtn` stay flat one-line delegates — every + // existing consumer (`WorkbenchShellDeps`, the catalog's idle-tick hook, + // `onDocChange` above) keeps calling them exactly as before. + const variableStrip = createVariableStrip({ + document: doc, + state, + activeTab: () => app.activeTab(), + params, + wallNow, + varStrip: () => app.dom.varStrip, + runBtn: () => app.dom.runBtn, + }); // The Export button reflects both browser support (canExport) and whether an // export is already running — the button stays aria-disabled (not natively // disabled) in either case so its tooltip still shows on hover. @@ -1250,7 +717,6 @@ export function createApp(env: CreateAppEnv = {}): App { : can ? 'Export full result to a file (streams to disk, uncapped)' : 'Large export requires Chrome/Edge over HTTPS'; } - app.setExportBtn = setExportBtn; // Busy state for the Format button — formatting a multi-statement script is one // request per statement, so it can take a moment; show a spinner + disable. function setFmtBtn(busy: boolean): void { @@ -1260,7 +726,6 @@ export function createApp(env: CreateAppEnv = {}): App { busy ? h('span', { class: 'spin' }, Icon.spinner()) : Icon.braces(), busy ? 'Formatting…' : 'Format'); } - app.setFmtBtn = setFmtBtn; // Pretty-print the editor's SQL via ClickHouse's formatQuery(), in place. The // raw (untrimmed) SQL is sent so a syntax error's reported position maps 1:1 @@ -1397,7 +862,6 @@ export function createApp(env: CreateAppEnv = {}): App { onAuthFailed: chCtx.onSignedOut, }, }); - app.graph = graph; function cancelSchemaGraph(opts?: { clearResult?: boolean }): void { graph.cancel(opts); @@ -1581,10 +1045,6 @@ export function createApp(env: CreateAppEnv = {}): App { // only History does) — this wrapper no longer string-compares a panel id. // `app.shell` is null before the first shell mount, so this is always a // safe no-op that early. - app.recordHistory = (tab, sqlText) => { - saved.recordHistory(tab, sqlText); - app.shell?.sidePanels.notifyRunComplete(); - }; // --- share + star ------------------------------------------------------ function share() { @@ -1690,337 +1150,63 @@ export function createApp(env: CreateAppEnv = {}): App { } const specBlocked = (tab: QueryTab): boolean => !tab.specParsed || hasBlockingSpecErrors(tab.specDiagnostics); - app.specBlocked = specBlocked; - - app.updateSaveBtn = () => { - if (!app.dom.saveBtn) return; - const tab = app.activeTab(); - // #457: the DOCUMENT KIND is checked first, exactly as `saveActiveQuery` - // checks it — a variable tab has no saved query behind it, so "saved" is - // simply "not dirty", no Spec can block it, and the conflict state below - // (a linked-saved-query concept) cannot apply to it. Ordering the two the - // same way in both places is what stops the button ever describing an - // action the Save action would not take. - if (variableDoc(tab) !== null) { - const stored = !tabSaveDirty(tab); - app.dom.saveBtn.classList.remove('conflict'); - app.dom.saveBtn.classList.toggle('saved', stored); - app.dom.saveBtn.replaceChildren(Icon.bookmark(), h('span', null, stored ? 'Saved' : 'Save')); - app.dom.saveBtn.disabled = false; - app.dom.saveBtn.title = stored - ? 'Saved — edit to re-save (⌘S)' - : 'Save this variable’s option SQL (⌘S)'; - return; - } - // #343: a tab whose linked saved query changed in another tab must not be - // silently re-saved. The Save button becomes "Resolve conflict" and opens - // the two-action chooser instead of committing. - if (tab.externalState === 'conflict') { - app.dom.saveBtn.classList.remove('saved'); - app.dom.saveBtn.classList.add('conflict'); - app.dom.saveBtn.replaceChildren(Icon.bookmark(), h('span', null, 'Resolve conflict')); - app.dom.saveBtn.disabled = false; - app.dom.saveBtn.title = 'This query changed in another tab — choose how to resolve it'; - return; - } - app.dom.saveBtn.classList.remove('conflict'); - const entry = savedForTab(app.state, tab); - const clean = !!entry && !tab.dirtySql && !tab.dirtySpec; - const blocked = !!entry && specBlocked(tab); - app.dom.saveBtn.classList.toggle('saved', clean); - app.dom.saveBtn.replaceChildren(Icon.bookmark(), h('span', null, clean ? 'Saved' : 'Save')); - app.dom.saveBtn.disabled = blocked; - app.dom.saveBtn.title = blocked - ? 'Fix blocking Spec errors before saving' - : clean ? 'Saved — edit to re-save (⌘S)' : 'Save query (⌘S)'; - }; - // Open `node` as a popover anchored under `anchorEl`: fixed-position below the - // button, Esc + click-outside close (capture listeners), stored at - // app.dom[refKey] and cleared on close. Returns { close }. - const anchoredPopoverClosers = new Set<() => void>(); - const closeAnchoredPopovers = (): void => { - for (const close of [...anchoredPopoverClosers]) close(); - }; - function anchoredPopover( - node: HTMLElement, anchorEl: HTMLElement, refKey: 'savePopover' | 'userMenu', - ): { close: () => void } { - const releaseKeyboard = app.acquireKeyboardOwner('popover'); - const close = (): void => { - anchoredPopoverClosers.delete(close); - doc.removeEventListener('keydown', onKey, true); - doc.removeEventListener('mousedown', onOutside, true); - if (app.dom[refKey]) { app.dom[refKey]!.remove(); app.dom[refKey] = undefined; } - releaseKeyboard(); - }; - const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; - const onOutside = (e: MouseEvent): void => { - if (app.dom[refKey] && !node.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) close(); - }; - app.dom[refKey] = node; - const r = anchorEl.getBoundingClientRect(); - // Right-align under the button. - const a = fixedAnchor(r, { viewportW: win.innerWidth || 0 }) as { top: number; right: number }; - node.style.position = 'fixed'; - node.style.top = a.top + 'px'; - if (app.state.isMobile.value) { - // Mobile (#126): the trigger can sit mid-toolbar (the toolbar scrolls), so - // right-aligning to it pushes a fixed-width popover off the narrow - // viewport's left edge. Center it horizontally instead (still dropped below - // the trigger via `top`); the mobile max-width clamps keep it in-bounds. - node.style.left = '50%'; - node.style.transform = 'translateX(-50%)'; - } else { - node.style.right = a.right + 'px'; - } - doc.body.appendChild(node); - doc.addEventListener('keydown', onKey, true); - doc.addEventListener('mousedown', onOutside, true); - anchoredPopoverClosers.add(close); - return { close }; - } - /** A warning-bearing save still succeeded. Preserve that confirmation and - * keep the actionable inference guidance visible long enough to read. */ - function flashSaved(diagnostics?: ReadonlyArray<{ message: string }>): void { - const warning = diagnostics?.[0]?.message; - flashToast(warning ? `Saved — ${warning}` : 'Saved', { - document: doc, - ...(warning ? { duration: 6000 } : {}), - }); - } - - async function commitLinkedQuery(): Promise { - const surfaceGeneration = app.captureSurfaceGeneration(); - const tab = app.activeTab(); - const evaluated = queryDoc.evaluateSpecDraft(tab, tab.specText, { dirty: tab.dirtySpec }); - // #343: `saved.commit` now runs its candidate-building transform through - // `app.mutateWorkspace`, which already enters the tab-local write queue and - // reads the latest committed aggregate at dequeue — no outer `serializeWrite` - // wrapper needed (it would only double-queue). - const result = await saved.commit(tab, evaluated); - // #466/#501-review: `saved.commit` already cleared `dirtySql`/`dirtySpec` - // on a real commit (`commitSavedQuery`, state.ts) — BEFORE the staleness - // bracket below, which can return early on a navigation that began - // mid-write. `rerenderTabs()` (which re-syncs this too) only runs past - // that bracket, so without this the guard stays installed for a tab that - // is, by now, genuinely clean and durably written. - if (result.ok) app.syncBeforeUnload(); - if (!app.refreshCurrentSurfaceAfterStale(surfaceGeneration, result.ok)) { - return result.ok ? result.entry : null; - } - if (!result.ok) { - // 'rejected' (commit's own defensive re-check inside the service, OR the - // aggregate strictly rejecting the whole-workspace commit — #287 W4) - // stays a silent no-op for the tab/editor state (nothing was mutated), - // but a real commit rejection still surfaces its first diagnostic. - if (result.reason === 'invalid-spec') { - queryDoc.revealFirstSpecError(tab); - flashToast('Fix Spec errors before saving', { document: doc }); - } else if (result.reason === 'empty') { - flashToast('Nothing to save', { document: doc }); - } else if (result.reason === 'deleted') { - // #343: the linked query vanished from the latest workspace (deleted in - // another tab) and the save aborted without recreating it. Refresh the - // tab association now — the reconcile turns this tab into an unsaved - // draft (dirty) or detaches it (clean) — instead of leaving a ghost - // link waiting for the next focus/visibility event. - flashToast('This query was deleted in another tab — your draft is kept as an unsaved query', { document: doc }); - void app.refreshWorkspaceFromStore(); - } else if (result.diagnostics?.length) { - flashToast('Save failed: ' + result.diagnostics[0].message, { document: doc }); - } - return null; - } - queryDoc.revalidateSpecDrafts(); - app.specEditor.syncFromState(); - app.updateSaveBtn(); - app.actions.rerenderTabs(); - renderSavedHistory(app); - renderResults(app); - app.updateEditorModeUi!(); - flashSaved(result.diagnostics); - return result.entry; - } - - /** - * #457 — Save on a `dashboard-variable` tab. The ONE write it performs is - * `dashboard.variableConfigs[variableName]`: no `SavedQueryV2` is created or - * touched, and the document is never added to the Library, History, favourites - * or Panels. - * - * The trim rule is the pure service's, never re-implemented here: blank (or - * whitespace-only) SQL REMOVES the configuration and returns the variable to - * direct input, rather than storing an empty string that would later read as - * configured-but-broken. - */ - async function saveVariableTab( - tab: QueryTab, binding: { dashboardId: string; variableName: string }, - ): Promise { - const surfaceGeneration = app.captureSurfaceGeneration(); - const sql = normalizeVariableSql(tab.sqlDraft); - // `lastKnownType` is what lets a configuration still display a type once its - // last declaring panel disappears. Recorded from whatever type is agreed NOW - // (a live declaration always wins over it), and read from the same projection - // the tab was opened through, at save time rather than at open time. - const type = dashboardVariables(app.currentWorkspace, binding.dashboardId) - .find((candidate) => candidate.name === binding.variableName)?.type ?? null; - const outcome = await commitVariableConfig(app, binding.dashboardId, binding.variableName, sql === null - ? null - : { sql, ...(type === null ? {} : { lastKnownType: type }) }); - // TAB-side state is applied on a real commit REGARDLESS of staleness, and - // before the bracket — the write is durable, so the tab must stop claiming - // unsaved work whether or not this caller still owns the renderer. The linked - // saved-query path has the same shape: `commitSavedQuery` clears `dirtySql` - // inside the service (state.ts), and only the DOM cascade after it sits behind - // `commitLinkedQuery`'s bracket. Gating the flag too left a committed tab - // permanently dirty whenever the user navigated mid-write — a dirty dot and a - // "Save" button for content already on disk, with nothing able to clear them. - if (outcome.ok) { - tab.dirtySql = false; - // `dirtySpec` is not part of a variable document (see `tabSaveDirty`), but - // the result toolbar's panel-type picker can still set it. Clearing it here - // keeps a saved variable tab from carrying a flag nothing else ever resets. - tab.dirtySpec = false; - // #466/#501-review: re-sync the `beforeunload` guard for THIS tab-side - // clear too — `rerenderTabs()` below the staleness bracket also does it, - // but that bracket can return early on a navigation that began mid-write. - app.syncBeforeUnload(); - } - // Same staleness bracket every other async save uses: a navigation that began - // mid-write must not be REPAINTED or TOASTED over. - if (!app.refreshCurrentSurfaceAfterStale(surfaceGeneration, outcome.ok)) return null; - if (outcome.ok) { - app.actions.rerenderTabs(); - app.updateSaveBtn(); - flashToast(sql === null ? 'Option SQL removed' : 'Saved', { document: doc }); - return null; - } - // `aborted` covers more than one thing, and only ONE of them is this - // transform's own refusal (`data === 'declined'` — the Dashboard is gone or - // its id is ambiguous, and nothing was written). The others are the primitive - // deciding the route moved on, and at least one of those keeps a durable - // write — so they say nothing rather than claim a failure that may not be one. - // Either way the draft stays dirty: it is the only copy of the user's edit. - if (outcome.aborted) { - if (outcome.data === 'declined') { - flashToast('This dashboard is no longer available — nothing was saved', { document: doc }); - } - return null; - } - flashToast('Save failed: ' + outcome.diagnostics[0].message, { document: doc }); - return null; - } - - async function saveActiveQuery(): Promise { - const tab = app.activeTab(); - // #457: Save dispatches on the DOCUMENT KIND first. A variable tab is not a - // saved query and must never reach the linked-save or Save-as-new paths. - const variable = variableDoc(tab); - if (variable !== null) return saveVariableTab(tab, variable); - // #343: while a linked tab is in conflict, Save opens the resolution chooser - // rather than silently overwriting the externally changed query. A - // 'deleted'-flagged orphan has `savedId === null` already, so it falls - // through to the normal Save-as-new popover (never an implicit recreate). - if (tab.externalState === 'conflict') { openConflictChooser(); return undefined; } - if (savedForTab(app.state, tab)) return commitLinkedQuery(); - openSavePopover(); - return undefined; - } - - // #343 §8: discard the active tab's local draft and adopt the latest committed - // version of its linked query — the "Reload saved version" conflict - // resolution. The committed query is already projected on `state.savedQueries` - // (a refresh ran to detect the conflict), so this reads it from there. - function reloadSavedVersion(): void { - const tab = app.activeTab(); - const entry = savedForTab(app.state, tab); - if (!entry) { - // Deleted between opening the chooser and resolving — nothing to reload; - // refresh so the reconcile gives this tab its deleted-elsewhere treatment - // instead of leaving the stale conflict state in place (#343 review). - void app.refreshWorkspaceFromStore(); - return; - } - adoptSavedIntoTab(tab, entry); - batch(() => { app.state.tabs.value = [...app.state.tabs.value]; }); // re-run the tab effect → editor + strip resync - app.updateSaveBtn(); - app.actions.rerenderTabs(); - renderSavedHistory(app); - flashToast('Reloaded the version saved in the other tab', { document: doc }); - } - - // #343 §8: the two-action conflict chooser, anchored under the Save button. - // "Reload saved version" fires immediately; "Keep my draft" confirms, then - // commits the full draft over the latest query via the normal linked-save path - // (`commitLinkedQuery` → `mutateWorkspace`), preserving unrelated workspace - // changes and clearing the conflict on success. - function openConflictChooser(): void { - if (app.dom.savePopover) return; - const tab = app.activeTab(); - let close: () => void; - const chooser = buildConflictChooser({ - queryName: tab.name, - onReloadSaved: () => { close(); reloadSavedVersion(); }, - onKeepDraft: () => { close(); void commitLinkedQuery(); }, - }); - ({ close } = anchoredPopover(chooser, app.dom.saveBtn!, 'savePopover')); - } - - // Creation-only Name/Description popover. Once linked, the textual Spec is - // authoritative and Save bypasses this UI entirely. - function openSavePopover(): void { - const tab = app.activeTab(); - // A queryless panel (text, #166) is authored entirely in its cfg, so it - // saves with empty SQL — the same per-type relaxation saveQuery applies. - if (!String(tab.sqlDraft || '').trim() && !isQuerylessPanel(tabPanel(tab))) { - flashToast('Nothing to save', { document: doc }); - return; - } - if (app.dom.savePopover) return; - const prefill = tab.name && tab.name !== 'Untitled' ? tab.name : inferQueryName(tab.sqlDraft); - const input = h('input', { class: 'sp-input', value: prefill }); - const descInput = h('textarea', { class: 'sp-desc', rows: '3', placeholder: 'What this query does — included in Markdown export' }); - let close: () => void; - const commit = async (): Promise => { - if (!input.value.trim()) return; - const surfaceGeneration = app.captureSurfaceGeneration(); - // #343: `saved.create` runs its transform through `app.mutateWorkspace`, - // which already serializes + reads the latest committed aggregate — no - // outer `serializeWrite` wrapper needed. - const result = await saved.create(tab, input.value, descInput.value); - // #466/#501-review: `saved.create` already cleared `dirtySql`/`dirtySpec` - // on success (`createSavedQuery`, state.ts) — before the staleness - // bracket, which can return early on a navigation that began mid-write. - if (result.ok) app.syncBeforeUnload(); - if (!app.refreshCurrentSurfaceAfterStale(surfaceGeneration, result.ok)) return; - if (!result.ok) { - if (result.diagnostics?.length) flashToast('Save failed: ' + result.diagnostics[0].message, { document: doc }); - return; - } - close(); - queryDoc.revalidateSpecDrafts(); - app.specEditor.syncFromState(); - app.updateSaveBtn(); - app.updateEditorModeUi!(); - app.actions.rerenderTabs(); - renderSavedHistory(app); - flashSaved(result.diagnostics); - }; - input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); - // In the multiline description, plain Enter inserts a newline; ⌘/Ctrl+Enter commits. - descInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); } }); - const pop = h('div', { class: 'save-popover' }, - h('div', { class: 'sp-label' }, 'Save query as'), - input, - h('div', { class: 'sp-label' }, 'Description', h('span', { class: 'sp-opt' }, ' — optional')), - descInput, - h('div', { class: 'sp-actions' }, - h('button', { class: 'sp-cancel', onclick: () => close() }, 'Cancel'), - h('button', { class: 'sp-save', onclick: commit }, 'Save'))); - ({ close } = anchoredPopover(pop, app.dom.saveBtn!, 'savePopover')); - setTimeout(() => { input.focus(); input.select(); }); - } - app.openSavePopover = openSavePopover; + // The Save-popover/user-menu light anchored popover (non-modal — distinct + // from `openAnchoredDialog`'s modal dialog chrome) now lives in + // `ui/popover.ts`'s `createAnchoredPopovers` (#588 W2), a pure extraction: + // every line of `anchoredPopover` + its closers registry moved verbatim, + // only `app.*`/`doc`/`win` reads rewritten onto the `deps` thunks below. + // `beginSurfaceTransition`/`disposeCurrentSurface` keep calling + // `popovers.closeAll()` exactly as they called `closeAnchoredPopovers()` + // before. The instance-scoped closers Set lives inside `popovers` now, not + // as a module-global here. + const popovers = createAnchoredPopovers({ + document: doc, + acquireKeyboardOwner: (kind) => app.acquireKeyboardOwner(kind), + isMobile: () => app.state.isMobile.value, + viewportWidth: () => win.innerWidth, + getRef: (key) => app.dom[key], + setRef: (key, node) => { app.dom[key] = node; }, + }); + const anchoredPopover = popovers.open; + const closeAnchoredPopovers = popovers.closeAll; + + // The Save cluster — `updateSaveBtn`, `saveActiveQuery`, and the linked + // commit/create/conflict-chooser paths it dispatches to — now lives in + // `ui/workbench/save-controller.ts`'s `createSaveController` (#588 W2), a + // pure extraction: every line moved verbatim, only `app.*`/`doc`/`saved`/ + // `queryDoc` reads rewritten onto the `deps` thunks below. The #457 + // kind-dispatch-first ordering (I-15) travels with the code UNCHANGED in + // both `updateSaveBtn` and `saveActiveQuery` — see that module's own header + // comment. `App.openSavePopover` is DROPPED (zero production consumers — + // #588 phase 4 plan §3-W2b); `app.updateSaveBtn` and `actions.save` stay + // flat delegates onto the controller for their wide existing consumers. + const saveController = createSaveController({ + document: doc, + state, + activeTab: () => app.activeTab(), + saved: { commit: (tab, evaluated) => saved.commit(tab, evaluated), create: (tab, name, description) => saved.create(tab, name, description) }, + queryDoc: { + evaluateSpecDraft: (tab, text, opts) => queryDoc.evaluateSpecDraft(tab, text, opts), + revalidateSpecDrafts: (opts) => queryDoc.revalidateSpecDrafts(opts), + revealFirstSpecError: (tab) => queryDoc.revealFirstSpecError(tab), + }, + currentWorkspace: () => app.currentWorkspace, + captureSurfaceGeneration: () => app.captureSurfaceGeneration(), + refreshCurrentSurfaceAfterStale: (generation, committed) => app.refreshCurrentSurfaceAfterStale(generation, committed), + syncBeforeUnload: () => app.syncBeforeUnload(), + refreshWorkspaceFromStore: () => app.workspaceSession.refreshWorkspaceFromStore(), + commitVariableConfig: (dashboardId, variableName, cfg) => commitVariableConfig(app, dashboardId, variableName, cfg), + saveBtn: () => app.dom.saveBtn, + savePopoverOpen: () => !!app.dom.savePopover, + anchoredPopover: popovers.open, + rerenderTabs: () => app.actions.rerenderTabs(), + updateEditorModeUi: () => app.updateEditorModeUi!(), + renderSavedHistory: () => renderSavedHistory(app), + renderResults: () => renderResults(app), + syncSpecEditorFromState: () => app.specEditor.syncFromState(), + specBlocked, + }); function formatSpec(): void { const tab = app.activeTab(); @@ -2053,14 +1239,6 @@ export function createApp(env: CreateAppEnv = {}): App { return true; } - app.activateInvalidSpecDraft = (tab) => { - if (!tab) return; - batch(() => { app.state.activeTabId.value = tab.id; }); - tab.editorMode = 'spec'; - app.updateEditorModeUi!(); - app.specEditor.focus(); - flashToast('Fix Spec JSON first', { document: doc }); - }; // User menu: dropdown under the header user button, holding the identity and // a Log out item. Same close model as the save popover (Esc + outside click). @@ -2078,7 +1256,6 @@ export function createApp(env: CreateAppEnv = {}): App { ({ close } = anchoredPopover(menu, app.dom.userBtn!, 'userMenu')); setTimeout(() => logoutBtn.focus()); } - app.openUserMenu = openUserMenu; function toggleTheme(): void { // The shared DOM composition (state-flip + persist + `data-theme` + @@ -2092,7 +1269,6 @@ export function createApp(env: CreateAppEnv = {}): App { } // Exposed so the schema-view overlay can drive the same toggle (keeps state + // saved pref + header icon in sync rather than flipping data-theme behind them). - app.toggleTheme = toggleTheme; // On mobile (#126), jump the bottom-nav to the Editor panel after an action // that changes the editor content; a no-op on desktop. @@ -2182,7 +1358,7 @@ export function createApp(env: CreateAppEnv = {}): App { const beginSurfaceTransition = (): void => { app.closeShortcutDialog(); resetShortcutChord(app); - advanceSurfaceGeneration(); + app.nav.advanceSurfaceGeneration(); closeAnchoredPopovers(); disposeFileMenuOverlays(app); // #586 REWRITE: this used to close ONLY the doc pane, on the reasoning @@ -2197,21 +1373,10 @@ export function createApp(env: CreateAppEnv = {}): App { // Reference. `closeInspector` is generic over the current occupant. closeInspector(app); }; - app.renderDashboard = () => { - if (conn.isSignedIn() && !activeExecutionScope) app.resumeAuthenticatedExecution(); - beginSurfaceTransition(); - const mounted = ensureShell(); - // Exposed BEFORE rendering: the grafana-grid engine measures its host's real - // width immediately after mount, and a hidden host measures 0 — which - // silently pins every Dashboard to the widest 12-column breakpoint. happy-dom - // always reports 0, so only a real browser can catch a regression here. - mounted.showHost('dashboard'); - return renderDashboard(app, dashboardRenderTarget(mounted)); - }; const disposeCurrentSurface = (): void => { app.closeShortcutDialog(); resetShortcutChord(app); - advanceSurfaceGeneration(); + app.nav.advanceSurfaceGeneration(); for (const control of app.root?.querySelectorAll( 'button, input, select, textarea', ) ?? []) control.disabled = true; @@ -2235,7 +1400,6 @@ export function createApp(env: CreateAppEnv = {}): App { // Declared here, above its first caller, so no path can reach it before // `createApp` has finished wiring the controller. const invalidateDashboardTree = (): void => { app.state.dashboardTreeRevision.value += 1; }; - app.invalidateDashboardTree = invalidateDashboardTree; const applyCommittedWorkspace = (workspace: StoredWorkspaceV5): void => { app.currentWorkspace = workspace; @@ -2292,8 +1456,11 @@ export function createApp(env: CreateAppEnv = {}): App { // #343 §2: this projection IS now the tab's committed baseline — record its // snapshot token so a later reload can cheaply tell whether anything changed. // Every projection funnels through here (boot, mutateWorkspace, reset), so - // the token stays consistent with what's on screen. - lastCommittedToken = workspaceToken(workspace); + // the token stays consistent with what's on screen. #588 phase 4 wave 3: + // the token itself now lives on `app.workspaceSession` — this calls + // `recordProjection` at the point this used to assign `lastCommittedToken` + // directly. + app.workspaceSession.recordProjection(workspace); // #426: EVERY projection funnels through here — boot, a committed mutation, // an external refresh, and a workspace switch — which makes this the one place // the Dashboard tree's invalidation has to fire. It is the whole reason the @@ -2334,385 +1501,121 @@ export function createApp(env: CreateAppEnv = {}): App { // render has to happen here. if (lostSelection) { if (app.sqlRoute.surface === 'dashboard') { - writeRoute(mainSurfaceRoute(QUERY_SURFACE, workspace.key), 'replace'); + // #588 phase 4 wave 4: `writeRoute` moved into `app.nav` (nav-private + // otherwise) — exposed as an escape hatch for exactly this call, which + // forces the QUERY surface's route with 'replace' regardless of the + // CURRENT route surface; see surface-navigation.ts's header comment + // for why neither `rewriteWorkspaceRoute` nor `showQuerySurface` is + // behavior-identical here. + app.nav.writeRoute(mainSurfaceRoute(QUERY_SURFACE, workspace.key), 'replace'); } app.renderCurrentSurface(); } }; - app.applyCommittedWorkspace = applyCommittedWorkspace; // #287 W5: the shared WorkspaceIdGen seam file-menu.js's New workspace / // Import / Replace operations use to mint fresh ids (`uid('ws-')`). - app.genId = () => uid('ws-'); - - // #287 review fix: serialize saved-query writes so overlapping async CRUD - // commits can't interleave. Without this, a delete and a star toggle fired in - // rapid succession each build a candidate from the same stale - // `state.savedQueries` snapshot, and whichever commits LAST wins — resurrecting - // a just-deleted query (or clobbering a concurrent edit). Chaining each op - // after the previous one fully resolves means the next op reads the freshest - // projected state. The chain swallows rejections so one failed op never - // wedges the queue; the op's own result/rejection still reaches its caller. - let writeChain: Promise = Promise.resolve(); - app.serializeWrite = (op: () => Promise): Promise => { - const run = writeChain.then(op, op); - writeChain = run.then(() => undefined, () => undefined); - return run; - }; - // #341: resolve once every write accepted BEFORE this call has settled (export - // waits on this so a bundle is built from the latest committed workspace, never - // mid-flight state). Writes queued AFTER this call are intentionally not awaited. - // `writeChain` itself is always rejection-swallowed by `serializeWrite`, so - // awaiting it is sufficient; callers still observe their own operation's - // rejection through the separately returned `run` promise. - app.flushWorkspaceWrites = async () => { await writeChain; }; - // #343 §5: this tab's random per-session id (crypto seam, like `uid`), stamped - // on every outgoing invalidation so a tab ignores its OWN broadcast. - const sourceTabId = uid('tab-'); - app.sourceTabId = sourceTabId; - app.documentVisible = documentVisible; - // #343 §2: snapshot-identity of the workspace this tab last committed. Only - // used to detect whether a later reload actually changed anything (not CAS). - let lastCommittedToken = ''; - app.getLastCommittedToken = () => lastCommittedToken; - // #343 step 4: the route/surface refresh hook a mounted route registers to - // react AFTER a refresh actually projected an external change — Dashboard - // overrides this to rebuild its viewer session - // from the latest committed workspace. Default no-op: the Workbench route's - // repaint is built into `refreshWorkspaceFromStore` itself. - app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; - // #343 §5: open the invalidation channel and route inbound pokes (that aren't - // our own) to the hook. Never carries the workspace body — only a signal. - const workspaceChannel = broadcastChannelFactory('asb:workspace'); - if (workspaceChannel) { - workspaceChannel.onmessage = (event) => { - const msg = event.data as WorkspaceChangedMessage | null; - if (!msg || msg.type !== 'workspace-changed' || msg.sourceTabId === sourceTabId - || msg.workspaceId !== app.state.workspaceId) return; - app.onExternalWorkspaceChange(msg); - }; - } - // Build every mutation from this tab's active workspace, reloaded by - // immutable id INSIDE the queue. Repository commits can never create, so an - // externally deleted active workspace aborts rather than resurrecting it. - // #343 §2: on a SUCCESSFUL commit the primitive itself owns the projection - // (`applyCommittedWorkspace`, exactly once), records the snapshot token, and - // broadcasts ONE invalidation — callers no longer project. An aborted - // transform (null / null candidate) commits nothing and notifies no one; a - // failed commit surfaces its diagnostics without projecting or notifying. - app.mutateWorkspace = (transform) => { - const requestedWorkspaceId = app.state.workspaceId; - const requestedWorkspaceKey = app.state.workspaceKey; - const requestedRouteGeneration = routeLoadGeneration; - const routeStillMatches = (): boolean => app.sqlRoute.workspaceKey === null - || app.sqlRoute.workspaceKey === requestedWorkspaceKey; - if (app.workspaceRouteStatus !== 'ready' - || !routeStillMatches()) { - return Promise.resolve({ ok: false as const, aborted: true as const }); - } - return app.serializeWrite(async () => { - if (app.workspaceRouteStatus !== 'ready' - || routeLoadGeneration !== requestedRouteGeneration - || app.state.workspaceId !== requestedWorkspaceId - || !routeStillMatches()) { - return { ok: false as const, aborted: true as const }; - } - const loaded = await app.workspace.loadById(requestedWorkspaceId); - if (loaded.status === 'corrupt') { - return { ok: false as const, diagnostics: loaded.diagnostics }; - } - if (loaded.status !== 'ok') { + // #588 phase 4 wave 3: queueing, repository calls, tokens, broadcasts, + // refresh scheduling, listeners, and beforeunload now live in + // `src/application/workspace-session.ts` — this call sites the whole thing + // in ONE place, wired to app.ts's own closures/fields through + // `hooks`/`routeCurrency`, exactly the layering `applyCommittedWorkspace` + // above stays out of (it is real UI orchestration, not "zero DOM"). + // `routeCurrency`'s three thunks read today's raw app.ts closures/fields + // directly — wave 4 (`src/application/surface-navigation.ts`) rewires their + // BODIES onto its own accessors; this session's own interface does not + // change then. + session = createWorkspaceSession({ + repository: workspaceRepo, + state, + uid, + genId: () => app.genId(), + broadcastChannelFactory, + documentVisible, + windowSeam: win, + documentSeam: doc, + // #588 phase 4 wave 4: `routeWorkspaceKey`/`routeStatus` keep reading + // app.ts's own `sqlRoute`/`workspaceRouteStatus` data properties directly + // (unaffected by this wave — they never lived in a moved closure); + // `loadGeneration` is rewired from wave 3's raw `routeLoadGeneration` + // closure read onto `app.nav`'s own accessor, now that the counter itself + // lives there. Only these THREE thunk BODIES change — `WorkspaceSession`'s + // own `routeCurrency` interface (workspace-session.ts) is untouched. + routeCurrency: { + routeWorkspaceKey: () => app.sqlRoute.workspaceKey, + routeStatus: () => app.workspaceRouteStatus, + loadGeneration: () => app.nav.loadGeneration(), + }, + hooks: { + applyCommittedWorkspace: (ws) => app.applyCommittedWorkspace(ws), + onWorkspaceMissing: () => { app.currentWorkspace = null; app.workspaceRouteStatus = 'not-found'; app.renderCurrentSurface(); - return { ok: false as const, aborted: true as const }; - } - const latest = loaded.workspace; - const input = await transform(latest); - if (!input || !input.candidate) { - return { ok: false as const, aborted: true as const, data: input ? input.data : undefined }; - } - if (app.workspaceRouteStatus !== 'ready' - || routeLoadGeneration !== requestedRouteGeneration - || app.state.workspaceId !== requestedWorkspaceId - || !routeStillMatches()) { - return { ok: false as const, aborted: true as const, data: input.data }; - } - const result = await app.workspace.commit(input.candidate); - if (!result.ok) return { ok: false as const, diagnostics: result.diagnostics, data: input.data }; - const routeIsStillCurrent = app.workspaceRouteStatus === 'ready' - && routeLoadGeneration === requestedRouteGeneration - && app.state.workspaceId === requestedWorkspaceId - && routeStillMatches(); - if (routeIsStillCurrent) { - app.applyCommittedWorkspace(result.workspace); // #343: also records lastCommittedToken - } - if (workspaceChannel) { - workspaceChannel.postMessage({ - type: 'workspace-changed', sourceTabId, workspaceId: result.workspace.id, - }); - } - // The persistence operation may already have crossed its commit boundary - // when navigation began. Keep that durable write, but do not let its - // route-local caller repaint/toast against the new URL. - if (!routeIsStillCurrent) { - return { ok: false as const, aborted: true as const, data: input.data }; - } - return { - ok: true as const, workspace: result.workspace, - dashboardRevision: result.dashboardRevision, data: input.data, - }; - }); - }; - - // #343 step 4: a non-destructive warning when a reload can't reach the store. - // The current projection stays on screen; the next focus/visibility event - // schedules another attempt (activation always refreshes), so this never - // wedges the workspace queue or discards data. - const warnRefreshFailed = (): void => { - flashToast( - 'Couldn’t reload the latest workspace — showing the last known version; will retry when you return to this tab.', - { document: doc }, - ); - }; - - // #343 steps 4/7/8: reload the committed workspace and, if it changed under - // us, project it + reconcile linked tabs. Runs INSIDE `serializeWrite` so it - // orders behind any pending local mutation and a token compare stops it - // projecting an older read over a newer local commit. A failed load keeps the - // projection and warns; it never rejects the queued op (no wedge). - const runWorkspaceRefresh = async (): Promise => { - const requestedWorkspaceId = app.state.workspaceId; - const requestedRouteGeneration = routeLoadGeneration; - let loaded: StoredWorkspaceV5 | null; - try { - const result = await app.workspace.loadById(requestedWorkspaceId); - if (result.status === 'corrupt') { warnRefreshFailed(); return; } - loaded = result.status === 'ok' ? result.workspace : null; - } catch { - warnRefreshFailed(); - return; - } - if (app.state.workspaceId !== requestedWorkspaceId - || routeLoadGeneration !== requestedRouteGeneration) return; - // Unchanged since this tab's last projection ⇒ cheap no-op (the common case - // for an activation refresh that raced no real external write). - if (workspaceToken(loaded) === lastCommittedToken) return; - if (!loaded) { - app.currentWorkspace = null; - app.workspaceRouteStatus = 'not-found'; - app.renderCurrentSurface(); - return; - } - // Reconcile linked tabs from the CURRENT (pre-projection) snapshots so the - // orphan/detach distinction survives, THEN project committed truth (which - // reconciles tab links + fills tokens + records lastCommittedToken). - const queriesDidChange = queriesChanged(app.state.savedQueries, loaded.queries); - reconcileLinkedTabsToLatest(app.state, loaded); - applyCommittedWorkspace(loaded); - // Workbench surface repaint. Dashboard reacts through the - // `onWorkspaceExternallyChanged` hook instead. - if (app.sqlRoute.surface === 'workspace') { + }, + isWorkbenchSurface: () => app.sqlRoute.surface === 'workspace', // Re-run the tab effect (editor doc re-sync for the active tab, parked - // reconcile for the rest, tab strip + Save button + var strip) by handing - // the tabs signal a fresh array reference. - batch(() => { app.state.tabs.value = [...app.state.tabs.value]; }); - app.updateSaveBtn(); - app.updateEditorModeUi?.(); - renderSavedHistory(app); - } - app.onWorkspaceExternallyChanged({ workspace: loaded, queriesChanged: queriesDidChange }); - }; - // Public entry point (#343): a single refresh ordered through the write queue. - app.refreshWorkspaceFromStore = () => app.serializeWrite(runWorkspaceRefresh); - - // #343 steps 4/6/7: coalesce every invalidation source (channel poke, window - // focus, tab becoming visible) into ONE queued refresh. `refreshPending` gates - // duplicates: pokes arriving while a refresh is already scheduled/in-flight - // collapse into that one; it clears the instant the queued op dequeues, so a - // poke landing during the actual store read schedules a fresh follow-up. The - // refresh is queued through `serializeWrite`, so a notification received mid - // local-write reloads only after that write settles (marks stale now, reloads - // in queue order). - let refreshPending = false; - const scheduleWorkspaceRefresh = (): void => { - if (refreshPending) return; - refreshPending = true; - void app.serializeWrite(async () => { - refreshPending = false; - await runWorkspaceRefresh(); - }); - }; - app.onExternalWorkspaceChange = () => scheduleWorkspaceRefresh(); - // #343 §6: focus/visibility fallback — required even with BroadcastChannel, - // because a poke can be missed while a tab is created/restored/suspended (or - // on a platform without the API). Activation ALWAYS schedules a refresh; the - // token compare inside makes an unchanged store a no-op. Works when - // `broadcastChannel` returned null (channel absent) too. - // Guarded so a stub `window`/`document` (some tests inject a minimal object - // without `addEventListener`) doesn't fault at construction — the seams stay - // optional, exactly like the BroadcastChannel "capability or null" default. - if (typeof win.addEventListener === 'function') { - win.addEventListener('focus', () => scheduleWorkspaceRefresh()); - } - if (typeof doc.addEventListener === 'function') { - doc.addEventListener('visibilitychange', () => { if (documentVisible()) scheduleWorkspaceRefresh(); }); - } - // #466/#501-review: warn on a whole-page reload/close too, not just a - // tab-strip close — the same `tabSaveDirty` predicate the tab strip's dirty - // dot and its own close-confirm (tabs.ts's `requestCloseTab`) already read. - // - // The listener itself is installed/removed as the aggregate dirty state - // flips, rather than registered once and left checking inside — an earlier - // version of this comment argued a permanent listener "costs nothing" and - // that this app has no bfcache-restore path to give up. Both were wrong: - // Firefox (and older Chromium) disqualify a page from bfcache merely for - // HAVING a `beforeunload` listener attached, independent of what the - // callback does or whether it ever calls `preventDefault()`; bfcache - // restoration itself needs no `pageshow`/`event.persisted` handling on this - // app's part — the browser thaws the whole in-memory page, `bootstrap()` - // and all, without a reload ever happening. `returnValue` must be a TRUTHY - // value (lib.dom.d.ts's own doc comment: "when set to a truthy value, - // triggers a browser-generated confirmation dialog") — its own default is - // the empty string, so assigning that back would be a no-op for the legacy - // UAs that key off it rather than `preventDefault()`. - // A successful OAuth checkpoint authorizes precisely one intentional - // navigation. The listener remains attached (so all ordinary unloads retain - // their warning); ownership tokens ensure an older failed redirect cannot - // disarm a newer arm. - let nextUnloadBypassGeneration = 0; - let armedUnloadBypassGeneration: number | null = null; - const beforeUnload = (e: BeforeUnloadEvent): void => { - if (armedUnloadBypassGeneration !== null) { - armedUnloadBypassGeneration = null; - return; - } - e.preventDefault(); - e.returnValue = true; - }; - armOAuthRedirectUnloadBypass = (): (() => void) => { - const generation = ++nextUnloadBypassGeneration; - armedUnloadBypassGeneration = generation; - return () => { - if (armedUnloadBypassGeneration === generation) armedUnloadBypassGeneration = null; - }; - }; - let beforeUnloadInstalled = false; - const canToggleBeforeUnload = typeof win.addEventListener === 'function' - && typeof win.removeEventListener === 'function'; - // Called from every place that can change the aggregate dirty state: the - // tab-list reactive effect (`workbench-shell.ts`, for a new/closed/switched - // tab — anything that touches the `tabs` SIGNAL's own identity) and - // `actions.rerenderTabs` (for an in-place `dirtySql`/`dirtySpec` mutation, - // which never touches that signal at all — the SQL editor's `onDocChange` - // already calls `rerenderTabs()` right after setting `dirtySql = true`, so - // this reuses that existing repaint path rather than a new aggregate - // signal). Idempotent: a redundant call when the aggregate hasn't actually - // flipped is a no-op, never a duplicate registration. - app.syncBeforeUnload = (): void => { - if (!canToggleBeforeUnload) return; - const needed = app.state.tabs.value.some(tabSaveDirty); - if (needed === beforeUnloadInstalled) return; - beforeUnloadInstalled = needed; - if (needed) win.addEventListener('beforeunload', beforeUnload); - else win.removeEventListener('beforeunload', beforeUnload); - }; - - const provisionInitialWorkspace = async (): Promise => { - const listed = await app.workspace.list(); - const key = deriveWorkspaceKey(DEFAULT_WORKSPACE_NAME, listed.summaries.map((item) => item.key)); - const created = await app.workspace.create(createNewWorkspace(app.genId, key, DEFAULT_WORKSPACE_NAME)); - if (created.ok) return { status: 'ok', workspace: created.workspace }; - // A different tab may have provisioned the collection after our empty - // resolution. Re-resolve instead of creating a second fallback workspace. - return app.workspace.resolveImplicit(); - }; - - const resolveImplicitOrProvision = async (): Promise => { - const resolved = await app.workspace.resolveImplicit(); - return resolved.status === 'empty' ? provisionInitialWorkspace() : resolved; - }; - - const recordOpened = async (workspace: StoredWorkspaceV5): Promise => { - const result = await app.workspace.markOpened(workspace.key); - if (!result.ok) { - flashToast('Workspace opened, but its last-used timestamp could not be saved.', { document: doc }); - } - }; - + // reconcile for the rest, tab strip + Save button + var strip) by + // handing the tabs signal a fresh array reference. + refreshWorkbenchUi: () => { + batch(() => { app.state.tabs.value = [...app.state.tabs.value]; }); + app.updateSaveBtn(); + app.updateEditorModeUi?.(); + renderSavedHistory(app); + }, + notifyExternallyChanged: (info) => app.onWorkspaceExternallyChanged(info), + onExternalInvalidation: (msg) => app.onExternalWorkspaceChange(msg), + // #343 step 4: a non-destructive warning when a reload can't reach the + // store. The current projection stays on screen; the next focus/ + // visibility event schedules another attempt (activation always + // refreshes), so this never wedges the workspace queue or discards data. + warnRefreshFailed: () => { + flashToast( + 'Couldn’t reload the latest workspace — showing the last known version; will retry when you return to this tab.', + { document: doc }, + ); + }, + warnMarkOpenedFailed: () => { + flashToast('Workspace opened, but its last-used timestamp could not be saved.', { document: doc }); + }, + }, + }); + // #343 step 4: the route/surface refresh hook a mounted route registers to + // react AFTER a refresh actually projected an external change — Dashboard + // overrides this to rebuild its viewer session from the latest committed + // workspace. Default no-op: the Workbench route's repaint is built into + // `app.workspaceSession.refreshWorkspaceFromStore` itself. Flat delegate + // (wide production consumer set — see app.types.ts). + // #343 §5/§6: invoked when another tab reports a workspace change (channel + // receive, or a focus/visibility event) — the session's own channel + // handler and focus/visibility listeners call `scheduleRefresh()` directly; + // this flat delegate is what a mounted route/test overrides to observe the + // signal itself (never receives this tab's own broadcast). + // Flat delegates onto the session for its wide production consumer set + // (workbench-shell.ts, oauth callbacks, save-controller.ts's thunk). + + // #588 phase 4 wave 4: `resetCorruptWorkspace` stays here (real UI + // orchestration — drives `app.workspace.delete` alongside + // `session.resolveImplicitOrProvision`, exactly like `applyCommittedWorkspace` + // above stays outside `workspaceSession`), but the route-currency reads/ + // writes it used to do directly (`routeLoadGeneration`/`routeSearch`) now go + // through `app.nav` — `nav.loadGeneration()` and `nav.rewriteWorkspaceRoute()` + // do byte-identical work (see `surface-navigation.ts`'s own `writeRoute`). const resetCorruptWorkspace = async (id: string): Promise => { - const expectedGeneration = routeLoadGeneration; + const expectedGeneration = app.nav.loadGeneration(); const deleted = await app.workspace.delete(id); if (!deleted.ok) return; - const result = await resolveImplicitOrProvision(); - if (result.status === 'ok' && routeLoadGeneration === expectedGeneration) { + const result = await session.resolveImplicitOrProvision(); + if (result.status === 'ok' && app.nav.loadGeneration() === expectedGeneration) { applyCommittedWorkspace(result.workspace); - await recordOpened(result.workspace); - if (routeLoadGeneration !== expectedGeneration) return; - app.sqlRoute = routeForWorkspace(app.sqlRoute, result.workspace.key); - routeSearch = buildSqlRouteSearch(app.sqlRoute, routeSearch); - win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); + await session.recordOpened(result.workspace); + if (app.nav.loadGeneration() !== expectedGeneration) return; + app.nav.rewriteWorkspaceRoute(result.workspace.key); app.retryPendingOAuthDocumentRecovery(); app.renderCurrentSurface(); } }; - const writeRoute = (route: SqlRoute, method: 'push' | 'replace'): void => { - app.sqlRoute = route; - routeSearch = buildSqlRouteSearch(route, routeSearch); - win.history[method === 'push' ? 'pushState' : 'replaceState']( - null, '', conn.basePath + routeSearch + (loc.hash || ''), - ); - }; - - app.loadWorkspaceOnBoot = async () => { - const generation = ++routeLoadGeneration; - const explicitKey = app.sqlRoute.workspaceKey; - const result = explicitKey !== null - ? await app.workspace.loadByKey(explicitKey) - : await resolveImplicitOrProvision(); - if (generation !== routeLoadGeneration) return null; - if (result.status === 'corrupt') { - app.currentWorkspace = null; - app.workspaceRouteStatus = 'error'; - flashToast( - 'Saved workspace could not be read. Other local workspaces remain unaffected.', - { - document: app.document, - action: { label: 'Reset workspace', onClick: () => { void resetCorruptWorkspace(result.id); } }, - }, - ); - return null; - } - if (result.status !== 'ok') { - app.currentWorkspace = null; - app.workspaceRouteStatus = explicitKey !== null ? 'not-found' : 'error'; - const normalized = normalizeSqlRouteSearch(routeSearch); - app.sqlRoute = normalized.route; - if (normalized.search !== routeSearch) { - routeSearch = normalized.search; - win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); - } - return null; - } - const workspace = result.workspace; - await recordOpened(workspace); - if (generation !== routeLoadGeneration) return null; - applyCommittedWorkspace(workspace); - const canonicalRoute = routeForWorkspace(app.sqlRoute, workspace.key); - const canonicalSearch = buildSqlRouteSearch(canonicalRoute, routeSearch); - app.sqlRoute = canonicalRoute; - if (canonicalSearch !== routeSearch) { - routeSearch = canonicalSearch; - win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); - } - // #425: this is a URL-driven open (boot, a deep link, or a workspace - // switch), so the ROUTE decides the surface — including which Dashboard, - // resolved through the compatibility selector because the URL carries no id. - adoptRouteMainSurface(); - return workspace; - }; - const renderWorkspaceNotFound = (): void => { disposeCurrentSurface(); app.root?.replaceChildren(h('main', { class: 'workspace-not-found' }, @@ -2728,474 +1631,550 @@ export function createApp(env: CreateAppEnv = {}): App { }, h('p', null, 'Loading workspace…'))); }; - app.renderCurrentSurface = () => { - if (app.workspaceRouteStatus === 'loading') { - renderWorkspaceLoading(); - return; - } - if (app.workspaceRouteStatus !== 'ready' || !app.currentWorkspace) { - renderWorkspaceNotFound(); - return; - } - if (app.sqlRoute.surface === 'dashboard') app.renderDashboard(); - else app.renderApp(); - }; - - app.navigateSqlRoute = async (route, method) => { - app.closeShortcutDialog(); - resetShortcutChord(app); - const workspaceChanged = route.workspaceKey !== app.sqlRoute.workspaceKey; - const needsWorkspaceLoad = workspaceChanged || app.currentWorkspace === null; - writeRoute(route, method); - if (needsWorkspaceLoad) { - app.workspaceRouteStatus = 'loading'; - app.currentWorkspace = null; - renderWorkspaceLoading(); - const expectedGeneration = routeLoadGeneration + 1; - const workspace = await app.loadWorkspaceOnBoot(); - if (routeLoadGeneration !== expectedGeneration) return; - if (workspace) app.retryPendingOAuthDocumentRecovery(); - } else { - adoptRouteMainSurface(); - if (app.currentWorkspace) app.retryPendingOAuthDocumentRecovery(); - } - app.renderCurrentSurface(); - }; - - app.handleSqlPopState = async () => { - app.closeShortcutDialog(); - resetShortcutChord(app); - const previousKey = app.sqlRoute.workspaceKey; - routeSearch = loc.search; - app.sqlRoute = parseSqlRoute(routeSearch); - if (app.sqlRoute.workspaceKey === previousKey && app.currentWorkspace !== null) { - // #425: Back/Forward between surfaces of the SAME workspace is a surface - // transition, not a teardown — the shell and the query column stay mounted - // so the editor state survives it. (It used to run `disposeCurrentSurface`, - // whose blanket control-disable would now inert the still-mounted editor - // toolbar, tabs, and sidebar inputs permanently.) - adoptRouteMainSurface(); - if (app.currentWorkspace) app.retryPendingOAuthDocumentRecovery(); - app.renderCurrentSurface(); - return; - } - app.workspaceRouteStatus = 'loading'; - app.currentWorkspace = null; - renderWorkspaceLoading(); - const expectedGeneration = routeLoadGeneration + 1; - const workspace = await app.loadWorkspaceOnBoot(); - if (routeLoadGeneration !== expectedGeneration) return; - if (workspace) app.retryPendingOAuthDocumentRecovery(); - app.renderCurrentSurface(); - }; - app.syncSqlRoute = (search) => { - routeSearch = search; - app.sqlRoute = parseSqlRoute(search); - }; - app.rewriteWorkspaceRoute = (workspaceKey) => { - writeRoute(routeForWorkspace(app.sqlRoute, workspaceKey), 'replace'); - }; - - // #425 — the main-surface navigation API. Every surface transition goes - // through these three functions, so `app.mainSurface` is the ONE writer of the - // route: the URL is always derived from the session surface, never the other - // way round, and the two can never disagree. - const surfaceRouteKey = (): string | null => - app.currentWorkspace?.key ?? app.state.workspaceKey; - // Surface changes stay in this tab and create one useful history entry; - // a View/Edit mode change replaces so presentation toggles do not pollute - // Back (ADR-0003). - // #471 — write the Dashboard the CURRENT history entry is showing onto that entry, - // with the scroll offset the DOM has right now. - // - // The URL deliberately carries neither (#425 keeps the selected id and the offset as - // session state), so an entry that records nothing cannot be returned to: Back out - // of a tile's Open-in-Workbench used to land on the collection's first Dashboard, at - // the top. It has to run BEFORE the transition, because `pushState` leaves the - // outgoing entry's state exactly as it was last written — and again after writing a - // Dashboard route, so a freshly created entry carries its id immediately (Forward - // into it, or a second Back, restores the same way). - const stampDashboardHistoryEntry = (): void => { - const snapshot = dashboardHistorySnapshot( - app.mainSurface, app.sqlRoute.workspaceKey, dashboardScrollTop() ?? 0, - ); - // `null` (Query mode) is written too: it clears a snapshot this entry may carry - // from an earlier surface, so a Query entry never restores a Dashboard. - // Unguarded, exactly like `writeRoute` immediately below — a platform with no - // history API fails there on the same transition either way. - win.history.replaceState({ dash: snapshot }, '', conn.basePath + routeSearch + (loc.hash || '')); - }; - - const applyMainSurface = (surface: MainSurfaceState, method: 'push' | 'replace'): void => { - stampDashboardHistoryEntry(); - app.mainSurface = surface; - writeRoute(mainSurfaceRoute(surface, surfaceRouteKey()), method); - if (surface.kind === 'dashboard') stampDashboardHistoryEntry(); - // #426: the tree lives in the PERSISTENT shell, so a surface transition does - // not repaint it as a side effect of re-rendering the work area — it needs - // telling. Current Dashboard/member styling is derived from this state. - app.invalidateDashboardTree(); - app.renderCurrentSurface(); - }; + // #588 phase 4 wave 4: the route locals + surface-generation guards, + // `writeRoute`, `loadWorkspaceOnBoot`, the `renderCurrentSurface` dispatch, + // `navigateSqlRoute`/`handleSqlPopState`, `syncSqlRoute`/ + // `rewriteWorkspaceRoute`, `surfaceRouteKey`/`stampDashboardHistoryEntry`/ + // `applyMainSurface`, `focusDashboardMember`, `openDashboard`/ + // `showQuerySurface`/`showDashboardSurface`, the saved-query/panel/variable + // tab openers, `adoptRouteMainSurface`, and `reloadDashboardRoute` all now + // live in `application/surface-navigation.ts`'s `createSurfaceNavigation` + // (a pure extraction — every line moved verbatim, only `app.*`/`win`/`loc`/ + // `doc` reads rewritten onto the `deps` thunks below). This shell supplies + // every `src/ui/**` touch point the moved code made (toast, tab loading, + // dashboard-tree reveal, dashboard scroll, render dispatch) as an INJECTED + // HOOK — `src/application/**` may not import `src/ui/**` at all, type-only + // imports included (build/check-boundaries.mjs). `surface: () => app` hands + // the module the live controller, narrowed structurally to + // `SurfaceStatePort` — mutations through it land on the real `app`, so + // nothing outside this file needs to change. + const nav = createSurfaceNavigation({ + state, + surface: () => app, + repository: workspaceRepo, + session, + history: win.history, + basePath: () => conn.basePath, + locationHash: () => loc.hash || '', + locationSearch: () => loc.search, + hooks: { + applyCommittedWorkspace: (ws) => app.applyCommittedWorkspace(ws), + renderApp: () => app.renderApp(), + renderDashboard: () => app.renderDashboard(), + renderWorkspaceLoading: () => renderWorkspaceLoading(), + renderWorkspaceNotFound: () => renderWorkspaceNotFound(), + onCorruptWorkspace: (id) => { void resetCorruptWorkspace(id); }, + retryPendingOAuthDocumentRecovery: () => { app.retryPendingOAuthDocumentRecovery(); }, + closeShortcutDialog: () => app.closeShortcutDialog(), + resetShortcutChord: () => app.resetShortcutChord(), + isSignedIn: () => conn.isSignedIn(), + invalidateDashboardTree: () => app.invalidateDashboardTree(), + toast: (message, opts) => flashToast(message, { document: doc, action: opts?.action }), + revealAssignedPanel: (dashboardId, tileId) => revealAssignedPanel(app, dashboardId, tileId), + loadIntoNewTab: (query) => { loadIntoNewTab(app, { ...query }); }, + openVariableTabUi: (binding, sql) => { openVariableTab(app, binding, sql); }, + toEditorOnMobile: () => toEditorOnMobile(), + runAction: (opts) => { app.actions.run(opts); }, + dashboardScrollTop: () => dashboardScrollTop(), + isAutoRunnableSql: (sql) => isAutoRunnable(sql), + // Four "self-dispatch" hooks (see surface-navigation.ts's own doc + // comment on them): read the LIVE `app.*` property at call time, so a + // test overriding e.g. `app.renderCurrentSurface = vi.fn()` is observed + // by every nav-internal cross-call exactly as the pre-extraction inline + // code was (every one of these four members was called via `app.foo()` + // property access from ANOTHER moved function, never a private local). + dispatchCurrentSurface: () => app.renderCurrentSurface(), + dispatchLoadWorkspaceOnBoot: () => app.loadWorkspaceOnBoot(), + dispatchShowQuerySurface: () => app.showQuerySurface(), + dispatchOpenDashboard: (request) => app.openDashboard(request), + }, + }); + // Flat delegates for every wide-consumer member (dashboard.ts, + // dashboard-tree.ts, file-menu.ts, app-shell.ts, shortcuts.ts, + // saved-history.ts, tests) — `handleSqlPopState`/`focusDashboardMember` + // (router-private) and `syncSqlRoute`/`rewriteWorkspaceRoute` (repointed to + // main.ts/file-menu.ts) have NO flat delegate; reach them via `app.nav.*`. - // #426 — deliver focus to one member of the ALREADY-RENDERED Dashboard through - // the route-local surface command port. `null`/wrong-surface/superseded ports - // all report `pending`, which means "not deliverable in place" rather than - // "gone" — the caller then takes the normal render transition. - app.focusDashboardMember = (member) => { - const port = app.surfaceCommands; - if (!port || port.surface !== 'dashboard') return 'pending'; - return port.focusMember(member); - }; + // --- actions registry -------------------------------------------------- + const withAuthenticatedExecution = (operation: () => T): T | undefined => + (app.requireAuthenticatedExecution() ? operation() : undefined); - app.openDashboard = (request) => { - const resolution = resolveOpenDashboard(app.currentWorkspace, request); - if (resolution.status !== 'ok') { - // Reported, never repaired: an ambiguous id must not be resolved by a - // guess, and a deleted one must not silently retarget another Dashboard. - flashToast(resolution.status === 'duplicate' - ? 'This workspace has more than one dashboard with that id — resolve the duplicate before opening it.' - : 'That dashboard is no longer part of this workspace.', { document: doc }); - return; - } - const sameSelection = isSameDashboardSelection(app.mainSurface, request) - && app.sqlRoute.surface === 'dashboard'; - if (sameSelection && resolution.surface.kind === 'dashboard') { - // A repeated open of the SAME id in the SAME mode with NO member is a no-op - // on the surface itself — but it still CLEARS the current member (opening a - // Dashboard row deselects whatever member was marked), so the tree repaints. - if (resolution.surface.pendingFocus === null) { - app.mainSurface = resolution.surface; - app.invalidateDashboardTree(); - return; + app = { + state, + dom: {}, + root: env.root || doc.getElementById('root'), + document: doc, + Chart: env.Chart || win.Chart, + cssVar: env.cssVar || ((name: string) => win.getComputedStyle(doc.documentElement).getPropertyValue(name)), + Dagre: env.Dagre || win.dagre, + openWindow: env.openWindow || ((...a: Parameters) => win.open(...a)), + stylesText: env.stylesText || (doc.querySelector('style') ? doc.querySelector('style')!.textContent || '' : ''), + faviconHref: env.faviconHref + || (doc.querySelector('link[rel~="icon"]') ? doc.querySelector('link[rel~="icon"]')!.getAttribute('href') || '' : ''), + showSaveFilePicker: env.showSaveFilePicker + || (typeof win.showSaveFilePicker === 'function' ? win.showSaveFilePicker.bind(win) : null), + showDirectoryPicker: env.showDirectoryPicker + || (typeof win.showDirectoryPicker === 'function' ? win.showDirectoryPicker.bind(win) : null), + isSecureContext: env.isSecureContext != null ? env.isSecureContext : !!win.isSecureContext, + build: env.build || 'dev', + matchMedia: env.matchMedia || (typeof win.matchMedia === 'function' ? win.matchMedia.bind(win) : null), + shell: null, + canExport: () => !!app.showSaveFilePicker && app.isSecureContext, + canExportScript: () => !!app.showDirectoryPicker && app.isSecureContext, + prefs: prefs, + saveJSON: saveJSON, + saveStr: saveStr, + workspace: workspaceRepo, + sqlRoute: parseSqlRoute(loc.search), + currentWorkspace: null, + workspaceRouteStatus: 'ready', + keyboardOwner: null, + resetShortcutChord: () => resetShortcutChord(app), + acquireKeyboardOwner: (kind) => { + const owner = { kind }; + keyboardOwners.push(owner); + app.keyboardOwner = owner; + resetShortcutChord(app); + let released = false; + return () => { + if (released) return; + released = true; + const index = keyboardOwners.indexOf(owner); + if (index >= 0) keyboardOwners.splice(index, 1); + app.keyboardOwner = keyboardOwners.at(-1) ?? null; + resetShortcutChord(app); + }; + }, + shortcutDialog: null, + closeShortcutDialog: () => { + const dialog = app.shortcutDialog; + app.shortcutDialog = null; + dialog?.close(); + }, + surfaceCommands: null, + mainSurface: QUERY_SURFACE, + FileReader: (env.FileReader || win.FileReader) as typeof FileReader, + downloadFile: downloadFile, + // #588 phase 4 wave 5: genuinely missing before this wave -- masked by + // the old `Partial` + `as App` cast (file-menu.js reads/writes + // `app.editingLibrary` directly, and a `boolean` field that's never + // initialized here read as `undefined`, which is falsy and so behaved + // like `false` at every existing read site -- but the field is NOT + // optional on `App`, so the one-literal construction below made this an + // explicit compile-time gap (`TS2739`) rather than a silent runtime one). + editingLibrary: false, + activeTab: () => activeTab(app.state), + specValidators: specValidators, + specCompletionSources: env.specCompletionSources || createSpecCompletionSources(), + CodeViewer: env.CodeViewer || (() => ({ + setText() {}, setLanguage() {}, setWrap() {}, focus() {}, destroy() {}, + })), + openDocEntry: (target) => { + if (!app.requireAuthenticatedExecution()) return; + openDocEntry(app, target); + }, + closeDocPane: () => { + if (!isDocPaneOpen(app)) return false; + closeDocPane(app); + return true; + }, + openDocDisambiguation: (name) => { + if (!app.requireAuthenticatedExecution()) return; + openDocDisambiguation(app, name); + }, + // Stage 5 (after the literal, below) overwrites both editor ports with + // the real construction -- these are intentional placeholders so every + // OTHER member of this literal can reference a fully-typed `EditorPort`/ + // `SpecEditorPort` shape immediately. + sqlEditor: createNoopPort(), + specEditor: createNoopSpecEditor(), + queryDoc: queryDoc, + restoreOAuthDocumentRecovery: (callbackState: string): OAuthDocumentRecoveryApplyResult => { + // A fresh validated callback starts a new authority decision; a later + // deferred retry deserves its own single safe notice. + deferredRecoveryWarningShown = false; + try { + const restored = oauthDocumentRecovery.restore(callbackState, app.currentWorkspace); + if (restored.kind === 'retry-deferred-retained') { + return deferOAuthDocumentRecovery(); + } + return finalizeOAuthDocumentRecovery(restored); + } catch { + // The session normally converts storage failures into explicit retained + // outcomes. Keep this boundary defensive: an unexpected pre-publication + // failure must not abort the signed-in shell or expose backend details. + return deferOAuthDocumentRecovery(); } - // #426 — IN-PLACE member navigation. The tree makes repeated - // same-Dashboard focusing a normal operation, so it must not rebuild the - // viewer, re-run the Dashboard, or push another history entry (#425 - // re-rendered here, which did all three). - const member = resolution.surface.pendingFocus; - const outcome = app.focusDashboardMember(member); - if (outcome === 'ok') { - app.mainSurface = withCurrentMember(app.mainSurface, member); - app.invalidateDashboardTree(); - return; + }, + retryPendingOAuthDocumentRecovery: (): OAuthDocumentRecoveryApplyResult => { + let pending: OAuthDocumentRecoveryRestoreResult; + try { + pending = oauthDocumentRecovery.retryPending(app.currentWorkspace); + } catch { + return deferOAuthDocumentRecovery(); } - if (outcome === 'missing') { - // Non-destructive: the Dashboard stays open and unchanged, and the member - // is deliberately NOT marked current — nothing there to mark. - flashToast(member.kind === 'tile' - ? 'That panel is no longer on this dashboard.' - : 'That variable is no longer on this dashboard.', { document: doc }); - return; + if (pending.kind === 'retry-deferred-retained') { + // Nothing was published: do not arm the dirty guard, revalidate, consume, + // or replace the current workspace. The retained recovery nevertheless + // owns callback precedence, so callers discard the legacy share handoff. + return deferOAuthDocumentRecovery(); } - // `pending` — a curated filter whose control the opening wave is about to - // replace, or a superseded port. Fall through to the normal transition, - // which delivers focus at the deterministic point the node is stable. - } - // #426: reaching here with the SAME Dashboard id means the MODE changed (the - // same-id/same-mode cases all returned above), and a View/Edit switch must - // preserve the member the user navigated to — `resolveOpenDashboard` builds - // the surface from the request alone and cannot know one was current. - applyMainSurface( - carryCurrentMember(app.mainSurface, resolution.surface), - app.sqlRoute.surface === 'dashboard' ? 'replace' : 'push', - ); - }; - - app.showQuerySurface = () => { - if (app.mainSurface.kind === 'query' && app.sqlRoute.surface === 'workspace') return; - applyMainSurface(QUERY_SURFACE, app.sqlRoute.surface === 'dashboard' ? 'push' : 'replace'); - }; - - // The Dashboard entry points that name no Dashboard themselves: the header - // surface switch, the Workbench "Dashboard →" nav, the `g d`/`g v`/`g e` - // shortcuts, and the View/Edit switch. An ALREADY-selected Dashboard wins — so - // a mode change retains the same document rather than retargeting the - // collection's first entry — and only an unselected surface falls back to the - // ONE compatibility Dashboard (there is no chooser until #426's tree). Either - // way the open is addressed BY ID. An empty collection still reaches the - // Dashboard surface so its "Create dashboard" state remains available. - app.showDashboardSurface = (mode) => { - const selectedId = app.mainSurface.kind === 'dashboard' - ? app.mainSurface.dashboardId - : app.currentWorkspace ? resolveCompatibilityDashboard(app.currentWorkspace).selectedId : null; - if (selectedId !== null) { - app.openDashboard({ dashboardId: selectedId, mode }); - return; - } - const method = app.sqlRoute.surface === 'dashboard' ? 'replace' : 'push'; - app.mainSurface = QUERY_SURFACE; - writeRoute({ surface: 'dashboard', workspaceKey: surfaceRouteKey(), mode }, method); - // The one surface transition that does not go through `applyMainSurface`, so it - // has to tell the tree itself — otherwise "every transition invalidates" has a - // hole in it. - invalidateDashboardTree(); - app.renderCurrentSurface(); - }; - - // Opening a saved query is a Query-mode act: it returns to the preserved - // Query surface first, so the tab it opens is the one the user then sees. - // - // #443 — RESOLVE BEFORE NAVIGATING. Switching first meant an id that resolves - // to nothing yanked the user off whatever surface they were on and pushed a - // history entry, then opened no tab and said nothing — a dead click that also - // lost their place. Report it the way `openDashboard` reports a missing - // Dashboard, and leave surface and route exactly as they were. Every current - // caller (`dashboard-tree.ts`'s open-query command and its post-assignment - // reveal, `dashboard.ts`'s Open in Workbench) addresses a query it just - // resolved or just created, so none depended on the unconditional switch. - /** Resolve a saved query for opening, or report that it is gone. The shared - * #443 pre-flight: nothing moves until the id resolves. */ - const savedQueryToOpen = (queryId: string): SavedQueryV2 | null => { - const query = app.state.savedQueries.find((saved) => saved.id === queryId); - if (query) return query; - flashToast('That query is no longer part of this workspace.', { document: doc }); - return null; - }; - /** Switch to Query mode and put `query` in a tab (re-selecting the tab already - * open on it). Spread, like saved-history.ts's own two call sites: - * `loadIntoNewTab` accepts the looser `string | Json` shape a `SavedQueryV2` - * satisfies structurally but not nominally (no index signature). */ - const openQueryDocument = (query: SavedQueryV2): void => { - app.showQuerySurface(); - loadIntoNewTab(app, { ...query }); - toEditorOnMobile(); - }; - - app.openSavedQuery = (queryId) => { - const query = savedQueryToOpen(queryId); - if (query) openQueryDocument(query); - }; - - // #535 — the tile's expand action. Order matters: the tree is revealed FIRST, - // exactly as the Library-drop settlement does it (ui/dashboard-tree.ts), so the - // row is expanded and armed as the tree's position and then `loadIntoNewTab` - // moves focus on to the editor. Revealing afterwards would steal focus back out - // of the editor the user was just sent to. - app.openPanelQuery = ({ dashboardId, tileId, queryId }) => { - const query = savedQueryToOpen(queryId); - if (!query) return; - revealAssignedPanel(app, dashboardId, tileId); - openQueryDocument(query); - // The tile was showing a rendered result, so the editor should too — and on - // the query's OWN saved view, or a chart panel would arrive as a raw table. - // A queryless (text) panel never exposes this action, so there is no run-less - // view-restore branch to mirror from saved-history.ts here. - // - // Gated on the tab that ACTUALLY opened, not on `query.sql`: `loadIntoNewTab` - // re-selects an existing tab for the same `savedId`, and that tab may hold an - // unsaved draft the saved document knows nothing about — including a DDL - // statement, which must never auto-run. A Spec-mode tab is skipped too, since - // `run` silently does nothing there. - const tab = app.activeTab(); - if (tab.editorMode !== 'spec' && isAutoRunnable(tab.sqlDraft)) { - app.actions.run({ view: queryView(query) }); - } - }; - - // #457 — opening a variable's option SQL is a Query-mode act for exactly the - // same reason opening a saved query is, and routes the same way. - // - // The variable is resolved through `dashboardVariables`, the SAME projection the - // Dashboards tree paints its rows from, so what opens always matches what was - // clicked — active, conflicted and orphaned rows alike. A name that no longer - // resolves (a click racing a repaint that has already dropped it) opens nothing - // at all, rather than a tab for a variable that does not exist. - app.openVariableTab = (dashboardId, variableName) => { - const variable = dashboardVariables(app.currentWorkspace, dashboardId) - .find((candidate) => candidate.name === variableName); - if (variable === undefined) return; - app.showQuerySurface(); - // A newly inferred variable opens EMPTY; a configured one opens on its stored - // SQL. An orphan is configured by definition, so it opens on its SQL. - openVariableTab(app, { dashboardId, variableName }, variable.sql ?? ''); - toEditorOnMobile(); - }; - - // Adopt the surface the ROUTE describes. Used at boot, on Back/Forward, and - // after a workspace switch — the three moments the URL, not a click, decides - // the surface. Back/Forward INSIDE the Dashboard surface keeps whatever is - // explicitly selected: the URL carries no Dashboard id (#425 leaves URLs - // unchanged), so re-deriving one here would silently retarget the surface to - // the collection's first entry. - const adoptRouteMainSurface = (): void => { - const workspace = app.currentWorkspace; - if (app.sqlRoute.surface !== 'dashboard') { app.mainSurface = QUERY_SURFACE; return; } - const mode: DashboardSurfaceMode = app.sqlRoute.mode; - if (app.mainSurface.kind === 'dashboard') { - // #426: the mode change owes no new delivery, but the member the user - // navigated to survives a View/Edit switch — "switching View/Edit through - // Dashboard chrome preserves the current member where possible". The - // spread carries `currentMember`; `reconcileMainSurface` then drops it if - // committed truth no longer contains it. - app.mainSurface = reconcileMainSurface({ ...app.mainSurface, mode, pendingFocus: null }, workspace); - return; - } - // #471: the route says "a Dashboard" but carries no id, and the session no longer - // holds one (we are arriving from Query — typically Back out of a tile's - // Open-in-Workbench). The history ENTRY is the only thing that knows WHICH - // Dashboard this was, so it is consulted before the compatibility fallback: - // without it, Back reliably opened the collection's first Dashboard instead of - // the one the user left, at the top of the page. - const snapshot = readDashboardHistorySnapshot(win.history?.state, app.sqlRoute.workspaceKey); - if (snapshot) { - const restored = restoreDashboardSurface(snapshot, mode, workspace); - // A snapshot whose Dashboard is gone reconciles to Query; fall through to the - // compatibility entry only then, exactly as a boot with no snapshot does. - if (restored.kind === 'dashboard') { app.mainSurface = restored; return; } - } - const selectedId = workspace ? resolveCompatibilityDashboard(workspace).selectedId : null; - app.mainSurface = selectedId === null - ? QUERY_SURFACE - : { - kind: 'dashboard', dashboardId: selectedId, mode, - currentMember: null, pendingFocus: null, pendingScrollTop: null, - }; - }; - - app.reloadDashboardRoute = () => { - // #424: fold the projected Dashboard back into the COLLECTION, preserving - // every other entry. A null projection means "this workspace has no - // Dashboard", which can only happen when the collection is already empty — - // never a reason to drop a stored Dashboard, so the array is left alone. - // #425: fold it back into the SELECTED entry, addressed by id. Writing the - // compatibility slot here would overwrite the collection's FIRST Dashboard - // while a different one is on screen. `replaceDashboard` returns null for a - // missing or ambiguous id, which leaves the collection untouched rather than - // guessing — the surface reconciles to Query mode on its next projection. - const selectedId = selectedDashboardId(app.mainSurface); - const foldProjection = (workspace: StoredWorkspaceV5): StoredWorkspaceV5 => { - if (!app.state.dashboard) return workspace; - if (selectedId === null) return withCompatibilityDashboard(workspace, app.state.dashboard); - return replaceDashboard(workspace, selectedId, app.state.dashboard) ?? workspace; - }; - app.currentWorkspace = app.currentWorkspace - ? { ...foldProjection(app.currentWorkspace), queries: app.state.savedQueries } - : null; - app.renderDashboard(); - }; + if (pending.kind === 'document-session-changed-retained') { + flashToast( + 'Recovered drafts were kept because this document session changed.', + { + document: doc, + action: { + label: 'Restore drafts', + onClick: () => { + const forced = oauthDocumentRecovery.retryPending( + app.currentWorkspace, + { allowChangedDocumentSession: true }, + ); + finalizeOAuthDocumentRecovery(forced); + app.renderCurrentSurface(); + }, + }, + }, + ); + return pending; + } + deferredRecoveryWarningShown = false; + return finalizeOAuthDocumentRecovery(pending); + }, + consumeLegacyShared: (allowRestore: boolean, consumedHandoff?: string | null): boolean => { + let encoded: string | null; + try { + encoded = consumedHandoff === undefined + ? ss.getItem('oauth_shared') + : consumedHandoff; + } catch { + return false; + } + if (encoded === null) return false; + // In-page Basic login owns the storage handoff here. Bootstrap passes its + // already-consumed value so the same parser/application path is reused. + if (consumedHandoff === undefined) { + try { + ss.removeItem('oauth_shared'); + } catch { + // Handoff cleanup is best-effort. Recovery precedence still suppresses + // the payload, and a storage backend failure must not abort rendering. + } + } + // The handoff is one-shot regardless of whether recovery suppresses it, + // its payload is malformed, or the current route has no Query surface. + if (!allowRestore || app.sqlRoute.surface !== 'workspace') return false; - // --- actions registry -------------------------------------------------- - const withAuthenticatedExecution = (operation: () => T): T | undefined => - (app.requireAuthenticatedExecution() ? operation() : undefined); - app.actions = { - run: (opts) => withAuthenticatedExecution(() => workbench.runEntry(opts)), - cancel: () => workbench.cancel(), - newTab: () => newTab(app), - selectTab: (id) => selectTab(app, id), - closeTab: (id) => closeTab(app, id), - // #425: opening a query is a Query-mode act, so every EXISTING opening path - // (the Library list, History, the schema tree's double-click) switches the - // main surface back before loading — otherwise the new tab would land behind - // a visible Dashboard. A no-op when the Query surface is already active. - loadIntoNewTab: (queryOrName, sql) => { - app.showQuerySurface(); - loadIntoNewTab(app, queryOrName, sql); - toEditorOnMobile(); + let shared; + try { + const raw = JSON.parse(encoded) as Record; + // Pre-#166 OAuth handoffs stored `{sql, chart}` directly; the normal + // upgrader preserves that compatibility while current v2 payloads pass + // through with their authored Spec intact. + shared = upgradeSavedQuery(raw.specVersion == null + ? { name: 'Shared query', ...raw } + : raw); + } catch { + return false; + } + const panel = queryPanel(shared); + if (!shared.sql && !panel) return false; + + const tab = app.state.tabs.value[0]; + tab.sqlDraft = shared.sql; + tab.name = queryName(shared); + tab.specVersion = shared.specVersion; + setTabSpecDraft(tab, cloneJson(shared.spec)); + const launchView = queryView(shared); + const normalized = launchView === 'chart' ? 'panel' : launchView; + if (SAVED_VIEWS.has(normalized ?? '')) { + app.state.resultView.value = normalized as App['state']['resultView']['value']; + } else if (!shared.sql && isQuerylessPanel(panel)) { + app.state.resultView.value = 'panel'; + } + // #588 phase 4 wave 4: the cached route-search string moved into `app.nav` + // — this reads the LIVE value through its `currentRouteSearch()` escape + // hatch (see surface-navigation.ts's header comment) rather than a + // module-local `routeSearch` this file no longer keeps. + win.history.replaceState(null, '', loc.pathname + app.nav.currentRouteSearch()); + return true; }, - login: (idpId, targetOrigin) => conn.beginOAuth(idpId, targetOrigin), - // Basic-auth login renders in-page (no page reload), so — unlike the OAuth - // path, where `main.ts`'s `bootstrap` awaits it — this is the only place - // workspace resolution runs for a username/password session. Without it, - // basic auth would keep rendering the placeholder workspace instead of the - // requested or last-used persisted workspace. - connect: async (input) => { - const resumeMountedDocument = shell !== null && activeExecutionScope === null; - await conn.connectBasic(input); - app.resumeAuthenticatedExecution(); - if (resumeMountedDocument) { - // Preserve the exact mounted document/editor/result objects. Only - // connection-scoped metadata and execution owners are refreshed. - await Promise.allSettled([catalog.loadSchema(), catalog.loadReference()]); - void catalog.loadVersion(); + saved: saved, + conn: conn, + executionScope: () => activeExecutionScope, + resumeAuthenticatedExecution: () => { + const epoch = conn.connection.value.epoch; + if (activeExecutionScope?.epoch === epoch && activeExecutionScope.isOpen()) { + hideAuthenticationRequired(); return; } - const workspace = await app.loadWorkspaceOnBoot(); - const pendingRecovery = workspace - ? app.retryPendingOAuthDocumentRecovery() - : null; - app.consumeLegacyShared( - !recoveryOwnsLegacyShare(pendingRecovery), - ); - app.renderCurrentSurface(); - void app.catalog.loadVersion(); + activeExecutionScope?.close(); + const scope = createAuthenticatedExecutionScope({ + epoch, + cancelRemote: (lease, queryId) => ch.killQueryWithLease(lease, queryId, sqlString), + }); + activeExecutionScope = scope; + // Connection-scoped caches/panes are owners even when they have no live + // server query id. Their own invalidation/generation guards make late + // completion inert; query-bearing owners register their current ids. + scope.register({ name: 'schema catalog', abort: () => catalog.invalidate() }); + scope.register({ name: 'schema graph', abort: () => graph.suspend() }); + // #586: whatever currently occupies the shared docked inspector (Cell, + // Rows, or Reference) — not just Reference — must not survive a + // connection-scope abort; `closeInspector` closes the current occupant + // generically, calling its own SurfaceLifecycle teardown. + scope.register({ name: 'docked inspector', abort: () => closeInspector(app) }); + hideAuthenticationRequired(); }, - share, - copyResult, - // `ActionsRegistry.copySnapshot`'s public `result: Json | null` is looser - // than the real always-`QueryResult`-shaped value every caller (results.ts's - // Copy button, the detached Data view) actually passes — `Json`'s index - // signature can't guarantee `QueryResult`'s required fields, so a wrapper - // (not the function reference directly) bridges the two: `| null` on both - // sides of the cast keeps it a single legal step (same pattern as - // `recordHistory`'s above). - copySnapshot: (result, targetDoc) => copySnapshot(result as QueryResult | null, targetDoc), - exportEntry: () => withAuthenticatedExecution(exportEntry), - exportDirect: (sqlInput, waveMs) => - withAuthenticatedExecution(() => exportDirect(sqlInput, waveMs)) ?? Promise.resolve(), - cancelExport, - cancelExportScript, - save: saveActiveQuery, - openUserMenu, - formatQuery: () => withAuthenticatedExecution(formatQuery) ?? Promise.resolve(), - formatSpec, - setEditorMode, - explainQuery: () => withAuthenticatedExecution(explainQuery), - setExplainView: (id) => withAuthenticatedExecution(() => setExplainView(id)), - setResultRowLimit, - showSchemaGraph: (focus) => - withAuthenticatedExecution(() => showSchemaGraph(focus)) ?? Promise.resolve(), - cancelSchemaGraph, - expandSchemaGraph: (focus) => - withAuthenticatedExecution(() => expandSchemaGraph(focus)) ?? Promise.resolve(), - openNodeDetail: (node, targetDoc) => - withAuthenticatedExecution(() => openNodeDetail(node, targetDoc)) ?? Promise.resolve(), - insertCreate: async (target) => { - if (!app.requireAuthenticatedExecution()) return; - await insertCreate(target); - toEditorOnMobile(); + requireAuthenticatedExecution: () => { + let scope = activeExecutionScope; + // Production bootstrap establishes the first scope explicitly, but + // controller entry points are also valid before a surface is mounted + // (and tests exercise that contract). An already-authenticated session can + // therefore materialize its scope lazily; an auth-required session cannot. + if (!scope && conn.isSignedIn()) { + app.resumeAuthenticatedExecution(); + scope = activeExecutionScope; + } + if (scope?.isOpen()) return scope; + revealAuthenticationRequired(conn.connection.value.detail); + return null; }, - openCreateInNewTab: (target, name) => - withAuthenticatedExecution(() => openCreateInNewTab(target, name)) ?? Promise.resolve(), - openShortcuts: () => { - const dialog = openShortcuts(app, () => { app.shortcutDialog = null; }); - if (dialog) app.shortcutDialog = dialog; + signOut: () => { + app.closeShortcutDialog(); + resetShortcutChord(app); + const closing = activeExecutionScope; + activeExecutionScope = null; + closing?.close(conn.captureCancellationLease()); + workbench.destroy(); + // Plain abort (no clearResult settle) — the login render replaces the + // whole DOM next, so settling the visible result would be a wasted paint. + graph.cancel(); + exportService.cancelExport(); + exportService.cancelExportScript(); + catalog.invalidate(); + // #313/#586: docked inspector content (Cell, Rows, or Reference — not + // just Reference) must never survive a connection change — closed + // alongside the catalog reset, before the login screen renders. + closeInspector(app); + conn.signOut(); + // #425: explicit logout owns Dashboard teardown, the surface-generation + // bump, and the main-surface reset through the full-screen login renderer. + renderLoginApp(); + }, + showLogin: (msg) => renderLoginApp(msg), + catalog: catalog, + updateBanner: updateBanner, + wallNow: wallNow, + exec: exec, + now: now, + tickElapsed: tickElapsed, + params: params, + saveVarRecent: () => params.saveVarRecent(), + exports: exportService, + workbench: workbench, + elapsedMs: () => workbench.elapsedMs(), + setRunBtn: (running, gate) => variableStrip.setRunBtn(running, gate), + renderVarStrip: () => variableStrip.renderVarStrip(), + setExportBtn: setExportBtn, + setFmtBtn: setFmtBtn, + graph: graph, + recordHistory: (tab, sqlText) => { + saved.recordHistory(tab, sqlText); + app.shell?.sidePanels.notifyRunComplete(); + }, + specBlocked: specBlocked, + updateSaveBtn: saveController.updateSaveBtn, + activateInvalidSpecDraft: (tab) => { + if (!tab) return; + batch(() => { app.state.activeTabId.value = tab.id; }); + tab.editorMode = 'spec'; + app.updateEditorModeUi!(); + app.specEditor.focus(); + flashToast('Fix Spec JSON first', { document: doc }); + }, + openUserMenu: openUserMenu, + toggleTheme: toggleTheme, + renderDashboard: () => { + if (conn.isSignedIn() && !activeExecutionScope) app.resumeAuthenticatedExecution(); + beginSurfaceTransition(); + const mounted = ensureShell(); + // Exposed BEFORE rendering: the grafana-grid engine measures its host's real + // width immediately after mount, and a hidden host measures 0 — which + // silently pins every Dashboard to the widest 12-column breakpoint. happy-dom + // always reports 0, so only a real browser can catch a regression here. + mounted.showHost('dashboard'); + return renderDashboard(app, dashboardRenderTarget(mounted)); + }, + invalidateDashboardTree: invalidateDashboardTree, + applyCommittedWorkspace: applyCommittedWorkspace, + genId: () => uid('ws-'), + workspaceSession: session, + onWorkspaceExternallyChanged: ignoreExternalWorkspaceChange, + onExternalWorkspaceChange: () => session.scheduleRefresh(), + mutateWorkspace: session.mutateWorkspace, + syncBeforeUnload: () => session.syncBeforeUnload(), + nav: nav, + navigateSqlRoute: nav.navigateSqlRoute, + renderCurrentSurface: nav.renderCurrentSurface, + loadWorkspaceOnBoot: nav.loadWorkspaceOnBoot, + reloadDashboardRoute: nav.reloadDashboardRoute, + openDashboard: nav.openDashboard, + showQuerySurface: nav.showQuerySurface, + showDashboardSurface: nav.showDashboardSurface, + openSavedQuery: nav.openSavedQuery, + openPanelQuery: nav.openPanelQuery, + openVariableTab: nav.openVariableTab, + captureSurfaceGeneration: nav.captureSurfaceGeneration, + isSurfaceGenerationCurrent: nav.isSurfaceGenerationCurrent, + refreshCurrentSurfaceAfterStale: nav.refreshCurrentSurfaceAfterStale, + actions: { + run: (opts) => withAuthenticatedExecution(() => workbench.runEntry(opts)), + cancel: () => workbench.cancel(), + newTab: () => newTab(app), + selectTab: (id) => selectTab(app, id), + closeTab: (id) => closeTab(app, id), + // #425: opening a query is a Query-mode act, so every EXISTING opening path + // (the Library list, History, the schema tree's double-click) switches the + // main surface back before loading — otherwise the new tab would land behind + // a visible Dashboard. A no-op when the Query surface is already active. + loadIntoNewTab: (queryOrName, sql) => { + app.showQuerySurface(); + loadIntoNewTab(app, queryOrName, sql); + toEditorOnMobile(); + }, + login: (idpId, targetOrigin) => conn.beginOAuth(idpId, targetOrigin), + // Basic-auth login renders in-page (no page reload), so — unlike the OAuth + // path, where `main.ts`'s `bootstrap` awaits it — this is the only place + // workspace resolution runs for a username/password session. Without it, + // basic auth would keep rendering the placeholder workspace instead of the + // requested or last-used persisted workspace. + connect: async (input) => { + const resumeMountedDocument = shell !== null && activeExecutionScope === null; + await conn.connectBasic(input); + app.resumeAuthenticatedExecution(); + if (resumeMountedDocument) { + // Preserve the exact mounted document/editor/result objects. Only + // connection-scoped metadata and execution owners are refreshed. + await Promise.allSettled([catalog.loadSchema(), catalog.loadReference()]); + void catalog.loadVersion(); + return; + } + const workspace = await app.loadWorkspaceOnBoot(); + const pendingRecovery = workspace + ? app.retryPendingOAuthDocumentRecovery() + : null; + app.consumeLegacyShared( + !recoveryOwnsLegacyShare(pendingRecovery), + ); + app.renderCurrentSurface(); + void app.catalog.loadVersion(); + }, + share, + copyResult, + // `ActionsRegistry.copySnapshot`'s public `result: Json | null` is looser + // than the real always-`QueryResult`-shaped value every caller (results.ts's + // Copy button, the detached Data view) actually passes — `Json`'s index + // signature can't guarantee `QueryResult`'s required fields, so a wrapper + // (not the function reference directly) bridges the two: `| null` on both + // sides of the cast keeps it a single legal step (same pattern as + // `recordHistory`'s above). + copySnapshot: (result, targetDoc) => copySnapshot(result as QueryResult | null, targetDoc), + exportEntry: () => withAuthenticatedExecution(exportEntry), + exportDirect: (sqlInput, waveMs) => + withAuthenticatedExecution(() => exportDirect(sqlInput, waveMs)) ?? Promise.resolve(), + cancelExport, + cancelExportScript, + save: saveController.saveActiveQuery, + openUserMenu, + formatQuery: () => withAuthenticatedExecution(formatQuery) ?? Promise.resolve(), + formatSpec, + setEditorMode, + explainQuery: () => withAuthenticatedExecution(explainQuery), + setExplainView: (id) => withAuthenticatedExecution(() => setExplainView(id)), + setResultRowLimit, + showSchemaGraph: (focus) => + withAuthenticatedExecution(() => showSchemaGraph(focus)) ?? Promise.resolve(), + cancelSchemaGraph, + expandSchemaGraph: (focus) => + withAuthenticatedExecution(() => expandSchemaGraph(focus)) ?? Promise.resolve(), + openNodeDetail: (node, targetDoc) => + withAuthenticatedExecution(() => openNodeDetail(node, targetDoc)) ?? Promise.resolve(), + insertCreate: async (target) => { + if (!app.requireAuthenticatedExecution()) return; + await insertCreate(target); + toEditorOnMobile(); + }, + openCreateInNewTab: (target, name) => + withAuthenticatedExecution(() => openCreateInNewTab(target, name)) ?? Promise.resolve(), + openShortcuts: () => { + const dialog = openShortcuts(app, () => { app.shortcutDialog = null; }); + if (dialog) app.shortcutDialog = dialog; + }, + // Editor-mutating actions jump the mobile bottom-nav to the Editor panel + // (#126) so a schema tap / SHOW CREATE lands where the user can see it. + insertAtCursor: (text) => { app.sqlEditor.insertAtCursor(text); toEditorOnMobile(); }, + replaceEditor: (text) => { app.sqlEditor.replaceDocument(text); toEditorOnMobile(); }, + loadColumns: (db, table) => + withAuthenticatedExecution(() => loadColumns(db, table)) ?? Promise.resolve(), + // #466/#501-review: `renderTabs` alone repaints the strip; an in-place + // `dirtySql`/`dirtySpec` mutation never touches the `tabs` SIGNAL itself + // (no new array), so this is also the one place that re-syncs the + // `beforeunload` guard for that case — the tab-list reactive effect + // (workbench-shell.ts) covers the signal-driven case (new/closed/switched + // tabs) on its own. + rerenderTabs: () => { renderTabs(app); app.syncBeforeUnload(); }, + rerenderResults: () => renderResults(app), + updateSaveBtn: () => app.updateSaveBtn(), + }, + renderApp: () => { + if (conn.isSignedIn() && !activeExecutionScope) app.resumeAuthenticatedExecution(); + beginSurfaceTransition(); + // The Dashboard's own route-scoped resources go; the query column does NOT + // (it is mounted once and preserved — see `ensureShell`). + disposeDashboardSurface(); + app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; + const mounted = ensureShell(); + mounted.setHeader(buildAppHeader(app)); + mounted.showHost('query'); + // Repaint the results pane on every return to this surface. A query that + // finished while the Dashboard was visible built its Chart.js canvas in a + // zero-size host, and chart-render only auto-resizes a laid-out one — so + // without this the chart comes back blank. Cheap and idempotent otherwise. + renderResults(app); }, - // Editor-mutating actions jump the mobile bottom-nav to the Editor panel - // (#126) so a schema tap / SHOW CREATE lands where the user can see it. - insertAtCursor: (text) => { app.sqlEditor.insertAtCursor(text); toEditorOnMobile(); }, - replaceEditor: (text) => { app.sqlEditor.replaceDocument(text); toEditorOnMobile(); }, - loadColumns: (db, table) => - withAuthenticatedExecution(() => loadColumns(db, table)) ?? Promise.resolve(), - // #466/#501-review: `renderTabs` alone repaints the strip; an in-place - // `dirtySql`/`dirtySpec` mutation never touches the `tabs` SIGNAL itself - // (no new array), so this is also the one place that re-syncs the - // `beforeunload` guard for that case — the tab-list reactive effect - // (workbench-shell.ts) covers the signal-driven case (new/closed/switched - // tabs) on its own. - rerenderTabs: () => { renderTabs(app); app.syncBeforeUnload(); }, - rerenderResults: () => renderResults(app), - updateSaveBtn: () => app.updateSaveBtn(), - }; - - app.renderApp = () => { - if (conn.isSignedIn() && !activeExecutionScope) app.resumeAuthenticatedExecution(); - beginSurfaceTransition(); - // The Dashboard's own route-scoped resources go; the query column does NOT - // (it is mounted once and preserved — see `ensureShell`). - disposeDashboardSurface(); - app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; - const mounted = ensureShell(); - mounted.setHeader(buildAppHeader(app)); - mounted.showHost('query'); - // Repaint the results pane on every return to this surface. A query that - // finished while the Dashboard was visible built its Chart.js canvas in a - // zero-size host, and chart-render only auto-resizes a laid-out one — so - // without this the chart comes back blank. Cheap and idempotent otherwise. - renderResults(app); }; + // Stage 5 -- late wiring: every statement below OVERWRITES a member the + // literal above already declared (or registers a listener); none of them + // is a first assignment. `app` is fully built by this point, so `Editor`/ + // `SpecEditor` (real CodeMirror adapters in production) receive a + // completely-wired controller instead of the partially-built object they + // used to see mid-construction. + app.sqlEditor = Editor(app); + app.specEditor = SpecEditor(app); + app.sqlEditor.onDocChange((value) => { + const tab = app.activeTab(); + tab.sqlDraft = value; + tab.dirtySql = true; + // #447: no re-evaluation of the Spec on a SQL keystroke any more. The ONLY + // validator whose diagnostics depended on the SQL text was the Filter role's + // (its source SQL had to be a single row-returning statement), and that role + // no longer exists — every surviving rule reads the Spec alone, so + // re-running the whole validator graph per keystroke is pure waste. + if (app.actions) app.actions.rerenderTabs(); + if (app.updateSaveBtn) app.updateSaveBtn(); + if (app.renderVarStrip) app.renderVarStrip(); + }); + // No flat `App` delegates for `evaluateSpecDraft`/`revalidateSpecDrafts`/ + // `revealFirstSpecError`/`registerSpecValidator` (#276 Phase 5 deleted + // them) — every consumer (including this file's own call sites further + // down) reads `queryDoc.*` directly. + app.specEditor.onDocChange((value) => { + queryDoc.evaluateSpecDraft(app.activeTab(), value); + }); if (typeof win.addEventListener === 'function') { - win.addEventListener('popstate', () => { void app.handleSqlPopState(); }); + win.addEventListener('popstate', () => { void app.nav.handleSqlPopState(); }); } return app; } diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 25bdc6e1..afa20c31 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -3,7 +3,8 @@ // internal ~290-property implementation — verified against real usage across // src/ui/*.ts, src/editor/*.ts and src/main.js (ADR-0002 phase 0 / #262, #267). // app.ts's own `createApp` return value is declared against this contract -// directly (`const app = {} as App;` + property assignment — see app.ts). +// directly (one `app: App = {...}` object literal, no cast — a member missing +// from the literal is a compile error; see app.ts's Stage 4/5 comments). // // `State`/`Tab` are the real src/state.ts types (ADR-0002 phase 2), re-exported // under the names this contract has always used. @@ -24,13 +25,14 @@ import type { SchemaCatalogService } from '../application/schema-catalog-service import type { SchemaGraphSession } from '../application/schema-graph-session.js'; import type { AppPreferences } from '../application/app-preferences.js'; import type { - DashboardFocusTarget, DashboardSurfaceMode, MainSurfaceState, OpenDashboardRequest, + DashboardSurfaceMode, MainSurfaceState, OpenDashboardRequest, WorkspaceRouteStatus, } from '../application/main-surface.js'; import type { WorkspaceRepository } from '../workspace/workspace-repository.js'; import type { StoredWorkspaceV5 } from '../generated/json-schema.types.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; import type { SqlRoute } from '../core/sql-route.js'; -import type { DashboardFocusOutcome, SurfaceCommandPort } from './shortcuts.js'; +import type { SurfaceCommandPort } from './shortcuts.js'; +import type { SurfaceNavigation } from '../application/surface-navigation.js'; import type { DynamicSources } from '../core/spec-completion.js'; import type { WorkbenchSession } from './workbench/workbench-session.js'; import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; @@ -38,6 +40,7 @@ import type { ExportService } from '../application/export-service.js'; import type { QueryDocumentSession } from '../application/query-document-session.js'; import type { SavedQueryService } from '../application/saved-query-service.js'; import type { OAuthDocumentRecoveryRestoreResult } from '../application/oauth-document-recovery-session.js'; +import type { WorkspaceSession, WorkspaceChangedMessage } from '../application/workspace-session.js'; // Type-only, and circular with `app-shell.ts` (which imports `App` from this // file) — TypeScript erases `import type` entirely, so this introduces no // runtime cycle. `AppShellHandle` is the ONE seam `app.shell` exposes: the @@ -76,14 +79,11 @@ export type OAuthDocumentRecoveryApplyResult = warning: 'spec-revalidation-failed' | 'checkpoint-remove-failed'; }; -/** The cross-tab invalidation signal (#343 §5) — a small "reload the record" - * poke, never the workspace body. `sourceTabId` lets a tab ignore its OWN - * broadcast; `workspaceId` scopes it to a specific aggregate. */ -export interface WorkspaceChangedMessage { - type: 'workspace-changed'; - sourceTabId: string; - workspaceId: string; -} +// #588 phase 4 §3-T #1: `WorkspaceChangedMessage` is now DECLARED in +// `src/application/workspace-session.ts` (the module that owns the +// BroadcastChannel wire it describes) and re-exported here so every existing +// importer (app.ts included) keeps compiling with zero call-site changes. +export type { WorkspaceChangedMessage } from '../application/workspace-session.js'; /** A schema entity reference — three real runtime shapes share this one loose * contract: `showSchemaGraph`/`expandSchemaGraph`'s FOCUS payload (schema.ts's @@ -108,9 +108,14 @@ export interface SchemaFocus { /** `app.dom` is reset wholesale (`{}`) at the top of every renderApp() call — * a stable dictionary of known-consumed keys, not a closed interface. Beyond * the keys other modules read (documented individually below), it also carries - * every DOM ref + var-strip rebuild bookkeeping field app.ts's own renderApp()/ - * renderVarStrip() attach to `app.dom` (never read outside app.ts, but typed - * here since AppDom is the one place `app.dom`'s shape is described). */ + * every DOM ref app.ts's own renderApp() attaches to `app.dom` (never read + * outside app.ts, but typed here since AppDom is the one place `app.dom`'s + * shape is described). The var-strip's own rebuild bookkeeping + * (`sig`/`rerenderPending`/`hookedStrip`) is no longer here — #588 W1 moved + * `renderVarStrip`/`setRunBtn` into `ui/workbench/variable-strip.ts`, whose + * `createVariableStrip` controller now owns that bookkeeping as private + * closure state, keyed to strip-ELEMENT identity rather than riding along + * with `app.dom`'s wholesale reset (see that module's header comment). */ export interface AppDom { fileBtn?: HTMLElement; libraryTitle?: HTMLElement; @@ -151,8 +156,8 @@ export interface AppDom { sqlEditorView?: EditorView; themeBtn?: HTMLElement; - // app.ts-internal only (renderApp()'s own mounted chrome + renderVarStrip()'s - // rebuild bookkeeping) — not read by any other module. + // app.ts-internal only (renderApp()'s own mounted chrome) — not read by + // any other module. banner?: HTMLElement; /** Stable in-shell mount for temporary authentication recovery controls. */ authHost?: HTMLElement; @@ -182,9 +187,6 @@ export interface AppDom { userBtn?: HTMLButtonElement; userMenu?: HTMLElement; varStrip?: HTMLElement; - varStripSig?: string; - varStripRerenderPending?: boolean; - varStripDeferHooked?: boolean; } /** The currently open UI primitive that has exclusive keyboard handling. */ @@ -435,8 +437,8 @@ export interface App { * without App/AppState/DOM: analyze/prepare/gate/execution-view, the #170 * hardening bookkeeping, the #172 v2 schema-cache enum-suggestion * inference, and the #171 recent-value + persistence policy. - * `renderVarStrip`/`setRunBtn` (DOM) stay in app.ts, calling this - * session's methods directly; the workbench-session hooks + the export + * `renderVarStrip`/`setRunBtn` (DOM — #588 W1: `ui/workbench/variable-strip.ts`) + * call this session's methods directly; the workbench-session hooks + the export * block's direct calls are re-pointed here too. `saveVarValues`/ * `saveFilterActive`/`saveVarRecentDisabled`/`recordBoundParams`/ * `clearVarRecent`/`clearAllVarRecent`/`hardenedVars` have no flat `App` @@ -492,12 +494,15 @@ export interface App { activateInvalidSpecDraft(tab: Tab | null): void; /** The saved-query create/commit policy, history recording, and share-URL * building (#276 Phase 4C — `src/application/saved-query-service.ts`), - * constructible without App/AppState/DOM. app.ts's `commitLinkedQuery`/ - * `openSavePopover`'s commit closure/`share` call this directly and keep - * owning the post-commit DOM cascade + clipboard/location writes - * themselves (see that module's header comment). */ + * constructible without App/AppState/DOM. `ui/workbench/save-controller.ts`'s + * `commitLinkedQuery`/`openSavePopover`'s commit closure and app.ts's own + * `share` call this directly and keep owning the post-commit DOM cascade + + * clipboard/location writes themselves (see that module's header comment). + * #588 W2 dropped the flat `App.openSavePopover` delegate (zero production + * consumers) — the controller still exposes it (see + * `save-controller.ts`'s `SaveController`) for its own internal + * `saveActiveQuery` dispatch and direct test coverage. */ saved: SavedQueryService; - openSavePopover(): void; openUserMenu(): void; // Rendering / lifecycle. @@ -548,12 +553,6 @@ export interface App { * surface command port — no rebuild, no rerun, no extra history entry — rather * than the full re-render #425 used to deliver it. */ openDashboard(request: OpenDashboardRequest): void; - /** #426 — deliver focus to ONE member of the already-rendered Dashboard through - * the route-local surface command port, without rebuilding or re-running it. - * `pending` means "not deliverable in place right now" (mid-wave curated - * filter, or a superseded/absent port) and is the caller's cue to take the - * normal render transition — never a diagnostic. */ - focusDashboardMember(member: DashboardFocusTarget): DashboardFocusOutcome; /** #426 — bump the Dashboard tree's explicit repaint invalidation. The tree * projects the committed workspace aggregate plus main-surface navigation * state, neither of which is a signal. */ @@ -592,7 +591,7 @@ export interface App { /** Current canonical `/sql` route and the live workspace resolved for it. */ sqlRoute: SqlRoute; currentWorkspace: StoredWorkspaceV5 | null; - workspaceRouteStatus: 'loading' | 'ready' | 'not-found' | 'error'; + workspaceRouteStatus: WorkspaceRouteStatus; /** Route-local commands registered by the mounted surface. They are cleared * before every transition, so a disposed Dashboard viewer cannot be called. */ surfaceCommands: SurfaceCommandPort | null; @@ -606,15 +605,11 @@ export interface App { * currently selected ready surface is refreshed from shared projection. */ refreshCurrentSurfaceAfterStale(generation: number, committed?: boolean): boolean; /** Navigate within the single artifact. Surface changes use push; mode and - * canonicalization use replace. */ + * canonicalization use replace. Flat delegate onto `app.nav` (#588 phase 4 + * wave 4) for its wide production consumer set. */ navigateSqlRoute(route: SqlRoute, method: 'push' | 'replace'): Promise; - /** Reparse the browser URL after Back/Forward and mount the selected surface. */ - handleSqlPopState(): Promise; - /** Synchronize route state after bootstrap rewrites an OAuth callback URL. */ - syncSqlRoute(search: string): void; - /** Point the current surface/mode at an already-projected workspace. */ - rewriteWorkspaceRoute(workspaceKey: string): void; - /** Repaint Dashboard after an in-tab import, retaining its route mode. */ + /** Repaint Dashboard after an in-tab import, retaining its route mode. Flat + * delegate onto `app.nav` (#588 phase 4 wave 4). */ reloadDashboardRoute(): void; /** Resolve the explicit or implicit route workspace and, when it * resolves a real aggregate, PROJECTS it onto `state` (`savedQueries`, @@ -641,25 +636,11 @@ export interface App { * shared generator: a minted id only needs to be unique, never to encode * which op minted it. */ genId(): string; - /** #287 review fix: serialize saved-query write operations per-app so two - * overlapping async CRUD commits can't interleave. Each queued op runs only - * after the previous fully resolved (compute → commit → project), so it - * reads the freshest `state.savedQueries` — without this, a delete and a - * star toggle fired in rapid succession could each build a candidate from the - * same stale snapshot and the later commit would resurrect the deleted query - * (or clobber a concurrent edit). Rejections propagate to the caller; the - * queue itself never rejects. */ - serializeWrite(op: () => Promise): Promise; - /** #341: resolve once every write already queued through `serializeWrite` - * has settled — the flush point exports use so a bundle is built from the - * latest COMMITTED workspace, never mid-flight state. A write queued AFTER - * this call is intentionally not awaited by it. */ - flushWorkspaceWrites(): Promise; /** #341/#344 review fix: the ONLY way a workspace mutation should build its * candidate. A queue around independently pre-built full-workspace * snapshots does not prevent lost updates — several `file-menu.ts` * producers used to build a whole candidate from `state` BEFORE entering - * `serializeWrite`, so a mutation that committed while they awaited a user + * the write queue, so a mutation that committed while they awaited a user * dialog (or just lost the race) got silently clobbered by the later, * stale write. `mutateWorkspace` closes that window: the queued op reads * the latest committed aggregate via `app.workspace.loadById()` at @@ -668,45 +649,58 @@ export interface App { * inside the queue slot is guaranteed fresh), hands it to `transform`, and * commits whatever `transform` returns. `transform` returning `null`/ * `undefined` aborts the op — nothing is committed and this resolves - * `null`. Rejections propagate to the caller like `serializeWrite`'s own; - * the queue itself never wedges. */ + * `null`. Rejections propagate to the caller like the queue's own; the + * queue itself never wedges. #588 phase 4 wave 3: the implementation + * (queueing, tokens, broadcast, refresh, provisioning) now lives in + * `app.workspaceSession` (`src/application/workspace-session.ts`) — this + * flat delegate stays for `mutateWorkspace`'s wide production consumer set. */ mutateWorkspace( transform: (latest: StoredWorkspaceV5 | null) => WorkspaceMutationInput | null | Promise | null>, ): Promise>; - /** #343 §5: this tab's random per-session id, minted through the crypto seam. - * Stamped on every outgoing invalidation so a tab can ignore its own poke. */ - sourceTabId: string; - /** #343 §6: whether this tab is currently visible (injected seam; see - * `CreateAppEnv.documentVisible`). Read by the focus/visibility refresh. */ - documentVisible(): boolean; - /** #343 §2: the snapshot-identity token of the workspace this tab last - * committed/projected (`workspaceToken`), used only to detect whether a - * later reload actually changed anything. `''` before the first commit. */ - getLastCommittedToken(): string; /** #343 §5/§6: invoked when another tab reports a workspace change (channel * receive, or a focus/visibility event). A no-op by default; the * cross-tab-refresh work (#343 step 4) replaces it with the coalesced - * `refreshWorkspaceFromStore` scheduler. Never receives this tab's own - * broadcast. */ + * `app.workspaceSession.scheduleRefresh` call. Never receives this tab's own + * broadcast. Kept as a flat delegate (#588 phase 4 wave 3) for its wide + * production consumer set (dashboard.ts et al.). */ onExternalWorkspaceChange(message: WorkspaceChangedMessage): void; - /** #343 step 4: reload the committed workspace and, when it changed under this - * tab, project it + reconcile linked tabs — ordered through the same - * `serializeWrite` queue as mutations (so it can't project an older read over - * a newer local commit). A no-op when the store is unchanged since this tab's - * last projection; a failed load keeps the projection, warns, and never - * wedges the queue. The channel-receive + focus/visibility listeners drive a - * coalesced version of this internally; this public entry is the direct, - * un-coalesced one (tests + explicit callers). */ - refreshWorkspaceFromStore(): Promise; /** #343 step 4: the route/surface refresh hook invoked AFTER a refresh * actually projected an external change — a mounted route (the standalone * Dashboard, a later step) overrides it to rebuild from the latest committed * workspace. `queriesChanged` reports whether the query collection moved * (a query-only change still needs a Dashboard viewer rebuild even when the * Dashboard document is byte-identical). Default no-op; the Workbench route's - * own repaint is built into `refreshWorkspaceFromStore`. */ + * own repaint is built into `app.workspaceSession.refreshWorkspaceFromStore`. + * Kept as a flat delegate (#588 phase 4 wave 3) for its wide production + * consumer set. */ onWorkspaceExternallyChanged(info: WorkspaceExternallyChangedInfo): void; + /** The workspace write/refresh/cross-tab session (#588 phase 4 wave 3, + * `src/application/workspace-session.ts`) — owns serialized writes, + * `mutateWorkspace`'s underlying queue, this tab's snapshot-identity token + * (`sourceTabId`/`getLastCommittedToken`/`recordProjection`), the + * BroadcastChannel wire + focus/visibility refresh fallback, the + * `beforeunload` dirty guard (`syncBeforeUnload`/ + * `armOAuthRedirectUnloadBypass`), and initial-workspace provisioning + * (`resolveImplicitOrProvision`/`recordOpened`). `applyCommittedWorkspace` + * (above) deliberately stays OUTSIDE this session — it is real UI + * orchestration (tab repaint, tree-click cancellation, route rewrite on a + * lost selection), not the "zero DOM" the #588 issue text implies. */ + workspaceSession: WorkspaceSession; + /** The main-surface / `/sql` route navigation session (#588 phase 4 wave 4, + * `src/application/surface-navigation.ts`) — owns the surface-generation + * guard cluster, route writes, boot/popstate/programmatic-navigation + * loading, and every main-surface transition. `handleSqlPopState`/ + * `focusDashboardMember` (router-private, no production consumer outside + * app.ts's own popstate listener / `openDashboard`) and `syncSqlRoute`/ + * `rewriteWorkspaceRoute` (repointed to `main.ts`/`file-menu.ts`) have NO + * flat `App` delegate — reach them via `app.nav.*`. Every wide-consumer + * member (`navigateSqlRoute`/`openDashboard`/`showQuerySurface`/ + * `showDashboardSurface`/`openSavedQuery`/`openPanelQuery`/ + * `openVariableTab`/`renderCurrentSurface`/`loadWorkspaceOnBoot`/ + * `reloadDashboardRoute`/the generation-guard trio) keeps its flat + * delegate onto this session. */ + nav: SurfaceNavigation; actions: ActionsRegistry; } diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 959e2088..237d56c5 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -116,6 +116,7 @@ import type { AuthenticatedExecutionScope } from '../application/authenticated-e import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; import type { WorkspaceCommitResult, WorkspaceRepository } from '../workspace/workspace-repository.js'; import type { AppPreferences } from '../application/app-preferences.js'; +import { keyboardOwnerChannel } from './keyboard-owner.js'; // icons.js is unconverted — the icons this module appends, pinned to the // one honest shape (same wrapper the pre-#286 module used). @@ -285,14 +286,6 @@ let installedNavHighlightClear: (() => void) | null = null; const NAV_HIGHLIGHT_MS = 2000; /** Tear down every resource owned by the currently mounted Dashboard surface. */ -function keyboardOwnerChannel(app: Pick): (owner: App['keyboardOwner']) => void { - let release: (() => void) | null = null; - return (owner) => { - release?.(); - release = owner ? app.acquireKeyboardOwner(owner.kind) : null; - }; -} - export function disposeDashboardSurface(): void { if (installedGridResizeListener) { installedGridResizeListener.win.removeEventListener('resize', installedGridResizeListener.handler); diff --git a/src/ui/file-menu.ts b/src/ui/file-menu.ts index 683e3c9b..ad4e54e0 100644 --- a/src/ui/file-menu.ts +++ b/src/ui/file-menu.ts @@ -77,19 +77,13 @@ import type { import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; import { EXAMPLE_DASHBOARDS } from '../generated/example-dashboards.js'; import type { ExampleDashboardEntry } from '../generated/example-dashboards.js'; +import { keyboardOwnerChannel } from './keyboard-owner.js'; /** Workspace/library name → safe file base (strips path/illegal chars, * collapses spaces). */ const fileBase = (name: unknown): string => (String(name || '')).replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, ' ').trim() || 'queries'; const queries = (n: number): string => n + (n === 1 ? ' query' : ' queries'); const first = (diagnostics: readonly WorkspaceDiagnostic[], fallback: string): string => diagnostics[0]?.message || fallback; -function keyboardOwnerChannel(app: Pick): (owner: App['keyboardOwner']) => void { - let release: (() => void) | null = null; - return (owner) => { - release?.(); - release = owner ? app.acquireKeyboardOwner(owner.kind) : null; - }; -} /** * What the surface currently on screen rendered — the ONLY thing a surface @@ -682,7 +676,7 @@ function newWorkspaceAction(app: App): void { } async function doNewWorkspace(app: App): Promise { - await app.serializeWrite(async () => { + await app.workspaceSession.serializeWrite(async () => { const listed = await app.workspace.list(); const name = 'SQL Library'; const key = deriveWorkspaceKey(name, [ @@ -695,7 +689,7 @@ async function doNewWorkspace(app: App): Promise { return; } app.applyCommittedWorkspace(result.workspace); - app.rewriteWorkspaceRoute(result.workspace.key); + app.nav.rewriteWorkspaceRoute(result.workspace.key); const opened = await app.workspace.markOpened(result.workspace.key); afterLibraryChange(app); flashToast( @@ -947,7 +941,7 @@ function startOpenWorkspace(app: App, bundle: PortableBundleV2): void { async function importWorkspace( app: App, bundle: PortableBundleV2, ): Promise { - await app.serializeWrite(async () => { + await app.workspaceSession.serializeWrite(async () => { const listed = await app.workspace.list(); const name = bundle.metadata?.name?.trim() || 'Imported workspace'; const key = deriveWorkspaceKey(name, [ @@ -966,7 +960,7 @@ async function importWorkspace( return; } app.applyCommittedWorkspace(result.workspace); - app.rewriteWorkspaceRoute(result.workspace.key); + app.nav.rewriteWorkspaceRoute(result.workspace.key); const opened = await app.workspace.markOpened(result.workspace.key); afterLibraryChange(app); flashToast( @@ -1019,7 +1013,7 @@ interface DashboardExportRequest { * export never becomes a silent no-op on an unhandled rejection. */ async function flushAndLoadCommitted(app: App, workspaceId: string): Promise { try { - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); const result = await app.workspace.loadById(workspaceId); return result.status === 'ok' ? result.workspace : null; } catch { diff --git a/src/ui/keyboard-owner.ts b/src/ui/keyboard-owner.ts new file mode 100644 index 00000000..4c517109 --- /dev/null +++ b/src/ui/keyboard-owner.ts @@ -0,0 +1,40 @@ +// #588 W2 (phase 4, decompose the `createApp` composition root): the +// keyboard-owner release/acquire adapter every menu/chooser primitive wires +// into `onKeyboardOwnerChange` — hoisted out of three near-identical private +// copies (`file-menu.ts`, `library-assign-menu.ts`, `dashboard.ts`) into one +// shared function. Verified byte-identical bodies before unifying (see the +// worker report for this phase): each held `let release: (() => void) | null +// = null;` and the exact same `(owner) => { release?.(); release = owner ? +// app.acquireKeyboardOwner(owner.kind) : null; }` — only the three copies' +// parameter TYPE varied (`Pick` in two, +// `Pick` in the third), and +// `DashboardApp['acquireKeyboardOwner']` is already declared as +// `App['acquireKeyboardOwner']` verbatim (`dashboard.ts`), so the narrow +// structural parameter below accepts all three real call sites unchanged. + +import type { KeyboardOwner, KeyboardOwnerRelease } from './app.types.js'; + +/** The narrow `app`-shaped seam this adapter reads — any object exposing + * `acquireKeyboardOwner` with `App`'s exact signature (an `App`, a + * `DashboardApp`, or a test fake) satisfies it structurally. */ +export interface KeyboardOwnerHost { + acquireKeyboardOwner(kind: KeyboardOwner['kind']): KeyboardOwnerRelease; +} + +/** + * Build a menu/popover's `onKeyboardOwnerChange` adapter bound to `app`: + * acquires ownership of the given `kind` on open, releases the PREVIOUS + * acquisition (if any) before acquiring the new one on an owner swap, and + * releases on close (`owner === null`). Each call returns a fresh, private + * `release` closure — never shared across menus — so releasing one menu's + * ownership can never clobber another's. + */ +export function keyboardOwnerChannel( + app: KeyboardOwnerHost, +): (owner: KeyboardOwner | null) => void { + let release: (() => void) | null = null; + return (owner) => { + release?.(); + release = owner ? app.acquireKeyboardOwner(owner.kind) : null; + }; +} diff --git a/src/ui/library-assign-menu.ts b/src/ui/library-assign-menu.ts index 31779aaf..3346a717 100644 --- a/src/ui/library-assign-menu.ts +++ b/src/ui/library-assign-menu.ts @@ -18,16 +18,7 @@ import { UNTITLED_DASHBOARD } from '../application/dashboard-tree-model.js'; import { revealAssignedPanel } from './dashboard-tree.js'; import type { SavedQueryV2 } from '../generated/json-schema.types.js'; import type { App } from './app.types.js'; - -const keyboardOwnerChannel = ( - app: Pick, -): ((owner: App['keyboardOwner']) => void) => { - let release: (() => void) | null = null; - return (owner) => { - release?.(); - release = owner ? app.acquireKeyboardOwner(owner.kind) : null; - }; -}; +import { keyboardOwnerChannel } from './keyboard-owner.js'; const dashboardCounts = (app: App): Map => { const counts = new Map(); diff --git a/src/ui/popover.ts b/src/ui/popover.ts index 55d0043a..43546667 100644 --- a/src/ui/popover.ts +++ b/src/ui/popover.ts @@ -194,3 +194,101 @@ export function openAnchoredDialog(opts: AnchoredDialogOptions): AnchoredDialogH return { dialog, isOpen: () => open, close, reclaimFocus }; } + +// ── `createAnchoredPopovers` ──────────────────────────────────────────────── +// #588 W2 (phase 4, decompose the `createApp` composition root): the Save +// popover / user-menu's own light, NON-modal anchored popover — extracted +// verbatim out of app.ts's `anchoredPopover` + its module-scoped closers +// registry. Deliberately kept BESIDE `openAnchoredDialog` above, not merged +// into it: that primitive is a modal dialog (overlay, `aria-modal`, Tab trap, +// focus-return); this one is a light, non-modal anchored popover with no +// overlay/backdrop and no Tab trap — a distinct primitive serving a distinct +// interaction (a small transient popover anchored under a toolbar button, +// dismissed by Escape or an outside click, never by a hidden backdrop). +// +// KNOWN, DELIBERATELY PRESERVED DEFECT (I-21, filed as inbox — see the phase +// 4 plan's §9-2): `close()` below removes whatever node currently occupies +// `deps.getRef(refKey)` WITHOUT checking that it is the node THIS `close()` +// opened. A caller that retains a stale `close()` handle past a second +// `open()` on the same `refKey` can clobber the newer popover. This is +// verbatim pre-extraction behavior, not fixed here — do not add an ownership +// guard as part of this move. + +/** The two anchored-popover slots app.ts's `AppDom` reserves for this + * primitive — each tracked independently on `app.dom`, cleared on close. */ +export type AnchoredPopoverRefKey = 'savePopover' | 'userMenu'; + +/** The narrow `app`-shaped seam `createAnchoredPopovers` reads — thunks + * rather than direct values/elements, since `app.dom[refKey]` is mutated by + * `open`/`close` themselves (see `getRef`/`setRef`) and the viewport/mobile + * reads must stay live across calls, not snapshotted at construction. */ +export interface AnchoredPopoverDeps { + document: Document; + acquireKeyboardOwner(kind: KeyboardOwner['kind']): () => void; + isMobile(): boolean; + viewportWidth(): number; + getRef(key: AnchoredPopoverRefKey): HTMLElement | undefined; + setRef(key: AnchoredPopoverRefKey, node: HTMLElement | undefined): void; +} + +/** Build the popover controller bound to `deps`. The closers registry + * (`closeAll`'s backing `Set`) is INSTANCE-scoped — a fresh `Set` per + * `createAnchoredPopovers` call, never a module-global — so multiple + * independent instances (e.g. a test harness building more than one) never + * share open/close bookkeeping. */ +export function createAnchoredPopovers(deps: AnchoredPopoverDeps): { + open(node: HTMLElement, anchorEl: HTMLElement, refKey: AnchoredPopoverRefKey): { close(): void }; + closeAll(): void; +} { + const closers = new Set<() => void>(); + + function closeAll(): void { + for (const close of [...closers]) close(); + } + + // Open `node` as a popover anchored under `anchorEl`: fixed-position below + // the button, Esc + click-outside close (capture listeners), stored at + // `deps.getRef(refKey)`/cleared via `deps.setRef` on close. Returns + // `{ close }`. + function open( + node: HTMLElement, anchorEl: HTMLElement, refKey: AnchoredPopoverRefKey, + ): { close: () => void } { + const releaseKeyboard = deps.acquireKeyboardOwner('popover'); + const close = (): void => { + closers.delete(close); + deps.document.removeEventListener('keydown', onKey, true); + deps.document.removeEventListener('mousedown', onOutside, true); + // I-21 (preserved verbatim — see this section's header comment): no + // check that `deps.getRef(refKey)` is still THIS popover's own node. + if (deps.getRef(refKey)) { deps.getRef(refKey)!.remove(); deps.setRef(refKey, undefined); } + releaseKeyboard(); + }; + const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; + const onOutside = (e: MouseEvent): void => { + if (deps.getRef(refKey) && !node.contains(e.target as Node) && !anchorEl.contains(e.target as Node)) close(); + }; + deps.setRef(refKey, node); + const r = anchorEl.getBoundingClientRect(); + // Right-align under the button. + const a = fixedAnchor(r, { viewportW: deps.viewportWidth() || 0 }) as { top: number; right: number }; + node.style.position = 'fixed'; + node.style.top = a.top + 'px'; + if (deps.isMobile()) { + // Mobile (#126): the trigger can sit mid-toolbar (the toolbar scrolls), so + // right-aligning to it pushes a fixed-width popover off the narrow + // viewport's left edge. Center it horizontally instead (still dropped below + // the trigger via `top`); the mobile max-width clamps keep it in-bounds. + node.style.left = '50%'; + node.style.transform = 'translateX(-50%)'; + } else { + node.style.right = a.right + 'px'; + } + deps.document.body.appendChild(node); + deps.document.addEventListener('keydown', onKey, true); + deps.document.addEventListener('mousedown', onOutside, true); + closers.add(close); + return { close }; + } + + return { open, closeAll }; +} diff --git a/src/ui/saved-history.ts b/src/ui/saved-history.ts index 3ddad222..6e91ed40 100644 --- a/src/ui/saved-history.ts +++ b/src/ui/saved-history.ts @@ -289,7 +289,7 @@ function renderSaved(app: App, list: HTMLElement): void { // now so this dead Library row (and any linked tab) reconciles instead // of lingering until the next activation. flashToast('This query was deleted in another tab', { document: app.document }); - void app.refreshWorkspaceFromStore(); + void app.workspaceSession.refreshWorkspaceFromStore(); } else if (result && !result.ok && result.diagnostics?.length) { flashToast('Couldn’t update favorite: ' + result.diagnostics[0].message, { document: app.document }); } @@ -385,7 +385,7 @@ function savedEditForm(app: App, q: SavedQueryV2): HTMLDivElement { else if (result && !result.ok && result.deletedExternally) { // #343 review: target vanished — refresh so the dead row reconciles. flashToast('This query was deleted in another tab', { document: app.document }); - void app.refreshWorkspaceFromStore(); + void app.workspaceSession.refreshWorkspaceFromStore(); } else if (result && !result.ok && result.diagnostics?.length) { flashToast('Couldn’t rename: ' + result.diagnostics[0].message, { document: app.document }); } else { diff --git a/src/ui/shortcuts.ts b/src/ui/shortcuts.ts index 89c72dba..c82a6f35 100644 --- a/src/ui/shortcuts.ts +++ b/src/ui/shortcuts.ts @@ -4,13 +4,21 @@ import { h, attachBackdropClose } from './dom.js'; import type { ActionsRegistry, KeyboardOwner, State, Tab } from './app.types.js'; import type { ConnectionSession } from '../application/connection-session.js'; import type { SqlRoute } from '../core/sql-route.js'; -import type { DashboardFocusTarget } from '../application/main-surface.js'; +import type { + DashboardFocusTarget, DashboardFocusOutcome, SurfaceCommandPort, WorkspaceRouteStatus, +} from '../application/main-surface.js'; + +// #588 phase 4 §3-T #2: `SurfaceCommandPort`/`DashboardFocusOutcome` moved to +// `src/application/main-surface.ts` (which already owns `DashboardFocusTarget` +// — the type this port's `focusMember` takes) — re-exported here so every +// existing importer (app.types.ts, dashboard.ts, this file's own tests) keeps +// compiling with zero call-site changes. +export type { SurfaceCommandPort, DashboardFocusOutcome } from '../application/main-surface.js'; type ShortcutSurface = 'workspace' | 'dashboard' | 'all'; type Section = 'application' | 'workspace' | 'dashboard' | 'general' | 'gestures'; type ShortcutDispatch = 'application' | 'editor'; type KeyName = 'mod-enter' | 'mod-shift-enter' | 'mod-s' | 'mod-shift-s' | 'mod-alt-1' | 'mod-alt-2' | 'mod-z' | 'mod-shift-z' | 'f1' | 'g-d' | 'g-w' | 'g-v' | 'g-e' | 'g-g' | 'g-f' | 'g-r' | 'g-2' | 'g-3' | 'g-style' | 'question' | 'escape'; -type DashboardStyle = 'grid' | 'full' | 'report' | 'columns-2' | 'columns-3'; export interface ShortcutDefinition { id: string; @@ -57,39 +65,13 @@ const GESTURES = [ ['Expand / collapse', 'Click'], ['Insert into editor', 'Double-click'], ['Insert DDL / col::type', 'Shift-click'], ] as const; -/** - * What an IN-PLACE member navigation could do (#426). Three outcomes, because - * two of them are not failures: - * - `ok` — delivered against the live surface; no rebuild happened. - * - `pending` — not deliverable in place *right now* (the opening wave has not - * settled, so a curated filter's control is about to be replaced; - * or this port has been superseded). The caller falls back to the - * normal render transition, which delivers focus at the - * deterministic point the node exists. NOT a diagnostic. - * - `missing` — the member is genuinely not on this Dashboard any more. The - * caller reports it non-destructively and changes nothing. - */ -export type DashboardFocusOutcome = 'ok' | 'pending' | 'missing'; - -export interface SurfaceCommandPort { - surface: 'dashboard'; - generation: number; - refresh(): void; - setDashboardStyle(style: DashboardStyle): void; - /** #426 — scroll/focus/highlight one already-rendered tile or curated filter - * WITHOUT rebuilding or re-running the Dashboard. Repeated same-Dashboard - * member navigation is a normal tree operation, so it must not cost a render - * or a history entry. */ - focusMember(member: DashboardFocusTarget): DashboardFocusOutcome; -} - /** Narrow controller contract; it deliberately avoids importing the full App. */ export interface ShortcutsApp { document?: Document; state: Pick; conn: Pick; sqlRoute: Pick & { mode?: 'view' | 'edit' }; - workspaceRouteStatus: 'loading' | 'ready' | 'not-found' | 'error'; + workspaceRouteStatus: WorkspaceRouteStatus; surfaceCommands?: SurfaceCommandPort | null; keyboardOwner?: KeyboardOwner | null; acquireKeyboardOwner(kind: KeyboardOwner['kind']): () => void; diff --git a/src/ui/workbench/save-controller.ts b/src/ui/workbench/save-controller.ts new file mode 100644 index 00000000..33a33075 --- /dev/null +++ b/src/ui/workbench/save-controller.ts @@ -0,0 +1,381 @@ +// #588 W2 (phase 4, decompose the `createApp` composition root): the Save +// cluster — `updateSaveBtn` (the Save button's state projection), +// `saveActiveQuery` (the Save action's document-kind dispatch), the +// linked-query commit/create/conflict paths it dispatches to, and their +// shared toast/popover choreography — extracted verbatim out of app.ts into +// its own controller. +// +// #457's kind-dispatch-first ordering (I-15 in the phase 4 invariant map) +// travels with the code UNCHANGED in both `updateSaveBtn` and +// `saveActiveQuery`: each checks the document KIND (`variableDoc(tab)`) +// before anything conflict/Spec-related, in the same order, so the button's +// visible state never describes an action Save itself would not take. Do +// NOT reconcile the two checks into one shared helper — the plan's own +// worked example treats this duplication as a deliberately preserved +// invariant, not an opportunity to simplify. +// +// Two deliberate deviations from the phase 4 plan's literal `SaveControllerDeps` +// draft (see this phase's own worker report): +// - `specBlocked` ADDED: `updateSaveBtn`'s non-variable branch calls the SAME +// `specBlocked` predicate `workbench-shell.ts` reads off `App.specBlocked` +// (app.ts keeps owning that one definition — this controller must not +// re-declare its own copy, which would let the two drift). +// - `specEditor(): SpecEditorPort` DROPPED: the plan draft listed it, but no +// moved statement ever calls it — every specEditor touch in the original +// code was `app.specEditor.syncFromState()`, already covered by the +// separate `syncSpecEditorFromState()` hook below. Keeping an unread thunk +// would leave its composition-root wiring permanently uncovered (breaks +// the 100% statement/line floor on app.ts) for no behavioral reason. + +import { h } from '../dom.js'; +import { Icon } from '../icons.js'; +import { + savedForTab, tabPanel, tabSaveDirty, variableDoc, adoptSavedIntoTab, +} from '../../state.js'; +import type { AppState, QueryTab, WorkspaceMutationOutcome } from '../../state.js'; +import type { SavedQueryV2, StoredWorkspaceV5 } from '../../generated/json-schema.types.js'; +import type { SavedQueryService } from '../../application/saved-query-service.js'; +import type { QueryDocumentSession } from '../../application/query-document-session.js'; +import type { createAnchoredPopovers } from '../popover.js'; +import { normalizeVariableSql } from '../../core/dashboard-variables.js'; +import { dashboardVariables } from '../../application/dashboard-tree-model.js'; +import type { VariableConfigAbort } from '../../application/dashboard-variable-config.js'; +import { isQuerylessPanel } from '../../core/panel-cfg.js'; +import { inferQueryName } from '../../core/format.js'; +import { flashToast } from '../toast.js'; +import { buildConflictChooser } from '../conflict-resolution.js'; +import { batch } from '@preact/signals-core'; + +/** The narrow `app`-shaped seam `createSaveController` reads. Frozen per the + * phase 4 plan except `specBlocked` (see this module's header comment). */ +export interface SaveControllerDeps { + document: Document; + state: AppState; + activeTab(): QueryTab; + saved: Pick; + queryDoc: Pick; + currentWorkspace(): StoredWorkspaceV5 | null; + captureSurfaceGeneration(): number; + refreshCurrentSurfaceAfterStale(generation: number, committed?: boolean): boolean; + syncBeforeUnload(): void; + refreshWorkspaceFromStore(): Promise; + commitVariableConfig( + dashboardId: string, variableName: string, cfg: { sql: string; lastKnownType?: string } | null, + ): unknown; + // `HTMLButtonElement`, not the plan draft's `HTMLElement` — `updateSaveBtn` + // reads `.disabled`, which only form-control element types declare; + // `AppDom.saveBtn` itself is already typed `HTMLButtonElement | undefined` + // (app.types.ts). + saveBtn(): HTMLButtonElement | undefined; + savePopoverOpen(): boolean; + anchoredPopover: ReturnType['open']; + rerenderTabs(): void; + updateEditorModeUi(): void; + renderSavedHistory(): void; + renderResults(): void; + syncSpecEditorFromState(): void; + /** #457's shared kind-dispatch-first Spec-blocking predicate. app.ts owns + * the ONE definition (also read by `workbench-shell.ts` off + * `App.specBlocked`) — this controller must not re-declare its own. */ + specBlocked(tab: QueryTab): boolean; +} + +export interface SaveController { + updateSaveBtn(): void; + saveActiveQuery(): Promise; + openConflictChooser(): void; + openSavePopover(): void; +} + +/** Build the Save cluster's controller bound to `deps`. Trivial constructor — + * no validation; `createApp` supplies the real `app`-backed thunks, unit + * tests supply fakes directly. */ +export function createSaveController(deps: SaveControllerDeps): SaveController { + function updateSaveBtn(): void { + const saveBtn = deps.saveBtn(); + if (!saveBtn) return; + const tab = deps.activeTab(); + // #457: the DOCUMENT KIND is checked first, exactly as `saveActiveQuery` + // checks it — a variable tab has no saved query behind it, so "saved" is + // simply "not dirty", no Spec can block it, and the conflict state below + // (a linked-saved-query concept) cannot apply to it. Ordering the two the + // same way in both places is what stops the button ever describing an + // action the Save action would not take. + if (variableDoc(tab) !== null) { + const stored = !tabSaveDirty(tab); + saveBtn.classList.remove('conflict'); + saveBtn.classList.toggle('saved', stored); + saveBtn.replaceChildren(Icon.bookmark(), h('span', null, stored ? 'Saved' : 'Save')); + saveBtn.disabled = false; + saveBtn.title = stored + ? 'Saved — edit to re-save (⌘S)' + : 'Save this variable’s option SQL (⌘S)'; + return; + } + // #343: a tab whose linked saved query changed in another tab must not be + // silently re-saved. The Save button becomes "Resolve conflict" and opens + // the two-action chooser instead of committing. + if (tab.externalState === 'conflict') { + saveBtn.classList.remove('saved'); + saveBtn.classList.add('conflict'); + saveBtn.replaceChildren(Icon.bookmark(), h('span', null, 'Resolve conflict')); + saveBtn.disabled = false; + saveBtn.title = 'This query changed in another tab — choose how to resolve it'; + return; + } + saveBtn.classList.remove('conflict'); + const entry = savedForTab(deps.state, tab); + const clean = !!entry && !tab.dirtySql && !tab.dirtySpec; + const blocked = !!entry && deps.specBlocked(tab); + saveBtn.classList.toggle('saved', clean); + saveBtn.replaceChildren(Icon.bookmark(), h('span', null, clean ? 'Saved' : 'Save')); + saveBtn.disabled = blocked; + saveBtn.title = blocked + ? 'Fix blocking Spec errors before saving' + : clean ? 'Saved — edit to re-save (⌘S)' : 'Save query (⌘S)'; + } + + /** A warning-bearing save still succeeded. Preserve that confirmation and + * keep the actionable inference guidance visible long enough to read. */ + function flashSaved(diagnostics?: ReadonlyArray<{ message: string }>): void { + const warning = diagnostics?.[0]?.message; + flashToast(warning ? `Saved — ${warning}` : 'Saved', { + document: deps.document, + ...(warning ? { duration: 6000 } : {}), + }); + } + + async function commitLinkedQuery(): Promise { + const surfaceGeneration = deps.captureSurfaceGeneration(); + const tab = deps.activeTab(); + const evaluated = deps.queryDoc.evaluateSpecDraft(tab, tab.specText, { dirty: tab.dirtySpec }); + // #343: `saved.commit` now runs its candidate-building transform through + // `app.mutateWorkspace`, which already enters the tab-local write queue and + // reads the latest committed aggregate at dequeue — no outer `serializeWrite` + // wrapper needed (it would only double-queue). + const result = await deps.saved.commit(tab, evaluated); + // #466/#501-review: `saved.commit` already cleared `dirtySql`/`dirtySpec` + // on a real commit (`commitSavedQuery`, state.ts) — BEFORE the staleness + // bracket below, which can return early on a navigation that began + // mid-write. `rerenderTabs()` (which re-syncs this too) only runs past + // that bracket, so without this the guard stays installed for a tab that + // is, by now, genuinely clean and durably written. + if (result.ok) deps.syncBeforeUnload(); + if (!deps.refreshCurrentSurfaceAfterStale(surfaceGeneration, result.ok)) { + return result.ok ? result.entry : null; + } + if (!result.ok) { + // 'rejected' (commit's own defensive re-check inside the service, OR the + // aggregate strictly rejecting the whole-workspace commit — #287 W4) + // stays a silent no-op for the tab/editor state (nothing was mutated), + // but a real commit rejection still surfaces its first diagnostic. + if (result.reason === 'invalid-spec') { + deps.queryDoc.revealFirstSpecError(tab); + flashToast('Fix Spec errors before saving', { document: deps.document }); + } else if (result.reason === 'empty') { + flashToast('Nothing to save', { document: deps.document }); + } else if (result.reason === 'deleted') { + // #343: the linked query vanished from the latest workspace (deleted in + // another tab) and the save aborted without recreating it. Refresh the + // tab association now — the reconcile turns this tab into an unsaved + // draft (dirty) or detaches it (clean) — instead of leaving a ghost + // link waiting for the next focus/visibility event. + flashToast('This query was deleted in another tab — your draft is kept as an unsaved query', { document: deps.document }); + void deps.refreshWorkspaceFromStore(); + } else if (result.diagnostics?.length) { + flashToast('Save failed: ' + result.diagnostics[0].message, { document: deps.document }); + } + return null; + } + deps.queryDoc.revalidateSpecDrafts(); + deps.syncSpecEditorFromState(); + updateSaveBtn(); + deps.rerenderTabs(); + deps.renderSavedHistory(); + deps.renderResults(); + deps.updateEditorModeUi(); + flashSaved(result.diagnostics); + return result.entry; + } + + /** + * #457 — Save on a `dashboard-variable` tab. The ONE write it performs is + * `dashboard.variableConfigs[variableName]`: no `SavedQueryV2` is created or + * touched, and the document is never added to the Library, History, favourites + * or Panels. + * + * The trim rule is the pure service's, never re-implemented here: blank (or + * whitespace-only) SQL REMOVES the configuration and returns the variable to + * direct input, rather than storing an empty string that would later read as + * configured-but-broken. + */ + async function saveVariableTab( + tab: QueryTab, binding: { dashboardId: string; variableName: string }, + ): Promise { + const surfaceGeneration = deps.captureSurfaceGeneration(); + const sql = normalizeVariableSql(tab.sqlDraft); + // `lastKnownType` is what lets a configuration still display a type once its + // last declaring panel disappears. Recorded from whatever type is agreed NOW + // (a live declaration always wins over it), and read from the same projection + // the tab was opened through, at save time rather than at open time. + const type = dashboardVariables(deps.currentWorkspace(), binding.dashboardId) + .find((candidate) => candidate.name === binding.variableName)?.type ?? null; + const outcome = await deps.commitVariableConfig(binding.dashboardId, binding.variableName, sql === null + ? null + : { sql, ...(type === null ? {} : { lastKnownType: type }) }) as WorkspaceMutationOutcome; + // TAB-side state is applied on a real commit REGARDLESS of staleness, and + // before the bracket — the write is durable, so the tab must stop claiming + // unsaved work whether or not this caller still owns the renderer. The linked + // saved-query path has the same shape: `commitSavedQuery` clears `dirtySql` + // inside the service (state.ts), and only the DOM cascade after it sits behind + // `commitLinkedQuery`'s bracket. Gating the flag too left a committed tab + // permanently dirty whenever the user navigated mid-write — a dirty dot and a + // "Save" button for content already on disk, with nothing able to clear them. + if (outcome.ok) { + tab.dirtySql = false; + // `dirtySpec` is not part of a variable document (see `tabSaveDirty`), but + // the result toolbar's panel-type picker can still set it. Clearing it here + // keeps a saved variable tab from carrying a flag nothing else ever resets. + tab.dirtySpec = false; + // #466/#501-review: re-sync the `beforeunload` guard for THIS tab-side + // clear too — `rerenderTabs()` below the staleness bracket also does it, + // but that bracket can return early on a navigation that began mid-write. + deps.syncBeforeUnload(); + } + // Same staleness bracket every other async save uses: a navigation that began + // mid-write must not be REPAINTED or TOASTED over. + if (!deps.refreshCurrentSurfaceAfterStale(surfaceGeneration, outcome.ok)) return null; + if (outcome.ok) { + deps.rerenderTabs(); + updateSaveBtn(); + flashToast(sql === null ? 'Option SQL removed' : 'Saved', { document: deps.document }); + return null; + } + // `aborted` covers more than one thing, and only ONE of them is this + // transform's own refusal (`data === 'declined'` — the Dashboard is gone or + // its id is ambiguous, and nothing was written). The others are the primitive + // deciding the route moved on, and at least one of those keeps a durable + // write — so they say nothing rather than claim a failure that may not be one. + // Either way the draft stays dirty: it is the only copy of the user's edit. + if (outcome.aborted) { + if (outcome.data === 'declined') { + flashToast('This dashboard is no longer available — nothing was saved', { document: deps.document }); + } + return null; + } + flashToast('Save failed: ' + outcome.diagnostics[0].message, { document: deps.document }); + return null; + } + + async function saveActiveQuery(): Promise { + const tab = deps.activeTab(); + // #457: Save dispatches on the DOCUMENT KIND first. A variable tab is not a + // saved query and must never reach the linked-save or Save-as-new paths. + const variable = variableDoc(tab); + if (variable !== null) return saveVariableTab(tab, variable); + // #343: while a linked tab is in conflict, Save opens the resolution chooser + // rather than silently overwriting the externally changed query. A + // 'deleted'-flagged orphan has `savedId === null` already, so it falls + // through to the normal Save-as-new popover (never an implicit recreate). + if (tab.externalState === 'conflict') { openConflictChooser(); return undefined; } + if (savedForTab(deps.state, tab)) return commitLinkedQuery(); + openSavePopover(); + return undefined; + } + + // #343 §8: discard the active tab's local draft and adopt the latest committed + // version of its linked query — the "Reload saved version" conflict + // resolution. The committed query is already projected on `state.savedQueries` + // (a refresh ran to detect the conflict), so this reads it from there. + function reloadSavedVersion(): void { + const tab = deps.activeTab(); + const entry = savedForTab(deps.state, tab); + if (!entry) { + // Deleted between opening the chooser and resolving — nothing to reload; + // refresh so the reconcile gives this tab its deleted-elsewhere treatment + // instead of leaving the stale conflict state in place (#343 review). + void deps.refreshWorkspaceFromStore(); + return; + } + adoptSavedIntoTab(tab, entry); + batch(() => { deps.state.tabs.value = [...deps.state.tabs.value]; }); // re-run the tab effect → editor + strip resync + updateSaveBtn(); + deps.rerenderTabs(); + deps.renderSavedHistory(); + flashToast('Reloaded the version saved in the other tab', { document: deps.document }); + } + + // #343 §8: the two-action conflict chooser, anchored under the Save button. + // "Reload saved version" fires immediately; "Keep my draft" confirms, then + // commits the full draft over the latest query via the normal linked-save path + // (`commitLinkedQuery` → `mutateWorkspace`), preserving unrelated workspace + // changes and clearing the conflict on success. + function openConflictChooser(): void { + if (deps.savePopoverOpen()) return; + const tab = deps.activeTab(); + let close: () => void; + const chooser = buildConflictChooser({ + queryName: tab.name, + onReloadSaved: () => { close(); reloadSavedVersion(); }, + onKeepDraft: () => { close(); void commitLinkedQuery(); }, + }); + ({ close } = deps.anchoredPopover(chooser, deps.saveBtn()!, 'savePopover')); + } + + // Creation-only Name/Description popover. Once linked, the textual Spec is + // authoritative and Save bypasses this UI entirely. + function openSavePopover(): void { + const tab = deps.activeTab(); + // A queryless panel (text, #166) is authored entirely in its cfg, so it + // saves with empty SQL — the same per-type relaxation saveQuery applies. + if (!String(tab.sqlDraft || '').trim() && !isQuerylessPanel(tabPanel(tab))) { + flashToast('Nothing to save', { document: deps.document }); + return; + } + if (deps.savePopoverOpen()) return; + const prefill = tab.name && tab.name !== 'Untitled' ? tab.name : inferQueryName(tab.sqlDraft); + const input = h('input', { class: 'sp-input', value: prefill }); + const descInput = h('textarea', { class: 'sp-desc', rows: '3', placeholder: 'What this query does — included in Markdown export' }); + let close: () => void; + const commit = async (): Promise => { + if (!input.value.trim()) return; + const surfaceGeneration = deps.captureSurfaceGeneration(); + // #343: `saved.create` runs its transform through `app.mutateWorkspace`, + // which already serializes + reads the latest committed aggregate — no + // outer `serializeWrite` wrapper needed. + const result = await deps.saved.create(tab, input.value, descInput.value); + // #466/#501-review: `saved.create` already cleared `dirtySql`/`dirtySpec` + // on success (`createSavedQuery`, state.ts) — before the staleness + // bracket, which can return early on a navigation that began mid-write. + if (result.ok) deps.syncBeforeUnload(); + if (!deps.refreshCurrentSurfaceAfterStale(surfaceGeneration, result.ok)) return; + if (!result.ok) { + if (result.diagnostics?.length) flashToast('Save failed: ' + result.diagnostics[0].message, { document: deps.document }); + return; + } + close(); + deps.queryDoc.revalidateSpecDrafts(); + deps.syncSpecEditorFromState(); + updateSaveBtn(); + deps.updateEditorModeUi(); + deps.rerenderTabs(); + deps.renderSavedHistory(); + flashSaved(result.diagnostics); + }; + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); + // In the multiline description, plain Enter inserts a newline; ⌘/Ctrl+Enter commits. + descInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); } }); + const pop = h('div', { class: 'save-popover' }, + h('div', { class: 'sp-label' }, 'Save query as'), + input, + h('div', { class: 'sp-label' }, 'Description', h('span', { class: 'sp-opt' }, ' — optional')), + descInput, + h('div', { class: 'sp-actions' }, + h('button', { class: 'sp-cancel', onclick: () => close() }, 'Cancel'), + h('button', { class: 'sp-save', onclick: commit }, 'Save'))); + ({ close } = deps.anchoredPopover(pop, deps.saveBtn()!, 'savePopover')); + setTimeout(() => { input.focus(); input.select(); }); + } + + return { updateSaveBtn, saveActiveQuery, openConflictChooser, openSavePopover }; +} diff --git a/src/ui/workbench/variable-strip.ts b/src/ui/workbench/variable-strip.ts new file mode 100644 index 00000000..5b2759c9 --- /dev/null +++ b/src/ui/workbench/variable-strip.ts @@ -0,0 +1,325 @@ +// #588 W1 (phase 4, decompose the `createApp` composition root): the +// Workbench `{name:Type}` query-variable STRIP — `setRunBtn` (the Run +// button's disabled/tooltip/label sync) and `renderVarStrip` (the strip's own +// DOM view) — extracted verbatim out of app.ts into their own controller. +// +// Deliberately NOT `src/ui/variable-bar.ts`: that module is a deliberately +// ADAPTER-facing port (#478) shared by the Dashboard and the detached Data +// view, with neutral, caller-agnostic names (`activeByName` vs this strip's +// `state.filterActive`, `params.saveActive` vs `saveFilterActive`) and its own +// private combo-field type. Forcing this Workbench-specific strip's own +// concrete `AppState`/`WorkbenchParameterSession` shape into that adapter +// contract would break it for its other two callers — see +// `variable-bar.ts`'s own header comment. This is a SEPARATE, Workbench-only +// view over the same leaf field-control builders +// (`buildEnumField`/`buildRelativeTimeField`/`buildRecentField` + +// `wireComboInput`), not a second implementation of them. +// +// Bookkeeping ownership: `sig`/`rerenderPending`/`hookedStrip` used to be +// plain `app.dom.varStripSig`/`varStripRerenderPending`/`varStripDeferHooked` +// fields — free bookkeeping resets because `app.dom` itself is reset wholesale +// (`{}`) on every shell mount (a sign-out/sign-in cycle rebuilds a fresh +// `
`). This controller is a stable singleton built once +// by `createApp` and never reconstructed, so its own closure state does NOT +// reset for free the same way — without an explicit check, a sign-out/sign-in +// cycle would compare a fresh signature against a STALE `sig` left over from +// the previous strip element (wrongly skipping the first rebuild) and, worse, +// leave the OLD element's `focusout` listener as the only one ever installed +// — the new element would never get one, silently breaking the mid-typing +// focus-containment guard below. `hookedStrip` tracks the exact element +// identity this controller's bookkeeping (and its one `focusout` listener) +// currently belongs to; the top of `renderVarStrip` resets `sig`/ +// `rerenderPending` and (re)installs the listener the moment `varStrip()` +// returns a DIFFERENT element than last time. + +import { h } from '../dom.js'; +import { Icon } from '../icons.js'; +import { variableDoc } from '../../state.js'; +import type { AppState, QueryTab } from '../../state.js'; +import type { WorkbenchParameterSession } from '../../application/workbench-parameter-session.js'; +import { analysisView, fieldControls, fieldControlKind } from '../../core/param-pipeline.js'; +import { paramComparisonColumns } from '../../core/param-comparison.js'; +import { recentOptions } from '../../core/recent-values.js'; +import { applyFieldState, applyFieldWidth } from '../var-field.js'; +import { buildRelativeTimeField } from '../relative-time-field.js'; +import type { RelativeTimeField } from '../relative-time-field.js'; +import { buildRecentField } from '../recent-field.js'; +import type { RecentField } from '../recent-field.js'; +import { buildEnumField } from '../enum-field.js'; +import type { EnumField } from '../enum-field.js'; +import { wireComboInput } from '../combobox.js'; + +/** The var-strip's combobox-based field controller — whichever of + * `buildEnumField`/`buildRelativeTimeField`/`buildRecentField` `ctl.kind` + * picks. Only `RelativeTimeField` actually declares `previewEl` (the #169 + * live date preview `applyFieldState` points `aria-describedby` at); the + * intersection makes reading it a safe optional no-op for the other two + * control kinds, which never populate it. */ +type VarStripCombo = (EnumField | RecentField | RelativeTimeField) & { previewEl?: HTMLElement }; + +/** The narrow slice of the real `app` controller `createVariableStrip` reads — + * a thunk for each DOM ref (`varStrip`/`runBtn`) rather than a direct + * `HTMLElement`, since neither exists yet at construction time (they're built + * later by `workbench-shell.ts`'s `mountWorkbenchShell`, into whatever fresh + * `app.dom` the current shell mount owns). */ +export interface VariableStripDeps { + document: Document; + state: AppState; + activeTab(): QueryTab | undefined; + params: Pick; + wallNow(): number; + varStrip(): HTMLElement | undefined; + runBtn(): HTMLButtonElement | undefined; +} + +export interface VariableStripController { + renderVarStrip(): void; + setRunBtn(running: boolean, gate?: { missing: string[]; invalid: string[]; errors: string[] }): void; +} + +/** Build the strip's controller bound to `deps`. Trivial constructor — no + * validation; `createApp` supplies the real `app`-backed thunks, unit tests + * supply fakes directly. */ +export function createVariableStrip(deps: VariableStripDeps): VariableStripController { + // Controller-private bookkeeping — see this module's header comment for why + // these must be reset explicitly on a strip-identity change rather than + // relying on `app.dom` being reset wholesale, the way the pre-extraction + // `app.dom.varStripSig`/`varStripRerenderPending`/`varStripDeferHooked` + // fields used to. + let sig: string | undefined; + let rerenderPending = false; + let hookedStrip: HTMLElement | undefined; + + // hardenVar/inputGate (#170 review bookkeeping) live on `deps.params` — + // setRunBtn's fallback and renderVarStrip's tail call + // `params.inputGate`/`params.hardenVar` directly. + function setRunBtn(running: boolean, gate?: { missing: string[]; invalid: string[]; errors: string[] }): void { + const runBtn = deps.runBtn(); + if (!runBtn) return; + // Disabled while running, or while any detected {name:Type} query variable + // is missing, invalid (#170), or fails to serialize (#170 review finding: + // the button's visible disabled state must match varGateBlocked's actual + // gate, which already blocks on missing+invalid+errors) — with a tooltip + // so the greyed-out button explains itself. Execution paths (run/ + // runScript) enforce the same gate via varGateBlocked. A caller that + // already has the prepared source (renderVarStrip) passes its + // {missing, invalid, errors} to avoid re-preparing; otherwise we compute + // it here via inputGate — a merely 'incomplete' value (#170) stays + // display-only and doesn't grey out the button while still focused. + const tab = deps.activeTab(); + if (gate == null) { + // #465 review: a dashboard-variable tab's text is option SQL, not an + // ordinary parameterised query — the {name:Type} gate never applies to + // it (optionSqlDiagnostics, surfaced on Run, is its complete policy). + gate = running || !tab || variableDoc(tab) !== null + ? { missing: [], invalid: [], errors: [] } + : deps.params.inputGate(deps.params.tabAnalysis(tab.sqlDraft)); + } + const blockers = gate.missing.concat(gate.invalid); + runBtn.disabled = running || blockers.length > 0 || gate.errors.length > 0; + runBtn.title = blockers.length + ? 'Enter a value for: ' + blockers.join(', ') + : gate.errors.length ? gate.errors[0] : ''; + // "Run selection" while the editor has a non-empty selection (so the mode is + // discoverable); plain "Run" otherwise. Build the children and drop the null + // (replaceChildren would coerce a null arg into a "null" text node). + const label = running ? 'Running…' : (deps.state.hasSelection.value ? 'Run selection' : 'Run'); + runBtn.replaceChildren( + ...[Icon.play(), h('span', null, label), + running ? null : h('kbd', null, '⌘↵')].filter((c): c is SVGElement | HTMLElement => c != null)); + } + + // Repaint the query-variable strip (#134) for the active tab. Values live in + // the shared, persisted `state.varValues` (keyed by variable name), so a value + // typed once is reused by every query that references the same variable and is + // restored on reload. The listed set comes from the all-active analysis view + // (#165): a param confined to /*[ ]*/ optional blocks stays listed — marked + // optional (blank allowed; blank keeps its blocks inactive) — while a param + // outside blocks stays required. Typing keeps `state.filterActive` in sync + // (blank ⇒ inactive, typed ⇒ active). Inputs rebuild only when the detected + // {name:Type} set changes (signature guard) — so typing in the SQL editor + // doesn't thrash the row or steal focus, and switching between tabs with the + // same variables keeps the (already-correct, shared) values in place. Always + // re-syncs the Run button's disabled/tooltip state. + // + // #172 v2 (schema-cache inference — the SUGGESTION tier) lives on + // `deps.params.inferredEnumOptions` — pure over schema + analysis, no DOM. + function renderVarStrip(): void { + const strip = deps.varStrip(); + if (!strip) return; + if (strip !== hookedStrip) { + // A fresh strip element (first render, or a shell remount) — reset + // every piece of bookkeeping and (re-)install the ONE `focusout` + // listener this controller keeps per element. See this module's header + // comment: without this, a remount would compare against a stale `sig` + // and never re-attach the listener onto the new node. + hookedStrip = strip; + sig = undefined; + rerenderPending = false; + strip.addEventListener('focusout', (e: FocusEvent) => { + if (!rerenderPending) return; + if (e.relatedTarget && strip.contains(e.relatedTarget as Node)) return; + rerenderPending = false; + renderVarStrip(); + }); + } + const tab = deps.activeTab(); + // #465 review: a dashboard-variable tab's own text is option SQL, not an + // ordinary parameterised query — the {name:Type} strip/gate never applies + // to it. A `{name:Type}` inside it is optionSqlDiagnostics' story to tell + // (surfaced in the results pane on Run), not an input field to fill in. + if (tab && variableDoc(tab) !== null) { + sig = ''; + strip.replaceChildren(); + strip.style.display = 'none'; + setRunBtn(deps.state.running.value); + return; + } + // One analysis per repaint (review F9): fieldControls, the #172 v2 + // comparison scan, a rebuild's initial field paint, and the tail's Run- + // button gate all feed off this single pass instead of re-analyzing the + // same SQL a second time per editor keystroke. + const analysis = tab ? deps.params.tabAnalysis(tab.sqlDraft) : null; + const vars = analysis ? fieldControls(analysis) : []; + // #172 v2 scans the tab SQL's ANALYSIS materialization (review F2): in + // the raw text a comparison inside a /*[ ]*/ optional block is one opaque + // comment span and could never match. `resolveComparisonColumnType` + // resolves each match's position against this same text. (Workbench-only + // — the Dashboard has no schema cache and gets v1 straight from the type.) + const scanSql = tab ? analysisView(tab.sqlDraft) : ''; + const comparisonColumns = tab ? paramComparisonColumns(scanSql) : {}; + // Each field's control kind + member list (shared enum > date-like > text + // priority; a type-conflicted field degrades to text — fieldControlKind). + const controls = vars.map((v) => fieldControlKind(v, deps.params.inferredEnumOptions(v, scanSql, comparisonColumns))); + // The signature folds in each var's control kind and resolved enum + // OPTION COUNT — not just name/type/optional — so a column landing on the + // idle-tick loader (loadColumns calls renderVarStrip on completion) + // upgrades a v2 field from plain input to the dropdown, and a type + // conflict appearing or resolving restyles the field, even though the + // {name:Type} set itself never changed. + // KNOWN PRE-EXISTING GAP (moved verbatim from app.ts, not introduced or + // fixed by #588's phase-4 extraction — tracked as #605): the signature + // only folds in enumOptions.LENGTH, + // not the option identities, so a same-cardinality option-set change + // (e.g. background reload swaps ['a','b'] for ['c','d']) does not bump + // the signature and the stale dropdown survives until something else + // changes the {name:Type} set. Deliberately not fixed here — a pure + // structural extraction is not the place to change this behavior. + const sigNew = vars.map((v, i) => { + const c = controls[i]; + return v.name + ':' + v.type + (v.optional ? '?' : '') + (v.conflict ? '!' : '') + + ':' + c.kind + (c.enumOptions ? c.enumOptions.length : ''); + }).join(','); + // The Run button's gate from this SAME analysis (review F9: setRunBtn's + // gate-less fallback would re-analyze the identical SQL). Lazy so the + // running / tab-less states (whose gate setRunBtn hard-empties anyway) + // skip the prepare entirely. + const runGate = () => (analysis && !deps.state.running.value ? deps.params.inputGate(analysis) : undefined); + if (sigNew !== sig) { + // A signature change while the user is focused INSIDE the strip would + // replaceChildren() every field out from under them — a background + // column load (loadColumns → renderVarStrip, the #172 v2 upgrade path) + // completing mid-typing would steal focus, wipe the in-progress text + // repaint, and destroy any open dropdown. Defer the rebuild until focus + // leaves the strip: the upgrade only matters on the NEXT interaction + // anyway. (Typing in the SQL editor also lands here on every keystroke, + // but then focus is in the editor, not the strip — no deferral.) + const active = deps.document.activeElement; + if (active && strip.contains(active)) { + rerenderPending = true; + setRunBtn(deps.state.running.value, runGate()); + return; + } + rerenderPending = false; + sig = sigNew; + if (!vars.length) { + strip.replaceChildren(); + strip.style.display = 'none'; + } else { + strip.style.display = ''; + // The freshly-(re)built strip paints each field's already-committed + // state ('execute' mode — no field is mid-typing right after a + // rebuild, e.g. a tab switch restoring a previously-invalid value). + const initialFields = deps.params.prepareAnalyzedBatch(analysis!, deps.wallNow(), 'execute').fields; + strip.replaceChildren(...vars.map((v, i) => { + // controls[i] (fieldControlKind above) picks the field's control: + // #172 enum members (v1 declared or v2 inferred) > #169 date-like + // preset combobox + live preview > plain text with recents (#171). + // The field stays free-text in every case (absolute values / non- + // members keep working); persistence/#170 validation stays exactly + // the shared logic below — the combobox only adds its own focus/ + // keydown-nav/composition hooks, called first from the same + // handlers (wireComboInput; see relative-time-field.js's header + // comment on why this beats two independent listeners). + const ctl = controls[i]; + // #173 acceptance (review F1): a type-conflicted field degrades to + // the plain text control (ctl.kind above) and says so visibly — a + // warning style distinct from is-invalid (the VALUE isn't wrong; + // the declarations disagree) plus a tooltip listing them. + const conflictNote = v.conflict + ? 'Conflicting type declarations: ' + v.conflict.join(' vs ') : null; + const baseTitle = v.name + ': ' + v.type + + (v.optional ? ' — optional: blank leaves its filter block out' : '') + + (conflictNote ? ' — ' + conflictNote : ''); + let combo: VarStripCombo; + let input: HTMLInputElement; + const onValueInput = (): void => { + deps.state.varValues[v.name] = input.value; + // Text controls sync activation with the value (#165). + deps.state.filterActive[v.name] = input.value !== ''; + deps.params.saveVarValues(); + deps.params.saveFilterActive(); + // Editing the value un-hardens it (#170 review): back to + // neutral, lenient behavior until it's committed again. + deps.params.hardenedVars.delete(v.name); + // 'input' mode (#170): a plausible prefix stays neutral while + // the field is focused — only a value that's already certainly + // wrong shows the inline error here. + const inputBatch = deps.params.prepareTabBatch(tab!.sqlDraft, deps.wallNow(), 'input'); + applyFieldState(input, inputBatch.fields[v.name], baseTitle, combo?.previewEl); + setRunBtn(deps.state.running.value, inputBatch.sources[0]); + }; + const onCommitHard = (): void => { + // Hardens 'incomplete' → 'invalid' on commit (#170). + const commitBatch = deps.params.prepareTabBatch(tab!.sqlDraft, deps.wallNow(), 'execute'); + deps.params.hardenVar(v.name, commitBatch.fields[v.name]); + applyFieldState(input, commitBatch.fields[v.name], baseTitle, combo?.previewEl); + setRunBtn(deps.state.running.value, commitBatch.sources[0]); + }; + // #171: live-filtered recents for this field (type + typed text), + // called fresh on every dropdown open/keystroke — never a snapshot + // — so a value recorded by a run that completes without changing + // the strip's {name:Type} signature is never stale. (#160's + // curated-param opt-out hook: nothing to check yet — no curated + // param exists before #160 lands.) + const getRecents = (text: string): string[] => recentOptions(deps.state.varRecent, v.name, v.type, text); + const onClearRecent = (): void => deps.params.clearVarRecent(v.name); + const fieldOpts = { + document: deps.document, name: v.name, type: v.type, value: deps.state.varValues[v.name] || '', + baseTitle, onValueInput, onCommit: onCommitHard, getRecents, onClearRecent, + }; + if (ctl.kind === 'enum') combo = buildEnumField({ ...fieldOpts, values: ctl.enumOptions! }); + else if (ctl.kind === 'date') combo = buildRelativeTimeField({ ...fieldOpts, wallNow: deps.wallNow }); + else combo = buildRecentField(fieldOpts); + input = combo.input; + // #345: a stable, type-appropriate width — set once per field + // build (never on keystroke), same rule the Dashboard/detached-view + // variable bar uses (variable-bar.js). + applyFieldWidth(input, v.type, ctl.kind === 'enum'); + wireComboInput(combo, { onValueInput, onCommit: onCommitHard }); + if (conflictNote) input.classList.add('is-conflict'); + deps.params.hardenVar(v.name, initialFields[v.name]); + applyFieldState(input, initialFields[v.name], baseTitle, combo?.previewEl); + return h('label', { class: 'var-field' + (v.optional ? ' is-optional' : '') }, + h('span', { class: 'var-name' }, v.name), combo.el); + })); + } + } + setRunBtn(deps.state.running.value, runGate()); + } + + return { renderVarStrip, setRunBtn }; +} diff --git a/tests/e2e/import-example-dashboard.html b/tests/e2e/import-example-dashboard.html index 6a3c2ccb..1887d52f 100644 --- a/tests/e2e/import-example-dashboard.html +++ b/tests/e2e/import-example-dashboard.html @@ -91,7 +91,7 @@ // comment). mounted.setHeader(buildAppHeader(app)); app.applyCommittedWorkspace(workspace); - app.rewriteWorkspaceRoute(workspace.key); + app.nav.rewriteWorkspaceRoute(workspace.key); // Read COMMITTED truth back out of the store, so an import test asserts // what was actually persisted rather than what the projection shows. diff --git a/tests/e2e/oauth-document-recovery/index.html b/tests/e2e/oauth-document-recovery/index.html index d997a051..5536fa79 100644 --- a/tests/e2e/oauth-document-recovery/index.html +++ b/tests/e2e/oauth-document-recovery/index.html @@ -196,7 +196,7 @@ // reload onto the matching route must use the persisted marker. const missingSearch = '?scenario=pending&ws=missing-workspace'; window.history.replaceState(null, '', window.location.pathname + missingSearch); - app.syncSqlRoute(missingSearch); + app.nav.syncSqlRoute(missingSearch); } window.__oauthRecoveryTrigger401 = async () => { // Go through the real protected workbench command, rather than calling diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index e89444e0..c92e7d80 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -32,6 +32,8 @@ import type { WorkspaceStore, WorkspaceStoreRecord, } from '../../src/workspace/workspace-store.types.js'; import type { App, ActionsRegistry, AppDom, ChCtx } from '../../src/ui/app.types.js'; +import type { WorkspaceSession } from '../../src/application/workspace-session.js'; +import type { SurfaceNavigation } from '../../src/application/surface-navigation.js'; import type { AppState } from '../../src/state.js'; import type { ConfigDoc, ResolvedIdpConfig } from '../../src/net/oauth-config.js'; import type { StreamResult } from '../../src/core/stream.js'; @@ -393,6 +395,71 @@ const prefsDefaults: AppPreferences = { toggleTheme: vi.fn(() => 'light'), }; +// Inert `WorkspaceSession` defaults (#588 phase 4 wave 3 — `app.workspaceSession` +// moved queueing/tokens/broadcast/refresh/beforeunload/provisioning off the flat +// `App` contract and into this nested service). Self-contained (not sharing +// `appDefaults.workspace`/`.applyCommittedWorkspace`) so this const and +// `appDefaults` below can each be constructed independently of declaration +// order. `makeApp()`'s own `base.workspaceSession` (below) overrides +// `serializeWrite`/`flushWorkspaceWrites`/`mutateWorkspace` with a REAL +// per-instance queue backed by the fixture's actual `workspace` repository — +// mirrors the pre-#588 `base` IIFE this replaces. No fixture in this repo +// currently exercises `scheduleRefresh`/`sourceTabId`/`getLastCommittedToken`/ +// `recordProjection`/`syncBeforeUnload`/`armOAuthRedirectUnloadBypass`/ +// `resolveImplicitOrProvision`/`recordOpened` through the fake app (only real +// `createApp()`-driven tests do), so these stay inert placeholders here. +const workspaceSessionDefaults: WorkspaceSession = { + serializeWrite: (op: () => Promise): Promise => op(), + flushWorkspaceWrites: async () => {}, + mutateWorkspace: async (transform) => { + const input = await transform(null); + return { ok: false, aborted: true, data: input ? input.data : undefined }; + }, + refreshWorkspaceFromStore: async () => {}, + scheduleRefresh: () => {}, + sourceTabId: 'tab-fake', + getLastCommittedToken: () => '', + recordProjection: () => {}, + syncBeforeUnload: () => {}, + armOAuthRedirectUnloadBypass: () => () => {}, + resolveImplicitOrProvision: async () => ({ status: 'empty' }), + recordOpened: async () => {}, +}; + +// Inert `SurfaceNavigation` defaults (#588 phase 4 wave 4 — `app.nav`). Self- +// contained, same convention as `workspaceSessionDefaults` above: independent +// of `appDefaults`'s own flat stubs for the wide-consumer members +// (`navigateSqlRoute`/`openDashboard`/`showQuerySurface`/…), which a fixture +// exercising THOSE still reads directly off `app.*` (unaffected by this wave). +const navDefaults: SurfaceNavigation = { + navigateSqlRoute: async () => {}, + handleSqlPopState: async () => {}, + syncSqlRoute: () => {}, + rewriteWorkspaceRoute: () => {}, + writeRoute: () => {}, + currentRouteSearch: () => '', + renderCurrentSurface: () => {}, + loadWorkspaceOnBoot: async () => null, + reloadDashboardRoute: () => {}, + openDashboard: () => {}, + showQuerySurface: () => {}, + showDashboardSurface: () => {}, + openSavedQuery: () => {}, + openPanelQuery: () => {}, + openVariableTab: () => {}, + // #426: the default fixture has no rendered Dashboard surface, so an + // in-place focus request is never deliverable — `pending` is the honest + // stub, and also what makes a spec that cares about in-place delivery + // override it explicitly rather than accidentally passing against a fake + // `ok`. + focusDashboardMember: () => 'pending', + captureSurfaceGeneration: () => 0, + isSurfaceGenerationCurrent: (generation) => generation === 0, + refreshCurrentSurfaceAfterStale: (generation) => generation === 0, + advanceSurfaceGeneration: () => {}, + loadGeneration: () => 0, +}; + // Every `App` member this file's own concrete stubs (below) don't cover, // filled with an inert placeholder never read by a fixture that doesn't // override it — same convention as (and previously duplicated by) each of @@ -476,9 +543,6 @@ const appDefaults: App = { isSurfaceGenerationCurrent: (generation) => generation === 0, refreshCurrentSurfaceAfterStale: (generation) => generation === 0, navigateSqlRoute: async () => {}, - handleSqlPopState: async () => {}, - syncSqlRoute: () => {}, - rewriteWorkspaceRoute: () => {}, renderCurrentSurface: () => {}, syncBeforeUnload: () => {}, // Recovery itself is covered by its application/session tests. UI fixtures @@ -494,19 +558,20 @@ const appDefaults: App = { // post-commit projection. applyCommittedWorkspace: () => {}, genId: () => 'gen-id', - // #343 §5/§6: inert cross-tab-consistency seams — a fixture exercising - // invalidation overrides these (or uses two real `createApp()` instances). - sourceTabId: 'tab-fake', - documentVisible: () => true, - getLastCommittedToken: () => '', + // #343 §5/§6: inert cross-tab-consistency seam — a fixture exercising + // invalidation overrides this (or uses two real `createApp()` instances). onExternalWorkspaceChange: () => {}, - refreshWorkspaceFromStore: async () => {}, onWorkspaceExternallyChanged: () => {}, - // Inert passthrough — `base` overrides with a real per-instance queue. - serializeWrite: (op: () => Promise): Promise => op(), - // #341: inert no-op — `base` overrides with the real per-instance flush that - // shares `serializeWrite`'s own queue. - flushWorkspaceWrites: async () => {}, + // #588 phase 4 wave 3: queueing/tokens/broadcast/refresh/beforeunload/ + // provisioning now live under `workspaceSession` — see + // `workspaceSessionDefaults` above. `base` below overrides + // `serializeWrite`/`flushWorkspaceWrites`/`mutateWorkspace` with a real + // per-instance queue backed by `workspaceRepo`. + workspaceSession: workspaceSessionDefaults, + // #588 phase 4 wave 4: `handleSqlPopState`/`focusDashboardMember` (router- + // private) and `syncSqlRoute`/`rewriteWorkspaceRoute` (no flat `App` + // delegate — see `navDefaults` above) are reachable only through `nav.*`. + nav: navDefaults, // #341/#344: inert placeholder — `transform` still runs (so a fixture that // never overrides this still exercises the caller's build-from-latest // logic), but `latest` is always `null` (no queue, no read-back) and the @@ -588,16 +653,10 @@ const appDefaults: App = { // `registerSpecValidator` have no flat `App` member (#276 Phase 5 deleted // them) — a fixture reads `queryDocDefaults`/`app.queryDoc.*` for those now. activateInvalidSpecDraft: () => {}, - openSavePopover: () => {}, openUserMenu: () => {}, renderApp: () => {}, renderDashboard: () => {}, openDashboard: () => {}, - // #426: the default fixture has no rendered Dashboard surface, so an in-place - // focus request is never deliverable — `pending` is the honest stub, and it is - // also what makes a spec that cares about in-place delivery override it - // explicitly rather than accidentally passing against a fake `ok`. - focusDashboardMember: () => 'pending', invalidateDashboardTree: () => {}, showQuerySurface: () => {}, showDashboardSurface: () => {}, @@ -623,7 +682,7 @@ const appDefaults: App = { // parameter as exactly what `makeApp` itself accepts, instead of // re-declaring a narrower `Partial` that would reject it. export type MakeAppOverrides = AppOverrides; -type AppOverrides = Partial> & { +type AppOverrides = Partial> & { /** Partial like the rest (#286 Phase 4) — Dashboard mutations read a * StoredWorkspaceV5 through `workspace.loadById`; a test overrides only * the repository methods it drives. */ @@ -664,6 +723,21 @@ type AppOverrides = Partial; graph?: Partial; prefs?: Partial; + /** Partial like the rest (#588 phase 4 wave 3) — most fixtures never touch + * the session directly; a test asserting e.g. + * `workspaceSession.flushWorkspaceWrites`'s return (a held-open gate) can + * override just that method, keeping `base`'s real per-instance + * `serializeWrite`/`mutateWorkspace` queue for the rest. */ + workspaceSession?: Partial; + /** Partial like `workspaceSession` above (#588 phase 4 wave 4) — most + * fixtures never touch the session directly; a test asserting e.g. + * `nav.focusDashboardMember`'s return can override just that method. The + * wide-consumer members (`navigateSqlRoute`/`openDashboard`/…) also keep + * their OWN independent flat-`App` stub (same convention as + * `workspaceSession.mutateWorkspace` vs. the flat `App.mutateWorkspace` + * stub above — this fixture is a hand-maintained test double, not a + * byte-accurate mirror of app.ts's real `app.foo = nav.foo` wiring). */ + nav?: Partial; }; // `overrides` is generic so its properties keep their OWN precise call-site @@ -787,8 +861,9 @@ export function makeApp>(override return run; }; // #341: shares the SAME `chain` `serializeWrite` advances, so a test - // awaiting `app.flushWorkspaceWrites()` sees every write queued before - // this call resolve — mirrors app.ts's own `writeChain`-backed pair. + // awaiting `app.workspaceSession.flushWorkspaceWrites()` sees every + // write queued before this call resolve — mirrors app.ts's own + // `writeChain`-backed pair. const flushWorkspaceWrites = (): Promise => chain.then(() => undefined, () => undefined); // #341/#344: mirrors app.ts's own `mutateWorkspace` — reads `workspaceRepo // .loadById()` (the SAME repo `workspace.commit` below publishes @@ -812,7 +887,14 @@ export function makeApp>(override dashboardRevision: result.dashboardRevision, data: input.data, }; }); - return { serializeWrite, flushWorkspaceWrites, mutateWorkspace }; + // `mutateWorkspace` stays flat too (`App.mutateWorkspace`, #588 phase 4 + // wave 3's wide-consumer flat delegate) — the SAME function reference + // as `workspaceSession.mutateWorkspace` below, mirroring app.ts's own + // `app.mutateWorkspace = session.mutateWorkspace` wiring. + return { + mutateWorkspace, + workspaceSession: { serializeWrite, flushWorkspaceWrites, mutateWorkspace }, + }; })(), // The one deliberate delegate survivor of #276 Phase 5's params-group // cleanup — see `App.saveVarRecent`'s own doc comment. @@ -931,6 +1013,10 @@ export function makeApp>(override graph: { ...graphDefaults, ...(overrides.graph ?? {}) }, prefs: { ...prefsDefaults, ...base.prefs, ...(overrides.prefs ?? {}) }, workspace: workspaceRepo, + workspaceSession: { + ...workspaceSessionDefaults, ...base.workspaceSession, ...(overrides.workspaceSession ?? {}), + }, + nav: { ...navDefaults, ...(overrides.nav ?? {}) }, }; // Assignability check only (a variable reference, not a fresh literal, so // this never trips an excess-property error) — `merged`'s own inferred type diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 65105786..95c4b23c 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -836,7 +836,7 @@ describe('createApp basics', () => { expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); location.search = '?ws=recovery'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(app.activeTab().sqlDraft).toBe('SELECT newer in-memory edit'); expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); @@ -1121,17 +1121,17 @@ describe('createApp basics', () => { expect(app.openDocDisambiguation('missing')).toBeUndefined(); expect(app.showLogin('Sign in again')).toBeUndefined(); expect(app.root!.textContent).toContain('Sign in again'); - await expect(app.serializeWrite(async () => { throw new Error('expected'); })).rejects.toThrow('expected'); - await expect(app.flushWorkspaceWrites()).resolves.toBeUndefined(); + await expect(app.workspaceSession.serializeWrite(async () => { throw new Error('expected'); })).rejects.toThrow('expected'); + await expect(app.workspaceSession.flushWorkspaceWrites()).resolves.toBeUndefined(); const dashboardApp = createApp(env()); await dashboardApp.loadWorkspaceOnBoot(); expect(dashboardApp.renderCurrentSurface()).toBeUndefined(); - await expect(dashboardApp.refreshWorkspaceFromStore()).resolves.toBeUndefined(); + await expect(dashboardApp.workspaceSession.refreshWorkspaceFromStore()).resolves.toBeUndefined(); const change = { type: 'workspace-changed' as const, sourceTabId: 'other', workspaceId: 'w1' }; dashboardApp.onExternalWorkspaceChange(change); dashboardApp.onExternalWorkspaceChange(change); // coalesced while the first refresh is queued - await dashboardApp.flushWorkspaceWrites(); + await dashboardApp.workspaceSession.flushWorkspaceWrites(); }); it('returns an unlinked tab forced into Spec mode to SQL mode', () => { @@ -1141,6 +1141,67 @@ describe('createApp basics', () => { app.updateEditorModeUi!(); expect(app.activeTab().editorMode).toBe('sql'); }); + + // #588 phase 4 wave 5: `createApp` now assembles `app` as ONE object literal + // (no `as App` cast) — see app.ts's own header comment on `let app: App;`. + // The one way this could still observe a partially-wired controller is an + // injected editor port whose `onDocChange` fires the subscriber SYNCHRONOUSLY + // at registration time (real CodeMirror never does this — it only calls back + // on a later keystroke — but the seam contract doesn't forbid it, and Stage 5 + // registers `onDocChange` right after `Editor(app)`/`SpecEditor(app)` are + // constructed, well after the literal has already assigned every OTHER + // member). This proves the eager-callback case lands on a fully-built `app`: + // the guarded reads inside the subscriber (`if (app.actions)` etc., #588 + // phase 4 wave 5 kept these verbatim) see real values rather than tripping a + // TDZ ReferenceError or silently no-oping against a still-partial object. + it('a sync-firing injected Editor/SpecEditor onDocChange observes a fully-constructed app, not a partial-construction hole (#588 wave 5)', () => { + const sqlValue = 'SELECT 1 -- fired synchronously at Editor registration'; + const specValue = '{"name":"fired-at-registration"}'; + const syncEditor = (): EditorPort => ({ + mount() {}, + destroy() {}, + focus() {}, + hasFocus: () => false, + getValue: () => sqlValue, + getSelection: () => ({ start: 0, end: 0, text: '' }), + insertAtCursor() {}, + replaceDocument() {}, + revealOffset() {}, + syncFromState() {}, + refreshReference() {}, + onDocChange: (cb) => { cb(sqlValue); return () => {}; }, + }); + const syncSpecEditor = () => ({ + ...syncEditor(), + requestMeasure() {}, + setDiagnostics() {}, + revealDiagnostic() {}, + getValue: () => specValue, + onDocChange: (cb: (value: string) => void) => { cb(specValue); return () => {}; }, + }); + + let app: App | undefined; + expect(() => { + app = createApp(env({ Editor: syncEditor, SpecEditor: syncSpecEditor })); + }).not.toThrow(); + + const tab = app!.activeTab(); + // The SQL `onDocChange` subscriber ran during construction (Stage 5) and + // still wrote through to the real, fully-wired `app.state` — not a + // detached or partially-built stand-in. + expect(tab.sqlDraft).toBe(sqlValue); + expect(tab.dirtySql).toBe(true); + // The guarded calls inside that same subscriber (`app.actions`/ + // `app.updateSaveBtn`/`app.renderVarStrip`) are all real by construction + // time now — assert the registry itself is the real one, not a hole. + expect(app!.actions).toBeDefined(); + expect(typeof app!.updateSaveBtn).toBe('function'); + expect(typeof app!.renderVarStrip).toBe('function'); + // The Spec `onDocChange` subscriber (`queryDoc.evaluateSpecDraft`) also + // ran during construction without throwing, against the same fully-built + // `app` (it reads `app.activeTab()` internally). + expect(app!.queryDoc).toBeDefined(); + }); }); // #341/#344 review fix: `app.mutateWorkspace` is the only correct way to @@ -1163,7 +1224,7 @@ describe('app.mutateWorkspace (#341/#344)', () => { // `mutateWorkspace` call fires while it's still pending. let release: () => void = () => {}; const gate = new Promise((r) => { release = r; }); - const first = app.serializeWrite(async () => { + const first = app.workspaceSession.serializeWrite(async () => { await gate; return app.workspace.commit(seedWorkspace({ queries: [savedQuery({ id: 'q1', name: 'Q1' })] })); }); @@ -1218,13 +1279,13 @@ describe('app.mutateWorkspace (#341/#344)', () => { expect(result.ok && result.data).toBe(42); expect(projected).toEqual(['Proj']); // exactly once expect(app.state.libraryName.value).toBe('Proj'); // projection took effect - expect(app.getLastCommittedToken().length).toBeGreaterThan(0); + expect(app.workspaceSession.getLastCommittedToken().length).toBeGreaterThan(0); }); it('does not project on an aborted or failed commit', async () => { const app = createApp(env()); await seedActiveWorkspace(app, seedWorkspace()); - const tokenBefore = app.getLastCommittedToken(); + const tokenBefore = app.workspaceSession.getLastCommittedToken(); let projections = 0; const orig = app.applyCommittedWorkspace; app.applyCommittedWorkspace = (ws) => { projections++; orig(ws); }; @@ -1236,7 +1297,7 @@ describe('app.mutateWorkspace (#341/#344)', () => { expect(failed.ok).toBe(false); expect(failed.ok === false && failed.aborted).toBeFalsy(); // a real failure, not an abort expect(projections).toBe(0); - expect(app.getLastCommittedToken()).toBe(tokenBefore); // never advanced + expect(app.workspaceSession.getLastCommittedToken()).toBe(tokenBefore); // never advanced }); it('fails closed when the active record is corrupt and never invokes the transform or commit', async () => { @@ -1282,7 +1343,7 @@ describe('app cross-tab invalidation (#343)', () => { candidate: { ...latest!, name: 'One' }, })); expect(posted).toHaveLength(1); - expect(posted[0]).toEqual({ type: 'workspace-changed', sourceTabId: app.sourceTabId, workspaceId: 'w1' }); + expect(posted[0]).toEqual({ type: 'workspace-changed', sourceTabId: app.workspaceSession.sourceTabId, workspaceId: 'w1' }); await app.mutateWorkspace(() => null); // abort await app.mutateWorkspace(() => ({ candidate: { storageVersion: 5, id: 'x', key: 'x', name: 'n', queries: [{ bad: true } as never], dashboards: [] } })); // fail expect(posted).toHaveLength(1); // still just the one success @@ -1303,7 +1364,7 @@ describe('app cross-tab invalidation (#343)', () => { await b.loadWorkspaceOnBoot(); await a.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Changed' } })); expect(aSeen).toHaveLength(0); // guard drops A's own poke - expect(bSeen).toEqual([{ type: 'workspace-changed', sourceTabId: a.sourceTabId, workspaceId: 'w1' }]); + expect(bSeen).toEqual([{ type: 'workspace-changed', sourceTabId: a.workspaceSession.sourceTabId, workspaceId: 'w1' }]); }); it('the construction-time invalidation hook safely absorbs a synchronous channel delivery', () => { @@ -1335,11 +1396,6 @@ describe('app cross-tab invalidation (#343)', () => { void a; }); - it('defaults documentVisible to the document visibility, and honors an injected reader', () => { - expect(createApp(env()).documentVisible()).toBe(true); // happy-dom is "visible" - expect(createApp(env({ documentVisible: () => false })).documentVisible()).toBe(false); - }); - it('opens a real BroadcastChannel when the platform provides one', async () => { class FakeBC { static made: FakeBC[] = []; @@ -1396,7 +1452,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { await app.workspace.commit({ ...q1ws(), queries: [] }); const saved = await app.actions.save(); expect(saved).toBeNull(); // aborted — never recreated - await app.flushWorkspaceWrites(); // the triggered refresh settles + await app.workspaceSession.flushWorkspaceWrites(); // the triggered refresh settles // The reconcile turned the ghost link into the orphan treatment: unsaved // draft, deleted-elsewhere flag, draft preserved exactly. expect(t.savedId).toBeNull(); @@ -1411,11 +1467,11 @@ describe('app workspace refresh + conflict UI (#343)', () => { await seedActiveWorkspace(app, q1ws()); const spy = vi.spyOn(app.workspace, 'loadById'); window.dispatchEvent(new Event('focus')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); expect(spy).toHaveBeenCalled(); const afterFocus = spy.mock.calls.length; document.dispatchEvent(new Event('visibilitychange')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); expect(spy.mock.calls.length).toBeGreaterThan(afterFocus); }); @@ -1424,7 +1480,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { await seedActiveWorkspace(app, q1ws()); const spy = vi.spyOn(app.workspace, 'loadById'); document.dispatchEvent(new Event('visibilitychange')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); expect(spy).not.toHaveBeenCalled(); }); @@ -1434,7 +1490,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { app.renderCurrentSurface = vi.fn(); app.workspace.loadById = vi.fn(async () => ({ status: 'empty' as const })); - await app.refreshWorkspaceFromStore(); + await app.workspaceSession.refreshWorkspaceFromStore(); expect(app.currentWorkspace).toBeNull(); expect(app.workspaceRouteStatus).toBe('not-found'); @@ -1575,7 +1631,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { vi.spyOn(app.workspace, 'loadById').mockResolvedValueOnce({ status: 'corrupt', id: 'w1', key: 'workspace_one', diagnostics: [], }); - await app.refreshWorkspaceFromStore(); // warns internally, returns + await app.workspaceSession.refreshWorkspaceFromStore(); // warns internally, returns expect(app.state.savedQueries[0].id).toBe('q1'); // projection intact const after = await app.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Still works' } })); expect(after.ok).toBe(true); // queue not wedged @@ -1633,7 +1689,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { // Another tab changes q1, then this tab's refresh detects the conflict. await other.loadWorkspaceOnBoot(); await renameSaved(other.state, 'q1', 'External name', undefined, other.mutateWorkspace); - await app.refreshWorkspaceFromStore(); + await app.workspaceSession.refreshWorkspaceFromStore(); expect(t.externalState).toBe('conflict'); await app.actions.save(); // opens the chooser (document.querySelector('.conflict-chooser .cf-reload') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); @@ -1653,12 +1709,12 @@ describe('app workspace refresh + conflict UI (#343)', () => { t.sqlDraft = 'SELECT my kept draft'; t.dirtySql = true; await other.loadWorkspaceOnBoot(); await renameSaved(other.state, 'q1', 'External name', undefined, other.mutateWorkspace); - await app.refreshWorkspaceFromStore(); + await app.workspaceSession.refreshWorkspaceFromStore(); expect(t.externalState).toBe('conflict'); await app.actions.save(); (document.querySelector('.conflict-chooser .cf-keep') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); (document.querySelector('.conflict-chooser .cf-overwrite') as HTMLElement).dispatchEvent(new Event('click', { bubbles: true })); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); expect(t.externalState ?? null).toBeNull(); // conflict resolved const persisted = await loadActiveWorkspace(app); expect(persisted!.queries.find((q) => q.id === 'q1')!.sql).toBe('SELECT my kept draft'); @@ -1679,7 +1735,7 @@ describe('app workspace refresh + conflict UI (#343)', () => { app.state.savedQueries = []; (document.querySelector('.conflict-chooser .cf-reload') as HTMLElement) .dispatchEvent(new Event('click', { bubbles: true })); - await app.flushWorkspaceWrites(); // the queued refreshWorkspaceFromStore settles + await app.workspaceSession.flushWorkspaceWrites(); // the queued refreshWorkspaceFromStore settles // The refresh projected the external delete and the reconcile gave this // clean ghost-linked tab its detach treatment: no link, no stale badge. expect(t.savedId).toBeNull(); @@ -2425,7 +2481,13 @@ describe('query run', () => { // incomplete→invalid hardening already relies on, now also reached via // the relative-time near-miss path (review finding #2's whole point: it // routes through the exact same states, not a bespoke gate). - app.dom.varStripSig = undefined; // force renderVarStrip to rebuild the strip + // #588 W1: the strip's rebuild-signature bookkeeping moved out of + // `app.dom` into `ui/workbench/variable-strip.ts`'s own controller- + // private state, so a test can no longer poke it directly to force a + // rebuild — add a second (harmless) variable instead, which changes the + // detected {name:Type} set and so legitimately triggers one. `from` + // stays the first declared variable, so `rebuiltInput` below is still it. + app.activeTab().sqlDraft = 'SELECT {from:DateTime}, {unused:UInt8}'; app.renderVarStrip(); expect(app.dom.runBtn!.disabled).toBe(true); const rebuiltInput = qs(app.dom.varStrip!, '.var-input'); @@ -4638,7 +4700,7 @@ describe('share + star + columns', () => { expect(qs(pop, '.sp-input').value).toBe('SELECT 42'); // inferred name qs(pop, '.sp-input').value = 'My fave'; qs(pop, '.sp-save').dispatchEvent(new Event('click')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); // let the popover handler finish its post-commit repaint expect(app.state.savedQueries).toHaveLength(1); expect(app.state.savedQueries[0]).toMatchObject({ sql: 'SELECT 42', spec: { name: 'My fave', favorite: false } }); @@ -4655,7 +4717,7 @@ describe('share + star + columns', () => { app.actions.save(); qs(document, '.save-popover .sp-input').value = 'Ambiguous range'; qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(app.state.savedQueries).toHaveLength(1); expect(app.state.savedQueries[0].spec.timeRanges).toBeUndefined(); @@ -4678,7 +4740,7 @@ describe('share + star + columns', () => { expect(app.state.savedQueries).toEqual([]); input.value = 'Keyboard save'; description.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true, cancelable: true })); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(app.state.savedQueries[0].spec.name).toBe('Keyboard save'); }); @@ -4692,7 +4754,7 @@ describe('share + star + columns', () => { expect(qsa(document, '.save-popover')).toHaveLength(1); qs(document, '.save-popover .sp-input').value = 'Q'; qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(app.dom.saveBtn!.textContent).toContain('Saved'); // edit → button reverts to "Save" @@ -5414,7 +5476,7 @@ describe('exhaustive controller coverage', () => { app.dom.saveBtn!.dispatchEvent(new Event('click')); // open save popover qs(document, '.save-popover .sp-input').value = 'Q'; qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); // commit - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); // let the popover handler finish its post-commit repaint app.dom.shareBtn!.dispatchEvent(new Event('click')); // share expect(app.state.tabs.value.length).toBeGreaterThan(1); @@ -6595,7 +6657,7 @@ describe('unified /sql routing', () => { it('resynchronizes route state after bootstrap cleans an OAuth callback URL', () => { const app = createApp(env()); - app.syncSqlRoute('?ws=ops&surface=dashboard&mode=view'); + app.nav.syncSqlRoute('?ws=ops&surface=dashboard&mode=view'); expect(app.sqlRoute).toEqual({ surface: 'dashboard', workspaceKey: 'ops', mode: 'view', }); @@ -6957,7 +7019,7 @@ describe('unified /sql routing', () => { it('an absent or non-Dashboard port reports `pending`, never `ok`', () => { const { app } = readyApp(['a'], '?ws=ops&surface=dashboard'); app.surfaceCommands = null; - expect(app.focusDashboardMember({ kind: 'tile', id: 't1' })).toBe('pending'); + expect(app.nav.focusDashboardMember({ kind: 'tile', id: 't1' })).toBe('pending'); }); it('a legacy no-chooser entry point opens the compatibility Dashboard BY ID', () => { @@ -7279,7 +7341,7 @@ describe('unified /sql routing', () => { app.renderCurrentSurface(); expectSurface(app, 'dashboard'); location.search = '?ws=ops'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); // The still-mounted Query controls must stay live: the pre-#425 teardown on // this path disabled every control under the root, permanently. expectSurface(app, 'query'); @@ -7351,14 +7413,14 @@ describe('unified /sql routing', () => { const { app, location } = readyApp(['first', 'second'], '?ws=ops&surface=dashboard'); app.openDashboard({ dashboardId: 'second', mode: 'edit' }); location.search = '?ws=ops&surface=dashboard&mode=view'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); // The URL carries no Dashboard id, so re-deriving one here would silently // retarget the surface to the collection's first entry. expect(app.mainSurface).toEqual({ kind: 'dashboard', dashboardId: 'second', mode: 'view', currentMember: null, pendingFocus: null, pendingScrollTop: null, }); location.search = '?ws=ops'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(app.mainSurface).toEqual({ kind: 'query' }); }); @@ -7391,7 +7453,7 @@ describe('unified /sql routing', () => { // "a Dashboard, in view mode". window.history.replaceState(leaving, '', '/sql?ws=ops&surface=dashboard&mode=view'); location.search = '?ws=ops&surface=dashboard&mode=view'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(app.mainSurface).toMatchObject({ kind: 'dashboard', dashboardId: 'second', mode: 'view', // A restored entry owes no focus DELIVERY: the ring is not re-flashed. (The @@ -7415,7 +7477,7 @@ describe('unified /sql routing', () => { ); app.mainSurface = { kind: 'query' }; location.search = '?ws=ops&surface=dashboard'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); // Falls back to the compatibility entry, exactly as a boot with no snapshot // does — never to the other workspace's remembered id. expect(app.mainSurface).toMatchObject({ dashboardId: 'first', pendingScrollTop: null }); @@ -7429,7 +7491,7 @@ describe('unified /sql routing', () => { ); app.mainSurface = { kind: 'query' }; location.search = '?ws=ops&surface=dashboard'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(app.mainSurface).toMatchObject({ dashboardId: 'first' }); }); }); @@ -7440,7 +7502,7 @@ describe('unified /sql routing', () => { origin: 'https://ch.example', pathname: '/sql', search: '?ws=old&surface=dashboard&mode=view', hash: '', host: 'ch.example', } as Location })); - app.rewriteWorkspaceRoute('new_workspace'); + app.nav.rewriteWorkspaceRoute('new_workspace'); expect(app.sqlRoute).toEqual({ surface: 'dashboard', workspaceKey: 'new_workspace', mode: 'view', }); @@ -7482,7 +7544,7 @@ describe('unified /sql routing', () => { ); app.renderCurrentSurface = vi.fn(); location.search = '?ws=second&surface=dashboard&mode=view'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(app.sqlRoute).toEqual({ surface: 'dashboard', workspaceKey: 'second', mode: 'view', }); @@ -7748,7 +7810,7 @@ describe('unified /sql routing', () => { await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); release(); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await vi.waitFor(() => expect(app.currentWorkspace!.dashboards[0] ?? null).not.toBeNull()); expectSurface(app, 'query'); @@ -7837,7 +7899,7 @@ describe('unified /sql routing', () => { app.workspace.markOpened = vi.fn(async () => ({ ok: true as const })); app.renderCurrentSurface = vi.fn(); - const refreshA = app.refreshWorkspaceFromStore(); + const refreshA = app.workspaceSession.refreshWorkspaceFromStore(); await Promise.resolve(); await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); resolveRefresh({ status: 'ok', workspace: workspace('a') }); @@ -7867,9 +7929,9 @@ describe('unified /sql routing', () => { })); app.renderCurrentSurface = vi.fn(); location.search = '?ws=b'; - const backToB = app.handleSqlPopState(); + const backToB = app.nav.handleSqlPopState(); location.search = '?ws=c'; - const forwardToC = app.handleSqlPopState(); + const forwardToC = app.nav.handleSqlPopState(); const c: StoredWorkspaceV5 = { storageVersion: 5, id: 'c', key: 'c', name: 'C', queries: [], dashboards: [], }; @@ -7901,7 +7963,7 @@ describe('unified /sql routing', () => { ); location.search = '?ws=a&surface=dashboard&mode=view'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(loadByKey).not.toHaveBeenCalled(); expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); @@ -7927,7 +7989,7 @@ describe('unified /sql routing', () => { app.retryPendingOAuthDocumentRecovery = vi.fn(() => ({ kind: 'absent' } as const)); app.renderCurrentSurface = vi.fn(); - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(app.workspace.loadByKey).toHaveBeenCalledWith('a'); expect(app.currentWorkspace).toBe(workspace); @@ -7952,7 +8014,7 @@ describe('unified /sql routing', () => { expect(app.state.shortcutsOpen.value).toBe(true); expect(document.querySelector('.modal-backdrop')).not.toBeNull(); location.search = '?ws=a'; - await app.handleSqlPopState(); + await app.nav.handleSqlPopState(); expect(app.state.shortcutsOpen.value).toBe(false); expect(document.querySelector('.modal-backdrop')).toBeNull(); expect(qs(app.root, '.workbench')).not.toBeNull(); @@ -8074,7 +8136,7 @@ describe('unified /sql routing', () => { expect(qsa(app.root, '.dashboard-mode-switch .editor-mode-btn') .find((button) => button.textContent === 'View')!.disabled).toBe(true); release(); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await vi.waitFor(() => { expect((app.currentWorkspace!.dashboards[0]!.layout as { preset?: string }).preset).toBe('full'); }); @@ -8099,7 +8161,7 @@ describe('unified /sql routing', () => { await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); expect(qs(app.root, '.workbench')).not.toBeNull(); release(); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await vi.waitFor(() => { expect((app.currentWorkspace!.dashboards[0]!.layout as { preset?: string }).preset).toBe('grid'); }); @@ -8124,7 +8186,7 @@ describe('unified /sql routing', () => { await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); rejectCommit(); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await Promise.resolve(); expectSurface(app, 'query'); @@ -8150,7 +8212,7 @@ describe('unified /sql routing', () => { }, 'push'); release(); await save; - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await vi.waitFor(() => { expect(app.currentWorkspace!.queries[0].sql).toBe('SELECT 2'); }); @@ -8185,7 +8247,7 @@ describe('unified /sql routing', () => { surface: 'dashboard', workspaceKey: 'w', mode: 'edit', }, 'push'); release(); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await vi.waitFor(() => expect(app.currentWorkspace!.queries).toHaveLength(1)); expect(app.currentWorkspace!.queries[0].sql).toBe('SELECT 42'); @@ -8249,7 +8311,7 @@ describe('unified /sql routing', () => { app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; app.applyCommittedWorkspace(a); let releaseQueue!: () => void; - const queuedAhead = app.serializeWrite(() => + const queuedAhead = app.workspaceSession.serializeWrite(() => new Promise((resolve) => { releaseQueue = resolve; })); await Promise.resolve(); const transform = vi.fn(); @@ -8297,11 +8359,11 @@ describe('unified /sql routing', () => { }) as typeof window.addEventListener, ); const app = createApp(env()); - app.handleSqlPopState = vi.fn(async () => {}); + app.nav.handleSqlPopState = vi.fn(async () => {}); expect(listener).not.toBeNull(); listener!(new PopStateEvent('popstate')); await Promise.resolve(); - expect(app.handleSqlPopState).toHaveBeenCalledOnce(); + expect(app.nav.handleSqlPopState).toHaveBeenCalledOnce(); addEventListener.mockRestore(); }); diff --git a/tests/unit/cross-tab-consistency.test.ts b/tests/unit/cross-tab-consistency.test.ts index 1ef001d4..c069249c 100644 --- a/tests/unit/cross-tab-consistency.test.ts +++ b/tests/unit/cross-tab-consistency.test.ts @@ -240,7 +240,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { await renameSaved(a.state, 'q1', 'Renamed in A', undefined, a.mutateWorkspace); // B still shows the old projection until it refreshes. expect(b.state.savedQueries.find((q) => q.id === 'q1')!.spec.name).toBe('q1'); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(b.state.savedQueries.find((q) => q.id === 'q1')!.spec.name).toBe('Renamed in A'); }); @@ -256,7 +256,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { aTab.savedId = 'q1'; aTab.sqlDraft = 'SELECT 42'; await commitSavedQuery(a.state, aTab, a.state.savedQueries[0].spec, a.mutateWorkspace); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(bTab.sqlDraft).toBe('SELECT 42'); // adopted expect(bTab.savedId).toBe('q1'); // still linked expect(bTab.dirtySql).toBe(false); // not dirty @@ -282,7 +282,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { bTab.dirtySql = true; // A changes q1; B refreshes → conflict (correctly flagged). await renameSaved(a.state, 'q1', 'Changed in A', undefined, a.mutateWorkspace); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(bTab.externalState).toBe('conflict'); // B renames q1 from its own Library (metadata patch over LATEST — the same // path the star button uses). This must not resolve the conflict. @@ -291,7 +291,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { expect(bTab.externalState).toBe('conflict'); // A then changes something UNRELATED (q2); B refreshes again. await renameSaved(a.state, 'q2', 'Unrelated change in A', undefined, a.mutateWorkspace); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); // The stale dirty draft is still behind the resolver — never silently // saveable without Reload-saved-version / Keep-my-draft. expect(bTab.externalState).toBe('conflict'); @@ -321,7 +321,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { expect(bTab.dirtySql).toBe(false); expect(bTab.externalState ?? null).toBeNull(); // …and a refresh afterwards changes nothing (already consistent). - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(bTab.sqlDraft).toBe('SELECT 42 /* from A */'); expect(bTab.externalState ?? null).toBeNull(); // The persisted workspace holds BOTH changes. @@ -344,7 +344,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { // B renames q1 immediately (folds into LATEST, including A's change) — the // stale tab's baseline token must NOT advance past A's unseen change. await renameSaved(b.state, 'q1', 'Renamed in B', undefined, b.mutateWorkspace); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(bTab.externalState).toBe('conflict'); // flagged, not silently in sync expect(bTab.sqlDraft).toBe('SELECT my stale draft'); }); @@ -358,7 +358,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { bTab.sqlDraft = 'SELECT my local draft'; bTab.dirtySql = true; await renameSaved(a.state, 'q1', 'Changed in A', undefined, a.mutateWorkspace); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(bTab.sqlDraft).toBe('SELECT my local draft'); // draft preserved expect(bTab.dirtySql).toBe(true); expect(bTab.savedId).toBe('q1'); @@ -381,7 +381,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { dirty.dirtySql = true; dirty.lastCommittedQueryToken = cleanTab.lastCommittedQueryToken; await deleteSaved(a.state, 'q1', a.mutateWorkspace); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(cleanTab.savedId).toBeNull(); // clean detaches expect(cleanTab.externalState ?? null).toBeNull(); expect(dirty.savedId).toBeNull(); // dirty orphan — unlinked, not recreated @@ -394,10 +394,10 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { const a = tab(store); const b = tab(store); await seed(a, b, oneQuery()); const before = b.state.savedQueries; - await b.refreshWorkspaceFromStore(); // token unchanged → no reproject + await b.workspaceSession.refreshWorkspaceFromStore(); // token unchanged → no reproject expect(b.state.savedQueries).toBe(before); // same reference, not reprojected await b.workspace.delete(b.state.workspaceId); // externally emptied - await b.refreshWorkspaceFromStore(); // loaded === null → keeps projection + await b.workspaceSession.refreshWorkspaceFromStore(); // loaded === null → keeps projection expect(b.state.savedQueries).toBe(before); }); @@ -410,7 +410,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { b.onExternalWorkspaceChange(poke); b.onExternalWorkspaceChange(poke); b.onExternalWorkspaceChange(poke); - await b.flushWorkspaceWrites(); + await b.workspaceSession.flushWorkspaceWrites(); expect(spy).toHaveBeenCalledTimes(1); void a; }); @@ -421,7 +421,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { await seed(a, b, oneQuery()); // #407 view mode is a read-only projection of the same live workspace. await renameSaved(b.state, 'q1', 'Changed by B', undefined, b.mutateWorkspace); - await a.refreshWorkspaceFromStore(); + await a.workspaceSession.refreshWorkspaceFromStore(); expect(a.state.savedQueries[0].spec.name).toBe('Changed by B'); }); @@ -435,7 +435,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { const a = mk(); const b = mk(); await seed(a, b, oneQuery()); await renameSaved(a.state, 'q1', 'No-channel rename', undefined, a.mutateWorkspace); - await b.refreshWorkspaceFromStore(); + await b.workspaceSession.refreshWorkspaceFromStore(); expect(b.state.savedQueries[0].spec.name).toBe('No-channel rename'); }); @@ -450,7 +450,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { bTab.sqlDraft = 'SELECT b-created'; const created = await createSavedQuery(b.state, bTab, 'B-new', '', b.mutateWorkspace); expect(created.ok).toBe(true); - await b.flushWorkspaceWrites(); + await b.workspaceSession.flushWorkspaceWrites(); const final = await committed(a); expect(final.queries.find((q) => q.id === 'q1')!.spec.name).toBe('A-rename'); // A's change survived expect(final.queries.some((q) => q.spec.name === 'B-new')).toBe(true); // B's write landed on refreshed latest @@ -458,7 +458,7 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { // A rejected reload warns internally but never wedges: a later write succeeds. const orig = b.workspace.loadById.bind(b.workspace); b.workspace.loadById = vi.fn(async () => { throw new Error('idb down'); }); - await b.refreshWorkspaceFromStore(); // swallowed + await b.workspaceSession.refreshWorkspaceFromStore(); // swallowed b.workspace.loadById = orig; const after = await b.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Recovered' } })); expect(after.ok).toBe(true); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 36780be7..e0782e44 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -333,7 +333,7 @@ const render = (app: TestApp, over: Partial = {}): Promis mode: app.sqlRoute.surface === 'dashboard' ? app.sqlRoute.mode : 'edit', ...over, }); -// #341: `runCommand` now commits through `app.serializeWrite` (a real +// #341: `runCommand` now commits through `app.workspaceSession.serializeWrite` (a real // microtask-chained queue, same as saved-history.test.ts's own convention) — // a synchronous assertion right after triggering a command can no longer // observe `commit` having been called; a macrotask flush lets every pending @@ -4684,7 +4684,7 @@ describe('renderDashboard — the shared header File control (#452)', () => { }); // ── runCommand — the #341 serialized write pipeline ───────────────────────── -// Every editable Dashboard command now commits through `app.serializeWrite` +// Every editable Dashboard command now commits through `app.workspaceSession.serializeWrite` // (the SAME queue saved-query mutations and file-menu commits use), projects // the returned committed workspace onto `app.state` via // `app.applyCommittedWorkspace`, and rolls back deterministically on failure. diff --git a/tests/unit/file-menu.test.ts b/tests/unit/file-menu.test.ts index ef4a4540..d7e6541d 100644 --- a/tests/unit/file-menu.test.ts +++ b/tests/unit/file-menu.test.ts @@ -930,7 +930,7 @@ describe('Dashboard rows dispatch against the exact target (#452)', () => { const app = mount({ currentWorkspace: mine, // The flush is the await the switch happens across. - flushWorkspaceWrites: () => gate, + workspaceSession: { flushWorkspaceWrites: () => gate }, workspace: { loadById: async (id: string) => ( id === 'workspace-b' @@ -954,7 +954,7 @@ describe('Dashboard rows dispatch against the exact target (#452)', () => { let released: () => void = () => {}; const gate = new Promise((resolve) => { released = resolve; }); const app = mount({ - flushWorkspaceWrites: () => gate, + workspaceSession: { flushWorkspaceWrites: () => gate }, workspace: { loadById: async () => ({ status: 'empty' as const }) }, }); app.state.workspaceId = 'workspace-a'; @@ -1063,7 +1063,7 @@ describe('Dashboard rows dispatch against the exact target (#452)', () => { let released: () => void = () => {}; const gate = new Promise((resolve) => { released = resolve; }); const app = mount({ - flushWorkspaceWrites: () => gate, + workspaceSession: { flushWorkspaceWrites: () => gate }, workspace: { loadById: async () => ({ status: 'ok' as const, workspace: committed }) }, }); app.state.workspaceId = 'workspace-a'; @@ -1574,7 +1574,7 @@ describe('Import workspace (#406 additive collection)', () => { }), }, }); - app.rewriteWorkspaceRoute = vi.fn(); + app.nav.rewriteWorkspaceRoute = vi.fn(); app.state.savedQueries = [panelQuery('old', 'Old')]; const oldId = app.state.workspaceId; openFileMenu(app); @@ -1597,7 +1597,7 @@ describe('Import workspace (#406 additive collection)', () => { expect(app.state.savedQueries).toHaveLength(2); expect(app.state.dashboard!.id).toBe('d1'); expect(app.state.dashboard!.tiles[0].queryId).not.toBe('p1'); - expect(app.rewriteWorkspaceRoute).toHaveBeenCalledWith('imported_ops_3'); + expect(app.nav.rewriteWorkspaceRoute).toHaveBeenCalledWith('imported_ops_3'); expect(toast()).toBe('Imported workspace'); }); @@ -1647,7 +1647,7 @@ describe('Import workspace (#406 additive collection)', () => { }); openFileMenu(app); pickFile(picker('Import workspace…')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(app.state.savedQueries.map((query) => query.id)).toEqual(['q1']); expect(toast()).toBe('Imported workspace, but its last-used timestamp could not be saved.'); @@ -1678,7 +1678,7 @@ describe('New workspace', () => { }), }, }); - app.rewriteWorkspaceRoute = vi.fn(); + app.nav.rewriteWorkspaceRoute = vi.fn(); const oldId = app.state.workspaceId; openFileMenu(app); click(item(/New workspace/)!); @@ -1688,7 +1688,7 @@ describe('New workspace', () => { expect(app.state.libraryName.value).toBe('SQL Library'); expect(app.state.workspaceKey).toBe('sql_library_3'); expect(app.state.workspaceId).not.toBe(oldId); - expect(app.rewriteWorkspaceRoute).toHaveBeenCalledWith('sql_library_3'); + expect(app.nav.rewriteWorkspaceRoute).toHaveBeenCalledWith('sql_library_3'); expect(toast()).toBe('Started a new workspace'); }); @@ -1723,7 +1723,7 @@ describe('New workspace', () => { }); openFileMenu(app); click(item(/New workspace/)!); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(app.state.libraryName.value).toBe('SQL Library'); expect(toast()).toBe('Started a new workspace, but its last-used timestamp could not be saved.'); @@ -2123,7 +2123,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { let release: () => void = () => {}; const gate = new Promise((r) => { release = r; }); - const pendingMutation = app.serializeWrite(async () => { + const pendingMutation = app.workspaceSession.serializeWrite(async () => { await gate; // stays pending in the queue until released below return app.workspace.commit({ ...seed, queries: [...seed.queries, panelQuery('q2', 'Q2')] }); }); @@ -2138,7 +2138,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { release(); await pendingMutation; - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); const finalWs = await loadActiveWorkspace(app); // A `renameWorkspaceAction` that built its candidate from a pre-queue @@ -2162,7 +2162,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { let release: () => void = () => {}; const gate = new Promise((r) => { release = r; }); - const pendingMutation = app.serializeWrite(async () => { + const pendingMutation = app.workspaceSession.serializeWrite(async () => { await gate; return app.workspace.commit({ ...seed, queries: [...seed.queries, panelQuery('q2', 'Q2')] }); }); @@ -2175,7 +2175,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { release(); await pendingMutation; - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); const finalWs = await loadActiveWorkspace(app); // A stale-snapshot import would have planned against [q1] only, dropping @@ -2202,7 +2202,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { let release: () => void = () => {}; const gate = new Promise((r) => { release = r; }); - const pendingMutation = app.serializeWrite(async () => { + const pendingMutation = app.workspaceSession.serializeWrite(async () => { await gate; // Mints the SAME id as the bundle's incoming query, with DIFFERENT content. return app.workspace.commit({ ...seed, queries: [...seed.queries, panelQuery('new1', 'Mine')] }); @@ -2215,7 +2215,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { release(); await pendingMutation; - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); const finalWs = await loadActiveWorkspace(app); // The import aborted whole: the queued mutation's new1 ('Mine') stands, @@ -2239,7 +2239,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { let release: () => void = () => {}; const gate = new Promise((r) => { release = r; }); - const pendingMutation = app.serializeWrite(async () => { + const pendingMutation = app.workspaceSession.serializeWrite(async () => { await gate; // Mints the same id with IDENTICAL content (the rapid double-import case). return app.workspace.commit({ ...seed, queries: [...seed.queries, panelQuery('new1', 'New1')] }); @@ -2250,7 +2250,7 @@ describe('mixed-producer serialization (#341/#344 review fix)', () => { release(); await pendingMutation; - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); const finalWs = await loadActiveWorkspace(app); // Auto-resolved to 'use-existing': exactly ONE new1, and the toast counts @@ -2273,7 +2273,7 @@ describe('commit failure', () => { const input = app.dom.libraryTitle!.querySelector('.lib-name-input')!; input.value = 'Renamed'; key(input, 'Enter'); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(toast()).toBe('✕ rename failed'); expect(app.state.libraryName.value).toBe('Original'); @@ -2303,7 +2303,7 @@ describe('commit failure', () => { }); openFileMenu(app); pickFile(picker('Import workspace…')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(toast()).toBe('✕ import blocked'); }); @@ -2317,7 +2317,7 @@ describe('commit failure', () => { }); openFileMenu(app); pickFile(picker('Import workspace…')); - await app.flushWorkspaceWrites(); + await app.workspaceSession.flushWorkspaceWrites(); await flush(); expect(create).not.toHaveBeenCalled(); expect(toast()).toMatch(/^✕ /); diff --git a/tests/unit/keyboard-owner.test.ts b/tests/unit/keyboard-owner.test.ts new file mode 100644 index 00000000..e9236189 --- /dev/null +++ b/tests/unit/keyboard-owner.test.ts @@ -0,0 +1,83 @@ +// #588 W2 — `keyboardOwnerChannel` (src/ui/keyboard-owner.ts), hoisted out of +// three byte-identical private copies (file-menu.ts, library-assign-menu.ts, +// dashboard.ts). Unit-tested directly against a fake `KeyboardOwnerHost` — no +// `createApp`. file-menu.test.ts/library-assign-menu.test.ts/dashboard.test.ts +// remain the composition safety net proving each real call site still wires +// its menu's `onKeyboardOwnerChange` through this shared adapter and that +// `app.keyboardOwner` observably updates end-to-end; this file is the +// adapter's own unit surface. +import { describe, it, expect, vi } from 'vitest'; +import { keyboardOwnerChannel } from '../../src/ui/keyboard-owner.js'; +import type { KeyboardOwnerHost } from '../../src/ui/keyboard-owner.js'; +import type { KeyboardOwner, KeyboardOwnerRelease } from '../../src/ui/app.types.js'; + +/** A fake host tracking every acquire call and the release each one hands + * back, so a test can assert exactly which release fired and when. */ +function makeHost(): { host: KeyboardOwnerHost; releases: Array> } { + const releases: Array> = []; + const host: KeyboardOwnerHost = { + acquireKeyboardOwner: (kind: KeyboardOwner['kind']): KeyboardOwnerRelease => { + const release = vi.fn(); + releases.push(release); + return release; + }, + }; + return { host, releases }; +} + +describe('keyboardOwnerChannel', () => { + it('acquires the given kind on the first owner and returns nothing to release yet', () => { + const { host, releases } = makeHost(); + const channel = keyboardOwnerChannel(host); + channel({ kind: 'menu' }); + expect(releases).toHaveLength(1); + expect(releases[0]).not.toHaveBeenCalled(); + }); + + it('an owner swap releases the PREVIOUS acquisition before acquiring the new one', () => { + const { host, releases } = makeHost(); + const channel = keyboardOwnerChannel(host); + channel({ kind: 'menu' }); + channel({ kind: 'modal' }); // swap while still "open" + expect(releases).toHaveLength(2); + expect(releases[0]).toHaveBeenCalledTimes(1); // the menu's acquisition released + expect(releases[1]).not.toHaveBeenCalled(); // the modal's is still held + }); + + it('null releases the current owner and acquires nothing new', () => { + const { host, releases } = makeHost(); + const channel = keyboardOwnerChannel(host); + channel({ kind: 'popover' }); + channel(null); + expect(releases).toHaveLength(1); + expect(releases[0]).toHaveBeenCalledTimes(1); + }); + + it('null with no prior owner is a safe no-op (never acquires, never throws)', () => { + const { host, releases } = makeHost(); + const channel = keyboardOwnerChannel(host); + expect(() => channel(null)).not.toThrow(); + expect(releases).toHaveLength(0); + }); + + it('a fresh channel per call site: two independent channels never share their release state', () => { + const { host, releases } = makeHost(); + const channelA = keyboardOwnerChannel(host); + const channelB = keyboardOwnerChannel(host); + channelA({ kind: 'menu' }); + channelB({ kind: 'menu' }); + channelA(null); + expect(releases[0]).toHaveBeenCalledTimes(1); // A's own release fired + expect(releases[1]).not.toHaveBeenCalled(); // B's is untouched by A's close + }); + + it('repeated null calls only release once each (each call reads the current `release`, already cleared to null)', () => { + const { host, releases } = makeHost(); + const channel = keyboardOwnerChannel(host); + channel({ kind: 'menu' }); + channel(null); + channel(null); // nothing acquired now — no-op, no new release + expect(releases).toHaveLength(1); + expect(releases[0]).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/main.test.ts b/tests/unit/main.test.ts index 1f487d87..4f2f4630 100644 --- a/tests/unit/main.test.ts +++ b/tests/unit/main.test.ts @@ -65,7 +65,9 @@ function fakeApp(over: Partial> & { conn?: Partial {}) }, renderCurrentSurface: vi.fn(), resumeAuthenticatedExecution: vi.fn(), - syncSqlRoute: vi.fn(), + // #588 phase 4 wave 4: `syncSqlRoute` moved off the flat `App` contract + // onto `app.nav`. + nav: { syncSqlRoute: vi.fn() }, showLogin: vi.fn(), // #287 W4: bootstrap awaits this before the first renderApp() on the // non-dashboard route — a no-op stub here (the aggregate-projection @@ -538,7 +540,7 @@ describe('bootstrap', () => { expect(env.history.replaceState).toHaveBeenCalledWith( null, '', 'https://ch/sql?ws=missing&surface=dashboard&mode=view&keep=1', ); - expect(app.syncSqlRoute).toHaveBeenCalledWith( + expect(app.nav.syncSqlRoute).toHaveBeenCalledWith( '?ws=missing&surface=dashboard&mode=view&keep=1', ); expect(env.sessionStorage.getItem('oauth_return_route')).toBeNull(); @@ -558,7 +560,7 @@ describe('bootstrap', () => { state: 'expected', search: '?ws=private', })); await bootstrap(app, env); - expect(app.syncSqlRoute).toHaveBeenCalledWith(''); + expect(app.nav.syncSqlRoute).toHaveBeenCalledWith(''); expect(env.sessionStorage.getItem('oauth_return_route')).not.toBeNull(); }); @@ -837,7 +839,7 @@ describe('bootstrap', () => { }); await bootstrap(app, env); expect(env.history.replaceState).toHaveBeenCalledWith(null, '', 'https://ch/sql?ws=ops'); - expect(app.syncSqlRoute).toHaveBeenCalledWith('?ws=ops'); + expect(app.nav.syncSqlRoute).toHaveBeenCalledWith('?ws=ops'); expect(app.showLogin).toHaveBeenCalled(); }); @@ -855,7 +857,7 @@ describe('bootstrap', () => { })); await bootstrap(app, env); expect(env.history.replaceState).toHaveBeenCalledWith(null, '', 'https://ch/sql?ws=ops'); - expect(app.syncSqlRoute).toHaveBeenCalledWith('?ws=ops'); + expect(app.nav.syncSqlRoute).toHaveBeenCalledWith('?ws=ops'); expect(env.sessionStorage.getItem('oauth_return_route')).toBeNull(); expect(app.showLogin).toHaveBeenCalledWith('Sign-in failed: access_denied'); }); diff --git a/tests/unit/popover.test.ts b/tests/unit/popover.test.ts index 6f22b2dd..a999d0f5 100644 --- a/tests/unit/popover.test.ts +++ b/tests/unit/popover.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { openAnchoredDialog } from '../../src/ui/popover.js'; -import type { AnchoredDialogOptions } from '../../src/ui/popover.js'; +import { openAnchoredDialog, createAnchoredPopovers } from '../../src/ui/popover.js'; +import type { AnchoredDialogOptions, AnchoredPopoverDeps, AnchoredPopoverRefKey } from '../../src/ui/popover.js'; import { h } from '../../src/ui/dom.js'; afterEach(() => document.body.replaceChildren()); @@ -442,3 +442,183 @@ describe('openAnchoredDialog — initialFocus', () => { expect(document.activeElement).toBe(trigger); }); }); + +// ── `createAnchoredPopovers` (#588 W2) ────────────────────────────────────── +// A different primitive from `openAnchoredDialog` above: a light, non-modal +// anchored popover (no overlay, no Tab trap) used for the Save popover and +// the user menu. Extracted verbatim out of app.ts's `anchoredPopover` + +// module-scoped closers Set — see popover.ts's own header comment on this +// section for what's deliberately preserved (the I-21 stale-close clobber). + +/** A tiny keyboard-owner stack modeling the REAL `app.acquireKeyboardOwner` + * contract (app.ts): each acquisition's own `release` is idempotent (a + * `released` flag guards a second call from popping the stack twice). This + * is the "real half" of I-21 ("each close releases the keyboard owner + * exactly once") — `createAnchoredPopovers` itself calls `releaseKeyboard()` + * unconditionally on every `close()`, so the exactly-once guarantee lives + * in the caller-supplied release closure, exactly as it does in app.ts. */ +function makeKeyboardOwnerStack(): { + acquireKeyboardOwner: AnchoredPopoverDeps['acquireKeyboardOwner']; + owners: { kind: string }[]; +} { + const owners: { kind: string }[] = []; + const acquireKeyboardOwner: AnchoredPopoverDeps['acquireKeyboardOwner'] = (kind) => { + const owner = { kind }; + owners.push(owner); + let released = false; + return () => { + if (released) return; + released = true; + const index = owners.indexOf(owner); + if (index >= 0) owners.splice(index, 1); + }; + }; + return { acquireKeyboardOwner, owners }; +} + +function setupPopovers(over: Partial = {}): { + popovers: ReturnType; + refs: Partial>; + owners: { kind: string }[]; +} { + const refs: Partial> = {}; + const { acquireKeyboardOwner, owners } = makeKeyboardOwnerStack(); + const popovers = createAnchoredPopovers({ + document, + acquireKeyboardOwner, + isMobile: () => false, + viewportWidth: () => 1024, + getRef: (key) => refs[key], + setRef: (key, node) => { refs[key] = node; }, + ...over, + }); + return { popovers, refs, owners }; +} + +const anchorEl = (): HTMLElement => document.body.appendChild(h('button', {})); + +describe('createAnchoredPopovers — open/close', () => { + it('mounts the node, records it at the refKey, and acquires the keyboard owner', () => { + const { popovers, refs, owners } = setupPopovers(); + const node = h('div', { class: 'my-pop' }); + const anchor = anchorEl(); + popovers.open(node, anchor, 'savePopover'); + expect(document.body.contains(node)).toBe(true); + expect(refs.savePopover).toBe(node); + expect(owners).toEqual([{ kind: 'popover' }]); + expect(node.style.position).toBe('fixed'); + }); + + it('close() unmounts the node, clears the ref, and releases the keyboard owner', () => { + const { popovers, refs, owners } = setupPopovers(); + const node = h('div', {}); + const { close } = popovers.open(node, anchorEl(), 'savePopover'); + close(); + expect(document.body.contains(node)).toBe(false); + expect(refs.savePopover).toBeUndefined(); + expect(owners).toEqual([]); + }); + + it('right-aligns under the anchor on desktop; centers horizontally on mobile', () => { + const desktop = setupPopovers({ isMobile: () => false }); + const nodeDesktop = h('div', {}); + desktop.popovers.open(nodeDesktop, anchorEl(), 'savePopover'); + expect(nodeDesktop.style.right).not.toBe(''); + expect(nodeDesktop.style.left).toBe(''); + + const mobile = setupPopovers({ isMobile: () => true }); + const nodeMobile = h('div', {}); + mobile.popovers.open(nodeMobile, anchorEl(), 'savePopover'); + expect(nodeMobile.style.left).toBe('50%'); + expect(nodeMobile.style.transform).toBe('translateX(-50%)'); + }); + + it('Escape closes the popover', () => { + const { popovers, refs } = setupPopovers(); + const node = h('div', {}); + popovers.open(node, anchorEl(), 'savePopover'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + expect(document.body.contains(node)).toBe(false); + expect(refs.savePopover).toBeUndefined(); + }); + + it('a mousedown outside both the popover and its anchor closes it', () => { + const { popovers } = setupPopovers(); + const node = h('div', {}); + const anchor = anchorEl(); + popovers.open(node, anchor, 'savePopover'); + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(document.body.contains(node)).toBe(false); + }); + + it('a mousedown inside the popover node, or on its anchor, does not close it', () => { + const { popovers } = setupPopovers(); + const node = h('div', {}, h('span', { class: 'inner' }, 'x')); + const anchor = anchorEl(); + popovers.open(node, anchor, 'savePopover'); + node.querySelector('.inner')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(document.body.contains(node)).toBe(true); + anchor.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(document.body.contains(node)).toBe(true); + }); + + it('closeAll() closes every currently-open popover, across different refKeys', () => { + const { popovers, refs } = setupPopovers(); + const nodeA = h('div', {}); + const nodeB = h('div', {}); + popovers.open(nodeA, anchorEl(), 'savePopover'); + popovers.open(nodeB, anchorEl(), 'userMenu'); + popovers.closeAll(); + expect(document.body.contains(nodeA)).toBe(false); + expect(document.body.contains(nodeB)).toBe(false); + expect(refs.savePopover).toBeUndefined(); + expect(refs.userMenu).toBeUndefined(); + expect(() => popovers.closeAll()).not.toThrow(); // nothing left to close + }); + + // I-21's "real" half: `createAnchoredPopovers` itself calls + // `releaseKeyboard()` unconditionally on every `close()` — the + // exactly-once guarantee depends on the CALLER's release closure being + // idempotent (as the real `app.acquireKeyboardOwner` is). Sabotage: if + // that release closure were NOT idempotent, a stale double `close()` would + // pop the keyboard-owner stack twice, catching this test. + it('a stale double close() cannot double-release the keyboard owner when the caller\'s release is idempotent', () => { + const { popovers, owners } = setupPopovers(); + const node = h('div', {}); + const { close } = popovers.open(node, anchorEl(), 'savePopover'); + expect(owners).toHaveLength(1); + close(); + expect(owners).toHaveLength(0); + close(); // stale second call + expect(owners).toHaveLength(0); // still 0 — release() itself no-oped + }); + + // I-21 (documented defect — phase 4 plan §9-2, NOT fixed here): `close()` + // removes WHATEVER node currently occupies `refKey`, without checking that + // it is the node THIS `close()` itself opened. A caller retaining a stale + // `close()` handle across a second `open()` on the same `refKey` clobbers + // the newer popover instead of being a safe no-op. + it('I-21: a stale close() from an earlier popover clobbers a newer popover sharing the same refKey (current, unfixed behavior)', () => { + const { popovers, refs } = setupPopovers(); + const anchor = anchorEl(); + const nodeA = h('div', { class: 'pop-a' }); + const nodeB = h('div', { class: 'pop-b' }); + const { close: closeA } = popovers.open(nodeA, anchor, 'savePopover'); + // B opens on the SAME refKey without A ever closing — the real Save + // popover/user-menu callers guard this with their own `if (app.dom[refKey]) + // return;` check before opening, but `createAnchoredPopovers` itself does + // not enforce it. + popovers.open(nodeB, anchor, 'savePopover'); + expect(document.body.contains(nodeA)).toBe(true); + expect(document.body.contains(nodeB)).toBe(true); + expect(refs.savePopover).toBe(nodeB); + + closeA(); // A's stale handle + + // Documented defect: closeA() clobbers B (whatever currently occupies the + // ref), NOT A's own node — A's node is left mounted and orphaned. + expect(document.body.contains(nodeB)).toBe(false); + expect(document.body.contains(nodeA)).toBe(true); + expect(refs.savePopover).toBeUndefined(); + }); +}); diff --git a/tests/unit/save-controller.test.ts b/tests/unit/save-controller.test.ts new file mode 100644 index 00000000..a364b84e --- /dev/null +++ b/tests/unit/save-controller.test.ts @@ -0,0 +1,604 @@ +// #588 W2 — `createSaveController` (src/ui/workbench/save-controller.ts), the +// Save cluster (`updateSaveBtn`/`saveActiveQuery` + the linked commit/create/ +// conflict-chooser paths they dispatch to) extracted verbatim from app.ts. +// Unit-tested directly against a fake `SaveControllerDeps` — no `createApp`, +// no full `App`. app.test.ts's own `actions.save`/`updateSaveBtn` suites +// remain the end-to-end composition safety net proving `createApp`'s real +// wiring reaches this controller (`app.updateSaveBtn`/`actions.save` stay +// flat delegates); this file is the controller's own unit surface, including +// the #457 kind-dispatch-first ordering (I-15) as a SABOTAGE-verified test in +// both `updateSaveBtn` and `saveActiveQuery`. +import { describe, it, expect, vi } from 'vitest'; +import { createSaveController } from '../../src/ui/workbench/save-controller.js'; +import type { SaveControllerDeps } from '../../src/ui/workbench/save-controller.js'; +import { createAnchoredPopovers } from '../../src/ui/popover.js'; +import type { AnchoredPopoverRefKey } from '../../src/ui/popover.js'; +import { createState, newTabObj } from '../../src/state.js'; +import type { QueryTab, AppState } from '../../src/state.js'; +import { savedQuery } from '../helpers/saved-query.js'; +import type { SavedQueryV2, StoredWorkspaceV5 } from '../../src/generated/json-schema.types.js'; +import type { CommitLinkedResult, CreateSavedResult } from '../../src/application/saved-query-service.js'; + +const qs = (root: ParentNode | null, selector: string): T => + root!.querySelector(selector) as T; +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +const reader = (over: Record = {}) => ({ + loadStr: (k: string, dflt: string) => (k in over ? (over[k] as string) : dflt), + loadJSON: (k: string, dflt: unknown) => (k in over ? over[k] : dflt), +}); + +// A workspace whose Dashboard `d` declares one {country:String} variable +// through query `q1`'s tile — enough for `dashboardVariables` (imported +// directly by save-controller.ts) to resolve a real `type` for the +// `lastKnownType` branch in `saveVariableTab`. +const variableWorkspace: StoredWorkspaceV5 = { + id: 'w1', + dashboards: [{ id: 'd', title: 'D', tiles: [{ id: 't1', queryId: 'q1' }] }] as StoredWorkspaceV5['dashboards'], + queries: [{ id: 'q1', sql: 'SELECT 1 WHERE c = {country:String}', specVersion: 1, spec: { specVersion: 1, name: 'Q1' } }], +} as unknown as StoredWorkspaceV5; + +function makeDeps(over: { + tab?: QueryTab; + savedQueries?: SavedQueryV2[]; + currentWorkspace?: StoredWorkspaceV5 | null; + specBlocked?: (tab: QueryTab) => boolean; + refreshCurrentSurfaceAfterStale?: (generation: number, committed?: boolean) => boolean; + commit?: (tab: QueryTab, evaluated: { parsed: unknown; diagnostics: unknown[] }) => Promise; + create?: (tab: QueryTab, name: unknown, description: unknown) => Promise; + commitVariableConfig?: (dashboardId: string, variableName: string, cfg: unknown) => unknown; +} = {}): { + deps: SaveControllerDeps; + tab: QueryTab; + state: AppState; + saveBtn: HTMLButtonElement; + refs: Partial>; + spies: { + rerenderTabs: ReturnType; + updateEditorModeUi: ReturnType; + renderSavedHistory: ReturnType; + renderResults: ReturnType; + syncSpecEditorFromState: ReturnType; + syncBeforeUnload: ReturnType; + refreshWorkspaceFromStore: ReturnType; + revealFirstSpecError: ReturnType; + revalidateSpecDrafts: ReturnType; + }; +} { + const tab = over.tab ?? newTabObj('t1'); + const state = createState(reader()); + state.workspaceId = 'w1'; + state.savedQueries = over.savedQueries ?? []; + state.tabs.value = [tab]; + state.activeTabId.value = tab.id; + + const refs: Partial> = {}; + const popovers = createAnchoredPopovers({ + document, + acquireKeyboardOwner: () => () => {}, + isMobile: () => false, + viewportWidth: () => 1024, + getRef: (key) => refs[key], + setRef: (key, node) => { refs[key] = node; }, + }); + const saveBtn = document.body.appendChild(document.createElement('button')); + + const spies = { + rerenderTabs: vi.fn(), + updateEditorModeUi: vi.fn(), + renderSavedHistory: vi.fn(), + renderResults: vi.fn(), + syncSpecEditorFromState: vi.fn(), + syncBeforeUnload: vi.fn(), + refreshWorkspaceFromStore: vi.fn(async () => {}), + revealFirstSpecError: vi.fn(), + revalidateSpecDrafts: vi.fn(), + }; + + const deps: SaveControllerDeps = { + document, + state, + activeTab: () => tab, + saved: { + commit: over.commit ?? (async () => ({ ok: true, entry: savedQuery({ id: 's1' }) })), + create: over.create ?? (async () => ({ ok: true, entry: savedQuery({ id: 's1' }) })), + }, + queryDoc: { + evaluateSpecDraft: () => ({ parsed: {}, diagnostics: [] }), + revalidateSpecDrafts: spies.revalidateSpecDrafts, + revealFirstSpecError: spies.revealFirstSpecError, + }, + currentWorkspace: () => (over.currentWorkspace === undefined ? null : over.currentWorkspace), + captureSurfaceGeneration: () => 0, + refreshCurrentSurfaceAfterStale: over.refreshCurrentSurfaceAfterStale ?? (() => true), + syncBeforeUnload: spies.syncBeforeUnload, + refreshWorkspaceFromStore: spies.refreshWorkspaceFromStore, + commitVariableConfig: over.commitVariableConfig ?? (async () => ({ ok: true, workspace: {} as StoredWorkspaceV5, dashboardRevision: null })), + saveBtn: () => saveBtn, + savePopoverOpen: () => !!refs.savePopover, + anchoredPopover: popovers.open, + rerenderTabs: spies.rerenderTabs, + updateEditorModeUi: spies.updateEditorModeUi, + renderSavedHistory: spies.renderSavedHistory, + renderResults: spies.renderResults, + syncSpecEditorFromState: spies.syncSpecEditorFromState, + specBlocked: over.specBlocked ?? (() => false), + }; + return { deps, tab, state, saveBtn, refs, spies }; +} + +describe('createSaveController — updateSaveBtn', () => { + it('no-ops when there is no save button', () => { + const { deps, saveBtn } = makeDeps(); + saveBtn.remove(); + const ctl = createSaveController({ ...deps, saveBtn: () => undefined }); + expect(() => ctl.updateSaveBtn()).not.toThrow(); + }); + + it('a variable tab reads Saved/Save off dirtySql alone, ignoring specBlocked', () => { + const tab: QueryTab = { ...newTabObj('v1'), doc: { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'country' } }; + const { deps, saveBtn } = makeDeps({ tab, specBlocked: () => true }); + const ctl = createSaveController(deps); + tab.dirtySql = false; + ctl.updateSaveBtn(); + expect(saveBtn.classList.contains('saved')).toBe(true); + expect(saveBtn.disabled).toBe(false); // never blocked, even though specBlocked() → true + expect(saveBtn.title).toContain('Saved'); + tab.dirtySql = true; + ctl.updateSaveBtn(); + expect(saveBtn.classList.contains('saved')).toBe(false); + expect(saveBtn.textContent).toContain('Save'); + }); + + it('a conflicted tab reads "Resolve conflict" regardless of the saved/entry state', () => { + const { deps, saveBtn, tab } = makeDeps(); + tab.externalState = 'conflict'; + createSaveController(deps).updateSaveBtn(); + expect(saveBtn.textContent).toContain('Resolve conflict'); + expect(saveBtn.classList.contains('conflict')).toBe(true); + }); + + it('an unsaved, unlinked tab is never "blocked" even when specBlocked() would say so', () => { + const { deps, saveBtn } = makeDeps({ specBlocked: () => true }); + createSaveController(deps).updateSaveBtn(); + expect(saveBtn.classList.contains('saved')).toBe(false); + expect(saveBtn.disabled).toBe(false); // no linked entry ⇒ `blocked` short-circuits false + expect(saveBtn.title).toBe('Save query (⌘S)'); + }); + + it('a clean linked tab reads "Saved", disabled only when specBlocked() blocks it', () => { + const entry = savedQuery({ id: 's1' }); + const { deps, saveBtn, tab } = makeDeps({ savedQueries: [entry], specBlocked: () => false }); + tab.savedId = 's1'; tab.dirtySql = false; tab.dirtySpec = false; + createSaveController(deps).updateSaveBtn(); + expect(saveBtn.classList.contains('saved')).toBe(true); + expect(saveBtn.disabled).toBe(false); + expect(saveBtn.title).toBe('Saved — edit to re-save (⌘S)'); + }); + + it('a clean linked tab with a blocking Spec is disabled with the blocking title', () => { + const entry = savedQuery({ id: 's1' }); + const { deps, saveBtn, tab } = makeDeps({ savedQueries: [entry], specBlocked: () => true }); + tab.savedId = 's1'; tab.dirtySql = false; tab.dirtySpec = false; + createSaveController(deps).updateSaveBtn(); + expect(saveBtn.disabled).toBe(true); + expect(saveBtn.title).toBe('Fix blocking Spec errors before saving'); + }); + + it('a dirty linked tab reads "Save", not "Saved"', () => { + const entry = savedQuery({ id: 's1' }); + const { deps, saveBtn, tab } = makeDeps({ savedQueries: [entry] }); + tab.savedId = 's1'; tab.dirtySql = true; + createSaveController(deps).updateSaveBtn(); + expect(saveBtn.classList.contains('saved')).toBe(false); + expect(saveBtn.title).toBe('Save query (⌘S)'); + }); + + // I-15 sabotage: `updateSaveBtn` must check the document KIND (`variableDoc`) + // BEFORE the conflict/Spec-blocked checks below it — reordering would let a + // variable tab (which can never be linked/conflicted) fall through into the + // linked-query branch instead of returning early from the variable branch. + it('I-15 sabotage: the kind check must run before the conflict/blocked checks (a variable tab never reaches them)', () => { + const tab: QueryTab = { ...newTabObj('v1'), doc: { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'country' } }; + tab.externalState = 'conflict'; // would read "Resolve conflict" if the kind check were skipped/reordered + const { deps, saveBtn } = makeDeps({ tab }); + createSaveController(deps).updateSaveBtn(); + expect(saveBtn.classList.contains('conflict')).toBe(false); + expect(saveBtn.textContent).not.toContain('Resolve conflict'); + }); +}); + +describe('createSaveController — saveActiveQuery dispatch', () => { + it('a variable tab routes to the variable-config write, never the saved-query paths', async () => { + const tab: QueryTab = { ...newTabObj('v1'), doc: { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'country' } }; + tab.sqlDraft = 'SELECT 1'; + const commitVariableConfig = vi.fn(async () => ({ ok: true, workspace: {} as StoredWorkspaceV5, dashboardRevision: null })); + const commit = vi.fn(async () => ({ ok: true, entry: savedQuery({ id: 's1' }) }) as CommitLinkedResult); + const { deps } = makeDeps({ tab, commitVariableConfig, commit }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(commitVariableConfig).toHaveBeenCalledWith('d', 'country', { sql: 'SELECT 1' }); + expect(commit).not.toHaveBeenCalled(); + }); + + it('a conflicted tab opens the chooser and resolves undefined, without touching commit', async () => { + const commit = vi.fn(async () => ({ ok: true, entry: savedQuery({ id: 's1' }) }) as CommitLinkedResult); + const { deps, tab } = makeDeps({ commit }); + tab.externalState = 'conflict'; + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeUndefined(); + expect(qs(document, '.conflict-chooser')).not.toBeNull(); + expect(commit).not.toHaveBeenCalled(); + }); + + it('a second call while the chooser is open is a no-op (savePopoverOpen() guard)', async () => { + const { deps, tab } = makeDeps(); + tab.externalState = 'conflict'; + const ctl = createSaveController(deps); + await ctl.saveActiveQuery(); + await ctl.saveActiveQuery(); + expect([...document.querySelectorAll('.conflict-chooser')]).toHaveLength(1); + }); + + it('a linked tab commits through the update-in-place path', async () => { + const entry = savedQuery({ id: 's1' }); + const commit = vi.fn(async () => ({ ok: true, entry }) as CommitLinkedResult); + const create = vi.fn(async () => ({ ok: true, entry }) as CreateSavedResult); + const { deps, tab } = makeDeps({ savedQueries: [entry], commit, create }); + tab.savedId = 's1'; + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBe(entry); + expect(commit).toHaveBeenCalledTimes(1); + expect(create).not.toHaveBeenCalled(); + }); + + it('an unlinked tab opens the create popover and resolves undefined', async () => { + const { deps, tab } = makeDeps(); + tab.sqlDraft = 'SELECT 1'; + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeUndefined(); + expect(qs(document, '.save-popover')).not.toBeNull(); + }); + + // I-15 sabotage: `saveActiveQuery` must check the document KIND first too — + // otherwise a variable tab whose binding happens to look "conflicted" or + // "linked" (neither of which a variable doc can ever legitimately be) could + // fall through into the wrong path. + it('I-15 sabotage: the kind check runs before the conflict/linked dispatch', async () => { + const tab: QueryTab = { + ...newTabObj('v1'), doc: { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'country' }, + sqlDraft: 'SELECT 1', + }; + tab.externalState = 'conflict'; + const commitVariableConfig = vi.fn(async () => ({ ok: true, workspace: {} as StoredWorkspaceV5, dashboardRevision: null })); + const { deps } = makeDeps({ tab, commitVariableConfig }); + await createSaveController(deps).saveActiveQuery(); + expect(commitVariableConfig).toHaveBeenCalled(); // reached the variable path, not the conflict chooser + expect(qs(document, '.conflict-chooser')).toBeNull(); + }); +}); + +describe('createSaveController — commitLinkedQuery (via saveActiveQuery on a linked tab)', () => { + const linked = (over: Parameters[0] = {}) => { + const entry = savedQuery({ id: 's1' }); + const m = makeDeps({ savedQueries: [entry], ...over }); + m.tab.savedId = 's1'; + return { ...m, entry }; + }; + + it('on success, repaints and returns the entry', async () => { + const entry = savedQuery({ id: 's1' }); + const commit = vi.fn(async () => ({ ok: true, entry }) as CommitLinkedResult); + const { deps, spies } = linked({ commit }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBe(entry); + expect(spies.rerenderTabs).toHaveBeenCalled(); + expect(spies.renderSavedHistory).toHaveBeenCalled(); + expect(spies.renderResults).toHaveBeenCalled(); + expect(spies.updateEditorModeUi).toHaveBeenCalled(); + expect(spies.syncSpecEditorFromState).toHaveBeenCalled(); + expect(spies.revalidateSpecDrafts).toHaveBeenCalled(); + expect(spies.syncBeforeUnload).toHaveBeenCalled(); + expect(qs(document, '.share-toast').textContent).toBe('Saved'); + }); + + it('a warning-bearing success keeps the confirmation and surfaces the warning', async () => { + const entry = savedQuery({ id: 's1' }); + const commit = vi.fn(async () => ({ ok: true, entry, diagnostics: [{ message: 'heads up' }] }) as CommitLinkedResult); + const { deps } = linked({ commit }); + await createSaveController(deps).saveActiveQuery(); + expect(qs(document, '.share-toast').textContent).toBe('Saved — heads up'); + }); + + it('a stale navigation (refreshCurrentSurfaceAfterStale → false) on a success still returns the entry but skips the repaint', async () => { + const entry = savedQuery({ id: 's1' }); + const commit = vi.fn(async () => ({ ok: true, entry }) as CommitLinkedResult); + const { deps, spies } = linked({ commit, refreshCurrentSurfaceAfterStale: () => false }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBe(entry); + expect(spies.rerenderTabs).not.toHaveBeenCalled(); + }); + + it('a stale navigation on a failure returns null without any toast', async () => { + const commit = vi.fn(async () => ({ ok: false, reason: 'empty' }) as CommitLinkedResult); + const { deps } = linked({ commit, refreshCurrentSurfaceAfterStale: () => false }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(document.querySelector('.share-toast')).toBeNull(); + }); + + it("reason 'invalid-spec' reveals the first Spec error and toasts", async () => { + const commit = vi.fn(async () => ({ ok: false, reason: 'invalid-spec' }) as CommitLinkedResult); + const { deps, spies } = linked({ commit }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(spies.revealFirstSpecError).toHaveBeenCalled(); + expect(qs(document, '.share-toast').textContent).toBe('Fix Spec errors before saving'); + }); + + it("reason 'empty' toasts Nothing to save", async () => { + const commit = vi.fn(async () => ({ ok: false, reason: 'empty' }) as CommitLinkedResult); + const { deps } = linked({ commit }); + await createSaveController(deps).saveActiveQuery(); + expect(qs(document, '.share-toast').textContent).toBe('Nothing to save'); + }); + + it("reason 'deleted' toasts and triggers a workspace refresh", async () => { + const commit = vi.fn(async () => ({ ok: false, reason: 'deleted' }) as CommitLinkedResult); + const { deps, spies } = linked({ commit }); + await createSaveController(deps).saveActiveQuery(); + expect(qs(document, '.share-toast').textContent).toContain('deleted in another tab'); + expect(spies.refreshWorkspaceFromStore).toHaveBeenCalled(); + }); + + it("reason 'rejected' with diagnostics toasts the first message", async () => { + const commit = vi.fn(async () => ({ + ok: false, reason: 'rejected', diagnostics: [{ path: [], severity: 'error', code: 'x', message: 'nope' }], + }) as CommitLinkedResult); + const { deps } = linked({ commit }); + await createSaveController(deps).saveActiveQuery(); + expect(qs(document, '.share-toast').textContent).toBe('Save failed: nope'); + }); + + it("reason 'rejected' without diagnostics stays silent", async () => { + const commit = vi.fn(async () => ({ ok: false, reason: 'rejected' }) as CommitLinkedResult); + const { deps } = linked({ commit }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(document.querySelector('.share-toast')).toBeNull(); + }); +}); + +describe('createSaveController — saveVariableTab (via saveActiveQuery on a variable tab)', () => { + const variableTab = (sqlDraft = 'SELECT 1'): QueryTab => ({ + ...newTabObj('v1'), doc: { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'country' }, sqlDraft, + }); + + it('on success, clears dirty flags, repaints, and toasts "Saved"', async () => { + const tab = variableTab(); + const commitVariableConfig = vi.fn(async () => ({ ok: true, workspace: {} as StoredWorkspaceV5, dashboardRevision: null })); + const { deps, spies } = makeDeps({ tab, commitVariableConfig }); + tab.dirtySql = true; tab.dirtySpec = true; + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(tab.dirtySql).toBe(false); + expect(tab.dirtySpec).toBe(false); + expect(spies.syncBeforeUnload).toHaveBeenCalled(); + expect(spies.rerenderTabs).toHaveBeenCalled(); + expect(qs(document, '.share-toast').textContent).toBe('Saved'); + }); + + it('resolves lastKnownType from the current workspace when the write clears the SQL', async () => { + const tab = variableTab(' '); // blank ⇒ trim rule removes the config + const commitVariableConfig = vi.fn(async () => ({ ok: true, workspace: {} as StoredWorkspaceV5, dashboardRevision: null })); + const { deps } = makeDeps({ tab, commitVariableConfig, currentWorkspace: variableWorkspace }); + await createSaveController(deps).saveActiveQuery(); + expect(commitVariableConfig).toHaveBeenCalledWith('d', 'country', null); + expect(qs(document, '.share-toast').textContent).toBe('Option SQL removed'); + }); + + it('carries lastKnownType through when the current workspace still declares the variable', async () => { + const tab = variableTab('SELECT {country:String}'); + const commitVariableConfig = vi.fn(async () => ({ ok: true, workspace: {} as StoredWorkspaceV5, dashboardRevision: null })); + const { deps } = makeDeps({ tab, commitVariableConfig, currentWorkspace: variableWorkspace }); + await createSaveController(deps).saveActiveQuery(); + expect(commitVariableConfig).toHaveBeenCalledWith('d', 'country', expect.objectContaining({ lastKnownType: 'String' })); + }); + + it('a stale navigation on success returns null without repainting', async () => { + const tab = variableTab(); + const commitVariableConfig = vi.fn(async () => ({ ok: true, workspace: {} as StoredWorkspaceV5, dashboardRevision: null })); + const { deps, spies } = makeDeps({ tab, commitVariableConfig, refreshCurrentSurfaceAfterStale: () => false }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(spies.rerenderTabs).not.toHaveBeenCalled(); + }); + + it('a declined abort (Dashboard gone) toasts explicitly', async () => { + const tab = variableTab(); + const commitVariableConfig = vi.fn(async () => ({ ok: false, aborted: true, data: 'declined' })); + const { deps } = makeDeps({ tab, commitVariableConfig }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(qs(document, '.share-toast').textContent).toContain('no longer available'); + }); + + it('a route-moved-on abort (no data) stays silent', async () => { + const tab = variableTab(); + const commitVariableConfig = vi.fn(async () => ({ ok: false, aborted: true })); + const { deps } = makeDeps({ tab, commitVariableConfig }); + const result = await createSaveController(deps).saveActiveQuery(); + expect(result).toBeNull(); + expect(document.querySelector('.share-toast')).toBeNull(); + }); + + it('a rejected commit toasts the first diagnostic', async () => { + const tab = variableTab(); + const commitVariableConfig = vi.fn(async () => ({ + ok: false, diagnostics: [{ path: [], severity: 'error', code: 'x', message: 'busted' }], + })); + const { deps } = makeDeps({ tab, commitVariableConfig }); + await createSaveController(deps).saveActiveQuery(); + expect(qs(document, '.share-toast').textContent).toBe('Save failed: busted'); + }); +}); + +describe('createSaveController — reloadSavedVersion (via the conflict chooser)', () => { + it('"Reload saved version" discards the draft and adopts the committed entry', async () => { + const entry = savedQuery({ id: 's1', name: 'External name', sql: 'SELECT external' }); + const { deps, tab, spies } = makeDeps({ savedQueries: [entry] }); + tab.savedId = 's1'; tab.externalState = 'conflict'; tab.sqlDraft = 'local draft'; tab.dirtySql = true; + await createSaveController(deps).saveActiveQuery(); // opens the chooser + qs(document, '.conflict-chooser .cf-reload').dispatchEvent(new Event('click', { bubbles: true })); + expect(tab.dirtySql).toBe(false); + expect(tab.name).toBe('External name'); + expect(tab.externalState ?? null).toBeNull(); + expect(spies.rerenderTabs).toHaveBeenCalled(); + expect(spies.renderSavedHistory).toHaveBeenCalled(); + expect(qs(document, '.share-toast').textContent).toContain('Reloaded the version saved in the other tab'); + }); + + it('a vanished linked query (deleted mid-chooser) refreshes instead of reloading', async () => { + const entry = savedQuery({ id: 's1' }); + const { deps, tab, state, spies } = makeDeps({ savedQueries: [entry] }); + tab.savedId = 's1'; tab.externalState = 'conflict'; + await createSaveController(deps).saveActiveQuery(); + state.savedQueries = []; // vanished between chooser open and resolve + qs(document, '.conflict-chooser .cf-reload').dispatchEvent(new Event('click', { bubbles: true })); + expect(spies.refreshWorkspaceFromStore).toHaveBeenCalled(); + }); + + it('"Keep my draft" requires the confirm step, then commits over the latest query', async () => { + const entry = savedQuery({ id: 's1' }); + const commit = vi.fn(async () => ({ ok: true, entry }) as CommitLinkedResult); + const { deps, tab } = makeDeps({ savedQueries: [entry], commit }); + tab.savedId = 's1'; tab.externalState = 'conflict'; + await createSaveController(deps).saveActiveQuery(); + qs(document, '.conflict-chooser .cf-keep').dispatchEvent(new Event('click', { bubbles: true })); + expect(qs(document, '.conflict-chooser .cf-overwrite')).not.toBeNull(); + qs(document, '.conflict-chooser .cf-overwrite').dispatchEvent(new Event('click', { bubbles: true })); + await flush(); + expect(commit).toHaveBeenCalledTimes(1); + }); +}); + +describe('createSaveController — openSavePopover', () => { + it('no-ops with a toast on empty SQL for an ordinary (non-queryless) panel', () => { + const { deps, tab } = makeDeps(); + tab.sqlDraft = ' '; + createSaveController(deps).openSavePopover(); + expect(document.querySelector('.save-popover')).toBeNull(); + expect(qs(document, '.share-toast').textContent).toBe('Nothing to save'); + }); + + it('a second open while already open is a no-op', () => { + const { deps, tab } = makeDeps(); + tab.sqlDraft = 'SELECT 1'; + const ctl = createSaveController(deps); + ctl.openSavePopover(); + ctl.openSavePopover(); + expect(document.querySelectorAll('.save-popover')).toHaveLength(1); + }); + + it('prefills the name from the tab, commits on Save, and repaints', async () => { + const entry = savedQuery({ id: 's1' }); + const create = vi.fn(async () => ({ ok: true, entry }) as CreateSavedResult); + const { deps, tab, spies } = makeDeps({ create }); + tab.sqlDraft = 'SELECT 42'; + createSaveController(deps).openSavePopover(); + const pop = qs(document, '.save-popover'); + expect(qs(pop, '.sp-input').value).toBe('SELECT 42'); // inferred name + qs(pop, '.sp-input').value = 'My fave'; + qs(pop, '.sp-save').dispatchEvent(new Event('click')); + await flush(); + expect(create).toHaveBeenCalledWith(tab, 'My fave', ''); + expect(spies.syncBeforeUnload).toHaveBeenCalled(); + expect(spies.rerenderTabs).toHaveBeenCalled(); + expect(spies.renderSavedHistory).toHaveBeenCalled(); + expect(document.querySelector('.save-popover')).toBeNull(); // closed + expect(qs(document, '.share-toast').textContent).toBe('Saved'); + }); + + it('Cancel closes without committing', () => { + const create = vi.fn(async () => ({ ok: true, entry: savedQuery({ id: 's1' }) }) as CreateSavedResult); + const { deps, tab } = makeDeps({ create }); + tab.sqlDraft = 'SELECT 1'; + createSaveController(deps).openSavePopover(); + qs(document, '.save-popover .sp-cancel').dispatchEvent(new Event('click')); + expect(document.querySelector('.save-popover')).toBeNull(); + expect(create).not.toHaveBeenCalled(); + }); + + it('Enter in the name field commits; a blank name is a no-op', async () => { + const create = vi.fn(async () => ({ ok: true, entry: savedQuery({ id: 's1' }) }) as CreateSavedResult); + const { deps, tab } = makeDeps({ create }); + tab.sqlDraft = 'SELECT 1'; + createSaveController(deps).openSavePopover(); + const input = qs(document, '.save-popover .sp-input'); + input.value = ' '; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await flush(); + expect(create).not.toHaveBeenCalled(); + input.value = 'Real name'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await flush(); + expect(create).toHaveBeenCalledWith(tab, 'Real name', ''); + }); + + it('plain Enter in the description is a newline (no commit); ⌘/Ctrl+Enter commits', async () => { + const create = vi.fn(async () => ({ ok: true, entry: savedQuery({ id: 's1' }) }) as CreateSavedResult); + const { deps, tab } = makeDeps({ create }); + tab.sqlDraft = 'SELECT 1'; + createSaveController(deps).openSavePopover(); + const description = qs(document, '.save-popover .sp-desc'); + description.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await flush(); + expect(create).not.toHaveBeenCalled(); + description.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true, cancelable: true })); + await flush(); + expect(create).toHaveBeenCalledTimes(1); + }); + + it('a failed create with diagnostics toasts and leaves the popover open', async () => { + const create = vi.fn(async () => ({ ok: false, diagnostics: [{ path: [], severity: 'error', code: 'x', message: 'bad name' }] }) as CreateSavedResult); + const { deps, tab } = makeDeps({ create }); + tab.sqlDraft = 'SELECT 1'; + createSaveController(deps).openSavePopover(); + qs(document, '.save-popover .sp-input').value = 'x'; + qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); + await flush(); + expect(qs(document, '.share-toast').textContent).toBe('Save failed: bad name'); + expect(document.querySelector('.save-popover')).not.toBeNull(); // stays open + }); + + it('a failed create without diagnostics stays silent', async () => { + const create = vi.fn(async () => ({ ok: false }) as CreateSavedResult); + const { deps, tab } = makeDeps({ create }); + tab.sqlDraft = 'SELECT 1'; + createSaveController(deps).openSavePopover(); + qs(document, '.save-popover .sp-input').value = 'x'; + qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); + await flush(); + expect(document.querySelector('.share-toast')).toBeNull(); + }); + + it('a stale navigation during create returns without repainting or closing', async () => { + const create = vi.fn(async () => ({ ok: true, entry: savedQuery({ id: 's1' }) }) as CreateSavedResult); + const { deps, tab, spies } = makeDeps({ create, refreshCurrentSurfaceAfterStale: () => false }); + tab.sqlDraft = 'SELECT 1'; + createSaveController(deps).openSavePopover(); + qs(document, '.save-popover .sp-input').value = 'x'; + qs(document, '.save-popover .sp-save').dispatchEvent(new Event('click')); + await flush(); + expect(spies.rerenderTabs).not.toHaveBeenCalled(); + expect(document.querySelector('.save-popover')).not.toBeNull(); // never closed — the bracket returned first + }); + + it('a queryless panel with blank SQL still opens (the per-type relaxation)', () => { + const tab = { ...newTabObj('p1'), specParsed: { name: 'Untitled', favorite: false, panel: { cfg: { type: 'text' } } } } as QueryTab; + tab.sqlDraft = ''; + const { deps } = makeDeps({ tab }); + createSaveController(deps).openSavePopover(); + expect(document.querySelector('.save-popover')).not.toBeNull(); + }); +}); diff --git a/tests/unit/saved-history.test.ts b/tests/unit/saved-history.test.ts index a42f7252..cb76f982 100644 --- a/tests/unit/saved-history.test.ts +++ b/tests/unit/saved-history.test.ts @@ -440,7 +440,7 @@ describe('renderSavedHistory', () => { return { ok: false as const, aborted: true as const, data: undefined }; }) as App['mutateWorkspace']; const refresh = vi.fn(async () => {}); - app.refreshWorkspaceFromStore = refresh; + app.workspaceSession.refreshWorkspaceFromStore = refresh; app.state.sidePanel.value = 'saved'; setSaved(app, [{ id: 's1', name: 'A', sql: '1', favorite: false }]); renderSavedHistory(app); @@ -459,7 +459,7 @@ describe('renderSavedHistory', () => { return { ok: false as const, aborted: true as const, data: undefined }; }) as App['mutateWorkspace']; const refresh = vi.fn(async () => {}); - app.refreshWorkspaceFromStore = refresh; + app.workspaceSession.refreshWorkspaceFromStore = refresh; app.state.sidePanel.value = 'saved'; setSaved(app, [{ id: 's1', name: 'Old', sql: '1', favorite: false }]); renderSavedHistory(app); diff --git a/tests/unit/surface-navigation.test.ts b/tests/unit/surface-navigation.test.ts new file mode 100644 index 00000000..c801beb5 --- /dev/null +++ b/tests/unit/surface-navigation.test.ts @@ -0,0 +1,1085 @@ +// Unit tests for `src/application/surface-navigation.ts` (#588 phase 4 wave 4). +// +// This module owns the surface-generation guard cluster, `/sql` route writes, +// boot/popstate/programmatic-navigation loading, and every main-surface +// transition — everything app.ts's pre-#588 route/nav block used to inline. +// These tests construct `createSurfaceNavigation(deps)` directly (no +// `createApp`), with a controllable fake repository/session/history pair so +// the ordering invariants (generation guards, resolve-before-navigate, the +// history-stamp pair, I-9's four await-boundary races) can be driven +// precisely — real controlled-interleaving tests per the #588 phase 4 plan's +// §4a, not weaker "call twice and see" tests. +// +// I-30 (boundary-enforcement, not a new test): `dashboardRenderTarget` — the +// single consumption point for `pendingScrollTop` + `pendingFocus` — did NOT +// move in this extraction. It stays in `src/ui/app.ts` (already covered by +// that file's own dashboard-render tests); this module only PRODUCES +// `pendingFocus`/`pendingScrollTop` (via `resolveOpenDashboard`/ +// `withPendingFocus`/history-snapshot restoration in `main-surface.ts`), it +// never consumes them. + +import { describe, it, expect, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { createSurfaceNavigation } from '../../src/application/surface-navigation.js'; +import type { + SurfaceNavigationDeps, SurfaceStatePort, SurfaceNavigation, +} from '../../src/application/surface-navigation.js'; +import { createState } from '../../src/state.js'; +import type { AppState } from '../../src/state.js'; +import type { DashboardDocumentV2, SavedQueryV2, StoredWorkspaceV5 } from '../../src/generated/json-schema.types.js'; +import type { WorkspaceRepository, WorkspaceLoadResult } from '../../src/workspace/workspace-repository.js'; +import type { WorkspaceSession } from '../../src/application/workspace-session.js'; +import { QUERY_SURFACE, mainSurfaceRoute } from '../../src/application/main-surface.js'; +import type { OpenDashboardRequest, SurfaceCommandPort } from '../../src/application/main-surface.js'; +import { savedQuery } from '../helpers/saved-query.js'; + +// --------------------------------------------------------------------------- +// Scaffolding +// --------------------------------------------------------------------------- + +function dash(id: string, tiles: Array<{ id: string; queryId: string }> = []): DashboardDocumentV2 { + return { + documentVersion: 2, id, title: id.toUpperCase(), revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + tiles, + }; +} + +function workspace(dashboards: DashboardDocumentV2[], queries: SavedQueryV2[] = []): StoredWorkspaceV5 { + return { storageVersion: 5, id: 'w', key: 'w', name: 'W', queries, dashboards }; +} + +function fakePort(outcome: 'ok' | 'pending' | 'missing'): SurfaceCommandPort { + return { + surface: 'dashboard', generation: 0, + refresh: vi.fn(), setDashboardStyle: vi.fn(), focusMember: vi.fn(() => outcome), + }; +} + +/** Every hook, defaulted to an inert stub — the four "self-dispatch" hooks + * (see surface-navigation.ts's own doc comment on them) default to calling + * the REAL nav method, mirroring app.ts's production wiring + * (`dispatchCurrentSurface: () => app.renderCurrentSurface()`, etc.) via a + * forward-declared `navRef` — `nav` itself is only assigned after + * `createSurfaceNavigation(deps)` returns, but the hooks are never CALLED + * until well after that (same forward-reference pattern app.ts uses + * throughout createApp). */ +function makeHooks(navRef: { current?: SurfaceNavigation }, port: SurfaceStatePort) { + return { + // Mirrors app.ts's real `applyCommittedWorkspace`'s ONE relevant effect + // for this module's own purposes: projecting the committed workspace onto + // `currentWorkspace`/`workspaceRouteStatus`. The rest of the real + // function (tab reconciliation, tree pruning, `state.dashboard` + // projection) is app.test.ts's territory, not this module's. + applyCommittedWorkspace: vi.fn((ws: StoredWorkspaceV5) => { + port.currentWorkspace = ws; + port.workspaceRouteStatus = 'ready'; + }), + renderApp: vi.fn(), + renderDashboard: vi.fn(), + renderWorkspaceLoading: vi.fn(), + renderWorkspaceNotFound: vi.fn(), + onCorruptWorkspace: vi.fn(), + retryPendingOAuthDocumentRecovery: vi.fn(), + closeShortcutDialog: vi.fn(), + resetShortcutChord: vi.fn(), + isSignedIn: vi.fn(() => true), + invalidateDashboardTree: vi.fn(), + toast: vi.fn(), + revealAssignedPanel: vi.fn(), + loadIntoNewTab: vi.fn(), + openVariableTabUi: vi.fn(), + toEditorOnMobile: vi.fn(), + runAction: vi.fn(), + dashboardScrollTop: vi.fn((): number | null => null), + isAutoRunnableSql: vi.fn(() => true), + dispatchCurrentSurface: vi.fn(() => navRef.current!.renderCurrentSurface()), + dispatchLoadWorkspaceOnBoot: vi.fn(() => navRef.current!.loadWorkspaceOnBoot()), + dispatchShowQuerySurface: vi.fn(() => navRef.current!.showQuerySurface()), + dispatchOpenDashboard: vi.fn((request: OpenDashboardRequest) => navRef.current!.openDashboard(request)), + }; +} + +function setup(over: { + state?: { savedQueries?: SavedQueryV2[] }; + repository?: Partial>; + session?: Partial>; +} = {}) { + const state: AppState = createState({ loadStr: (_k, d) => d, loadJSON: (_k, d) => d }); + if (over.state?.savedQueries) state.savedQueries = over.state.savedQueries; + + const port: SurfaceStatePort = { + sqlRoute: { surface: 'workspace', workspaceKey: null }, + mainSurface: QUERY_SURFACE, + currentWorkspace: null, + workspaceRouteStatus: 'ready', + surfaceCommands: null, + }; + + let historyStateValue: unknown = null; + const pushState = vi.fn((_data: unknown, _unused: string, _url?: string) => {}); + const replaceState = vi.fn((s: unknown, _unused: string, _url?: string) => { historyStateValue = s; }); + const history = { + pushState, + replaceState, + get state(): unknown { return historyStateValue; }, + }; + + const repository: Pick = { + loadByKey: vi.fn(async () => ({ status: 'empty' as const })), + ...over.repository, + }; + const session: Pick = { + resolveImplicitOrProvision: vi.fn(async () => ({ status: 'empty' as const })), + recordOpened: vi.fn(async () => {}), + ...over.session, + }; + + const navRef: { current?: SurfaceNavigation } = {}; + const hooks = makeHooks(navRef, port); + + let locationSearchValue = ''; + const deps: SurfaceNavigationDeps = { + state, + surface: () => port, + repository, + session, + history, + basePath: () => '/sql', + locationHash: () => '', + locationSearch: () => locationSearchValue, + hooks, + }; + const nav = createSurfaceNavigation(deps); + navRef.current = nav; + return { + nav, deps, state, port, history, repository, session, hooks, + setLocationSearch: (s: string) => { locationSearchValue = s; }, + }; +} + +// --------------------------------------------------------------------------- +// Surface-generation guard cluster +// --------------------------------------------------------------------------- + +describe('surface-generation guard cluster', () => { + it('bumps the generation on a TRANSITION call, not merely because a mount happened', () => { + const { nav } = setup(); + const before = nav.captureSurfaceGeneration(); + expect(nav.isSurfaceGenerationCurrent(before)).toBe(true); + nav.advanceSurfaceGeneration(); + expect(nav.isSurfaceGenerationCurrent(before)).toBe(false); + expect(nav.captureSurfaceGeneration()).toBe(before + 1); + }); + + it('advanceSurfaceGeneration clears surfaceCommands', () => { + const { nav, port } = setup(); + port.surfaceCommands = fakePort('ok'); + nav.advanceSurfaceGeneration(); + expect(port.surfaceCommands).toBeNull(); + }); + + describe('refreshCurrentSurfaceAfterStale — the compound re-render gate', () => { + it('generation-match short-circuit: returns true immediately and renders nothing when the caller is still current', () => { + const { nav, hooks } = setup(); + const gen = nav.captureSurfaceGeneration(); + expect(nav.refreshCurrentSurfaceAfterStale(gen, true)).toBe(true); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + }); + + it('committed flag: a stale, UNcommitted caller returns false and renders nothing', () => { + const { nav, port, hooks } = setup(); + const gen = nav.captureSurfaceGeneration(); + nav.advanceSurfaceGeneration(); + port.currentWorkspace = workspace([]); + port.workspaceRouteStatus = 'ready'; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + expect(nav.refreshCurrentSurfaceAfterStale(gen, false)).toBe(false); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + }); + + // I-8 + it('signed-in guard (I-8): a stale, committed caller with no signed-in session must not remount over login', () => { + const { nav, port, hooks } = setup(); + const gen = nav.captureSurfaceGeneration(); + nav.advanceSurfaceGeneration(); + port.currentWorkspace = workspace([]); + port.workspaceRouteStatus = 'ready'; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + hooks.isSignedIn.mockReturnValue(false); + expect(nav.refreshCurrentSurfaceAfterStale(gen, true)).toBe(false); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + // Sabotage-verified manually (see wave 4 report): removing the + // `isSignedIn()` condition from the compound gate makes this test fail. + }); + + it('route-key match: a stale, committed, signed-in caller whose route names a DIFFERENT workspace renders nothing', () => { + const { nav, port, hooks } = setup(); + const gen = nav.captureSurfaceGeneration(); + nav.advanceSurfaceGeneration(); + port.currentWorkspace = workspace([]); + port.workspaceRouteStatus = 'ready'; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'other' }; + expect(nav.refreshCurrentSurfaceAfterStale(gen, true)).toBe(false); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + }); + + it('workspaceRouteStatus/currentWorkspace: a stale, committed, signed-in caller with no ready projected workspace renders nothing', () => { + const { nav, port, hooks } = setup(); + const gen = nav.captureSurfaceGeneration(); + nav.advanceSurfaceGeneration(); + port.workspaceRouteStatus = 'loading'; + expect(nav.refreshCurrentSurfaceAfterStale(gen, true)).toBe(false); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + }); + + it('all conditions satisfied: a stale, committed, signed-in caller whose route matches renders once and still returns false', () => { + const { nav, port, hooks } = setup(); + const gen = nav.captureSurfaceGeneration(); + nav.advanceSurfaceGeneration(); + port.currentWorkspace = workspace([]); + port.workspaceRouteStatus = 'ready'; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + expect(nav.refreshCurrentSurfaceAfterStale(gen, true)).toBe(false); + expect(hooks.dispatchCurrentSurface).toHaveBeenCalledTimes(1); + }); + + it('a null route workspaceKey matches any projected workspace', () => { + const { nav, port, hooks } = setup(); + const gen = nav.captureSurfaceGeneration(); + nav.advanceSurfaceGeneration(); + port.currentWorkspace = workspace([]); + port.workspaceRouteStatus = 'ready'; + port.sqlRoute = { surface: 'workspace', workspaceKey: null }; + expect(nav.refreshCurrentSurfaceAfterStale(gen, true)).toBe(false); + expect(hooks.dispatchCurrentSurface).toHaveBeenCalledTimes(1); + }); + }); +}); + +// --------------------------------------------------------------------------- +// I-9 — one race per await boundary (four boundaries, controlled interleaving) +// --------------------------------------------------------------------------- + +describe('I-9: one race per await boundary', () => { + it('① loadWorkspaceOnBoot: a stale call\'s own post-initial-await generation check blocks it, isolated from boundary② (its own result is not "ok", a path boundary② never reaches)', async () => { + const resolvers = new Map void>(); + const { nav, port, hooks } = setup({ + repository: { loadByKey: vi.fn((key: string) => new Promise((resolve) => resolvers.set(key, resolve))) }, + }); + port.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + const callA = nav.loadWorkspaceOnBoot(); // generation 1 + port.sqlRoute = { surface: 'workspace', workspaceKey: 'b' }; + const callB = nav.loadWorkspaceOnBoot(); // generation 2 + + const b: StoredWorkspaceV5 = { storageVersion: 5, id: 'b', key: 'b', name: 'B', queries: [], dashboards: [] }; + resolvers.get('b')!({ status: 'ok', workspace: b }); + expect(await callB).toBe(b); + expect(hooks.applyCommittedWorkspace).toHaveBeenCalledTimes(1); + expect(hooks.applyCommittedWorkspace).toHaveBeenCalledWith(b); + expect(port.currentWorkspace).toBe(b); + expect(port.workspaceRouteStatus).toBe('ready'); + + // A's OWN load result comes back NOT-ok — a branch that returns BEFORE + // ever reaching boundary②'s `recordOpened` check, so only THIS (the + // first) generation check can protect B's already-committed projection + // from being clobbered by A's stale not-ok branch. + resolvers.get('a')!({ status: 'empty' }); + expect(await callA).toBeNull(); + expect(port.currentWorkspace).toBe(b); // still B — A's stale branch never ran + expect(port.workspaceRouteStatus).toBe('ready'); // never flipped to 'not-found'/'error' + expect(hooks.applyCommittedWorkspace).toHaveBeenCalledTimes(1); // still just B + }); + + it('② loadWorkspaceOnBoot: a stale call\'s own post-recordOpened generation check blocks it once a newer call wins', async () => { + let releaseA: () => void = () => {}; + const gateA = new Promise((resolve) => { releaseA = resolve; }); + const { nav, port, hooks } = setup({ + repository: { + loadByKey: vi.fn(async (key: string): Promise => ({ + status: 'ok' as const, + workspace: { storageVersion: 5, id: key, key, name: key.toUpperCase(), queries: [], dashboards: [] }, + })), + }, + session: { + recordOpened: vi.fn(async (ws: StoredWorkspaceV5) => { if (ws.key === 'a') await gateA; }), + }, + }); + port.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + const callA = nav.loadWorkspaceOnBoot(); // generation 1, gated inside recordOpened + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + port.sqlRoute = { surface: 'workspace', workspaceKey: 'b' }; + const callB = nav.loadWorkspaceOnBoot(); // generation 2, resolves fully first + expect(await callB).not.toBeNull(); + expect(hooks.applyCommittedWorkspace).toHaveBeenCalledTimes(1); + + releaseA(); + expect(await callA).toBeNull(); // caught by the SECOND generation check, after recordOpened + expect(hooks.applyCommittedWorkspace).toHaveBeenCalledTimes(1); // still just B + }); + + it('③ navigateSqlRoute: a stale call\'s own post-await check prevents it rendering after losing the race', async () => { + const resolvers = new Map void>(); + const { nav, hooks } = setup({ + repository: { loadByKey: vi.fn((key: string) => new Promise((resolve) => resolvers.set(key, resolve))) }, + }); + const navA = nav.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'a' }, 'push'); + const navB = nav.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); + + const b: StoredWorkspaceV5 = { storageVersion: 5, id: 'b', key: 'b', name: 'B', queries: [], dashboards: [] }; + resolvers.get('b')!({ status: 'ok', workspace: b }); + await navB; + const rendersAfterB = (hooks.dispatchCurrentSurface as Mock).mock.calls.length; + expect(rendersAfterB).toBeGreaterThan(0); + expect(hooks.retryPendingOAuthDocumentRecovery).toHaveBeenCalledTimes(1); + + const a: StoredWorkspaceV5 = { storageVersion: 5, id: 'a', key: 'a', name: 'A', queries: [], dashboards: [] }; + resolvers.get('a')!({ status: 'ok', workspace: a }); + await navA; + + // The loser's own post-await generation check exits BEFORE reaching the + // unconditional render call at the end of `navigateSqlRoute` — no extra + // render, and no extra recovery retry, for the stale wave. + expect((hooks.dispatchCurrentSurface as Mock).mock.calls.length).toBe(rendersAfterB); + expect(hooks.retryPendingOAuthDocumentRecovery).toHaveBeenCalledTimes(1); + }); + + it('④ handleSqlPopState: a stale call\'s own post-await check prevents it rendering after losing the race', async () => { + const resolvers = new Map void>(); + const { nav, hooks, setLocationSearch } = setup({ + repository: { loadByKey: vi.fn((key: string) => new Promise((resolve) => resolvers.set(key, resolve))) }, + }); + setLocationSearch('?ws=a'); + const popA = nav.handleSqlPopState(); + setLocationSearch('?ws=b'); + const popB = nav.handleSqlPopState(); + + const b: StoredWorkspaceV5 = { storageVersion: 5, id: 'b', key: 'b', name: 'B', queries: [], dashboards: [] }; + resolvers.get('b')!({ status: 'ok', workspace: b }); + await popB; + const rendersAfterB = (hooks.dispatchCurrentSurface as Mock).mock.calls.length; + expect(rendersAfterB).toBeGreaterThan(0); + + const a: StoredWorkspaceV5 = { storageVersion: 5, id: 'a', key: 'a', name: 'A', queries: [], dashboards: [] }; + resolvers.get('a')!({ status: 'ok', workspace: a }); + await popA; + + expect((hooks.dispatchCurrentSurface as Mock).mock.calls.length).toBe(rendersAfterB); + }); +}); + +// --------------------------------------------------------------------------- +// I-10 — same-workspace popstate is a surface transition, not a teardown +// --------------------------------------------------------------------------- + +describe('I-10: same-workspace popstate is a surface transition, not a teardown', () => { + it('never enters the loading/teardown state; adopts the route and renders directly', async () => { + const ws = workspace([dash('a')]); + const { nav, port, hooks, setLocationSearch } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.workspaceRouteStatus = 'ready'; + setLocationSearch('?ws=w&surface=dashboard&mode=view'); + + await nav.handleSqlPopState(); + + // Neither the loading placeholder nor a currentWorkspace/status reset + // fires — both are the signals `ensureShell`/`disposeShell` (app.ts) use + // to decide whether the persistent shell must be rebuilt. + expect(hooks.renderWorkspaceLoading).not.toHaveBeenCalled(); + expect(port.workspaceRouteStatus).toBe('ready'); + expect(port.currentWorkspace).toBe(ws); + expect(hooks.dispatchCurrentSurface).toHaveBeenCalledTimes(1); + expect(port.mainSurface).toMatchObject({ kind: 'dashboard', dashboardId: 'a', mode: 'view' }); + }); +}); + +// --------------------------------------------------------------------------- +// I-11 — pendingFocus consumed exactly once; currentMember survives delivery +// and View/Edit switches +// --------------------------------------------------------------------------- + +describe('I-11: pendingFocus one-shot delivery; currentMember survives', () => { + it('an in-place focus delivery (outcome "ok") clears pendingFocus but marks currentMember', () => { + const ws = workspace([dash('a', [{ id: 't1', queryId: 'q1' }])]); + const { nav, port } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + const port2 = fakePort('ok'); + port.surfaceCommands = port2; + + nav.openDashboard({ dashboardId: 'a', mode: 'edit', focus: { kind: 'tile', id: 't1' } }); + + expect(port2.focusMember).toHaveBeenCalledWith({ kind: 'tile', id: 't1' }); + expect(port.mainSurface).toMatchObject({ + kind: 'dashboard', currentMember: { kind: 'tile', id: 't1' }, pendingFocus: null, + }); + }); + + it('a View/Edit mode switch (adoptRouteMainSurface, via same-workspace popstate) preserves currentMember', async () => { + const ws = workspace([dash('a', [{ id: 't1', queryId: 'q1' }])]); + const { nav, port, setLocationSearch } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: { kind: 'tile', id: 't1' }, pendingFocus: null, pendingScrollTop: null, + }; + setLocationSearch('?ws=w&surface=dashboard&mode=view'); + + await nav.handleSqlPopState(); + + expect(port.mainSurface).toMatchObject({ + kind: 'dashboard', mode: 'view', currentMember: { kind: 'tile', id: 't1' }, pendingFocus: null, + }); + }); +}); + +// --------------------------------------------------------------------------- +// I-12 — dashboard history-entry snapshot stamped before the transition AND +// after a dashboard push +// --------------------------------------------------------------------------- + +describe('I-12: dashboard history-entry snapshot stamped before AND after a dashboard push', () => { + it('stamps twice when opening a Dashboard: once before the route write (with no prior selection), once after', () => { + const ws = workspace([dash('a')]); + const { nav, port, history, hooks } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = QUERY_SURFACE; + hooks.dashboardScrollTop.mockReturnValue(42); + + nav.openDashboard({ dashboardId: 'a', mode: 'edit' }); + + // Route writes (push/replace with `null` state) are distinct calls from a + // history STAMP (`replaceState` carrying a `{dash: …}` state object). + const stamps = (history.replaceState as Mock).mock.calls.filter((call) => call[0] !== null); + expect(stamps).toHaveLength(2); + expect(stamps[0][0]).toEqual({ dash: null }); // BEFORE: leaving Query, nothing to remember + expect(stamps[1][0]).toEqual({ + dash: { workspaceKey: 'w', dashboardId: 'a', currentMember: null, scrollTop: 42 }, + }); + // Sabotage-verified manually (see wave 4 report): dropping the SECOND + // stamp call in `applyMainSurface` makes this assertion fail (only one + // stamp would be recorded). + }); +}); + +// --------------------------------------------------------------------------- +// I-24 — resolve-before-navigate +// --------------------------------------------------------------------------- + +describe('I-24: resolve-before-navigate', () => { + it('openDashboard: a missing id changes no route/surface and calls no render hook', () => { + const ws = workspace([dash('a')]); + const { nav, port, hooks, history } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = QUERY_SURFACE; + + nav.openDashboard({ dashboardId: 'gone', mode: 'edit' }); + + expect(port.mainSurface).toBe(QUERY_SURFACE); + expect(port.sqlRoute).toEqual({ surface: 'workspace', workspaceKey: 'w' }); + expect(history.pushState).not.toHaveBeenCalled(); + expect(history.replaceState).not.toHaveBeenCalled(); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + expect(hooks.toast).toHaveBeenCalledTimes(1); + }); + + it('openSavedQuery: an unresolved id changes no surface and opens no tab', () => { + const { nav, port, hooks } = setup({ state: { savedQueries: [] } }); + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + + nav.openSavedQuery('gone'); + + expect(port.mainSurface).toMatchObject({ kind: 'dashboard' }); + expect(hooks.dispatchShowQuerySurface).not.toHaveBeenCalled(); + expect(hooks.loadIntoNewTab).not.toHaveBeenCalled(); + expect(hooks.toast).toHaveBeenCalledWith('That query is no longer part of this workspace.'); + }); + + it('openVariableTab: an unresolved variable name opens no tab and switches no surface', () => { + const ws = workspace([dash('a')]); // no queries, so no variable is inferred + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + + nav.openVariableTab('a', 'does-not-exist'); + + expect(port.mainSurface).toMatchObject({ kind: 'dashboard' }); + expect(hooks.dispatchShowQuerySurface).not.toHaveBeenCalled(); + expect(hooks.openVariableTabUi).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// I-25 — openPanelQuery reveals the tree row BEFORE opening the query +// --------------------------------------------------------------------------- + +describe('I-25: openPanelQuery reveals the tree row before opening the query', () => { + it('calls revealAssignedPanel before loadIntoNewTab', () => { + const query = savedQuery({ id: 'q1', sql: 'SELECT 1', view: 'panel' }); + const { nav, hooks } = setup({ state: { savedQueries: [query] } }); + + nav.openPanelQuery({ dashboardId: 'a', tileId: 't1', queryId: 'q1' }); + + expect(hooks.revealAssignedPanel).toHaveBeenCalledWith('a', 't1'); + expect(hooks.loadIntoNewTab).toHaveBeenCalledWith({ ...query }); + const revealOrder = (hooks.revealAssignedPanel as Mock).mock.invocationCallOrder[0]; + const loadOrder = (hooks.loadIntoNewTab as Mock).mock.invocationCallOrder[0]; + expect(revealOrder).toBeLessThan(loadOrder); + // Sabotage-verified manually (see wave 4 report): swapping the two calls + // makes `revealOrder < loadOrder` fail. + }); +}); + +// --------------------------------------------------------------------------- +// I-3 — app.mainSurface is the single writer of the /sql route +// --------------------------------------------------------------------------- + +describe('I-3: mainSurface is the single writer of the /sql route', () => { + it('every surface-changing operation leaves sqlRoute exactly mainSurfaceRoute(mainSurface, key)', () => { + const ws = workspace([dash('a'), dash('b')]); + const { nav, port } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = QUERY_SURFACE; + + const assertConsistent = (): void => { + expect(port.sqlRoute).toEqual(mainSurfaceRoute(port.mainSurface, port.currentWorkspace?.key ?? null)); + }; + + nav.openDashboard({ dashboardId: 'a', mode: 'edit' }); + assertConsistent(); + nav.showDashboardSurface('view'); + assertConsistent(); + nav.showQuerySurface(); + assertConsistent(); + nav.openDashboard({ dashboardId: 'b', mode: 'edit' }); + assertConsistent(); + // Sabotage-verified manually (see wave 4 report): a hypothetical direct + // `writeRoute(...)` call bypassing `app.mainSurface` (or vice versa) would + // desync the two and fail `assertConsistent`. + }); +}); + +// --------------------------------------------------------------------------- +// openDashboard — remaining branches +// --------------------------------------------------------------------------- + +describe('openDashboard — remaining branches', () => { + it('reports a duplicate id via diagnostics rather than guessing an entry', () => { + const ws = workspace([dash('dup'), dash('dup')]); + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + + nav.openDashboard({ dashboardId: 'dup', mode: 'edit' }); + + expect(hooks.toast).toHaveBeenCalledWith(expect.stringContaining('more than one dashboard')); + }); + + it('re-opening the same id/mode with no focus target clears currentMember but does not re-render', () => { + const ws = workspace([dash('a')]); + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: { kind: 'tile', id: 't1' }, pendingFocus: null, pendingScrollTop: null, + }; + + nav.openDashboard({ dashboardId: 'a', mode: 'edit' }); + + expect(port.mainSurface).toMatchObject({ currentMember: null }); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + expect(hooks.invalidateDashboardTree).toHaveBeenCalled(); + }); + + it('a "missing" in-place outcome is non-destructive and reports variable-specific wording', () => { + const ws = workspace([dash('a')]); + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + port.surfaceCommands = fakePort('missing'); + + nav.openDashboard({ dashboardId: 'a', mode: 'edit', focus: { kind: 'variable', id: 'p' } }); + + expect(hooks.toast).toHaveBeenCalledWith('That variable is no longer on this dashboard.'); + expect(port.mainSurface).toMatchObject({ currentMember: null }); + }); + + it('a "missing" in-place outcome reports panel-specific wording for a tile member', () => { + const ws = workspace([dash('a')]); + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + port.surfaceCommands = fakePort('missing'); + + nav.openDashboard({ dashboardId: 'a', mode: 'edit', focus: { kind: 'tile', id: 'gone' } }); + + expect(hooks.toast).toHaveBeenCalledWith('That panel is no longer on this dashboard.'); + }); + + it('a "pending" in-place outcome falls through to the normal render transition', () => { + const ws = workspace([dash('a')]); + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + port.surfaceCommands = fakePort('pending'); + + nav.openDashboard({ dashboardId: 'a', mode: 'edit', focus: { kind: 'tile', id: 't1' } }); + + expect(hooks.dispatchCurrentSurface).toHaveBeenCalledTimes(1); + expect(port.mainSurface).toMatchObject({ pendingFocus: { kind: 'tile', id: 't1' } }); + }); +}); + +// --------------------------------------------------------------------------- +// openPanelQuery — run-on-arrival branches +// --------------------------------------------------------------------------- + +describe('openPanelQuery — run-on-arrival branches', () => { + it('runs when auto-runnable and not in Spec mode, on the query\'s own saved view', () => { + const query = savedQuery({ id: 'q1', sql: 'SELECT 1', view: 'panel' }); + const { nav, state, hooks } = setup({ state: { savedQueries: [query] } }); + state.tabs.value[0].editorMode = 'sql'; + state.tabs.value[0].sqlDraft = 'SELECT 1'; + hooks.isAutoRunnableSql.mockReturnValue(true); + + nav.openPanelQuery({ dashboardId: 'a', tileId: 't1', queryId: 'q1' }); + + expect(hooks.runAction).toHaveBeenCalledWith({ view: 'panel' }); + }); + + it('does not run when not auto-runnable', () => { + const query = savedQuery({ id: 'q1', sql: 'DROP TABLE t' }); + const { nav, state, hooks } = setup({ state: { savedQueries: [query] } }); + state.tabs.value[0].editorMode = 'sql'; + hooks.isAutoRunnableSql.mockReturnValue(false); + + nav.openPanelQuery({ dashboardId: 'a', tileId: 't1', queryId: 'q1' }); + + expect(hooks.runAction).not.toHaveBeenCalled(); + }); + + it('does not run on a Spec-mode tab', () => { + const query = savedQuery({ id: 'q1', sql: 'SELECT 1' }); + const { nav, state, hooks } = setup({ state: { savedQueries: [query] } }); + state.tabs.value[0].editorMode = 'spec'; + hooks.isAutoRunnableSql.mockReturnValue(true); + + nav.openPanelQuery({ dashboardId: 'a', tileId: 't1', queryId: 'q1' }); + + expect(hooks.runAction).not.toHaveBeenCalled(); + }); + + it('an unresolved id reveals nothing and runs nothing', () => { + const { nav, hooks } = setup({ state: { savedQueries: [] } }); + + nav.openPanelQuery({ dashboardId: 'a', tileId: 't1', queryId: 'gone' }); + + expect(hooks.revealAssignedPanel).not.toHaveBeenCalled(); + expect(hooks.runAction).not.toHaveBeenCalled(); + expect(hooks.toast).toHaveBeenCalledWith('That query is no longer part of this workspace.'); + }); +}); + +// --------------------------------------------------------------------------- +// openSavedQuery / openVariableTab — success paths +// --------------------------------------------------------------------------- + +describe('openSavedQuery / openVariableTab — success paths', () => { + it('openSavedQuery: a resolved id switches to Query and loads the tab', () => { + const query = savedQuery({ id: 'q1', sql: 'SELECT 1' }); + const { nav, hooks } = setup({ state: { savedQueries: [query] } }); + + nav.openSavedQuery('q1'); + + expect(hooks.dispatchShowQuerySurface).toHaveBeenCalledTimes(1); + expect(hooks.loadIntoNewTab).toHaveBeenCalledWith({ ...query }); + expect(hooks.toEditorOnMobile).toHaveBeenCalledTimes(1); + }); + + it('openVariableTab: a resolved variable switches to Query and opens on its inferred SQL', () => { + const query = savedQuery({ id: 'q1', sql: 'SELECT {p:String}' }); + const ws = workspace([dash('a', [{ id: 't1', queryId: 'q1' }])], [query]); + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + + nav.openVariableTab('a', 'p'); + + expect(hooks.dispatchShowQuerySurface).toHaveBeenCalledTimes(1); + expect(hooks.openVariableTabUi).toHaveBeenCalledWith({ dashboardId: 'a', variableName: 'p' }, ''); + expect(hooks.toEditorOnMobile).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// adoptRouteMainSurface branches (reached only via handleSqlPopState's +// same-workspace path, or loadWorkspaceOnBoot — it is nav-private otherwise) +// --------------------------------------------------------------------------- + +describe('adoptRouteMainSurface branches', () => { + it('a non-dashboard route resets mainSurface to Query', async () => { + const ws = workspace([dash('a')]); + const { nav, port, setLocationSearch } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'a', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + setLocationSearch('?ws=w'); + + await nav.handleSqlPopState(); + + expect(port.mainSurface).toEqual(QUERY_SURFACE); + }); + + it('falls back to the compatibility Dashboard when no history snapshot exists and none is selected', async () => { + const ws = workspace([dash('first'), dash('second')]); + const { nav, port, setLocationSearch } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = QUERY_SURFACE; + setLocationSearch('?ws=w&surface=dashboard&mode=edit'); + + await nav.handleSqlPopState(); + + expect(port.mainSurface).toMatchObject({ kind: 'dashboard', dashboardId: 'first' }); + }); + + it('falls back to Query when the collection has no Dashboard at all', async () => { + const ws = workspace([]); + const { nav, port, setLocationSearch } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = QUERY_SURFACE; + setLocationSearch('?ws=w&surface=dashboard&mode=edit'); + + await nav.handleSqlPopState(); + + expect(port.mainSurface).toEqual(QUERY_SURFACE); + }); + + it('a stale history snapshot whose Dashboard is gone falls through to the compatibility entry', async () => { + const ws = workspace([dash('first')]); + const { nav, port, history, setLocationSearch } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = QUERY_SURFACE; + history.replaceState({ dash: { workspaceKey: 'w', dashboardId: 'deleted-since', currentMember: null, scrollTop: 90 } }, '', ''); + setLocationSearch('?ws=w&surface=dashboard&mode=edit'); + + await nav.handleSqlPopState(); + + expect(port.mainSurface).toMatchObject({ dashboardId: 'first' }); + }); + + it('a valid history snapshot restores the remembered Dashboard', async () => { + const ws = workspace([dash('first'), dash('second')]); + const { nav, port, history, setLocationSearch } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + port.mainSurface = QUERY_SURFACE; + history.replaceState({ dash: { workspaceKey: 'w', dashboardId: 'second', currentMember: null, scrollTop: 90 } }, '', ''); + setLocationSearch('?ws=w&surface=dashboard&mode=edit'); + + await nav.handleSqlPopState(); + + expect(port.mainSurface).toMatchObject({ dashboardId: 'second', pendingScrollTop: 90 }); + }); +}); + +// --------------------------------------------------------------------------- +// reloadDashboardRoute branches +// --------------------------------------------------------------------------- + +describe('reloadDashboardRoute branches', () => { + it('folds the projection into the selected Dashboard by id, preserving every other entry', () => { + const ws = workspace([dash('first'), dash('second')]); + const { nav, port, state, hooks } = setup(); + port.currentWorkspace = ws; + port.mainSurface = { + kind: 'dashboard', dashboardId: 'second', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + state.dashboard = { ...dash('second'), revision: 7 }; + + nav.reloadDashboardRoute(); + + expect(port.currentWorkspace!.dashboards.map((d) => [d.id, d.revision])).toEqual([['first', 1], ['second', 7]]); + expect(hooks.renderDashboard).toHaveBeenCalledTimes(1); + }); + + it('folds into the compatibility slot when nothing is selected', () => { + const ws = workspace([dash('first'), dash('second')]); + const { nav, port, state } = setup(); + port.currentWorkspace = ws; + port.mainSurface = QUERY_SURFACE; + state.dashboard = { ...dash('imported'), revision: 4 }; + + nav.reloadDashboardRoute(); + + expect(port.currentWorkspace!.dashboards.map((d) => d.id)).toEqual(['imported', 'second']); + }); + + it('leaves the collection untouched when there is no projection to fold', () => { + const ws = workspace([dash('first')]); + const { nav, port, state } = setup(); + port.currentWorkspace = ws; + port.mainSurface = QUERY_SURFACE; + state.dashboard = null; + + nav.reloadDashboardRoute(); + + expect(port.currentWorkspace!.dashboards).toEqual([dash('first')]); + }); + + it('leaves the collection untouched when the selection is gone (no guessed fold target)', () => { + const only = dash('first'); + const { nav, port, state } = setup(); + port.currentWorkspace = workspace([only]); + port.mainSurface = { + kind: 'dashboard', dashboardId: 'deleted', mode: 'edit', + currentMember: null, pendingFocus: null, pendingScrollTop: null, + }; + state.dashboard = { ...only, id: 'deleted', revision: 9 }; + + nav.reloadDashboardRoute(); + + expect(port.currentWorkspace!.dashboards).toEqual([only]); + }); + + it('with no current workspace, currentWorkspace stays null and the render hook still fires', () => { + const { nav, port, hooks } = setup(); + port.currentWorkspace = null; + + nav.reloadDashboardRoute(); + + expect(port.currentWorkspace).toBeNull(); + expect(hooks.renderDashboard).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Coverage sweep — remaining direct members / branches +// --------------------------------------------------------------------------- + +describe('coverage sweep — remaining branches', () => { + it('syncSqlRoute reparses the route and updates currentRouteSearch', () => { + const { nav, port } = setup(); + nav.syncSqlRoute('?ws=ops&surface=dashboard&mode=view'); + expect(port.sqlRoute).toEqual({ surface: 'dashboard', workspaceKey: 'ops', mode: 'view' }); + expect(nav.currentRouteSearch()).toBe('?ws=ops&surface=dashboard&mode=view'); + }); + + it('rewriteWorkspaceRoute preserves the current surface/mode, only swapping the workspace key', () => { + const { nav, port, history } = setup(); + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'old', mode: 'view' }; + nav.rewriteWorkspaceRoute('new-key'); + expect(port.sqlRoute).toEqual({ surface: 'dashboard', workspaceKey: 'new-key', mode: 'view' }); + expect(history.replaceState).toHaveBeenCalled(); + }); + + it('writeRoute: push vs. replace both drive history and sqlRoute', () => { + const { nav, port, history } = setup(); + nav.writeRoute({ surface: 'workspace', workspaceKey: 'a' }, 'push'); + expect(history.pushState).toHaveBeenCalledTimes(1); + expect(port.sqlRoute).toEqual({ surface: 'workspace', workspaceKey: 'a' }); + nav.writeRoute({ surface: 'workspace', workspaceKey: 'b' }, 'replace'); + expect(history.replaceState).toHaveBeenCalled(); + expect(port.sqlRoute).toEqual({ surface: 'workspace', workspaceKey: 'b' }); + }); + + it('renderCurrentSurface dispatches to renderApp for a ready workspace-surface route', () => { + const { nav, port, hooks } = setup(); + port.currentWorkspace = workspace([]); + port.workspaceRouteStatus = 'ready'; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + nav.renderCurrentSurface(); + expect(hooks.renderApp).toHaveBeenCalledTimes(1); + expect(hooks.renderDashboard).not.toHaveBeenCalled(); + }); + + it('renderCurrentSurface dispatches to renderWorkspaceNotFound when not ready with no workspace', () => { + const { nav, hooks } = setup(); + nav.renderCurrentSurface(); + expect(hooks.renderWorkspaceNotFound).toHaveBeenCalledTimes(1); + }); + + it('renderCurrentSurface dispatches to renderWorkspaceLoading while loading', () => { + const { nav, port, hooks } = setup(); + port.workspaceRouteStatus = 'loading'; + nav.renderCurrentSurface(); + expect(hooks.renderWorkspaceLoading).toHaveBeenCalledTimes(1); + }); + + it('showDashboardSurface with no selection opens the legacy no-chooser entry point directly (push)', () => { + const { nav, port, state, hooks, history } = setup(); + port.currentWorkspace = null; + port.mainSurface = QUERY_SURFACE; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + // No `currentWorkspace`, so `surfaceRouteKey()` falls back to + // `state.workspaceKey` (not `sqlRoute.workspaceKey` — #425's route-key + // resolution is deliberately not just an echo of the incoming route). + state.workspaceKey = 'w'; + + nav.showDashboardSurface('edit'); + + expect(port.sqlRoute).toEqual({ surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }); + expect(history.pushState).toHaveBeenCalledTimes(1); + expect(hooks.invalidateDashboardTree).toHaveBeenCalledTimes(1); + expect(hooks.dispatchCurrentSurface).toHaveBeenCalledTimes(1); + }); + + it('showDashboardSurface with no selection, already on the Dashboard surface, replaces instead of pushing', () => { + const { nav, port, history } = setup(); + port.currentWorkspace = null; + port.mainSurface = QUERY_SURFACE; + port.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'view' }; + + nav.showDashboardSurface('edit'); + + expect(history.pushState).not.toHaveBeenCalled(); + expect(history.replaceState).toHaveBeenCalled(); + }); + + it('showDashboardSurface with an unselected but non-empty workspace resolves the compatibility Dashboard by id', () => { + const ws = workspace([dash('first'), dash('second')]); + const { nav, port, hooks } = setup(); + port.currentWorkspace = ws; + port.mainSurface = QUERY_SURFACE; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + + nav.showDashboardSurface('edit'); + + expect(hooks.dispatchOpenDashboard).toHaveBeenCalledWith({ dashboardId: 'first', mode: 'edit' }); + }); + + it('showQuerySurface is a no-op when the Query surface is already active', () => { + const { nav, port, history, hooks } = setup(); + port.mainSurface = QUERY_SURFACE; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + + nav.showQuerySurface(); + + expect(history.pushState).not.toHaveBeenCalled(); + expect(history.replaceState).not.toHaveBeenCalled(); + expect(hooks.dispatchCurrentSurface).not.toHaveBeenCalled(); + }); + + it('focusDashboardMember reports "pending" with no port, and "pending" with a non-dashboard port', () => { + const { nav, port } = setup(); + port.surfaceCommands = null; + expect(nav.focusDashboardMember({ kind: 'tile', id: 't1' })).toBe('pending'); + }); + + it('loadWorkspaceOnBoot: an explicit unresolved key canonicalizes the route without falling back', async () => { + const { nav, port } = setup({ repository: { loadByKey: vi.fn(async () => ({ status: 'empty' as const })) } }); + port.sqlRoute = { surface: 'workspace', workspaceKey: 'missing' }; + + const result = await nav.loadWorkspaceOnBoot(); + + expect(result).toBeNull(); + expect(port.workspaceRouteStatus).toBe('not-found'); + }); + + it('loadWorkspaceOnBoot: an explicit unresolved key ALSO strips a retired legacy hint from the canonicalized URL', async () => { + const { nav, port, history } = setup({ repository: { loadByKey: vi.fn(async () => ({ status: 'empty' as const })) } }); + nav.syncSqlRoute('?ws=missing&iss=https%3A%2F%2Faccounts.google.com'); + expect(port.sqlRoute).toEqual({ surface: 'workspace', workspaceKey: 'missing' }); + + const result = await nav.loadWorkspaceOnBoot(); + + expect(result).toBeNull(); + expect(port.workspaceRouteStatus).toBe('not-found'); + expect(nav.currentRouteSearch()).toBe('?ws=missing'); + expect(history.replaceState).toHaveBeenCalledWith(null, '', '/sql?ws=missing'); + }); + + it('loadWorkspaceOnBoot: a corrupt record surfaces via the toast hook with a Reset action wired to onCorruptWorkspace', async () => { + const { nav, port, hooks } = setup({ + repository: { loadByKey: vi.fn(async () => ({ status: 'corrupt' as const, id: 'corrupt-id', key: 'k', diagnostics: [] })) }, + }); + port.sqlRoute = { surface: 'workspace', workspaceKey: 'k' }; + + const result = await nav.loadWorkspaceOnBoot(); + + expect(result).toBeNull(); + expect(port.workspaceRouteStatus).toBe('error'); + expect(hooks.toast).toHaveBeenCalledTimes(1); + const [, opts] = (hooks.toast as Mock).mock.calls[0]; + opts.action.onClick(); + expect(hooks.onCorruptWorkspace).toHaveBeenCalledWith('corrupt-id'); + }); + + it('loadWorkspaceOnBoot: an implicit resolution (no explicit key) goes through resolveImplicitOrProvision', async () => { + const ws: StoredWorkspaceV5 = { storageVersion: 5, id: 'w', key: 'w', name: 'W', queries: [], dashboards: [] }; + const { nav, port, session } = setup({ + session: { resolveImplicitOrProvision: vi.fn(async () => ({ status: 'ok' as const, workspace: ws })) }, + }); + port.sqlRoute = { surface: 'workspace', workspaceKey: null }; + + const result = await nav.loadWorkspaceOnBoot(); + + expect(result).toBe(ws); + expect(session.resolveImplicitOrProvision).toHaveBeenCalledTimes(1); + }); + + it('loadWorkspaceOnBoot: an implicit resolution that comes back empty is an "error" status, not "not-found"', async () => { + const { nav, port } = setup({ + session: { resolveImplicitOrProvision: vi.fn(async () => ({ status: 'empty' as const })) }, + }); + port.sqlRoute = { surface: 'workspace', workspaceKey: null }; + + const result = await nav.loadWorkspaceOnBoot(); + + expect(result).toBeNull(); + expect(port.workspaceRouteStatus).toBe('error'); + }); + + it('loadGeneration reflects the load-attempt counter directly', async () => { + const { nav } = setup(); + expect(nav.loadGeneration()).toBe(0); + await nav.loadWorkspaceOnBoot(); + expect(nav.loadGeneration()).toBe(1); + await nav.loadWorkspaceOnBoot(); + expect(nav.loadGeneration()).toBe(2); + }); + + it('navigateSqlRoute: a same-workspace navigation adopts the surface in place without reloading', async () => { + const ws: StoredWorkspaceV5 = { storageVersion: 5, id: 'w', key: 'w', name: 'W', queries: [], dashboards: [] }; + const { nav, port, repository, hooks } = setup(); + port.currentWorkspace = ws; + port.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + + await nav.navigateSqlRoute({ surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }, 'replace'); + + expect(repository.loadByKey).not.toHaveBeenCalled(); + expect(hooks.dispatchCurrentSurface).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/variable-strip.test.ts b/tests/unit/variable-strip.test.ts new file mode 100644 index 00000000..d8b00f06 --- /dev/null +++ b/tests/unit/variable-strip.test.ts @@ -0,0 +1,437 @@ +// #588 W1 — `createVariableStrip` (src/ui/workbench/variable-strip.ts), the +// Workbench `{name:Type}` query-variable strip's DOM view, extracted verbatim +// from app.ts. Unit-tested directly against a fake `VariableStripDeps` (a +// real `WorkbenchParameterSession` over a fake `AppState`-shaped state bag, +// exactly like `workbench-parameter-session.test.ts`'s own fakes) — no +// `createApp`, no full `App`. app.test.ts's own var-strip suites remain the +// end-to-end composition safety net proving `createApp`'s real wiring reaches +// this controller (`app.renderVarStrip`/`app.setRunBtn` stay flat delegates); +// this file is the controller's own unit surface, including the two pieces of +// bookkeeping behavior that have NO other test anywhere: the mid-typing +// focus-containment deferral, and resetting that bookkeeping when the strip +// ELEMENT identity changes (a shell remount — sign-out/sign-in cycle). +import { describe, it, expect, vi } from 'vitest'; +import { createVariableStrip } from '../../src/ui/workbench/variable-strip.js'; +import type { VariableStripDeps } from '../../src/ui/workbench/variable-strip.js'; +import { createWorkbenchParameterSession } from '../../src/application/workbench-parameter-session.js'; +import { newTabObj } from '../../src/state.js'; +import type { QueryTab, AppState } from '../../src/state.js'; +import { emptyRecentMap } from '../../src/core/recent-values.js'; +import type { RecentMap } from '../../src/core/recent-values.js'; +import type { SchemaDb } from '../../src/core/from-scope.js'; + +const qs = (root: ParentNode | null, selector: string): T => + root!.querySelector(selector) as T; +const qsa = (root: ParentNode | null, selector: string): T[] => + [...root!.querySelectorAll(selector)] as T[]; + +// ── Fakes ──────────────────────────────────────────────────────────────────── + +/** A minimal `AppState`-shaped bag — only the fields `createVariableStrip` + * and `createWorkbenchParameterSession` actually read/write. Plain values + * (not signals) for `varValues`/`filterActive`/`varRecent`, matching + * `workbench-parameter-session.test.ts`'s own `makeState()`; `running`/ + * `hasSelection` need `.value` (real `AppState` signals), faked with the + * same shape here since `createVariableStrip` only ever reads `.value`. */ +function makeState(over: { running?: boolean; hasSelection?: boolean } = {}): { + state: Pick; + setRunning(v: boolean): void; +} { + const varValues: Record = {}; + const filterActive: Record = {}; + let varRecent = emptyRecentMap(); + let running = over.running ?? false; + const state = { + get running() { return { value: running } as AppState['running']; }, + hasSelection: { value: over.hasSelection ?? false } as AppState['hasSelection'], + varValues, filterActive, + get varRecent() { return varRecent; }, + set varRecent(v: RecentMap) { varRecent = v; }, + }; + return { state, setRunning: (v) => { running = v; } }; +} + +function makeDeps(over: { + tab?: QueryTab; + strip?: HTMLElement; + runBtn?: HTMLButtonElement; + running?: boolean; + hasSelection?: boolean; +} = {}): { + deps: VariableStripDeps; + tab: QueryTab; + strip: HTMLElement; + runBtn: HTMLButtonElement; + setRunning(v: boolean): void; + setStrip(el: HTMLElement | undefined): void; + setRunBtnEl(el: HTMLButtonElement | undefined): void; +} { + const tab = over.tab || newTabObj('t1'); + let strip: HTMLElement | undefined = over.strip + ?? document.body.appendChild(document.createElement('div')); + let runBtn: HTMLButtonElement | undefined = over.runBtn + ?? document.body.appendChild(document.createElement('button')); + const { state, setRunning } = makeState({ running: over.running, hasSelection: over.hasSelection }); + const params = createWorkbenchParameterSession({ + varValues: () => state.varValues, + filterActive: () => state.filterActive, + varRecent: () => state.varRecent, + setVarRecent: (map) => { state.varRecent = map; }, + varRecentDisabled: () => false, + schema: () => null as SchemaDb[] | null, + activeTab: () => tab, + wallNow: () => 1700000000000, + saveJSON: () => {}, + hooks: { onGateBlocked: () => {}, saveVarRecent: () => {} }, + }); + const deps: VariableStripDeps = { + document, + state: state as unknown as AppState, + activeTab: () => tab, + params, + wallNow: () => 1700000000000, + varStrip: () => strip, + runBtn: () => runBtn, + }; + return { + deps, tab, strip: strip!, runBtn: runBtn!, setRunning, + setStrip: (el) => { strip = el; }, + setRunBtnEl: (el) => { runBtn = el; }, + }; +} + +// ── setRunBtn ──────────────────────────────────────────────────────────────── + +describe('setRunBtn', () => { + it('no-ops when the Run button ref is absent (early return)', () => { + const { deps, setRunBtnEl } = makeDeps(); + setRunBtnEl(undefined); + const ctl = createVariableStrip(deps); + expect(() => ctl.setRunBtn(false)).not.toThrow(); + }); + + it('"Running…" with no trailing "null"; "Run" + kbd when idle; "Run selection" with a selection', () => { + const { deps, runBtn } = makeDeps({ hasSelection: false }); + const ctl = createVariableStrip(deps); + ctl.setRunBtn(true); + expect(runBtn.disabled).toBe(true); + expect(runBtn.textContent).toBe('Running…'); + ctl.setRunBtn(false); + expect(runBtn.disabled).toBe(false); + expect(runBtn.textContent).toContain('Run'); + expect(qs(runBtn, 'kbd')).not.toBeNull(); + + const sel = makeDeps({ hasSelection: true }); + const ctl2 = createVariableStrip(sel.deps); + ctl2.setRunBtn(false); + expect(sel.runBtn.textContent).toContain('Run selection'); + }); + + it('gate-less fallback: blocks on an unfilled {name:Type}, with a tooltip', () => { + const { deps, tab, runBtn } = makeDeps(); + tab.sqlDraft = 'SELECT {id:UInt32}'; + const ctl = createVariableStrip(deps); + ctl.setRunBtn(false); + expect(runBtn.disabled).toBe(true); + expect(runBtn.title).toContain('id'); + }); + + it('gate-less fallback never gates a dashboard-variable tab (#465)', () => { + const { deps, tab, runBtn } = makeDeps({ + tab: { ...newTabObj('t2'), doc: { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'v' } }, + }); + tab.sqlDraft = '{unfilled:UInt32}'; // would block an ordinary tab + const ctl = createVariableStrip(deps); + ctl.setRunBtn(false); + expect(runBtn.disabled).toBe(false); + }); + + it('an explicit gate wins over the fallback computation (missing/invalid/errors)', () => { + const { deps, runBtn } = makeDeps(); + const ctl = createVariableStrip(deps); + ctl.setRunBtn(false, { missing: [], invalid: [], errors: ['boom'] }); + expect(runBtn.disabled).toBe(true); + expect(runBtn.title).toBe('boom'); + }); +}); + +// ── renderVarStrip ─────────────────────────────────────────────────────────── + +describe('renderVarStrip', () => { + it('no-ops when the strip ref is absent (early return, first call site guard)', () => { + const { deps, setStrip } = makeDeps(); + setStrip(undefined); + const ctl = createVariableStrip(deps); + expect(() => ctl.renderVarStrip()).not.toThrow(); + }); + + it('renders an input per detected {name:Type}, hides when none, gates Run (tail call site)', () => { + const { deps, tab, strip, runBtn } = makeDeps(); + tab.sqlDraft = 'SELECT {database:String}, {table:String}'; + const ctl = createVariableStrip(deps); + ctl.renderVarStrip(); + expect(strip.style.display).not.toBe('none'); + const fields = qsa(strip, '.var-field'); + expect(fields.map((f) => qs(f, '.var-name').textContent)).toEqual(['database', 'table']); + expect(runBtn.disabled).toBe(true); + expect(runBtn.title).toContain('database'); + // idempotent re-render (signature guard skips the rebuild) + const before = qs(strip, '.var-input'); + ctl.renderVarStrip(); + expect(qs(strip, '.var-input')).toBe(before); + // no variables → strip hidden again + tab.sqlDraft = 'SELECT 1'; + ctl.renderVarStrip(); + expect(strip.style.display).toBe('none'); + expect(runBtn.disabled).toBe(false); + }); + + it('a dashboard-variable tab hides the strip unconditionally and never gates Run (first setRunBtn call site)', () => { + const { deps, tab, strip, runBtn } = makeDeps({ + tab: { ...newTabObj('t2'), doc: { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'v' } }, + }); + tab.sqlDraft = 'SELECT {p:UInt8}'; // would otherwise render a field + block Run + const ctl = createVariableStrip(deps); + ctl.renderVarStrip(); + expect(strip.style.display).toBe('none'); + expect(strip.children.length).toBe(0); + expect(runBtn.disabled).toBe(false); + }); + + it('enum field variant: a declared Enum8 renders the dropdown control', () => { + const { deps, tab, strip } = makeDeps(); + tab.sqlDraft = "SELECT {k:Enum8('a' = 1, 'b' = 2)}"; + createVariableStrip(deps).renderVarStrip(); + const input = qs(strip, '.var-input'); + input.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + expect(qsa(strip, '[role="option"]').length).toBeGreaterThan(0); + }); + + it('relative-time field variant: a DateTime var gets the preset+preview combobox', () => { + const { deps, tab, strip } = makeDeps(); + tab.sqlDraft = 'SELECT {ts:DateTime}'; + createVariableStrip(deps).renderVarStrip(); + const input = qs(strip, '.var-input'); + input.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + expect(qsa(strip, '[role="option"]').length).toBeGreaterThan(0); + }); + + it('recent field variant: a plain String var gets the recents-only combobox (no date presets)', () => { + const { deps, tab, strip } = makeDeps(); + tab.sqlDraft = 'SELECT {name:String}'; + createVariableStrip(deps).renderVarStrip(); + const input = qs(strip, '.var-input'); + input.dispatchEvent(new FocusEvent('focus', { bubbles: true })); + expect(qs(strip, '.var-combo-preview')).toBeNull(); + }); + + it('typing commits the shared store and re-syncs Run (onValueInput/onCommitHard call setRunBtn)', () => { + const { deps, tab, strip, runBtn } = makeDeps(); + tab.sqlDraft = 'SELECT {id:UInt32}'; + createVariableStrip(deps).renderVarStrip(); + const input = qs(strip, '.var-input'); + input.value = '42'; + input.dispatchEvent(new Event('input', { bubbles: true })); + expect(deps.state.varValues.id).toBe('42'); + expect(runBtn.disabled).toBe(false); + input.dispatchEvent(new Event('blur', { bubbles: true })); + expect(runBtn.title).toBe(''); + }); + + it('a type-conflicted variable (declared with disagreeing types) degrades to plain text with a visible warning (#173 acceptance)', () => { + const { deps, tab, strip } = makeDeps(); + const ENUM_TYPE = "Enum8('active' = 1, 'deleted' = 2)"; + tab.sqlDraft = `SELECT * FROM t WHERE status = {status:${ENUM_TYPE}}; SELECT {status:String}`; + createVariableStrip(deps).renderVarStrip(); + const input = qs(strip, '.var-input'); + expect(input.classList.contains('is-conflict')).toBe(true); + expect(input.title).toContain('Conflicting type declarations'); + }); + + it('an optional (/*[ ]*/-block-only) variable gets the `.is-optional` affordance', () => { + const { deps, tab, strip } = makeDeps(); + tab.sqlDraft = 'SELECT {y:UInt16} FROM t /*[ AND d = {d:String} ]*/'; + createVariableStrip(deps).renderVarStrip(); + const fields = qsa(strip, '.var-field'); + expect(fields.map((f) => f.classList.contains('is-optional'))).toEqual([false, true]); + expect(qs(fields[1], '.var-input').title).toContain('optional'); + }); + + it('"Clear recent" (the dropdown footer) calls through to params.clearVarRecent for that field', () => { + const { deps, tab, strip } = makeDeps(); + tab.sqlDraft = 'SELECT {tenant:String}'; + deps.state.varValues.tenant = 'acme'; + const spy = vi.spyOn(deps.params, 'clearVarRecent'); + createVariableStrip(deps).renderVarStrip(); + const input = qs(strip, '.var-input'); + input.dispatchEvent(new Event('focus', { bubbles: true })); + const clearBtn = qs(strip, 'button.var-combo-clear'); + clearBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })); + expect(spy).toHaveBeenCalledWith('tenant'); + }); + + it('no active tab: hides the strip, and the tail setRunBtn call gets an `undefined` gate (no analysis to prepare)', () => { + const { deps, strip, runBtn } = makeDeps(); + deps.activeTab = () => undefined; + createVariableStrip(deps).renderVarStrip(); + expect(strip.style.display).toBe('none'); + expect(runBtn.disabled).toBe(false); // no analysis ⇒ gate-less ⇒ never blocks + }); + + it('setRunBtn\'s own runGate ternary: a valid analysis while `running` yields an undefined (not recomputed) gate', () => { + const { deps, tab, runBtn } = makeDeps({ running: true }); + tab.sqlDraft = 'SELECT {id:UInt32}'; // would otherwise block Run + createVariableStrip(deps).renderVarStrip(); + expect(runBtn.disabled).toBe(true); // disabled because RUNNING, not because of the gate + expect(runBtn.title).toBe(''); + }); + + // ── Focus-containment deferral (sabotage-verified) ──────────────────────── + // + // Mirrors the pre-extraction behavior app.test.ts's own "v2: a background + // column load never steals focus mid-typing" case exercises end-to-end + // through the real schema-catalog service; this is the controller's OWN + // unit-level guarantee, independent of that composition. + it('defers the rebuild while focus is INSIDE the strip, then applies it on focusout (relatedTarget leaves the strip)', () => { + const { deps, tab, strip, runBtn } = makeDeps(); + tab.sqlDraft = 'SELECT {a:UInt8}'; + const ctl = createVariableStrip(deps); + ctl.renderVarStrip(); + const firstInput = qs(strip, '.var-input'); + firstInput.focus(); + expect(document.activeElement).toBe(firstInput); + + // A signature change while focus is inside the strip (e.g. a background + // schema-cache upgrade landing mid-typing) must NOT replace the children. + tab.sqlDraft = 'SELECT {a:UInt8}, {b:UInt8}'; + ctl.renderVarStrip(); + expect(qsa(strip, '.var-field')).toHaveLength(1); // unchanged — deferred + expect(qs(strip, '.var-input')).toBe(firstInput); // same node + // setRunBtn still ran (against the OLD, single-field analysis) — the + // deferred branch's own call site. + expect(runBtn.title).toContain('b'); // #a is filled by nothing yet... but gate reflects new analysis via runGate() + + // Focus merely moving BETWEEN fields of the strip (relatedTarget still + // inside it) must NOT apply the deferred rebuild. A manually-dispatched + // `focusout` (not a real `.focus()` transfer) leaves `document.activeElement` + // untouched, matching app.test.ts's own "moving focus BETWEEN strip fields" + // case — only the event's `relatedTarget` is under test here. + const outsideButStillInStrip = document.createElement('input'); + strip.appendChild(outsideButStillInStrip); + firstInput.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outsideButStillInStrip })); + expect(qsa(strip, '.var-field')).toHaveLength(1); // still deferred + + // Focus actually leaving the strip (a real `.blur()` — happy-dom fires the + // real `focusout` with the correct `relatedTarget` and updates + // `document.activeElement`) applies the deferred rebuild. + firstInput.blur(); + expect(qsa(strip, '.var-field')).toHaveLength(2); // rebuilt now + }); + + it('sabotage check: without the deferral, a background rebuild mid-typing would steal focus/replace the node', () => { + // Documents the guard this suite protects — if `renderVarStrip` rebuilt + // unconditionally on every signature change (no focus-containment check), + // the SAME scenario above would replace `firstInput` immediately instead + // of waiting for focusout. This case only asserts the (correct) deferred + // behavior again from a fresh strip, keyed on a DIFFERENT initial field + // count, so the two tests can't pass by coincidentally sharing state. + const { deps, tab, strip } = makeDeps(); + tab.sqlDraft = 'SELECT {x:String}'; + const ctl = createVariableStrip(deps); + ctl.renderVarStrip(); + const input = qs(strip, '.var-input'); + input.focus(); + tab.sqlDraft = 'SELECT {x:String}, {y:String}, {z:String}'; + ctl.renderVarStrip(); + expect(qs(strip, '.var-input')).toBe(input); // NOT replaced while focused + }); + + // ── Strip-identity reset (sabotage-verified) ────────────────────────────── + // + // #588 W1: `sig`/`rerenderPending`/`hookedStrip` are now controller-private + // closure state (no longer `app.dom.*`, which used to reset for free + // because `app.dom` itself is rebuilt wholesale on every shell mount). A + // shell remount (sign-out/sign-in) hands this SAME controller instance a + // BRAND NEW `
` — this must not leak stale + // bookkeeping from the old element, and the new element must get its own + // working `focusout` listener. + it('a fresh strip element resets `sig` — a remount rendering the SAME {name:Type} set stripA last committed must still populate the new (empty) element', () => { + const { deps, tab, setStrip } = makeDeps(); + const stripA = document.body.appendChild(document.createElement('div')); + setStrip(stripA); + const ctl = createVariableStrip(deps); + tab.sqlDraft = 'SELECT {a:UInt8}'; // stripA commits ITS `sig` to this set + ctl.renderVarStrip(); + expect(qsa(stripA, '.var-field')).toHaveLength(1); + + // Simulate a shell remount: a brand new (EMPTY) strip element for the + // SAME controller instance (app.dom resets wholesale, but the controller + // is a singleton built once by createApp) — SAME {name:Type} set as + // stripA's last commit. If `sig` were not reset on the identity change, + // `sigNew !== sig` would be FALSE (identical signature string) and the + // rebuild would be wrongly skipped, leaving stripB with ZERO children — + // a fresh element that has never had anything rendered into it. + const stripB = document.body.appendChild(document.createElement('div')); + setStrip(stripB); + ctl.renderVarStrip(); + expect(qsa(stripB, '.var-field')).toHaveLength(1); + expect(stripB.style.display).not.toBe('none'); + }); + + it('a fresh strip element resets `rerenderPending` and gets its own working focusout listener', () => { + const { deps, tab, setStrip } = makeDeps(); + const stripA = document.body.appendChild(document.createElement('div')); + setStrip(stripA); + const ctl = createVariableStrip(deps); + tab.sqlDraft = 'SELECT {a:UInt8}'; + ctl.renderVarStrip(); + const inputA = qs(stripA, '.var-input'); + inputA.focus(); + // Leave a rebuild PENDING on stripA (simulates a stale + // `rerenderPending`/`sig` if the remount below failed to reset them). + tab.sqlDraft = 'SELECT {a:UInt8}, {a2:UInt8}'; + ctl.renderVarStrip(); + expect(qsa(stripA, '.var-field')).toHaveLength(1); // deferred, as above + + // Simulate a shell remount: a brand new strip element for the SAME + // controller instance. + const stripB = document.body.appendChild(document.createElement('div')); + setStrip(stripB); + ctl.renderVarStrip(); + // The rebuild against stripB must reflect the CURRENT tab state, not be + // silently skipped by a stale `sig`/`rerenderPending` carried over from + // stripA. + expect(qsa(stripB, '.var-field')).toHaveLength(2); + + // stripB must have gotten its OWN focusout listener — not merely inherit + // "already hooked" bookkeeping from stripA (which would leave stripB with + // no listener at all, and the deferred rebuild below would never apply). + const inputB = qs(stripB, '.var-input'); + inputB.focus(); + tab.sqlDraft = 'SELECT {a:UInt8}, {a2:UInt8}, {a3:UInt8}'; + ctl.renderVarStrip(); + expect(qsa(stripB, '.var-field')).toHaveLength(2); // deferred again + inputB.blur(); // real focus transfer — a real `focusout` reaches stripB's own listener + expect(qsa(stripB, '.var-field')).toHaveLength(3); // stripB's OWN listener applied it + }); + + it('the dashboard-variable-tab branch also resets `sig` so the NEXT ordinary render always rebuilds', () => { + const { deps, tab, strip } = makeDeps(); + tab.sqlDraft = 'SELECT {a:UInt8}'; + const ctl = createVariableStrip(deps); + ctl.renderVarStrip(); + expect(qsa(strip, '.var-field')).toHaveLength(1); + // Switch to a dashboard-variable tab with the SAME nominal SQL text — + // hides the strip and must clear the signature. + tab.doc = { kind: 'dashboard-variable', dashboardId: 'd', variableName: 'v' }; + ctl.renderVarStrip(); + expect(strip.style.display).toBe('none'); + // Switching back to an ordinary tab with the SAME {name:Type} set must + // still rebuild (a stale non-empty `sig` would wrongly skip it, leaving + // the strip hidden with an empty body). + tab.doc = { kind: 'query' }; + ctl.renderVarStrip(); + expect(strip.style.display).not.toBe('none'); + expect(qsa(strip, '.var-field')).toHaveLength(1); + }); +}); diff --git a/tests/unit/workspace-session.test.ts b/tests/unit/workspace-session.test.ts new file mode 100644 index 00000000..93715f87 --- /dev/null +++ b/tests/unit/workspace-session.test.ts @@ -0,0 +1,499 @@ +// Unit tests for `src/application/workspace-session.ts` (#588 phase 4 wave 3). +// +// This module owns queueing, repository calls, this tab's snapshot-identity +// token, the BroadcastChannel wire + focus/visibility refresh fallback, the +// `beforeunload` dirty guard, and initial-workspace provisioning — everything +// `app.ts`'s pre-#588 write/refresh/cross-tab block used to inline. These tests +// construct `createWorkspaceSession(deps)` directly (no `createApp`), with a +// small controllable fake repository/event-target pair so the ordering +// invariants (queue serialization, read-at-dequeue, stale-route re-checks, +// generation-tokened bypass) can be driven precisely. +// +// Real controlled-interleaving tests per the #588 phase 4 plan's §4a (NOT +// weaker "call twice and see" tests): every gate below is released explicitly, +// at a chosen point, so the assertion proves ORDER, not just eventual outcome. + +import { describe, it, expect, vi } from 'vitest'; +import { createWorkspaceSession } from '../../src/application/workspace-session.js'; +import type { WorkspaceSessionDeps } from '../../src/application/workspace-session.js'; +import { createState } from '../../src/state.js'; +import type { AppState } from '../../src/state.js'; +import type { SavedQueryV2, StoredWorkspaceV5 } from '../../src/generated/json-schema.types.js'; +import type { WorkspaceRepository } from '../../src/workspace/workspace-repository.js'; +import { workspaceToken, queryToken } from '../../src/workspace/workspace-sync.js'; +import type { BroadcastChannelPort } from '../../src/env.types.js'; + +// --------------------------------------------------------------------------- +// Scaffolding +// --------------------------------------------------------------------------- + +/** A minimal DOM-EventTarget-shaped fake — `addEventListener`/`removeEventListener` + * really register/unregister, and `dispatch` really invokes the registered + * listeners, so the beforeunload/focus/visibility tests exercise the REAL + * registration lifecycle rather than asserting on a spy's call args alone. */ +function fakeEventTarget() { + const listeners = new Map void>>(); + return { + addEventListener: (type: string, fn: (e: unknown) => void) => { + let set = listeners.get(type); + if (!set) { set = new Set(); listeners.set(type, set); } + set.add(fn); + }, + removeEventListener: (type: string, fn: (e: unknown) => void) => { + listeners.get(type)?.delete(fn); + }, + dispatch(type: string, event: unknown = {}): void { + for (const fn of [...(listeners.get(type) ?? [])]) fn(event); + }, + listenerCount(type: string): number { + return listeners.get(type)?.size ?? 0; + }, + }; +} + +class FakeBeforeUnloadEvent { + defaultPrevented = false; + returnValue: unknown = ''; + preventDefault(): void { this.defaultPrevented = true; } +} + +/** A controllable in-memory `WorkspaceRepository` — `gateLoadById`/`gateCommit` + * let a test hold either call open until it releases a promise, mirroring the + * plan's "injected fake repository with per-call deferreds" (§4a). Logs every + * `loadById`/`commit` call (id / candidate) so a test can assert ORDER and + * COUNT, not just the final persisted value. */ +function makeFakeRepository(initial: StoredWorkspaceV5 | null) { + let current = initial; + let loadByIdGate: (() => Promise) | null = null; + let commitGate: (() => Promise) | null = null; + let rejectNextLoadById: Error | null = null; + const loadByIdLog: string[] = []; + const commitLog: StoredWorkspaceV5[] = []; + const repository: WorkspaceRepository = { + list: async () => ({ summaries: [], corrupt: [] }), + loadById: async (id) => { + loadByIdLog.push(id); + if (rejectNextLoadById) { + const err = rejectNextLoadById; + rejectNextLoadById = null; + throw err; + } + if (loadByIdGate) await loadByIdGate(); + return current && current.id === id + ? { status: 'ok' as const, workspace: current } + : { status: 'empty' as const }; + }, + loadByKey: async () => ({ status: 'empty' as const }), + create: async (candidate) => { + current = candidate; + return { ok: true as const, workspace: candidate, dashboardRevision: null }; + }, + commit: async (candidate) => { + commitLog.push(candidate); + if (commitGate) await commitGate(); + current = candidate; + return { ok: true as const, workspace: candidate, dashboardRevision: null }; + }, + delete: async (id) => { + const deleted = current?.id === id; + if (deleted) current = null; + return { ok: true as const, deleted }; + }, + resolveImplicit: async () => ( + current ? { status: 'ok' as const, workspace: current } : { status: 'empty' as const } + ), + markOpened: async () => ({ ok: true as const }), + }; + return { + repository, + loadByIdLog, + commitLog, + gateLoadById: (gate: () => Promise) => { loadByIdGate = gate; }, + gateCommit: (gate: () => Promise) => { commitGate = gate; }, + rejectNextLoadByIdWith: (err: Error) => { rejectNextLoadById = err; }, + getCurrent: () => current, + }; +} + +function makeHooks() { + return { + applyCommittedWorkspace: vi.fn<(ws: StoredWorkspaceV5) => void>(), + onWorkspaceMissing: vi.fn<() => void>(), + isWorkbenchSurface: vi.fn(() => true), + refreshWorkbenchUi: vi.fn(), + notifyExternallyChanged: vi.fn(), + onExternalInvalidation: vi.fn(), + warnRefreshFailed: vi.fn(), + warnMarkOpenedFailed: vi.fn(), + }; +} + +function setup(over: { + initial?: StoredWorkspaceV5 | null; + broadcastChannelFactory?: (name: string) => BroadcastChannelPort | null; +} = {}) { + const state: AppState = createState({ loadStr: (_k, d) => d, loadJSON: (_k, d) => d }); + const fakeRepo = makeFakeRepository(over.initial ?? null); + const hooks = makeHooks(); + const win = fakeEventTarget(); + const doc = fakeEventTarget(); + const routeStatusBox: { value: 'loading' | 'ready' | 'not-found' | 'error' } = { value: 'ready' }; + const loadGenBox = { value: 0 }; + let uidCounter = 0; + const deps: WorkspaceSessionDeps = { + repository: fakeRepo.repository, + state, + uid: (prefix) => `${prefix}${++uidCounter}`, + genId: () => `gen-${++uidCounter}`, + broadcastChannelFactory: over.broadcastChannelFactory ?? (() => null), + documentVisible: () => true, + windowSeam: win, + documentSeam: doc, + routeCurrency: { + routeWorkspaceKey: () => state.workspaceKey, + routeStatus: () => routeStatusBox.value, + loadGeneration: () => loadGenBox.value, + }, + hooks, + }; + const session = createWorkspaceSession(deps); + return { session, deps, state, hooks, fakeRepo, win, doc, routeStatusBox, loadGenBox }; +} + +const microtask = (): Promise => Promise.resolve(); +const settle = async (n = 2): Promise => { for (let i = 0; i < n; i++) await microtask(); }; + +// --------------------------------------------------------------------------- +// I-1 — queue serialization +// --------------------------------------------------------------------------- + +describe('I-1: queue serialization', () => { + it('holds mutation A open; a concurrent refresh queued behind it does not read until A settles; a rejected read warns without wedging the chain; a later mutation still commits', async () => { + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'Base', queries: [], dashboards: [] }; + const { session, state, fakeRepo, hooks } = setup({ initial: ws1 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + + let releaseCommit: () => void = () => {}; + const commitGate = new Promise((resolve) => { releaseCommit = resolve; }); + fakeRepo.gateCommit(() => commitGate); + + // Mutation A enters the queue and reaches its (gated) commit. + const mutationA = session.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'A' } })); + // A refresh is requested WHILE A is still pending — it must queue behind A, + // not race it. + const refreshPromise = session.refreshWorkspaceFromStore(); + await settle(); + + // A's own `loadById` (the read-at-dequeue) has fired, but the refresh's + // read must NOT have — it is still queued behind A's gated commit. + expect(fakeRepo.loadByIdLog).toEqual(['w1']); + + releaseCommit(); + await mutationA; + await refreshPromise; + + // Now that A settled, the refresh's own read DID fire. + expect(fakeRepo.loadByIdLog).toEqual(['w1', 'w1']); + + // A rejected read warns internally and does not reject the chain. + fakeRepo.rejectNextLoadByIdWith(new Error('idb down')); + await expect(session.refreshWorkspaceFromStore()).resolves.toBeUndefined(); + expect(hooks.warnRefreshFailed).toHaveBeenCalledTimes(1); + + // The queue is not wedged: a later mutation still commits. + const mutationC = await session.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'C' } })); + expect(mutationC.ok).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// I-2 — read-at-dequeue +// --------------------------------------------------------------------------- + +describe('I-2: read-at-dequeue', () => { + it("mutation B, invoked while A is pending, reads the aggregate AFTER A's commit and its transform sees A's committed value", async () => { + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'Base', queries: [], dashboards: [] }; + const { session, state, fakeRepo } = setup({ initial: ws1 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + + let releaseA: () => void = () => {}; + const gateA = new Promise((resolve) => { releaseA = resolve; }); + const mutationA = session.mutateWorkspace(async (latest) => { + await gateA; // A's transform is held open + return { candidate: { ...latest!, name: 'V1' } }; + }); + await settle(); + + // B is invoked while A is still pending. + const seenByB: string[] = []; + const mutationB = session.mutateWorkspace((latest) => { + seenByB.push(latest!.name); + return { candidate: { ...latest!, name: 'V2-over-' + latest!.name } }; + }); + + releaseA(); + await mutationA; + await mutationB; + + // B's transform ran only after A committed, and saw A's committed 'V1' — + // not the 'Base' value that was current when B was invoked. + expect(seenByB).toEqual(['V1']); + expect(fakeRepo.commitLog.map((ws) => ws.name)).toEqual(['V1', 'V2-over-V1']); + }); +}); + +// --------------------------------------------------------------------------- +// I-13 — beforeunload generation tokens +// --------------------------------------------------------------------------- + +describe('I-13: beforeunload OAuth-redirect bypass generation tokens', () => { + it("an older arm's release cannot disarm a newer arm", () => { + const { session, state, win } = setup(); + state.tabs.value[0].dirtySql = true; // make the guard actually install + session.syncBeforeUnload(); + expect(win.listenerCount('beforeunload')).toBe(1); + + const releaseOld = session.armOAuthRedirectUnloadBypass(); + const releaseNew = session.armOAuthRedirectUnloadBypass(); + releaseOld(); // stale — must be a no-op against the newer arm + + const stillArmed = new FakeBeforeUnloadEvent(); + win.dispatch('beforeunload', stillArmed); + expect(stillArmed.defaultPrevented).toBe(false); // the NEW arm is still armed + + const afterConsumed = new FakeBeforeUnloadEvent(); + win.dispatch('beforeunload', afterConsumed); + expect(afterConsumed.defaultPrevented).toBe(true); // the arm is one-shot; ordinary unloads warn again + void releaseNew; // exercised above (its arm was the one `stillArmed` consumed) + }); +}); + +// --------------------------------------------------------------------------- +// I-22 — cross-tab wire compat +// --------------------------------------------------------------------------- + +describe('I-22: cross-tab wire compat', () => { + it('pins the BroadcastChannel name', () => { + const seen: string[] = []; + setup({ broadcastChannelFactory: (name) => { seen.push(name); return null; } }); + expect(seen).toEqual(['asb:workspace']); + }); + + it('pins the WorkspaceChangedMessage wire shape posted on a successful commit', async () => { + const posted: unknown[] = []; + const channel: BroadcastChannelPort = { onmessage: null, postMessage: (m) => posted.push(m), close: () => {} }; + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; + const { session, state } = setup({ initial: ws1, broadcastChannelFactory: () => channel }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + + await session.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Changed' } })); + + expect(posted).toEqual([{ type: 'workspace-changed', sourceTabId: session.sourceTabId, workspaceId: 'w1' }]); + }); +}); + +// --------------------------------------------------------------------------- +// I-26 — refreshPending clears at DEQUEUE, not read-completion +// --------------------------------------------------------------------------- + +describe('I-26: refreshPending clears at dequeue', () => { + it('a poke arriving while the store read is still in flight schedules a fresh follow-up (not coalesced away)', async () => { + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; + const { session, state, fakeRepo } = setup({ initial: ws1 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + + let releaseRead: () => void = () => {}; + const readGate = new Promise((resolve) => { releaseRead = resolve; }); + fakeRepo.gateLoadById(() => readGate); + + session.scheduleRefresh(); // 1st poke + await settle(); + // The queued op has DEQUEUED (its read has started) — `refreshPending` + // must already be false at this point, even though the read itself is + // still gated (not yet resolved). + expect(fakeRepo.loadByIdLog).toHaveLength(1); + + session.scheduleRefresh(); // a 2nd poke arriving mid-read + releaseRead(); + await session.flushWorkspaceWrites(); + + // Two SEPARATE reads: the second poke was not coalesced into the first + // (which had already dequeued), because `refreshPending` had already + // cleared before the read resolved. + expect(fakeRepo.loadByIdLog).toHaveLength(2); + }); +}); + +// --------------------------------------------------------------------------- +// I-27 — queriesDidChange computed from a PRE-projection snapshot +// --------------------------------------------------------------------------- + +describe('I-27: queriesDidChange from the pre-projection snapshot', () => { + it('compares state.savedQueries as it stood BEFORE applyCommittedWorkspace projects the new collection', async () => { + const oldQuery: SavedQueryV2 = { id: 'q1', sql: 'SELECT 1', specVersion: 1, spec: { name: 'q1', favorite: false } }; + const newQuery: SavedQueryV2 = { id: 'q1', sql: 'SELECT 2', specVersion: 1, spec: { name: 'q1', favorite: false } }; + const ws2: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [newQuery], dashboards: [] }; + const { session, state, hooks } = setup({ initial: ws2 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + state.savedQueries = [oldQuery]; // the PRE-projection snapshot, different from the store + // Mirrors app.ts's own `applyCommittedWorkspace`: projects `savedQueries`. + hooks.applyCommittedWorkspace.mockImplementation((ws) => { state.savedQueries = ws.queries; }); + + await session.refreshWorkspaceFromStore(); + + expect(hooks.notifyExternallyChanged).toHaveBeenCalledWith({ workspace: ws2, queriesChanged: true }); + }); +}); + +// --------------------------------------------------------------------------- +// I-28 — pre-commit stale-route re-check (distinct from the post-commit check) +// --------------------------------------------------------------------------- + +describe('I-28: pre-commit stale-route re-check', () => { + it('a workspace switch landing DURING the transform aborts before commit is ever called', async () => { + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; + const { session, state, fakeRepo } = setup({ initial: ws1 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + + const result = await session.mutateWorkspace(async (latest) => { + // A route/workspace switch lands while this transform is still + // resolving (e.g. a user dialog, an async Spec evaluation) — before any + // candidate ever reaches the durable commit boundary below. + state.workspaceId = 'w2'; + return { candidate: { ...latest!, name: 'Should never commit' } }; + }); + + expect(result).toEqual({ ok: false, aborted: true, data: undefined }); + expect(fakeRepo.commitLog).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// I-4 (session-owned half) — token recording via recordProjection +// --------------------------------------------------------------------------- + +describe('I-4 (session-owned half): recordProjection / getLastCommittedToken', () => { + it('starts empty, and recordProjection sets the snapshot token to the given workspace', () => { + const { session } = setup(); + expect(session.getLastCommittedToken()).toBe(''); + const ws: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; + session.recordProjection(ws); + expect(session.getLastCommittedToken()).toBe(workspaceToken(ws)); + expect(session.getLastCommittedToken().length).toBeGreaterThan(0); + }); + + it('a successful mutateWorkspace commit records the new token via the applyCommittedWorkspace hook path', async () => { + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; + const { session, state, hooks } = setup({ initial: ws1 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + // Mirrors app.ts's applyCommittedWorkspace: it is the ONE place that calls + // `recordProjection` (#588 — the token half of I-4 stays enforced even + // though the projection funnel itself lives in app.ts). + hooks.applyCommittedWorkspace.mockImplementation((ws) => session.recordProjection(ws)); + + await session.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Changed' } })); + + expect(session.getLastCommittedToken()).toBe(workspaceToken({ ...ws1, name: 'Changed' })); + }); +}); + +// --------------------------------------------------------------------------- +// I-5 — broadcast fires exactly once after a real commit, even when the route +// went stale DURING the commit await (distinct from I-28's pre-commit check) +// --------------------------------------------------------------------------- + +describe('I-5: broadcast ordering', () => { + it('posts exactly one invalidation after a successful commit, even when the route goes stale while the commit is in flight', async () => { + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; + const posted: unknown[] = []; + const channel: BroadcastChannelPort = { onmessage: null, postMessage: (m) => posted.push(m), close: () => {} }; + const { session, state, fakeRepo, hooks } = setup({ initial: ws1, broadcastChannelFactory: () => channel }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + + let releaseCommit: () => void = () => {}; + const commitGate = new Promise((resolve) => { releaseCommit = resolve; }); + fakeRepo.gateCommit(() => commitGate); + + const pending = session.mutateWorkspace((latest) => ({ candidate: { ...latest!, name: 'Changed' } })); + await settle(); + // The durable write already began (past I-28's pre-commit check); the + // route now goes stale WHILE the commit is in flight. + state.workspaceId = 'w2'; + releaseCommit(); + const result = await pending; + + // This tab's own caller sees the abort (it left the route) … + expect(result).toEqual({ ok: false, aborted: true, data: undefined }); + expect(hooks.applyCommittedWorkspace).not.toHaveBeenCalled(); + // … but the OTHER tab is still told about the durable write that landed. + expect(posted).toEqual([{ type: 'workspace-changed', sourceTabId: session.sourceTabId, workspaceId: 'w1' }]); + }); +}); + +// --------------------------------------------------------------------------- +// I-6 (session-owned half) — reconcile linked tabs from the PRE-projection +// snapshot, before applyCommittedWorkspace projects the new collection +// --------------------------------------------------------------------------- + +describe('I-6 (session-owned half): reconcile-then-project ordering', () => { + it('a clean linked tab whose query vanished externally detaches — proof reconcileLinkedTabsToLatest ran against pre-projection state', async () => { + const query: SavedQueryV2 = { id: 'q1', sql: 'SELECT 1', specVersion: 1, spec: { name: 'q1', favorite: false } }; + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; // q1 deleted externally + const { session, state, hooks } = setup({ initial: ws1 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + state.savedQueries = [query]; + const tab = state.tabs.value[0]; + tab.savedId = 'q1'; + tab.dirtySql = false; + tab.lastCommittedQueryToken = queryToken(query); + hooks.applyCommittedWorkspace.mockImplementation((ws) => { state.savedQueries = ws.queries; }); + + await session.refreshWorkspaceFromStore(); + + expect(tab.savedId).toBeNull(); // clean + deleted externally → detach + }); +}); + +// --------------------------------------------------------------------------- +// General wiring sanity (onWorkspaceMissing / isWorkbenchSurface / +// provisioning) — not a named invariant row on their own, but the surrounding +// hook plumbing these tests exercise IS what I-1/I-2/I-26/I-27/I-28 above run +// through. +// --------------------------------------------------------------------------- + +describe('supporting hook wiring', () => { + it('onWorkspaceMissing fires when the active record is gone during a mutation, and the transform never runs', async () => { + const { session, state, hooks } = setup({ initial: null }); + state.workspaceId = 'missing'; state.workspaceKey = 'k1'; + const transform = vi.fn(); + + const result = await session.mutateWorkspace(transform); + + expect(result).toEqual({ ok: false, aborted: true }); + expect(transform).not.toHaveBeenCalled(); + expect(hooks.onWorkspaceMissing).toHaveBeenCalledTimes(1); + }); + + it('onWorkspaceMissing fires when a refresh discovers the workspace was deleted', async () => { + const ws1: StoredWorkspaceV5 = { storageVersion: 5, id: 'w1', key: 'k1', name: 'N', queries: [], dashboards: [] }; + const { session, state, fakeRepo, hooks } = setup({ initial: ws1 }); + state.workspaceId = 'w1'; state.workspaceKey = 'k1'; + session.recordProjection(ws1); // seed a real baseline token so this isn't a no-op + await fakeRepo.repository.delete('w1'); + + await session.refreshWorkspaceFromStore(); + + expect(hooks.onWorkspaceMissing).toHaveBeenCalledTimes(1); + }); + + it('resolveImplicitOrProvision provisions a fresh workspace when the collection is empty, and recordOpened warns on a failed markOpened', async () => { + const { session, hooks, fakeRepo } = setup({ initial: null }); + + const result = await session.resolveImplicitOrProvision(); + expect(result.status).toBe('ok'); + + fakeRepo.repository.markOpened = async () => ({ ok: false, diagnostics: [] }); + if (result.status === 'ok') await session.recordOpened(result.workspace); + expect(hooks.warnMarkOpenedFailed).toHaveBeenCalledTimes(1); + }); +});