diff --git a/CHANGELOG.md b/CHANGELOG.md index 070d0bd3..b14863c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **Unified `/sql` routing for Workbench and Dashboard surfaces** (#407). + Workspace identity, surface, and presentation mode now live in canonical + `ws`, `surface`, and `mode` query parameters. Workbench/Dashboard switches + stay in the same tab with useful Back-button history, while View/Edit uses + replacement history. Dashboard view mode renders the same live workspace + document without authoring controls; an explicit missing workspace never + falls back, and an empty workspace offers Create only in edit mode. The old + surface becomes an inert loading route before cross-workspace navigation, + while same-workspace Back/Forward switches immediately. Dashboard uses the + shared header row for its surface, tile count, File, active-style menu, name, + View/Edit, update, Refresh, and theme controls. Global Workbench shortcuts + fail closed outside a ready matching route, and renderer generations let in-flight + durable writes finish without obsolete Dashboard or Workbench callbacks + repainting the selected surface. Direct Dashboard startup now shares the + authenticated server-version probe. Empty Dashboard routes also react when + another tab creates the Dashboard. The old `/sql/dashboard` bootstrap split, + detached Dashboard snapshot stores, + one-time state handoff, and cross-tab credential handoff have been removed. - **Multi-workspace local persistence foundation** (#406). Stored workspaces now use the V2 contract with separate immutable opaque `id`, immutable human-readable URL `key`, and mutable display `name`. IndexedDB stores one diff --git a/README.md b/README.md index 7a449a9c..a2799f97 100644 --- a/README.md +++ b/README.md @@ -532,10 +532,11 @@ npm run dev # build + serve dist/ at http://localhost:8900 ### Run in Docker The production image is a static **nginx** server for the single-file SPA — no -application backend. It serves `/sql` (and the `/sql/dashboard` client route), -serves a `config.json` you provide at `/sql/config.json`, and answers `/healthz` -for probes. Queries are **not** proxied: the browser POSTs them straight to the -chosen ClickHouse cluster, exactly like the on-cluster deployment. +application backend. It serves `/sql`, including Workbench and Dashboard +surfaces selected by query parameters, serves a `config.json` you provide at +`/sql/config.json`, and answers `/healthz` for probes. Queries are **not** +proxied: the browser POSTs them straight to the chosen ClickHouse cluster, +exactly like the on-cluster deployment. Pull the published multi-arch image (`linux/amd64` + `linux/arm64`): @@ -697,12 +698,9 @@ see "Security headers" below), and uploads the SPA + config into ClickHouse 1. Add the rendered `dist/http_handlers.xml` to the server's `config.d/` (or push it as an ACM cluster setting `config.d/sql-browser.xml`) and reload ClickHouse. - The SPA handler serves both `/sql` (the workbench) and `/sql/dashboard` (the - favorites dashboard) from the same file. -2. Register the redirect URI `https:///sql` with your OAuth IdP. If users - will open `/sql/dashboard` via a cold/bookmarked link (rather than from the app, - which hands credentials over in-session), also register - `https:///sql/dashboard` so that direct sign-in can complete. + The SPA handler serves `/sql`; Workbench and Dashboard bookmarks differ only + by the `ws`, `surface`, and optional `mode` query parameters. +2. Register the single redirect URI `https:///sql` with your OAuth IdP. 3. Make sure ClickHouse accepts the bearer JWT — either a CH `` entry validating your IdP's JWKS, or a delegated `` verifier. See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). diff --git a/build/check-boundaries.mjs b/build/check-boundaries.mjs index ce88cd72..90589832 100644 --- a/build/check-boundaries.mjs +++ b/build/check-boundaries.mjs @@ -3,7 +3,7 @@ // service layer coordinates state and network, it does not reach into DOM // rendering or the CodeMirror editor adapters. // Phase 3 — the route sessions must not import each other's implementation -// modules (ui/workbench ↔ ui/dashboard), and the dashboard route must not +// modules (ui/workbench ↔ ui/dashboard), and the Dashboard surface must not // depend on the editor ports at all. // Phase 5 — neither route shell (ui/workbench/**, ui/dashboard.ts + // ui/dashboard/**) may import src/ui/app.ts: both receive everything they diff --git a/build/local.py b/build/local.py index b5e46623..e18fac95 100644 --- a/build/local.py +++ b/build/local.py @@ -242,10 +242,8 @@ def do_GET(self): if path.endswith("/config.json"): self._send(CONFIG, "application/json; charset=utf-8") return - # Serve the SPA for the workbench (/sql) and the client-side dashboard - # route (/sql/dashboard) — mirrors the production http_handlers rule so - # "Open as dashboard" works under `npm run local` too (#149 D1). - if path.rstrip("/") in ("", "/sql", "/sql.html", "/sql/dashboard"): + # Workbench and Dashboard are query-selected surfaces of /sql. + if path.rstrip("/") in ("", "/sql", "/sql.html"): try: with open(SPA, "rb") as f: html = f.read() diff --git a/deploy/http_handlers.xml b/deploy/http_handlers.xml index 046cab23..8f1fa568 100644 --- a/deploy/http_handlers.xml +++ b/deploy/http_handlers.xml @@ -6,7 +6,6 @@ Routes: GET /sql -> the SPA (dist/sql.html in user_files) - GET /sql/dashboard -> the SAME SPA (client-side route: the favorites dashboard) GET /sql/config.json -> the OAuth config (issuer + client_id [+ audience]) The SPA POSTs queries to "/" with `Authorization: Bearer `; that is @@ -24,10 +23,8 @@ - - regex:^/sql(/dashboard)?/?$ + + regex:^/sql/?$ GET static diff --git a/deploy/nginx/default.conf.template b/deploy/nginx/default.conf.template index d8cda29d..2ccd1ad1 100644 --- a/deploy/nginx/default.conf.template +++ b/deploy/nginx/default.conf.template @@ -31,12 +31,12 @@ server { return 302 /sql; } - # The workbench (/sql) and the client-side dashboard route (/sql/dashboard) - # are the SAME single-file SPA. alias to a file (no captured path appended - # inside a regex location). The CSP's connect-src bounds where the + # Workbench and Dashboard are query-selected surfaces of /sql. Alias to a + # file (no captured path appended inside a regex location). CSP connect-src + # bounds where the # sessionStorage tokens can be sent; script-src/style-src need # 'unsafe-inline' because the JS+CSS are inlined into this one HTML file. - location ~ ^/sql(/dashboard)?/?$ { + location ~ ^/sql/?$ { alias /app/sql.html; # NB: set the charset via the `charset` directive, NOT inside # default_type — a "text/html; charset=…" content_type string defeats diff --git a/docs/ADR-0003-dashboard-viewing.md b/docs/ADR-0003-dashboard-viewing.md index 205e7d17..3b5870b9 100644 --- a/docs/ADR-0003-dashboard-viewing.md +++ b/docs/ADR-0003-dashboard-viewing.md @@ -1,160 +1,102 @@ -# ADR-0003: Dashboard viewing — edit vs view modes, and the one-time cross-tab state handoff +# ADR-0003: Dashboard viewing and unified `/sql` routes -- **Status:** Accepted — 2026-07-18 (#288 + #302, Dashboard v1 phase 6, the final - phase of epic #280). Implemented on branch `feat/dashboard-viewing-288`. -- **Date:** 2026-07-18 -- **Context tracking:** roadmap #68 (Dashboard track); epic #280; closes #153. -- **Related:** #149 (original standalone-dashboard route + postMessage credential - handoff — its *state*-transport decision is superseded here), #284 (IndexedDB - `StoredWorkspaceV1` persistence, whose adapter pattern this reuses), #287 - (portable bundle codecs + transactional import planner this builds on). +- **Status:** Accepted; detached-snapshot decision superseded by #407 on + 2026-07-23 +- **Date:** 2026-07-18; revised 2026-07-23 +- **Context tracking:** roadmap #68; #288, #302, #406, #407 ## Context -Phases 1–5 of #280 made the Dashboard a first-class module: an explicit -`StoredWorkspaceV1` aggregate persisted atomically to IndexedDB (#284), an -independent read-only `DashboardViewerSession` + `flow@1` layout (#286), and a -canonical `PortableBundleV1` with a transactional import planner (#287). Phase 6 -(#288) is the viewing surface: how a Dashboard is *opened* — bookmarkably for the -current workspace, and in a detached read-only tab — without the read-only path -dragging in Workbench or editor construction. In parallel, #302 asked to move -Dashboard navigation and Dashboard-scoped file operations out of the (overloaded) -Workbench File menu and next to the Dashboard resource itself. - -The original #288 spec framed the second open kind as a *temporary, one-time, -non-bookmarkable* "session-bundle" for full-screen preview and external-bundle -viewing. During planning the owner refined this into a cleaner, more durable -product model, which this ADR records. +The original Dashboard viewing design separated an editable primary workspace +from durable read-only snapshots. Opening a view created a second workspace +record through a one-time IndexedDB handoff and opened a separate +`/sql/dashboard` application tab. That model duplicated local data, required +parallel stores and credential/state transport, and made view mode diverge from +the workspace users were actually editing. -## Decision - -### Two explicit viewing modes +Multi-workspace persistence (#406) established an immutable human-readable +workspace `key` as the canonical URL identity. Unified routing (#407) uses that +identity for both application surfaces and treats View/Edit as presentation +modes over one live workspace. -| | **Edit mode** | **View mode** | -|---|---|---| -| Entry | Workbench header `Dashboard →` control | Dashboard header **File → "Open for viewing…"** | -| Tab | new tab | new tab | -| Open source | `current-workspace` (`?ws=&dash=`) | detached snapshot of the current dashboard | -| Storage | **shared** primary workspace collection (`asb-workspaces-v2`) | its **own** detached store (`asb-dashboard-views`), fresh id | -| Editable | yes — drag reorder + layout preset persist to the shared aggregate | **read-only** | -| Auth | existing postMessage credential handoff (#149) | same | -| Survives relogin / reload | yes (shared store) | yes (own persisted record) | - -`Dashboard →` opens a **new tab** (an owner override of #302's "current tab" -wording), keeping the Workbench tab available and reusing the existing new-tab -credential handoff. +## Decision -### The `DashboardOpenSource` contract +Workbench and Dashboard are surfaces of the same `/sql` application: -```ts -type DashboardOpenSource = - | { kind: 'current-workspace'; workspaceKey: string; dashboardId: string } - | { kind: 'session-bundle'; token: string; dashboardId: string }; +```text +/sql?ws=clickhouse_operations +/sql?ws=clickhouse_operations&surface=dashboard +/sql?ws=clickhouse_operations&surface=dashboard&mode=view ``` -Encoded as **query params on `/dashboard`** — never path segments — so the -pathname stays exactly `/dashboard` and `isDashboardRoute`/`configBase`/the OAuth -`redirect_uri` (all keyed on the `/dashboard` suffix) are untouched, and the -params survive `bootstrap`'s OAuth-param strip automatically. `?ws=&dash=` is -current-workspace; `?st=&dash=` is session-bundle. - -> **Critical constraint:** the session token param is **`st`**, never `state`. -> `bootstrap` (`src/main.ts`) reads `?state` as the OAuth CSRF value; a token -> there would raise "OAuth state mismatch". - -**Mode is discriminated by which store the `ws` key resolves in**, not by a -spoofable URL flag: the key present in the primary workspace store → edit mode; -present in the detached views store → view mode; in neither → a not-found panel -that executes nothing (never silently opens a different dashboard). - -### The one-time cross-tab state handoff - -"Open for viewing…" hands the dashboard *state* (not credentials) to the new tab -through a one-time IndexedDB token, then materializes a durable detached copy: - -1. Snapshot the current dashboard's dependency closure into a `PortableBundleV1` - (`buildDashboardExportBundle`). -2. Generate an unguessable 256-bit token (`crypto.getRandomValues`); write a - record `{ text: bundleJSON, dashboardId, detachedWorkspaceId, expiresAt }` to - a dedicated IndexedDB database (`asb-dashboard-handoff`) **before** opening the - tab. -3. `window.open('/dashboard?st=&dash=')` and grant the credential - handoff. -4. The new tab **atomically consumes + deletes** the record in one readwrite - transaction (`take` = get+delete, then reject if expired), and strips `st` - from the URL via `history.replaceState`. -5. It **materializes** the bundle into the detached store under - `detachedWorkspaceId`, assigns the detached copy a stable local key, rewrites - the URL to `?ws=&dash=`, - and renders read-only. - -Auth is orthogonal and unchanged: the new tab still restores credentials via the -#149 postMessage handoff, falling back to a normal OAuth relogin (which the -detached `?ws=` URL survives). - -## Consequences / deliberate evolutions of #288 - -- **"session-bundle is not bookmarkable" is refined, not violated.** The `?st=` - token URL is genuinely one-time and dead after consumption — not bookmarkable. - The *detached view it produces* (`?ws=`) is persistent and - bookmarkable/relogin-surviving. This is the intended product behavior: a - view-mode tab you can leave open, reload, and re-authenticate into. -- **External-file *viewing* + the untrusted-bundle "Run Dashboard on ``" - trust preflight are descoped.** External `.json` files go through #302's - transactional **Import Dashboard…** (validate + commit via the planner). - "Open for viewing…" detaches the workspace's *own*, already-trusted dashboard, - so there is no untrusted external SQL to gate. The viewer's existing safety - limits (row/byte caps, bounded concurrency, per-tile cancellation, stale-wave - protection, no Setup execution) still apply. -- **A new IndexedDB store family.** Two dedicated databases join the primary - workspace database (now `asb-workspaces-v2`): - `asb-dashboard-handoff` (one-time tokens) and `asb-dashboard-views` - (multi-record detached snapshots, keyed by workspace id, with a small retention - cap so abandoned views don't grow unbounded). Both reuse the #284 adapter - pattern (lazy cached open, single readwrite-txn atomicity) behind injected - seams, so the pure/seam logic stays 100%-covered and tests use in-memory fakes. -- **Read-only path stays clean.** The viewer path constructs no Workbench/editor - modules; the `check:arch` boundary guard + `dashboard-boundaries.test.js` are - extended to the new `dashboard/application` + `workspace` modules. -- **File-menu ownership clarified (#302).** The Workbench File menu owns - workspace/query operations only; Dashboard navigation moves to a Workbench - header `Dashboard →` control, and Dashboard import/export + "Open for viewing…" - move to a resource-scoped File menu on the Dashboard header. +The pure route contract owns `ws`, `surface`, and `mode`: + +- absent or unknown `surface` means Workbench; +- `surface=dashboard` means Dashboard; +- Dashboard edit is the default, so `mode=edit` is accepted but omitted from + canonical URLs; +- `mode=view` renders the same current workspace dashboard without mutation + controls; +- unrelated parameters are preserved until their owning flow consumes them. + +An explicit `ws` resolves exactly that workspace key. Failure renders +**Workspace not found** and never falls back. An implicit `/sql` open resolves +the last-used workspace, deterministically selects an existing workspace when +needed, or provisions the default workspace, then rewrites the URL with its +canonical key. + +Surface changes use same-tab `history.pushState()` so browser Back remains +useful. View/Edit changes use `history.replaceState()` so presentation toggles +do not pollute history. The Dashboard header exposes +`[Workspace | Dashboard] [View | Edit]`. + +Each workspace owns zero or one dashboard: + +- edit mode shows **Create dashboard** when none exists, but visiting does not + create one; +- view mode shows **This workspace has no dashboard** and executes no queries; +- view mode registers no reorder, resize, delete, layout-persistence, or other + authoring paths. + +## Superseded implementation + +The following original ADR decisions are intentionally retired: + +- pathname-based `/sql/dashboard` bootstrapping; +- default new-tab Workbench/Dashboard navigation; +- `DashboardOpenSource` current-workspace/session-bundle discrimination; +- the `st` one-time state transport parameter; +- `asb-dashboard-handoff` and `asb-dashboard-views`; +- detached workspace materialization and retention; +- **Open for viewing…** snapshot creation; +- store-membership-based edit/view discrimination; +- dashboard-specific cross-tab credential handoff. + +There is no migration requirement for those development-era URLs or IndexedDB +records. + +## Consequences + +- Edit and view always observe the same canonical workspace/dashboard data. +- A bookmarked view remains read-only in presentation, not authorization; the + local user can switch back to edit. +- Workbench and Dashboard share authentication, configuration, workspace + refresh, import/export, and query execution without parallel bootstrap + applications. +- Dashboard route resources are disposed when switching surfaces or rebuilding + the current surface; the Workbench shell likewise disposes signal and media + listeners before remounting. +- OAuth uses one `/sql` redirect URI. Callback cleanup retains route parameters + while removing only OAuth callback parameters. ## Alternatives considered -- **Same-tab navigation for `Dashboard →`** (#302 as literally written): rejected - by the owner in favor of a new tab, to keep the Workbench visible. -- **Ephemeral one-time view (strict original #288):** the view would not survive - relogin/close. Rejected — the owner wants a durable, detached view surface; - the token remains the *transport*, persistence is layered on top. -- **Path-segment routes (`/dashboard/ws/`):** rejected — breaks - `isDashboardRoute`/`configBase`/OAuth-redirect, all keyed on the `/dashboard` - suffix. -- **A `?view=1` mode flag:** rejected in favor of store-membership discrimination - (not spoofable, and naturally yields the not-found case). - -## Addendum — 2026-07-23: stable workspace keys and collection persistence (#406) - -`StoredWorkspaceV2` replaces the single fixed `current` aggregate with one -IndexedDB record per workspace. Workspace identity is deliberately split: - -- `id` is the immutable opaque object-store key; -- `key` is the immutable, unique lowercase ASCII URL identity resolved by - `?ws=`; -- `name` is mutable display metadata and never rewrites bookmarks. - -The primary store uses `id` as its key path and a unique `key` index. Create, -replace, delete, and last-opened metadata updates are transaction-scoped; -replace cannot create a missing record or alter its key. Portable workspace -import creates a fresh local ID/key instead of replacing the active workspace. - -Implicit opens persist the successfully opened key in a separate preferences -store and stamp store-owned `lastOpenedAt` metadata using the injected wall -clock. If that preference is absent or invalid, resolution chooses the valid -workspace with the newest timestamp, breaking ties by key; records without a -timestamp fall back deterministically by key. Explicit `?ws=` opens never fall -back to another workspace. - -The detached-view store follows the same key-based `?ws=` resolution contract, -while its generated local key remains separate from its opaque detached ID. +- **Durable detached snapshots:** rejected because they silently diverge from + the live workspace and require duplicate persistence and transport. +- **New-tab navigation by default:** rejected because it encourages concurrent + editing and makes Back navigation ineffective. +- **Path-segment routes:** rejected because workspace, surface, and mode are + independent application state and query parameters preserve the single SPA + handler and OAuth redirect. +- **View as authorization:** out of scope. View mode is a local presentation + choice, not an access-control boundary. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fdff9dc1..31a8e936 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -44,7 +44,7 @@ service is tested with plain stubs at the per-file coverage gate. | Module | Owns | |---|---| | `query-execution-service` (`app.exec`) | the shared request/stream/normalize read core + the script transport loop (retry classification, stop-on-first-failure, per-attempt `query_id`); stateless `kill(queryId)` — cancellation is caller-owned (`AbortController`s live with the owning session) | -| `connection-session` (`app.conn`) | auth + connection lifecycle: OAuth PKCE login/refresh, Basic probing, IdP config, identity, token storage, sign-out, the cross-tab dashboard auth handoff, and **the single live `chCtx` object** (mutated in place — `authConfirmed` by `net/ch-client`, `origin` by sign-in/handoff — never reconstructed) | +| `connection-session` (`app.conn`) | auth + connection lifecycle: OAuth PKCE login/refresh, Basic probing, IdP config, identity, token storage, sign-out, and **the single live `chCtx` object** (mutated in place — `authConfirmed` by `net/ch-client`, `origin` by sign-in — never reconstructed) | | `schema-catalog-service` (`app.catalog`) | server version, schema tree, lazy columns, SQL reference/completions, entity-doc cache, `invalidate()` | | `workbench-parameter-session` (`app.params`) | `{name:Type}` analysis/prepare/gate policy, input-vs-execute hardening, enum inference, recent values; reads the live shared `AppState` slices through accessors | | `export-service` (`app.exports`) | direct + script export behind an injectable `ExportSink` (`pickFile`/`pickDirectory`); hold-back exception inspection, `.partial` semantics, its own cancellation state | diff --git a/src/application/connection-session.ts b/src/application/connection-session.ts index 6085b68a..9180b78e 100644 --- a/src/application/connection-session.ts +++ b/src/application/connection-session.ts @@ -1,28 +1,20 @@ // #276 Phase 2's ConnectionSession — auth + config + ClickHouse connection -// lifecycle (OAuth PKCE login/refresh, Basic probing, IdP config resolution, -// cross-tab auth handoff), constructible without App/AppState/DOM; no imports +// lifecycle (OAuth PKCE login/refresh, Basic probing, IdP config resolution), +// constructible without App/AppState/DOM; no imports // from src/ui/** or src/editor/** (check:arch enforces). Rendering is the // shell's job — auth loss surfaces through the injected `onAuthLost` callback, -// never a render/toast call. This is a byte-for-byte port of the auth/config/ -// connection lifecycle that used to live inline in src/ui/app.ts (see that -// file's history around the OAuth/basic-auth/handoff sections) — comments +// never a render/toast call. This is a port of the auth/config/connection +// lifecycle that used to live inline in src/ui/app.ts — comments // below are carried over, adapted only where the code itself moved (e.g. // `app.token` becomes a closed-over local, `ss`/`loc`/`win` become the // injected `deps.storage`/`deps.location`/`deps.win`, and a couple of // `app.renderApp()`/`renderLoginApp()` calls are gone because rendering isn't -// this module's job any more — see each call site below for the specific -// note). `openDashboard` itself (opening the new tab) stays app-side; this -// module only owns the credential-grant half of the handoff. +// this module's job any more — see each call site below for the specific note). import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; import { generatePKCE, randomState } from '../core/pkce.js'; import type { PkceCrypto } from '../core/pkce.js'; -import { configBase } from '../core/dashboard.js'; import { resolveTarget } from '../core/target.js'; -import { - snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, AUTH_REQUEST, AUTH_GRANT, -} from '../core/auth-handoff.js'; -import type { AuthSnapshot, StorageLike } from '../core/auth-handoff.js'; import { buildAuthorizeUrl, refreshTokens, bearerFromTokens } from '../net/oauth.js'; import { memoizeConfig, loadConfigDoc, resolveIdp } from '../net/oauth-config.js'; import type { ConfigDoc, ResolvedIdpConfig, ChAuthKind } from '../net/oauth-config.js'; @@ -30,10 +22,10 @@ import type { ChCtx as NetChCtx, queryJson } from '../net/ch-client.js'; // ── Injected dependency seam ───────────────────────────────────────────────── -/** The sessionStorage-like surface this module needs — `core/auth-handoff.js`'s - * canonical `StorageLike` plus `removeItem` (the token/handoff bookkeeping - * below removes one-shot keys alongside `getItem`/`setItem`). */ -export interface SessionStorageLike extends StorageLike { +/** The sessionStorage-like surface used by OAuth PKCE token bookkeeping. */ +export interface SessionStorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; removeItem(key: string): void; } @@ -46,29 +38,15 @@ export type SessionCrypto = PkceCrypto; /** Every side effect this session needs, injected as a narrow bag — mirrors * `query-execution-service.ts`'s own `QueryExecutionDeps` seam. Production - * wires the real browser/env objects (`main.js`'s bootstrap resolves - * `handoffMs`/`handoffListenMs` defaults before constructing the session — - * this module never defaults them itself); tests inject plain stubs. */ + * wires the real browser/env objects; tests inject plain stubs. */ export interface ConnectionSessionDeps { fetch: typeof fetch; storage: SessionStorageLike; /** `href` is WRITTEN (the OAuth redirect assigns it) as well as read. */ location: { origin: string; pathname: string; search: string; href: string }; crypto: SessionCrypto; - /** Cross-tab auth-handoff listeners/timeouts. */ - win: Pick; /** Basic-auth sign-in probe. */ queryJson: typeof queryJson; - /** The child's own wait for a grant once it asks (a same-origin reply is - * near-instant, so this is short) — resolved by the caller; the historical - * 4000ms env default stays app-side. */ - handoffMs: number; - /** How long the opener keeps listening for a request — far longer than - * `handoffMs` because it must survive the child's cold JS load before the - * child can even ask; a short opener window would drop a slow tab's - * request and force a needless re-login. The historical 30000ms env - * default stays app-side. */ - handoffListenMs: number; /** Auth was lost (no token / expired-and-unrefreshable / CH rejected a * valid login). The session never renders — it calls this and lets the * shell decide how to show the login screen. */ @@ -134,13 +112,10 @@ export interface ConnectionSession { signOut(): void; ensureFreshToken(): Promise; - // cross-tab handoff (message contract: core/auth-handoff) - grantHandoffTo(child: Window): void; - receiveAuthHandoff(env: { opener?: Window | null }): Promise; } export function createConnectionSession(deps: ConnectionSessionDeps): ConnectionSession { - const { storage: ss, location: loc, crypto: cryptoObj, fetch: fetchFn, win, queryJson: queryJsonFn } = deps; + const { storage: ss, location: loc, crypto: cryptoObj, fetch: fetchFn, queryJson: queryJsonFn } = deps; // Two ways to be signed in: OAuth (a JWT bearer, the default) or 'basic' — // a ClickHouse username/password sent as Authorization: Basic, optionally @@ -156,12 +131,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection // config.json may list several IdPs. Fetch the doc once; resolve OIDC // discovery per selected IdP. The chosen IdP id is persisted so it survives // the OAuth redirect (like oauth_state) and drives token exchange/refresh. - // configBase strips a trailing `/dashboard` so config.json / OAuth discovery - // resolve from the SPA base (`/sql/config.json`) on the dashboard route too. - // The same base is the single source of truth for the workbench<->dashboard - // links (openDashboard, the dashboard's Back link) rather than hardcoding - // `/sql` in several shapes. - const basePath = configBase(loc.pathname); + const basePath = loc.pathname.replace(/\/+$/, ''); const loadDoc = memoizeConfig(() => loadConfigDoc(fetchFn, basePath)); const resolvedCache = new Map>(); let idpId: string | null = ss.getItem('oauth_idp') || null; @@ -220,7 +190,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection authMode = 'oauth'; chCtx.origin = loc.origin; chCtx.authConfirmed = false; // a fresh sign-in starts unconfirmed again - ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', 'oauth_origin', + ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_return_route', 'oauth_idp', 'oauth_origin', 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin'].forEach((k) => ss.removeItem(k)); } // `signOut` is exactly today's app.ts `clearTokens()` — no render. (app.ts's @@ -241,8 +211,15 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection const state = randomState(cryptoObj); ss.setItem('oauth_verifier', verifier); ss.setItem('oauth_state', state); + const returnParams = new URLSearchParams(loc.search); + ['code', 'state', 'scope', 'authuser', 'prompt', 'error', 'error_description', 'error_uri'] + .forEach((key) => returnParams.delete(key)); + const returnSearch = returnParams.toString(); + ss.setItem('oauth_return_route', JSON.stringify({ + state, search: returnSearch ? `?${returnSearch}` : '', + })); loc.href = buildAuthorizeUrl(cfg, { - redirectUri: loc.origin + loc.pathname, + redirectUri: loc.origin + basePath, challenge, state, }); @@ -363,77 +340,6 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection return !!(await getToken()); } - // One-time cross-tab auth handoff. The dashboard opens in a new same-origin - // tab whose sessionStorage starts empty; rather than force a second sign-in, - // this (opener) side grants its live credentials once when the child asks. - // Both sides pin the target origin AND the peer window; a timeout stops the - // opener listening if the child never asks. Message contract: core/auth-handoff. - // Two windows: the child waits `handoffMs` for a grant once it *asks* (a - // same-origin reply is near-instant, so this is short); the opener listens far - // longer (`handoffListenMs`) because it must survive the child's cold JS load - // before the child can ask — a short opener window would drop a slow tab's - // request and force a needless re-login. - function grantHandoffTo(child: Window): void { - const onMsg = (e: MessageEvent): void => { - if (!isAuthRequest(e, loc.origin, child)) return; - const creds = snapshotAuth(ss); - // Only grant when we actually hold credentials — never hand over an empty - // snapshot (which the child would have to reject anyway). - if (hasAuth(creds)) child.postMessage({ type: AUTH_GRANT, creds }, loc.origin); - win.removeEventListener('message', onMsg); - }; - win.addEventListener('message', onMsg); - win.setTimeout(() => win.removeEventListener('message', onMsg), deps.handoffListenMs); - } - - // Restore a handed-off credential snapshot into BOTH this tab's sessionStorage - // and the already-constructed in-memory auth fields — token/authMode/idp/origin - // were snapshotted from an empty ss at construction, so writing keys back alone - // wouldn't take effect until a reload. - function applyAuthSnapshot(creds: AuthSnapshot): void { - restoreAuth(ss, creds); - if (creds.ch_basic_auth) { - authMode = 'basic'; - chCtx.origin = creds.ch_basic_origin || loc.origin; - } else { - if (creds.oauth_id_token) setTokens(creds.oauth_id_token, creds.oauth_refresh_token); - if (creds.oauth_idp) idpId = creds.oauth_idp; - chCtx.origin = creds.oauth_origin || loc.origin; - } - } - // Child side: ask the opener for credentials once. Resolves true once a valid - // grant is applied; false when there's no opener or the request times out (a - // cold/bookmarked visit → the caller falls through to the normal login flow). - function receiveAuthHandoff(handoffEnv: { opener?: Window | null }): Promise { - return new Promise((resolve) => { - const opener = handoffEnv.opener; - if (!opener) { resolve(false); return; } - let done = false; - const finish = (ok: boolean): void => { - if (done) return; - done = true; - win.removeEventListener('message', onMsg); - resolve(ok); - }; - const onMsg = (e: MessageEvent): void => { - if (!isAuthGrant(e, loc.origin, opener)) return; - // `e.data` is a real handoff grant `{type, creds}` once isAuthGrant has - // confirmed `type` — `AuthMessageEvent`'s own contract type only pins - // `type` (the shared predicate's whole point); `creds` rides alongside. - const data = e.data as { type?: string; creds?: AuthSnapshot } | null; - // Ignore an empty grant (opener signed out / mid-sign-in) — keep waiting so - // the request times out into the normal login rather than falsely - // reporting success with no credentials applied. - if (!hasAuth(data?.creds)) return; - applyAuthSnapshot(data!.creds!); - finish(true); - }; - win.addEventListener('message', onMsg); - opener.postMessage({ type: AUTH_REQUEST }, loc.origin); - win.setTimeout(() => finish(false), deps.handoffMs); - }); - } - return { basePath, hostHint, @@ -461,7 +367,5 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection connectBasic, signOut, ensureFreshToken, - grantHandoffTo, - receiveAuthHandoff, }; } diff --git a/src/core/auth-handoff.ts b/src/core/auth-handoff.ts deleted file mode 100644 index b04aa5f2..00000000 --- a/src/core/auth-handoff.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Pure helpers for the one-time cross-tab auth handoff (#149 D1). No DOM. -// -// "Open as dashboard" opens a new same-origin tab whose sessionStorage starts -// empty. Rather than force a second sign-in, the opener grants its live -// credentials once via postMessage: the child requests them, the opener replies -// with a snapshot of its auth session keys, and the child restores them into its -// own (per-tab) sessionStorage. Everything here is pure — the postMessage wiring -// + origin/source checks live in the app controller (over injected window seams); -// these are the message contract, the key set, and the origin/source predicates, -// kept here so they are trivially 100% testable. - -/** A sessionStorage-like object: only the two methods this module uses. */ -export interface StorageLike { - getItem(key: string): string | null; - setItem(key: string, value: string): void; -} - -/** A snapshot of the present auth keys (only those that were set). */ -export type AuthSnapshot = Partial>; - -/** The sessionStorage keys that carry a live auth session (OAuth or basic). */ -export const AUTH_SS_KEYS: string[] = [ - 'oauth_id_token', 'oauth_refresh_token', 'oauth_idp', 'oauth_origin', - 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin', -]; - -/** postMessage `data.type` values for the handoff handshake. */ -export const AUTH_REQUEST = 'asb-auth-request'; -export const AUTH_GRANT = 'asb-auth-grant'; - -/** Read the present auth keys out of a sessionStorage-like object. */ -export function snapshotAuth(ss: StorageLike): AuthSnapshot { - const snap: AuthSnapshot = {}; - for (const k of AUTH_SS_KEYS) { - const v = ss.getItem(k); - if (v != null) snap[k] = v; - } - return snap; -} - -/** Write a snapshot's auth keys into a sessionStorage-like object. */ -export function restoreAuth(ss: StorageLike, snap: AuthSnapshot | null | undefined): void { - for (const k of AUTH_SS_KEYS) { - if (snap && snap[k] != null) ss.setItem(k, snap[k]); - } -} - -/** Does a snapshot carry usable credentials (an OAuth token or basic creds)? */ -export function hasAuth(snap: AuthSnapshot | null | undefined): boolean { - return !!(snap && (snap.oauth_id_token || snap.ch_basic_auth)); -} - -/** The subset of a MessageEvent this module's predicates read. */ -export interface AuthMessageEvent { - origin: string; - source: unknown; - data?: { type?: string } | null; -} - -/** A well-formed credential *request* from the expected origin + source window. */ -export function isAuthRequest(e: AuthMessageEvent | null | undefined, origin: string, source: unknown): boolean { - return !!e && e.origin === origin && e.source === source - && !!e.data && e.data.type === AUTH_REQUEST; -} - -/** A well-formed credential *grant* from the expected origin + source window. */ -export function isAuthGrant(e: AuthMessageEvent | null | undefined, origin: string, source: unknown): boolean { - return !!e && e.origin === origin && e.source === source - && !!e.data && e.data.type === AUTH_GRANT; -} diff --git a/src/core/dashboard.ts b/src/core/dashboard.ts index bc8fa392..1b37d41d 100644 --- a/src/core/dashboard.ts +++ b/src/core/dashboard.ts @@ -1,33 +1,12 @@ // Pure logic for the Dashboard view (#149). No DOM, no globals. // // A dashboard is "the favorited subset of the Library, rendered together" — no -// new schema. This module holds the route helpers and the tile result caps. +// new schema. This module holds legacy layout preference helpers and tile caps. // (Per-tile classification moved to core/panel-cfg.js's autoPanel/resolvePanel // in #166 — the panel union replaced classifyTile's chart-vs-skip ladder. The // tiles stream through the shared `app.exec.executeRead` seam as of #193/#276, so the // former `FORMAT JSON` → array-rows transform and its SQL prep were retired.) -/** - * True on the standalone dashboard route (a path ending in `/dashboard`, - * trailing slash ok). Matches on the `/dashboard` suffix rather than a pinned - * `/sql/dashboard` so it stays consistent with `configBase` (which strips the - * same suffix) and survives the SPA being mounted somewhere other than `/sql`. - * The server only serves the artifact at its SPA routes, so nothing unexpected - * reaches this predicate. - */ -export function isDashboardRoute(pathname?: string | null): boolean { - return /\/dashboard\/?$/.test(pathname || ''); -} - -/** - * The SPA base path for config.json / OAuth resolution, independent of the - * dashboard sub-route: `/sql/dashboard` → `/sql` so `loadConfigDoc` fetches - * `/sql/config.json` (not the non-existent `/sql/dashboard/config.json`). - */ -export function configBase(pathname?: string | null): string { - return (pathname || '').replace(/\/dashboard\/?$/, ''); -} - /** * Dashboard layout modes (#149 D2, #184): `arrange` = uniform multi-column grid * (default, column count from `dashCols`), `report` = single centered column diff --git a/src/core/handoff-token.ts b/src/core/handoff-token.ts deleted file mode 100644 index 41559ced..00000000 --- a/src/core/handoff-token.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Pure token generation for the one-time dashboard view handoff (#288/#302). -// No DOM, no globals — `crypto` is injected exactly like the OAuth PKCE -// verifier/state generation in `src/net/oauth.ts`, so this stays testable -// without a real `crypto` global. - -const HEX = '0123456789abcdef'; - -/** - * An unguessable 256-bit token, hex-encoded (64 lowercase hex chars). Backs - * the one-time `?st=` handoff URL param: the opener writes a `HandoffRecord` - * keyed by this token, the new tab consumes+deletes it exactly once. - * - * `crypto` is injected (the caller passes `env.crypto` or the real - * `globalThis.crypto`) so tests can supply a deterministic fake. Reads exactly - * 32 bytes via `getRandomValues(new Uint8Array(32))`. - */ -export function randomHandoffToken(crypto: Pick): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - let hex = ''; - for (let i = 0; i < bytes.length; i++) { - const byte = bytes[i]; - hex += HEX[(byte >> 4) & 0xf] + HEX[byte & 0xf]; - } - return hex; -} diff --git a/src/core/sql-route.ts b/src/core/sql-route.ts new file mode 100644 index 00000000..041bf8fe --- /dev/null +++ b/src/core/sql-route.ts @@ -0,0 +1,55 @@ +// Pure parser/builder for the single `/sql` application route (#407). +// The caller supplies `location.search`; this module owns only `ws`, `surface`, +// and `mode`, leaving OAuth callback and other application parameters intact. + +export type SqlRoute = + | { surface: 'workspace'; workspaceKey: string | null } + | { surface: 'dashboard'; workspaceKey: string | null; mode: 'edit' | 'view' }; + +/** Parse route-owned parameters into their normalized application meaning. */ +export function parseSqlRoute(search: string): SqlRoute { + const params = new URLSearchParams(search); + const workspaceKey = params.has('ws') ? (params.get('ws') ?? '') : null; + if (params.get('surface') === 'dashboard') { + return { + surface: 'dashboard', + workspaceKey, + mode: params.get('mode') === 'view' ? 'view' : 'edit', + }; + } + return { surface: 'workspace', workspaceKey }; +} + +/** + * Build a canonical search string for `route`, preserving every parameter this + * route contract does not own. The result is empty or begins with `?`. + */ +export function buildSqlRouteSearch(route: SqlRoute, currentSearch = ''): string { + const params = new URLSearchParams(currentSearch); + params.delete('ws'); + params.delete('surface'); + params.delete('mode'); + // Retired Dashboard snapshot route state (#407); never carry it forward. + params.delete('st'); + params.delete('dash'); + if (route.workspaceKey !== null) params.set('ws', route.workspaceKey); + if (route.surface === 'dashboard') { + params.set('surface', 'dashboard'); + if (route.mode === 'view') params.set('mode', 'view'); + } + const encoded = params.toString(); + return encoded ? `?${encoded}` : ''; +} + +/** Parse and canonicalize a raw search string in one step. */ +export function normalizeSqlRouteSearch(search: string): { route: SqlRoute; search: string } { + const route = parseSqlRoute(search); + return { route, search: buildSqlRouteSearch(route, search) }; +} + +/** Return the same route pointed at another workspace key. */ +export function routeForWorkspace(route: SqlRoute, workspaceKey: string): SqlRoute { + return route.surface === 'dashboard' + ? { surface: 'dashboard', workspaceKey, mode: route.mode } + : { surface: 'workspace', workspaceKey }; +} diff --git a/src/dashboard/application/dashboard-open-source.ts b/src/dashboard/application/dashboard-open-source.ts deleted file mode 100644 index a6ce585e..00000000 --- a/src/dashboard/application/dashboard-open-source.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Pure parsing/building of the standalone Dashboard route's open-source -// contract (#288/#302). No DOM, no globals — the caller supplies the raw URL -// search string (e.g. `location.search`) and gets back a discriminated union -// telling it which store to read the dashboard from. -// -// Two ways a dashboard tab can be opened (see the coordinator's two-mode -// model): EDIT mode resolves `?ws=&dash=` against the -// shared primary workspace store; VIEW mode's one-time handoff resolves -// `?st=&dash=` against the dedicated handoff store, then -// rewrites the URL to a persistent `?ws=` once materialized. `st` -// takes precedence over `ws` when both are present (a stale `ws` left over -// from history should never shadow a fresh handoff token). - -/** Which store a standalone Dashboard tab should resolve its dashboard from. */ -export type DashboardOpenSource = - | { kind: 'current-workspace'; workspaceKey: string; dashboardId: string } - | { kind: 'session-bundle'; token: string; dashboardId: string }; - -/** - * Parse a URL search string (e.g. `location.search`, with or without a leading - * `?`) into a `DashboardOpenSource`. Precedence: a non-empty `st` param wins as - * `session-bundle`; else a non-empty `ws` param is `current-workspace`; else - * `null` (the legacy bare `/dashboard` open, or garbage input). A missing - * `dash` param becomes `''` rather than failing parse — the discriminator only - * needs to know WHICH store to look in; a missing dashboard id inside that - * store is the caller's not-found case to handle. - * - * `state` is deliberately never read here — it is main.ts's OAuth CSRF param, - * and this contract does not use that name for its token (see the coordinator - * spec's URL param naming rule). - */ -export function parseDashboardOpenSource(search: string): DashboardOpenSource | null { - const params = new URLSearchParams(search); - const dashboardId = params.get('dash') ?? ''; - const token = params.get('st'); - if (token) return { kind: 'session-bundle', token, dashboardId }; - const workspaceKey = params.get('ws'); - if (workspaceKey) return { kind: 'current-workspace', workspaceKey, dashboardId }; - return null; -} - -/** - * Build the `?`-prefixed search string for a `DashboardOpenSource`, the - * inverse of `parseDashboardOpenSource`. `current-workspace` → `?ws=..&dash=..`; - * `session-bundle` → `?st=..&dash=..`. Values are URL-encoded by - * `URLSearchParams`. - */ -export function buildDashboardSearch(source: DashboardOpenSource): string { - const params = new URLSearchParams(); - if (source.kind === 'session-bundle') { - params.set('st', source.token); - } else { - params.set('ws', source.workspaceKey); - } - params.set('dash', source.dashboardId); - return '?' + params.toString(); -} diff --git a/src/dashboard/application/session-bundle.ts b/src/dashboard/application/session-bundle.ts deleted file mode 100644 index 3c8a0f8b..00000000 --- a/src/dashboard/application/session-bundle.ts +++ /dev/null @@ -1,125 +0,0 @@ -// Pure application glue for the standalone Dashboard route's view-mode -// session-bundle handoff (#288). Ties together the open-source contract -// (dashboard-open-source.ts), the one-time handoff record shape -// (workspace/handoff-store.types.ts), and the portable-bundle export/codec -// pair (dashboard-export.ts / portable-bundle-codec.ts) into three pure -// functions the coordinator (app.ts/dashboard.ts) calls around its impure -// IndexedDB/crypto/clock seams. -// -// NO DOM, NO globals, NO IndexedDB — those live in the coordinator. This -// module obeys the same import boundary as every other `dashboard/application` -// file: only `src/dashboard/**`, `src/workspace/**` types, `src/core/**`, and -// `src/generated/**`. - -import { buildDashboardExportBundle } from '../model/dashboard-export.js'; -import { decodePortableBundleJson, encodePortableBundleJson } from '../model/portable-bundle-codec.js'; -import { diagnostic } from '../model/workspace-diagnostics.js'; -import type { WorkspaceDiagnostic } from '../model/workspace-diagnostics.js'; -import type { HandoffRecord } from '../../workspace/handoff-store.types.js'; -import type { DashboardOpenSource } from './dashboard-open-source.js'; -import type { - DashboardDocumentV1, PortableBundleV1, SavedQueryV2, StoredWorkspaceV2, -} from '../../generated/json-schema.types.js'; -import { deriveWorkspaceKey, normalizeWorkspaceKeyLookup } from '../../core/workspace-key.js'; - -export type BuildViewHandoffRecordResult = - | { ok: true; record: HandoffRecord } - | { ok: false; diagnostics: WorkspaceDiagnostic[] }; - -/** - * Build the one-time handoff record for opening a Dashboard in view mode: - * snapshot `dashboard` plus its dependency closure (`buildDashboardExportBundle`) - * into a portable bundle, canonically encode it, and assemble the record the - * coordinator writes to the handoff store. `nowISO`/`expiresAt`/ - * `detachedWorkspaceId` are all supplied by the caller — every impure source - * (clock, id generator) lives in app.ts, never here. - */ -export function buildViewHandoffRecord( - dashboard: DashboardDocumentV1, - queries: readonly SavedQueryV2[], - opts: { detachedWorkspaceId: string; expiresAt: number; nowISO: string }, -): BuildViewHandoffRecordResult { - const bundle = buildDashboardExportBundle(dashboard, queries, opts.nowISO); - const encoded = encodePortableBundleJson({ - queries: bundle.queries, dashboards: bundle.dashboards, nowISO: bundle.exportedAt, - }); - if (!encoded.ok) return { ok: false, diagnostics: encoded.diagnostics }; - return { - ok: true, - record: { - text: encoded.value, - dashboardId: dashboard.id, - detachedWorkspaceId: opts.detachedWorkspaceId, - expiresAt: opts.expiresAt, - }, - }; -} - -export type MaterializeDetachedWorkspaceResult = - | { ok: true; workspace: StoredWorkspaceV2 } - | { ok: false; diagnostics: WorkspaceDiagnostic[] }; - -/** - * Materialize a consumed handoff record's bundle text into a detached, - * read-only `StoredWorkspaceV2`. Decodes and validates `text`, selects the - * Dashboard matching `dashboardId` out of the bundle (failing with a single - * diagnostic when absent), and assembles the detached workspace under - * `detachedWorkspaceId`. `name` is the selected Dashboard's title, falling - * back to `'Dashboard'` when the title is empty. - */ -export function materializeDetachedWorkspace( - text: string, dashboardId: string, detachedWorkspaceId: string, -): MaterializeDetachedWorkspaceResult { - const decoded = decodePortableBundleJson(text); - if (!decoded.ok) return { ok: false, diagnostics: decoded.diagnostics }; - const bundle: PortableBundleV1 = decoded.value; - const selected = bundle.dashboards.find((candidate) => candidate.id === dashboardId); - if (!selected) { - return { - ok: false, - diagnostics: [diagnostic(['dashboards'], 'dashboard-not-found', `Dashboard ${dashboardId} not found in bundle`)], - }; - } - return { - ok: true, - workspace: { - storageVersion: 2, - id: detachedWorkspaceId, - key: deriveWorkspaceKey(detachedWorkspaceId), - name: selected.title || 'Dashboard', - queries: bundle.queries, - dashboard: selected, - }, - }; -} - -/** Pure mode resolution result for a parsed `current-workspace` open source. */ -export type DashboardModeResolution = - | { mode: 'edit'; workspace: StoredWorkspaceV2 } - | { mode: 'view'; workspace: StoredWorkspaceV2 } - | { mode: 'not-found' }; - -const matches = ( - workspace: StoredWorkspaceV2 | null, source: Extract, -): workspace is StoredWorkspaceV2 => - !!workspace - && workspace.key === normalizeWorkspaceKeyLookup(source.workspaceKey) - && workspace.dashboard?.id === source.dashboardId; - -/** - * Resolve a parsed `current-workspace` `DashboardOpenSource` against the two - * candidate workspaces already loaded by the coordinator: `primary` (from the - * shared `asb-workspaces-v2` store) and `detached` (from the `asb-dashboard-views` - * detached-views store). Both the workspace key AND the Dashboard id must - * match — a workspace-key match with a differing (or missing) Dashboard id is - * `not-found`, never a silent fall-through to edit mode. - */ -export function resolveDashboardMode( - source: Extract, - primary: StoredWorkspaceV2 | null, - detached: StoredWorkspaceV2 | null, -): DashboardModeResolution { - if (matches(primary, source)) return { mode: 'edit', workspace: primary }; - if (matches(detached, source)) return { mode: 'view', workspace: detached }; - return { mode: 'not-found' }; -} diff --git a/src/env.types.ts b/src/env.types.ts index 131f876f..31ea8532 100644 --- a/src/env.types.ts +++ b/src/env.types.ts @@ -77,8 +77,6 @@ export interface CreateAppEnv { * `{ clipboard: { writeText } }` or omits the field entirely. */ navigator?: { clipboard?: Clipboard } & Record; download?: (filename: string, mime: string, content: BlobPart) => void; - handoffMs?: number; - handoffListenMs?: number; } /** The env param of `bootstrap(app, env)` — a distinct, fully-required shape @@ -88,5 +86,4 @@ export interface BootstrapEnv { sessionStorage: Storage; history: History; fetch: typeof fetch; - opener?: Window | null; } diff --git a/src/main.ts b/src/main.ts index c4b5072a..f50570b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; import { decodeShare } from './core/share.js'; import { cloneJson, queryName, queryPanel, queryView, upgradeSavedQuery } from './core/saved-query.js'; -import { isDashboardRoute } from './core/dashboard.js'; +import { parseSqlRoute } from './core/sql-route.js'; import { rolePreviewView } from './core/result-choice.js'; import { isQuerylessPanel } from './core/panel-cfg.js'; import { setTabSpecDraft, SAVED_VIEWS } from './state.js'; @@ -38,10 +38,11 @@ import type { ShortcutKeydownEvent } from './ui/shortcuts.js'; * its real name on `ConnectionSession`). */ export interface BootstrapApp { state: Pick; + catalog: { loadVersion(): Promise }; conn: Pick; - renderDashboard(): void; - renderApp(): void; + 'basePath' | 'isSignedIn' | 'resolveConfig' | 'setTokens' | 'ensureConfig'>; + renderCurrentSurface(): void; + 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 @@ -62,13 +63,11 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ const loc = env.location; const ss = env.sessionStorage; const hist = env.history; - // The standalone dashboard route (#149) reuses this same bootstrap + app: it - // shares the OAuth-callback handling below, but renders the dashboard instead - // of the workbench and skips editor-only share-link seeding. - const dash = isDashboardRoute(loc.pathname); + let dash = parseSqlRoute(loc.search).surface === 'dashboard'; const u = new URL(loc.href); const code = u.searchParams.get('code'); const stateParam = u.searchParams.get('state'); + const expectedState = ss.getItem('oauth_state'); const errorParam = u.searchParams.get('error'); let callbackError: string | null = null; @@ -77,7 +76,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // a code — surface it rather than dropping silently onto the login screen. callbackError = 'Sign-in failed: ' + (u.searchParams.get('error_description') || errorParam); } else if (code && stateParam) { - if (stateParam !== ss.getItem('oauth_state')) { + if (stateParam !== expectedState) { callbackError = 'OAuth state mismatch — please try again.'; } else { try { @@ -89,7 +88,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // behavior guard) keeps the exact pre-existing pass-through-null // runtime shape for a stale/direct hit with no stashed verifier. verifier: ss.getItem('oauth_verifier') as string, - redirectUri: loc.origin + loc.pathname, + redirectUri: loc.origin + app.conn.basePath, }); const bearer = bearerFromTokens(tokens, cfg.bearer); if (!bearer) throw new Error('Token response missing bearer token'); @@ -102,8 +101,27 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ if (errorParam || (code && stateParam)) { ['code', 'state', 'scope', 'authuser', 'prompt', 'error', 'error_description', 'error_uri'] .forEach((k) => u.searchParams.delete(k)); + if (stateParam && stateParam === expectedState) { + try { + const saved = JSON.parse(ss.getItem('oauth_return_route') || 'null') as { + state?: unknown; search?: unknown; + } | null; + if (saved?.state === stateParam && typeof saved.search === 'string') { + const restored = new URLSearchParams(saved.search); + for (const key of new Set(restored.keys())) u.searchParams.delete(key); + for (const [key, value] of restored) u.searchParams.append(key, value); + ss.removeItem('oauth_return_route'); + } + } catch { + // Invalid same-tab return metadata is ignored; callback validation and + // the normal implicit route remain authoritative. + } + } const qs = u.searchParams.toString(); - hist.replaceState(null, '', loc.origin + loc.pathname + (qs ? '?' + qs : '') + loc.hash); + const cleanedSearch = qs ? '?' + qs : ''; + hist.replaceState(null, '', loc.origin + loc.pathname + cleanedSearch + loc.hash); + app.syncSqlRoute(cleanedSearch); + dash = parseSqlRoute(cleanedSearch).surface === 'dashboard'; } // A shared query (SQL + complete Spec) rides in the URL hash, which is lost @@ -160,18 +178,6 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ } } - // A freshly-opened dashboard tab is signed out (per-tab sessionStorage); try a - // one-time credential handoff from the opener before deciding what to render. - // A cold/bookmarked visit has no opener → falls through to the login screen, - // which after sign-in returns to /sql/dashboard and renders the dashboard. - if (dash && !app.conn.isSignedIn()) { - await app.conn.receiveAuthHandoff(env); - // The opener may hand over an *expired* id_token whose refresh token is still - // good (an idle opener refreshes only lazily). Attempt a refresh before - // giving up — otherwise a valid handoff would still bounce to a full re-login. - if (!app.conn.isSignedIn()) await app.conn.ensureFreshToken(); - } - if (app.conn.isSignedIn()) { // Signed in either via a valid OAuth token or a restored basic session. ss.removeItem('oauth_shared'); // consumed @@ -179,15 +185,9 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // ch_auth=basic username, not the raw email claim) on first paint. // (ensureConfig is a no-op in basic mode.) await app.conn.ensureConfig(); - if (dash) app.renderDashboard(); - else { - // #287 W4: resolve + project the aggregate before the Workbench's first - // paint so the saved-query sidebar/Save flow already treat it as the - // single source of truth (the /dashboard branch above keeps resolving - // its own copy via `renderDashboard` → `loadDashboardWorkspace`). - await app.loadWorkspaceOnBoot(); - app.renderApp(); - } + await app.loadWorkspaceOnBoot(); + app.renderCurrentSurface(); + void app.catalog.loadVersion(); } else { app.showLogin(callbackError); } @@ -227,7 +227,6 @@ if (typeof document !== 'undefined' && !globalThis.__ASB_NO_AUTOSTART__) { sessionStorage: window.sessionStorage, history: window.history, fetch: window.fetch.bind(window), - opener: window.opener, // dashboard tab reads its opener for the auth handoff }); } /* c8 ignore stop */ diff --git a/src/styles.css b/src/styles.css index 0115f424..4c533a8b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1752,6 +1752,8 @@ body.detached-tab .graph-overlay-panel { box-shadow: 0 1px 2px rgba(0, 0, 0, .14); } .editor-mode-btn.is-disabled { opacity: .42; cursor: not-allowed; } +.app-surface-switch .editor-mode-btn { min-width: 0; white-space: nowrap; } +.dashboard-mode-switch { flex-shrink: 0; } .editor-mode-btn:focus-visible, .tb-btn:focus-visible, .run-btn:focus-visible { @@ -2374,7 +2376,14 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } /* Tighten the header's gap/padding so the decluttered controls fit the narrowest portrait phones (~360px), where the default 14px gaps alone still overflowed. */ - .app-header { gap: 8px; padding: 0 8px; } + .app-header { gap: 4px; padding: 0 6px; } + .app-header .editor-mode-switch { padding: 1px; gap: 1px; } + .app-header .editor-mode-btn { height: 20px; min-width: 0; padding: 0 5px; font-size: 10px; } + .app-header .hd-file-btn { padding: 0 4px; font-size: 11px; } + .app-header .lib-title { flex: 1 1 40px; overflow: hidden; } + .app-header .lib-name { max-width: 100%; padding: 0 3px; font-size: 11px; } + .app-header .hd-btn.user-btn { width: 26px; padding: 0 5px; } + .app-header .hd-btn.user-btn .user-short { display: none; } /* The desktop drawer handle owns this boundary. Restore a structural line when mobile hides that non-touch resize affordance. */ .cd-panel { border-left: 1px solid var(--border); } @@ -2482,20 +2491,14 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .docs-panel { width: 100vw !important; min-width: 0; } } -/* ── Dashboard (#149 D1) ─────────────────────────────────────────────────── - The standalone /sql/dashboard page: sticky header + a responsive grid of - read-only chart tiles rendered from favorited Library queries. */ +/* ── Dashboard (#149 D1 / #407) ───────────────────────────────────────────── + The `/sql?surface=dashboard` surface: sticky header + responsive tiles. */ /* The dashboard's own scroll container: #root is a fixed overflow:hidden flex column (the workbench shell), so the dashboard fills it and scrolls itself. */ .dash-page { height: 100%; overflow-y: auto; overflow-x: hidden; background: var(--bg); } -/* Header + layout toolbar share one sticky top bar (#149 D2) so the - Arrange/Report switcher stays visible while the grid scrolls. */ +/* The one-row application header and optional filter toolbar share one sticky + top bar so Dashboard controls stay visible while the grid scrolls. */ .dash-topbar { position: sticky; top: 0; z-index: 40; background: var(--bg-header); } -.dash-header { - display: flex; align-items: center; - gap: 12px; padding: 11px 20px; background: var(--bg-header); - border-bottom: 1px solid var(--border); flex-wrap: wrap; -} /* Filter toolbar (#149 D2, now filters-only since the layout switcher moved to the header — 2026-07-18). `flex-wrap: nowrap` at every width (#294): filters used to wrap the toolbar onto extra rows once several `{name:Type}` params @@ -2508,10 +2511,9 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } padding: 8px 20px; background: var(--bg-header); border-bottom: 1px solid var(--border); } .dash-toolbar:not(.has-filters) { display: none; } -/* The flow preset switcher (2026-07-18: a `.result-panel-select` dropdown in - the header's top row, replacing the old `.dash-seg` segmented button group - that lived in the filter toolbar — see dashboard.ts). */ -.dash-layout-wrap { display: inline-flex; align-items: center; gap: 8px; flex: 0 0 auto; } +/* The File-style layout menu in the header's top row, replacing the old + `.dash-seg` segmented button group that lived in the filter toolbar. */ +.dash-layout-wrap { display: inline-flex; align-items: center; flex: 0 0 auto; } /* Global filter bar (#149 D3) — one field per detected {name:Type}, reusing the workbench's var-field/var-name/var-input classes. #294: the field region IS its own horizontally-scrolling viewport @@ -2545,15 +2547,24 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } } .dash-back:hover { background: var(--bg-hover); color: var(--fg); } .dash-back svg { transform: scaleX(-1); } -.dash-title { font-size: 16px; font-weight: 700; color: var(--fg); letter-spacing: -.01em; } +.dash-title { + flex: 1 1 auto; min-width: 0; overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; + font-size: 12.5px; font-weight: 500; color: var(--fg); +} .dash-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--fg-mute); background: var(--bg-chip); padding: 3px 9px; border-radius: 20px; } -.dash-fav { color: var(--accent); } +.dash-fav { color: var(--accent); flex-shrink: 0; white-space: nowrap; } .dash-src { font-family: var(--mono); } .dash-dot { width: 6px; height: 6px; border-radius: 6px; background: #22C55E; } -.dash-updated { font-size: 11px; color: var(--fg-faint); font-family: var(--mono); } +.dash-updated { + flex-shrink: 0; white-space: nowrap; + font-size: 11px; color: var(--fg-faint); font-family: var(--mono); +} +.dash-refresh-wrap { flex-shrink: 0; } +.dash-refresh { width: 42px; padding: 0; white-space: nowrap; } .dash-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; background: var(--bg-chip); color: var(--fg); border: 1px solid var(--border); @@ -2771,9 +2782,6 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } their fixed-position popovers. These are CSS-only overrides, so widening restores the persisted desktop layout without writing preferences. */ @media (max-width: 768px) { - .dash-header { - flex-wrap: nowrap; gap: 8px; padding: 8px 10px; - } .dash-back { width: 30px; height: 30px; padding: 0; justify-content: center; flex-shrink: 0; } @@ -2782,15 +2790,12 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .dash-src, .dash-updated, .dash-spacer, - .dash-refresh-label, .dash-layout-wrap { display: none; } .dash-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .dash-icobtn, - .dash-refresh { width: 30px; height: 30px; flex-shrink: 0; } - .dash-refresh { padding: 0; justify-content: center; } + .dash-icobtn { width: 30px; height: 30px; flex-shrink: 0; } .dash-toolbar { padding: 6px 10px; } diff --git a/src/ui/app-header.ts b/src/ui/app-header.ts new file mode 100644 index 00000000..d0c93bb1 --- /dev/null +++ b/src/ui/app-header.ts @@ -0,0 +1,134 @@ +import { h } from './dom.js'; +import { Icon } from './icons.js'; +import { shortVersion, userShortName } from '../core/format.js'; +import { libraryControls } from './file-menu.js'; +import type { App } from './app.types.js'; + +export interface AppHeaderOptions { + /** Dashboard's route-owned controls, already wired to its live viewer + * session. Supplying this bag selects the compact one-row Dashboard header; + * Workbench continues to use the normal Library controls and status tail. */ + dashboardControls?: { + tileCount: HTMLElement; + fileButton: HTMLButtonElement; + style: HTMLElement | null; + title: HTMLElement; + updated: HTMLElement; + refresh: HTMLElement; + }; + /** Missing-dashboard routes have no viewer session, but still own the + * Dashboard-scoped File menu. */ + dashboardFileButton?: HTMLButtonElement; +} + +function routeButton( + label: string, active: boolean, onClick: () => void, +): HTMLButtonElement { + return h('button', { + class: `editor-mode-btn${active ? ' active' : ''}`, + 'aria-pressed': active ? 'true' : 'false', + disabled: active, + onclick: active ? undefined : onClick, + }, label); +} + +function surfaceSwitch(app: App): HTMLElement { + const key = app.currentWorkspace?.key ?? app.state.workspaceKey; + const dashboard = app.sqlRoute.surface === 'dashboard'; + return h('div', { + class: 'editor-mode-switch app-surface-switch', + role: 'group', 'aria-label': 'Application surface', + }, + routeButton('SQL Browser', !dashboard, () => { + void app.navigateSqlRoute({ surface: 'workspace', workspaceKey: key }, 'push'); + }), + routeButton('Dashboard', dashboard, () => { + void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: key, mode: 'edit' }, 'push'); + })); +} + +function dashboardModeSwitch(app: App): HTMLElement { + const route = app.sqlRoute as Extract; + const key = app.currentWorkspace?.key ?? route.workspaceKey; + return h('div', { + class: 'editor-mode-switch dashboard-mode-switch', + role: 'group', 'aria-label': 'Dashboard mode', + }, + routeButton('View', route.mode === 'view', () => { + void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: key, mode: 'view' }, 'replace'); + }), + routeButton('Edit', route.mode === 'edit', () => { + void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: key, mode: 'edit' }, 'replace'); + })); +} + +/** The one application header used by both Workbench and Dashboard. */ +export function buildAppHeader(app: App, options: AppHeaderOptions = {}): HTMLElement { + app.dom.themeBtn = h('button', { + class: 'hd-btn', title: 'Toggle theme', onclick: () => app.toggleTheme(), + }, app.state.theme === 'dark' ? Icon.sun() : Icon.moon()); + + const dashboard = app.sqlRoute.surface === 'dashboard'; + const prefix = [ + h('div', { class: 'logo-mark' }, Icon.brand()), + h('div', { class: 'logo-name' }, 'Altinity®'), + surfaceSwitch(app), + h('div', { class: 'env-chip' }, app.conn.host()), + ]; + if (dashboard) { + const controls = options.dashboardControls; + app.dom.libraryTitle = undefined; + app.dom.dashboardNav = undefined; + if (!controls) { + app.dom.fileBtn = options.dashboardFileButton; + return h('div', { class: 'app-header dashboard-app-header' }, + ...prefix, + options.dashboardFileButton!, + h('div', { + class: 'dash-title', title: app.state.libraryName.value, + }, app.state.libraryName.value), + dashboardModeSwitch(app), + h('div', { class: 'dash-spacer', style: { flex: '1' } }), + app.dom.themeBtn); + } + app.dom.fileBtn = controls.fileButton; + return h('div', { class: 'app-header dashboard-app-header' }, + ...prefix, + controls.tileCount, + controls.fileButton, + controls.style, + controls.title, + dashboardModeSwitch(app), + h('div', { class: 'dash-spacer', style: { flex: '1' } }), + controls.updated, + controls.refresh, + app.dom.themeBtn); + } + + const version = app.state.serverVersion; + app.dom.connStatus = h('div', { + class: `conn-status${version ? '' : ' dim'}`, + title: version ? `ClickHouse ${version}` : '', + }, h('span', { class: 'ver' }, version ? `ClickHouse ${shortVersion(version)}` : 'Connecting…')); + app.dom.userBtn = h('button', { + class: 'hd-btn user-btn', title: app.conn.email(), onclick: () => app.actions.openUserMenu(), + }, h('span', { class: 'user-short' }, userShortName(app.conn.email())), Icon.chevDown()); + const workspaceControls = libraryControls(app); + return h('div', { class: 'app-header' }, + ...prefix, + h('div', { class: 'hd-divider' }), + ...workspaceControls, + h('div', { style: { flex: '1' } }), + app.dom.connStatus, + h('a', { + class: 'hd-btn hd-hide-mobile', + href: 'https://github.com/Altinity/altinity-sql-browser/tree/main/examples', + target: '_blank', rel: 'noopener noreferrer', title: 'View examples', + }, Icon.github()), + h('button', { + class: 'hd-btn hd-hide-mobile', + title: 'Keyboard shortcuts (?)', onclick: () => app.actions.openShortcuts(), + }, Icon.shortcuts()), + app.dom.themeBtn, + app.dom.userBtn); +} diff --git a/src/ui/app.ts b/src/ui/app.ts index 24b0e816..c9ae64a2 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -39,7 +39,7 @@ import type { QueryOrName } from './tabs.js'; import { batch } from '@preact/signals-core'; import { renderResults } from './results.js'; import type { Result, QueryResult, ScriptResult, ScriptEntry } from './results.js'; -import { renderDashboard } from './dashboard.js'; +import { disposeDashboardSurface, renderDashboard } from './dashboard.js'; import { toggleThemeDom } from './theme-toggle.js'; import { openSchemaView } from './explain-graph.js'; import type { SchemaLineageNode, DetachedGraphApp } from './explain-graph.js'; @@ -77,17 +77,17 @@ import { createAppPreferences } from '../application/app-preferences.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 { createIndexedDbHandoffStore } from '../workspace/indexeddb-handoff-store.js'; -import { createIndexedDbDetachedViewsStore } from '../workspace/indexeddb-detached-views-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 { isDashboardRoute } from '../core/dashboard.js'; -import { parseDashboardOpenSource, buildDashboardSearch } from '../dashboard/application/dashboard-open-source.js'; -import { buildViewHandoffRecord, materializeDetachedWorkspace } from '../dashboard/application/session-bundle.js'; -import { randomHandoffToken } from '../core/handoff-token.js'; -import { exportDashboardAction, triggerImportDashboard, renderDashboardNav } from './file-menu.js'; +import { + buildSqlRouteSearch, normalizeSqlRouteSearch, parseSqlRoute, routeForWorkspace, +} from '../core/sql-route.js'; +import type { SqlRoute } from '../core/sql-route.js'; +import { + disposeFileMenuOverlays, exportDashboardAction, triggerImportDashboard, renderDashboardNav, +} from './file-menu.js'; import { createWorkbenchSession } from './workbench/workbench-session.js'; import { createQueryDocumentSession } from '../application/query-document-session.js'; import { createSavedQueryService } from '../application/saved-query-service.js'; @@ -128,12 +128,6 @@ interface WindowExtras { * `registerSpecValidator` action) that other modules never call directly. */ type AppSpecValidators = QuerySpecValidationService; -/** #288 Phase 6 — how long a one-time view-mode handoff token stays consumable - * (ADR-0003). Long enough to survive a cold viewer tab's OAuth round-trip, - * short enough to bound exposure; the real guarantee is delete-on-consume, not - * the TTL. 5 minutes. */ -const VIEW_HANDOFF_TTL_MS = 5 * 60_000; - export function createApp(env: CreateAppEnv = {}): App { const doc = env.document || document; const win = (env.window || window) as Window & WindowExtras; @@ -233,28 +227,26 @@ export function createApp(env: CreateAppEnv = {}): App { // 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 }); - // #288 Phase 6 — Dashboard viewing. Two dedicated IndexedDB stores (own - // databases, same injected factory + lazy-open pattern as the workspace - // store): `handoff` is the one-time cross-tab token transport that carries a - // view-mode Dashboard snapshot to a new tab; `detachedViews` is the - // persistent store that the consumed handoff materializes into (a read-only - // copy under its own fresh workspace id, detached from the editable primary - // workspace — see ADR-0003). `dashboardOpenSource` is THIS tab's parsed - // /dashboard route: `?ws=&dash=` (edit, current-workspace) or `?st=&dash=` - // (a one-time view handoff), or null on a bare/legacy `/dashboard` open. - app.handoff = createIndexedDbHandoffStore(env.indexedDB || win.indexedDB); - app.detachedViews = createIndexedDbDetachedViewsStore(env.indexedDB || win.indexedDB); - app.dashboardOpenSource = parseDashboardOpenSource(loc.search); - // Static per-tab flag: is THIS tab the standalone `/dashboard` route (vs the - // Workbench)? Lets shared post-commit logic (`afterLibraryChange`) repaint the - // right surface — a Dashboard-page import re-renders the dashboard, not the - // absent Workbench chrome. - app.dashboardRoute = isDashboardRoute(loc.pathname); - // #343 step 6: set true by `renderDashboard` only while a DETACHED/read-only - // Dashboard is on screen. A Workbench tab (and an editable Dashboard) leaves it - // false, so the cross-tab refresh below projects normally; a detached view - // flips it so refresh skips projecting the primary workspace over its snapshot. - app.dashboardReadOnly = false; + // #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.captureSurfaceGeneration = () => surfaceGeneration; + app.isSurfaceGenerationCurrent = (generation) => generation === surfaceGeneration; + app.refreshCurrentSurfaceAfterStale = (generation, committed = false) => { + if (generation === surfaceGeneration) return true; + const routeKey = app.sqlRoute.workspaceKey; + if (committed && app.workspaceRouteStatus === 'ready' && app.currentWorkspace + && (routeKey === null || routeKey === app.currentWorkspace.key)) { + app.renderCurrentSurface(); + } + return false; + }; // 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 @@ -389,19 +381,14 @@ export function createApp(env: CreateAppEnv = {}): App { // either way). const renderLoginApp = (msg?: string): void => renderLogin(app as App & { root: Element }, msg); // The auth + config + ClickHouse connection lifecycle (#276 Phase 2) — OAuth - // PKCE login/refresh, Basic probing, IdP config resolution, and cross-tab - // auth handoff all now live in `application/connection-session.ts`, + // PKCE login/refresh, Basic probing, and IdP config resolution live in + // `application/connection-session.ts`, // 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). The two - // handoff windows (how long the child waits for a grant vs. how long the - // opener keeps listening) are env-injectable seams whose defaults are - // documented in full on `ConnectionSessionDeps` itself. + // job — the session only ever calls `onAuthLost`, never renders). const conn = createConnectionSession({ - fetch: fetchFn, storage: ss, location: loc, crypto: cryptoObj, win, + fetch: fetchFn, storage: ss, location: loc, crypto: cryptoObj, queryJson: ch.queryJson, - handoffMs: env.handoffMs != null ? env.handoffMs : 4000, - handoffListenMs: env.handoffListenMs != null ? env.handoffListenMs : 30000, onAuthLost: (detail) => renderLoginApp(detail), }); app.conn = conn; @@ -1260,10 +1247,15 @@ export function createApp(env: CreateAppEnv = {}): App { // 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 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; } @@ -1291,6 +1283,7 @@ export function createApp(env: CreateAppEnv = {}): App { doc.body.appendChild(node); doc.addEventListener('keydown', onKey, true); doc.addEventListener('mousedown', onOutside, true); + anchoredPopoverClosers.add(close); return { close }; } @@ -1305,6 +1298,7 @@ export function createApp(env: CreateAppEnv = {}): App { } 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 @@ -1312,6 +1306,9 @@ export function createApp(env: CreateAppEnv = {}): App { // reads the latest committed aggregate at dequeue — no outer `serializeWrite` // wrapper needed (it would only double-queue). const result = await saved.commit(tab, evaluated); + 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) @@ -1414,10 +1411,12 @@ export function createApp(env: CreateAppEnv = {}): App { 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); + if (!app.refreshCurrentSurfaceAfterStale(surfaceGeneration, result.ok)) return; if (!result.ok) { if (result.diagnostics?.length) flashToast('Save failed: ' + result.diagnostics[0].message, { document: doc }); return; @@ -1505,9 +1504,9 @@ export function createApp(env: CreateAppEnv = {}): App { function toggleTheme(): void { // The shared DOM composition (state-flip + persist + `data-theme` + // icon swap) now lives in `ui/theme-toggle.ts`'s `toggleThemeDom` (#276 - // Phase 5) — both route shells (workbench header, dashboard) wire their - // own theme button to it; this thin wrapper is kept as `app.toggleTheme` - // solely for explain-graph.ts's detached schema-graph overlay, which + // Phase 5) — the shared application header wires its theme button to this + // thin `app.toggleTheme` wrapper, which is also kept solely for + // explain-graph.ts's detached schema-graph overlay, which // takes it as an optional callback (see theme-toggle.ts's own header // comment for why that one seam isn't mechanical to repoint). toggleThemeDom({ prefs, document: doc, themeBtn: () => app.dom.themeBtn }); @@ -1526,13 +1525,38 @@ export function createApp(env: CreateAppEnv = {}): App { // `runSlotTile`), the same path run() and the detached Data view use; the // former bespoke `runTile`/`queryDashboardTile`/`parseJsonResult` machinery // was retired so cap/settings fixes can't apply to only one path. - app.renderDashboard = () => renderDashboard(app); + let disposeWorkbenchMount: (() => void) | null = null; + const ignoreExternalWorkspaceChange = (): void => {}; + app.renderDashboard = () => { + surfaceGeneration += 1; + closeAnchoredPopovers(); + disposeFileMenuOverlays(app); + disposeWorkbenchMount?.(); + disposeWorkbenchMount = null; + workbench.destroy(); + return renderDashboard(app); + }; + const disposeCurrentSurface = (): void => { + surfaceGeneration += 1; + for (const control of app.root?.querySelectorAll( + 'button, input, select, textarea', + ) ?? []) control.disabled = true; + closeAnchoredPopovers(); + disposeFileMenuOverlays(app); + disposeDashboardSurface(); + disposeWorkbenchMount?.(); + disposeWorkbenchMount = null; + workbench.destroy(); + app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; + }; // Project the active StoredWorkspaceV2 onto the current application surface. // Persistence is now a collection; this projection identifies which record // this tab is editing, while the repository remains independently addressable // by immutable id and stable URL key. const applyCommittedWorkspace = (workspace: StoredWorkspaceV2): void => { + app.currentWorkspace = workspace; + app.workspaceRouteStatus = 'ready'; const workspaceChanged = app.state.workspaceId !== workspace.id; if (workspaceChanged) detachWorkspaceBoundTabs(app.state); app.state.savedQueries = workspace.queries; @@ -1595,11 +1619,11 @@ export function createApp(env: CreateAppEnv = {}): App { 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 — the standalone - // Dashboard route (a later step) overrides this to rebuild its viewer session + // 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 = () => {}; + 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'); @@ -1620,29 +1644,70 @@ export function createApp(env: CreateAppEnv = {}): App { // 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) => app.serializeWrite(async () => { - const loaded = await app.workspace.loadById(app.state.workspaceId); - if (loaded.status === 'corrupt') { - return { ok: false as const, diagnostics: loaded.diagnostics }; - } - const latest = loaded.status === 'ok' ? loaded.workspace : null; - const input = await transform(latest); - if (!input || !input.candidate) { - return { ok: false as const, aborted: true as const, data: input ? input.data : undefined }; - } - const result = await app.workspace.commit(input.candidate); - if (!result.ok) return { ok: false as const, diagnostics: result.diagnostics, data: input.data }; - app.applyCommittedWorkspace(result.workspace); // #343: also records lastCommittedToken - if (workspaceChannel) { - workspaceChannel.postMessage({ - type: 'workspace-changed', sourceTabId, workspaceId: result.workspace.id, - }); + 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 { - ok: true as const, workspace: result.workspace, - dashboardRevision: result.dashboardRevision, data: input.data, - }; - }); + 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') { + 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 @@ -1661,34 +1726,37 @@ export function createApp(env: CreateAppEnv = {}): App { // 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 => { - // #343 step 6: a detached/read-only Dashboard renders from a detached - // snapshot, not the primary workspace — primary-workspace invalidation must - // never project over it (it would clobber `app.state` with a different - // aggregate). The Dashboard route sets `dashboardReadOnly` per render; a - // Workbench tab and an editable Dashboard leave it false. - if (app.dashboardReadOnly) return; + const requestedWorkspaceId = app.state.workspaceId; + const requestedRouteGeneration = routeLoadGeneration; let loaded: StoredWorkspaceV2 | null; try { - const result = await app.workspace.loadById(app.state.workspaceId); + 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). An external - // clear (had a workspace, now empty) also leaves the projection untouched. - if (workspaceToken(loaded) === lastCommittedToken || !loaded) return; + // 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. The standalone Dashboard route has no Workbench - // chrome — it reacts through the `onWorkspaceExternallyChanged` hook instead. - if (!app.dashboardRoute) { + // Workbench surface repaint. Dashboard reacts through the + // `onWorkspaceExternallyChanged` hook instead. + if (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. @@ -1758,18 +1826,6 @@ export function createApp(env: CreateAppEnv = {}): App { } }; - app.loadDashboardWorkspace = async (key?: string, dashboardId?: string) => { - const result = key - ? await app.workspace.loadByKey(key) - : await resolveImplicitOrProvision(); - if (result.status !== 'ok') return null; - // A keyed Dashboard route is valid only when both parts of its identity - // resolve. Do not make a workspace "last used" for a not-found Dashboard. - if (dashboardId !== undefined && result.workspace.dashboard?.id !== dashboardId) return null; - await recordOpened(result.workspace); - return result.workspace; - }; - const resetCorruptWorkspace = async (id: string): Promise => { const deleted = await app.workspace.delete(id); if (!deleted.ok) return; @@ -1777,17 +1833,31 @@ export function createApp(env: CreateAppEnv = {}): App { if (result.status === 'ok') { applyCommittedWorkspace(result.workspace); await recordOpened(result.workspace); - app.renderApp(); + app.sqlRoute = routeForWorkspace(app.sqlRoute, result.workspace.key); + routeSearch = buildSqlRouteSearch(app.sqlRoute, routeSearch); + win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); + 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 workspaceParams = new URLSearchParams(loc.search); - const explicitKey = workspaceParams.has('ws') ? (workspaceParams.get('ws') ?? '') : null; + 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.', { @@ -1797,103 +1867,111 @@ export function createApp(env: CreateAppEnv = {}): App { ); return null; } - const workspace = result.status === 'ok' ? result.workspace : null; - if (workspace) { - applyCommittedWorkspace(workspace); - await recordOpened(workspace); + 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 || '')); } return workspace; }; - // Open the dashboard in a new EDIT-mode tab (#288/#302): the route carries - // the current workspace + dashboard ids (`?ws=&dash=`) so the viewer verifies - // both and shares the primary workspace store with this Workbench tab. We - // stand ready to hand it our credentials — the cross-tab auth-handoff GRANT - // side is the session's job (`conn.grantHandoffTo`, #276 Phase 2); this stays - // app-side only because opening the tab (window.open) is a DOM/browser - // concern. A workspace with no dashboard opens the bare route (legacy). + const renderWorkspaceNotFound = (): void => { + disposeCurrentSurface(); + app.root?.replaceChildren(h('main', { class: 'workspace-not-found' }, + h('h1', null, 'Workspace not found'), + h('p', null, `No local workspace exists for “${app.sqlRoute.workspaceKey ?? ''}”.`), + h('a', { href: conn.basePath || '/sql' }, 'Open the last-used workspace'))); + }; + + const renderWorkspaceLoading = (): void => { + disposeCurrentSurface(); + app.root?.replaceChildren(h('main', { + class: 'workspace-loading', 'aria-busy': 'true', 'aria-live': 'polite', + }, 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) => { + const workspaceChanged = route.workspaceKey !== app.sqlRoute.workspaceKey; + writeRoute(route, method); + if (workspaceChanged) { + app.workspaceRouteStatus = 'loading'; + app.currentWorkspace = null; + renderWorkspaceLoading(); + const expectedGeneration = routeLoadGeneration + 1; + await app.loadWorkspaceOnBoot(); + if (routeLoadGeneration !== expectedGeneration) return; + } + app.renderCurrentSurface(); + }; + + app.handleSqlPopState = async () => { + const previousKey = app.sqlRoute.workspaceKey; + routeSearch = loc.search; + app.sqlRoute = parseSqlRoute(routeSearch); + if (app.sqlRoute.workspaceKey === previousKey) { + disposeCurrentSurface(); + app.renderCurrentSurface(); + return; + } + app.workspaceRouteStatus = 'loading'; + app.currentWorkspace = null; + renderWorkspaceLoading(); + const expectedGeneration = routeLoadGeneration + 1; + await app.loadWorkspaceOnBoot(); + if (routeLoadGeneration !== expectedGeneration) return; + app.renderCurrentSurface(); + }; + app.syncSqlRoute = (search) => { + routeSearch = search; + app.sqlRoute = parseSqlRoute(search); + }; + app.rewriteWorkspaceRoute = (workspaceKey) => { + writeRoute(routeForWorkspace(app.sqlRoute, workspaceKey), 'replace'); + }; + + // Workbench -> Dashboard stays in this tab and creates one useful history + // entry. Dashboard edit is canonical, so `mode=edit` is omitted. function openDashboard(): void { - const dashId = app.state.dashboard?.id; - const wsKey = app.state.workspaceKey; - const search = (wsKey && dashId) - ? buildDashboardSearch({ kind: 'current-workspace', workspaceKey: wsKey, dashboardId: dashId }) - : ''; - const child = app.openWindow(loc.origin + conn.basePath + '/dashboard' + search); - if (child) conn.grantHandoffTo(child); + void app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: app.state.workspaceKey, mode: 'edit', + }, 'push'); } app.openDashboard = openDashboard; - // #288 Phase 6 — open the current dashboard in a new READ-ONLY VIEW-mode tab - // via the one-time IndexedDB token handoff (ADR-0003). The token is generated - // synchronously so the child tab can be opened in the SAME user-gesture task - // (popup-safe) already pointed at `?st=&dash=`; the validated bundle is - // written to the handoff store in parallel, and the viewer retries the - // one-time `take` briefly to cover the write/read race. The detached workspace - // id minted here is what the consumed handoff materializes under, detached - // from (and unaffected by later edits to) this primary workspace. - function openDashboardForViewing(): void { - const dashboard = app.state.dashboard; - if (!dashboard) { flashToast('No dashboard to view', { document: doc }); return; } - const token = randomHandoffToken(cryptoObj); - const detachedWorkspaceId = uid('wsview-'); - const built = buildViewHandoffRecord(dashboard, app.state.savedQueries, { - detachedWorkspaceId, expiresAt: wallNow() + VIEW_HANDOFF_TTL_MS, - nowISO: new Date(wallNow()).toISOString(), - }); - if (!built.ok) { flashToast('✕ ' + (built.diagnostics[0]?.message || 'Could not prepare dashboard for viewing'), { document: doc }); return; } - const search = buildDashboardSearch({ kind: 'session-bundle', token, dashboardId: dashboard.id }); - const child = app.openWindow(loc.origin + conn.basePath + '/dashboard' + search); - // A blocked popup means nothing will ever consume the token — don't write a - // (potentially multi-MB) orphan record the store never sweeps. Only persist - // the handoff once we know a viewer tab is actually opening. - if (!child) { flashToast('Allow pop-ups to open the dashboard view', { document: doc }); return; } - conn.grantHandoffTo(child); - // Write AFTER opening the child (open must stay in the gesture task); the - // child only reads the token after a full load + auth handoff, well after - // this fast IndexedDB write lands. - app.handoff.put(token, built.record).catch(() => { - flashToast('✕ Could not prepare dashboard for viewing', { document: doc }); - }); - } - app.openDashboardForViewing = openDashboardForViewing; - - // #288 Phase 6 — the VIEW-mode viewer's side of the one-time handoff - // (ADR-0003). Atomically consume this tab's `?st=` token, materialize the - // carried bundle into the persistent detached store under its own id, then - // rewrite the URL to the durable `?ws=&dash=` form (dropping the - // dead token) so a relogin/reload re-opens the detached view rather than a - // spent token. Returns the detached workspace, or null (missing/expired - // token, or an undecodable bundle) → the viewer shows not-found. The opener - // writes the token before opening this tab, and this tab only reaches here - // after a full load + auth handoff, so the record is present by now. - app.consumeDashboardHandoff = async () => { - const src = app.dashboardOpenSource; - if (!src || src.kind !== 'session-bundle') return null; - const record = await app.handoff.take(src.token, wallNow()); - if (!record) return null; - const materialized = materializeDetachedWorkspace(record.text, record.dashboardId, record.detachedWorkspaceId); - if (!materialized.ok) return null; - await app.detachedViews.put({ workspace: materialized.workspace, savedAt: wallNow() }); - const search = buildDashboardSearch({ - kind: 'current-workspace', workspaceKey: materialized.workspace.key, dashboardId: record.dashboardId, - }); - win.history.replaceState(null, '', loc.origin + conn.basePath + '/dashboard' + search); - app.dashboardOpenSource = parseDashboardOpenSource(search); - return materialized.workspace; - }; - - // #302 — after an import committed FROM the standalone Dashboard page, point - // the tab's URL at the (possibly new) current dashboard id and re-render the - // viewer. Import replaces the current dashboard (new id), so the pre-import - // URL's `dash=` would otherwise fail the viewer's strict id verification. app.reloadDashboardRoute = () => { - const dash = app.state.dashboard; - const wsKey = app.state.workspaceKey; - if (dash && wsKey) { - const search = buildDashboardSearch({ kind: 'current-workspace', workspaceKey: wsKey, dashboardId: dash.id }); - win.history.replaceState(null, '', loc.origin + conn.basePath + '/dashboard' + search); - app.dashboardOpenSource = parseDashboardOpenSource(search); - } + app.currentWorkspace = app.currentWorkspace + ? { ...app.currentWorkspace, queries: app.state.savedQueries, dashboard: app.state.dashboard } + : null; app.renderDashboard(); }; @@ -1911,7 +1989,12 @@ export function createApp(env: CreateAppEnv = {}): App { // 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) => { await conn.connectBasic(input); await app.loadWorkspaceOnBoot(); app.renderApp(); }, + connect: async (input) => { + await conn.connectBasic(input); + await app.loadWorkspaceOnBoot(); + app.renderCurrentSurface(); + void app.catalog.loadVersion(); + }, share, copyResult, // `ActionsRegistry.copySnapshot`'s public `result: Json | null` is looser @@ -1942,7 +2025,6 @@ export function createApp(env: CreateAppEnv = {}): App { openCreateInNewTab: (target, name) => openCreateInNewTab(target, name), openShortcuts: () => openShortcuts(app), openDashboard, - openDashboardForViewing, // #302: Dashboard import/export invoked from the Dashboard page's own File // menu (and still from the Workbench during the transition). Export is a // read-only bundle download; import runs the transactional planner and, on @@ -1959,7 +2041,18 @@ export function createApp(env: CreateAppEnv = {}): App { updateSaveBtn: () => app.updateSaveBtn(), }; - app.renderApp = () => renderApp(app, { toggleTheme, startDrag }); + app.renderApp = () => { + surfaceGeneration += 1; + closeAnchoredPopovers(); + disposeFileMenuOverlays(app); + disposeDashboardSurface(); + app.onWorkspaceExternallyChanged = ignoreExternalWorkspaceChange; + disposeWorkbenchMount?.(); + disposeWorkbenchMount = renderApp(app, { toggleTheme, startDrag }); + }; + if (typeof win.addEventListener === 'function') { + win.addEventListener('popstate', () => { void app.handleSqlPopState(); }); + } return app; } @@ -1978,8 +2071,8 @@ export interface RenderAppHelpers { * byte-identically, driven by a narrow `WorkbenchShellDeps` bag instead of * the full `App` — see that module's header comment for what stays coupled * to `app` and why. */ -export function renderApp(app: App, helpers: RenderAppHelpers): void { - mountWorkbenchShell({ +export function renderApp(app: App, helpers: RenderAppHelpers): () => void { + return mountWorkbenchShell({ app, root: app.root, document: app.document, diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 8a104635..8e4dc095 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -21,11 +21,9 @@ import type { SchemaGraphSession } from '../application/schema-graph-session.js' import type { AppPreferences } from '../application/app-preferences.js'; import type { WorkspaceRepository } from '../workspace/workspace-repository.js'; import type { WorkspaceDiagnostic } from '../dashboard/model/workspace-diagnostics.js'; -import type { HandoffStore } from '../workspace/handoff-store.types.js'; -import type { DetachedViewsStore } from '../workspace/detached-views-store.types.js'; -import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; import type { StoredWorkspaceV2 } 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 { DynamicSources } from '../core/spec-completion.js'; import type { WorkbenchSession } from './workbench/workbench-session.js'; import type { WorkbenchParameterSession } from '../application/workbench-parameter-session.js'; @@ -108,8 +106,8 @@ export interface AppDom { fileBtn?: HTMLElement; fileDialog?: HTMLElement; libraryTitle?: HTMLElement; - /** #302 — the Workbench header "Dashboard →" nav control (shown only when the - * current workspace has a Dashboard). */ + /** Workbench's post-workspace `Dashboard →` shortcut; Dashboard replaces + * this slot with the View/Edit segmented control. */ dashboardNav?: HTMLElement; qtabsInner?: HTMLElement; resultsRegion?: HTMLElement; @@ -206,8 +204,6 @@ export interface ActionsRegistry { openCreateInNewTab(target: string, name?: string): Promise; openShortcuts(): void; openDashboard(): void; - /** #288 Phase 6 — open the current dashboard read-only in a new view tab. */ - openDashboardForViewing(): void; /** #302 — export the current dashboard's dependency closure as a bundle. */ exportDashboard(): void; /** #302 — import a Dashboard bundle through the transactional planner. */ @@ -227,8 +223,8 @@ export interface App { document: Document; /** The auth + config + ClickHouse connection lifecycle (#276 Phase 2) — - * OAuth PKCE login/refresh, Basic probing, IdP config resolution, and - * cross-tab auth handoff, constructible without App/AppState/DOM + * OAuth PKCE login/refresh, Basic probing, and IdP config resolution, + * constructible without App/AppState/DOM * (`src/application/connection-session.ts`). The identity/auth members * below (`isSignedIn`/`email`/`host`/…) are Phase-2 delegates onto this — * shells/bootstrap consume those; a future phase re-points them to @@ -279,8 +275,8 @@ export interface App { // `chAuth`/`basicUserClaim`/`idpId`/`selectIdp`/`chUsername` likewise moved // there in Phase 2; the flat `App` delegates that used to forward onto them // (`isSignedIn`/`email`/`host`/`hostHint`/`basePath`/`setTokens`/ - // `loadConfig`/`loadIdps`/`ensureConfig`/`ensureFreshToken`/`chCtx`/ - // `receiveAuthHandoff`) were deleted in #276 Phase 5 — every consumer reads + // `loadConfig`/`loadIdps`/`ensureConfig`/`ensureFreshToken`/`chCtx`) were + // deleted in #276 Phase 5 — every consumer reads // `app.conn.*` directly now. `showLogin`/`signOut` stay here: they compose // rendering (`renderLoginApp`), not pure forwards. activeTab(): Tab; @@ -313,21 +309,6 @@ export interface App { * favorites-driven Dashboard render still reads legacy keys; Phases 3-6 of * #280 route reads/commits through it and retire the legacy keys. */ workspace: WorkspaceRepository; - /** #288 Phase 6 — the one-time cross-tab token store backing VIEW-mode - * Dashboard handoff (ADR-0003): the opener writes a validated snapshot - * bundle under an unguessable token before opening the viewer tab, which - * atomically consumes (get+delete) and materializes it. Injected IndexedDB - * seam, own database. */ - handoff: HandoffStore; - /** #288 Phase 6 — the persistent detached-views store a consumed handoff - * materializes into: a read-only Dashboard copy under its own fresh - * workspace id, detached from the editable primary workspace so it survives - * relogin/reload and is unaffected by later Workbench edits (ADR-0003). */ - detachedViews: DetachedViewsStore; - /** #288 Phase 6 — THIS tab's parsed `/dashboard` open source: `?ws=&dash=` - * (edit, current-workspace) or `?st=&dash=` (a one-time view handoff), or - * null on a bare/legacy `/dashboard` open. Read by `renderDashboard`. */ - dashboardOpenSource: DashboardOpenSource | null; saveJSON(key: string, value: unknown): void; saveStr(key: string, value: string): void; /** The one deliberate delegate survivor of #276 Phase 5's params-group @@ -442,36 +423,33 @@ export interface App { // Rendering / lifecycle. renderApp(): void; renderDashboard(): void; + renderCurrentSurface(): void; openDashboard(): void; - /** #288 Phase 6 — open the current dashboard in a read-only VIEW-mode tab via - * the one-time IndexedDB token handoff (ADR-0003). */ - openDashboardForViewing(): void; - /** #288/#302 — true when THIS tab is the standalone `/dashboard` route (set - * once from the pathname). Lets shared post-commit logic repaint the right - * surface. */ - dashboardRoute: boolean; - /** #343 step 6 — true while the standalone Dashboard route is showing a - * DETACHED/read-only view (a `?st=` handoff or a `?ws=` id that resolved only - * in the detached-views store). Such a view renders from a detached snapshot, - * not the primary workspace, so `refreshWorkspaceFromStore` must NOT project - * primary-workspace invalidation over it. A Workbench tab and an editable - * Dashboard leave it `false`; `renderDashboard` sets it per render. */ - dashboardReadOnly: boolean; - /** #302 — repaint the standalone Dashboard route after an in-tab import: point - * the URL at the (possibly new) current dashboard id, then re-render. */ + /** Current canonical `/sql` route and the live workspace resolved for it. */ + sqlRoute: SqlRoute; + currentWorkspace: StoredWorkspaceV2 | null; + workspaceRouteStatus: 'loading' | 'ready' | 'not-found' | 'error'; + /** Renderer lifetime, distinct from workspace-load ordering. Any surface + * teardown/remount advances it so obsolete async callbacks can finish their + * durable work without settling against a replacement renderer. */ + captureSurfaceGeneration(): number; + isSurfaceGenerationCurrent(generation: number): boolean; + /** Return true while the caller still owns the mounted renderer. A stale + * caller gets no settlement rights; after a successful durable commit, the + * 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. */ + 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. */ reloadDashboardRoute(): void; - /** #288 Phase 6 — the VIEW-mode viewer's side of the one-time handoff: consume - * this tab's `?st=` token, materialize the carried bundle into the persistent - * detached store, rewrite the URL to the durable `?ws=` form, and return the - * detached workspace (or null when the token is missing/expired/undecodable). - * ADR-0003. */ - consumeDashboardHandoff(): Promise; - /** Resolve the keyed or implicit StoredWorkspaceV2 for the Dashboard viewer. - * Returns null when no matching workspace (or requested Dashboard id) is - * available. */ - loadDashboardWorkspace(key?: string, dashboardId?: string): Promise; - /** #287 W4: the async boot-init step — runs `loadDashboardWorkspace` - * (resolve the explicit key or implicit last-opened workspace) and, when it + /** Resolve the explicit or implicit route workspace and, when it * resolves a real aggregate, PROJECTS it onto `state` (`savedQueries`, * `dashboard`, `workspaceId`, `libraryName`) so the whole app (not only * the /dashboard route) treats the aggregate as the saved-query diff --git a/src/ui/dashboard.ts b/src/ui/dashboard.ts index 1d330c9e..5b9605e9 100644 --- a/src/ui/dashboard.ts +++ b/src/ui/dashboard.ts @@ -1,9 +1,8 @@ -// The read-only Dashboard page (#149 / #240 / #280 / #286). Phase 4 of #280 +// The live Dashboard surface (#149 / #240 / #280 / #286 / #407). Phase 4 of #280 // FLIPS Dashboard membership reads off `spec.favorite` and onto // `dashboard.tiles[]`: this module resolves the current `StoredWorkspaceV2` -// (via `app.loadDashboardWorkspace()` — the phase-2 repository, migrating the -// legacy favorites/layout keys once when no aggregate exists), constructs a -// standalone `DashboardViewerSession` over that document + the workspace +// from `app.currentWorkspace`, constructs a `DashboardViewerSession` over that +// document + the workspace // queries, and renders the DOM from the session's `state` signal. The heavy // runtime — presentation resolution, the filter/tile execution waves (with // #235 parallelism), bounded concurrency, per-tile cancellation, and the @@ -31,6 +30,7 @@ import { effect } from '@preact/signals-core'; import { h } from './dom.js'; import { Icon as IconUntyped } from './icons.js'; +import { buildAppHeader } from './app-header.js'; import { openMenu } from './menu.js'; import type { MenuHandle, MenuRow } from './menu.js'; import { flashToast } from './toast.js'; @@ -76,8 +76,8 @@ import type { GrafanaGridLayoutModel, GridRenderMode } from '../dashboard/layout import { applyCommand } from '../dashboard/application/dashboard-commands.js'; import type { DashboardCommand } from '../dashboard/application/dashboard-commands.js'; import { removeTileMembership } from '../dashboard/application/tile-membership.js'; +import { createEmptyDashboard } from '../dashboard/application/empty-dashboard.js'; import { createQueryResolver } from '../dashboard/application/dashboard-query-resolver.js'; -import { resolveDashboardMode } from '../dashboard/application/session-bundle.js'; import { readDashboardFilterBag, writeDashboardFilterBag, filterBagSignature, } from '../dashboard/model/dashboard-filter-store.js'; @@ -89,8 +89,7 @@ import type { SavedQueryV2, StoredWorkspaceV2, } from '../generated/json-schema.types.js'; import type { App, AppDom, ActionsRegistry } from './app.types.js'; -import type { DashboardOpenSource } from '../dashboard/application/dashboard-open-source.js'; -import type { DetachedViewsStore } from '../workspace/detached-views-store.types.js'; +import type { SqlRoute } from '../core/sql-route.js'; import type { AppState } from '../state.js'; import type { ConnectionSession } from '../application/connection-session.js'; import type { QueryExecutionService } from '../application/query-execution-service.js'; @@ -106,12 +105,10 @@ const Icon: { refresh(): SVGElement; sun(): SVGElement; moon(): SVGElement; - arrow(): SVGElement; trash(): SVGElement; chevDown(): SVGElement; download(): SVGElement; upload(): SVGElement; - eye(): SVGElement; } = IconUntyped; const formatRows: (n: number | null | undefined) => string = formatRowsUntyped; @@ -132,14 +129,13 @@ export interface DashboardApp { wallNow(): number; params: Pick; workspace: Pick; - loadDashboardWorkspace(key?: string, dashboardId?: string): Promise; - // #288 Phase 6 — viewer routing (ADR-0003): the parsed open-source of this - // tab, the detached-views lookup, the one-time-handoff consumer, and the - // projection of the resolved workspace onto app.state (so the File menu's - // export/import act on THIS dashboard). - dashboardOpenSource: DashboardOpenSource | null; - detachedViews: Pick; - consumeDashboardHandoff(): Promise; + currentWorkspace: StoredWorkspaceV2 | null; + sqlRoute: SqlRoute; + navigateSqlRoute(route: SqlRoute, method: 'push' | 'replace'): Promise; + renderDashboard(): void; + captureSurfaceGeneration(): number; + isSurfaceGenerationCurrent(generation: number): boolean; + refreshCurrentSurfaceAfterStale(generation: number, committed?: boolean): boolean; applyCommittedWorkspace(workspace: StoredWorkspaceV2): void; // #341/#344: every editable Dashboard command commits through // `mutateWorkspace` — the same serialized-queue-plus-read-at-dequeue seam @@ -149,16 +145,13 @@ export interface DashboardApp { // producer's), never a route-local snapshot that another producer's // in-flight commit could make stale. mutateWorkspace: App['mutateWorkspace']; - // #343 step 6: set per render to mark a detached/read-only view (so the - // app-level cross-tab refresh skips projecting the primary workspace over it). - dashboardReadOnly: boolean; // #343 step 6: the route/surface refresh hook — `renderDashboard` overrides it // (per render) so an external workspace change rebuilds this viewer session from - // committed truth. Fires only AFTER the app-level refresh projected a real - // change; a detached read-only route no-ops it. + // committed truth. Fires only AFTER the app-level refresh projected a real change. onWorkspaceExternallyChanged: App['onWorkspaceExternallyChanged']; // #302 — the Dashboard page's own File-menu operations. - actions: Pick; + actions: Pick; + genId(): string; /** #303: persists the isolated per-dashboard filter store (`KEYS.dashFilters`). */ saveJSON(key: string, value: unknown): void; /** #332: the shared cell-detail drawer's own resize persist (`openCellDetail` @@ -199,28 +192,63 @@ let installedGestureCancel: (() => void) | null = null; let installedDashboardChartInteraction: DashboardChartInteractionController | null = null; let installedDashboardCleanup: (() => void) | null = null; -/** - * Build the flow preset switcher as a compact ` - - - - - - - -
- clickhouse.example.internal - Updated 12:34 - - - +
@@ -55,6 +36,110 @@ import { buildTimeRangeField } from '/src/ui/time-range-field.js'; import { parseParamType } from '/src/core/param-type.js'; import { wireComboInput } from '/src/ui/combobox.js'; + import { buildAppHeader } from '/src/ui/app-header.js'; + import { openMenu } from '/src/ui/menu.js'; + + const dashboardFileButton = document.createElement('button'); + dashboardFileButton.className = 'hd-file-btn dash-file-btn'; + dashboardFileButton.textContent = 'File'; + const tileCount = document.createElement('span'); + tileCount.className = 'dash-chip dash-fav'; + tileCount.textContent = '★ 6 tiles'; + const styleWrap = document.createElement('div'); + styleWrap.className = 'dash-layout-wrap'; + const styleButton = document.createElement('button'); + styleButton.className = 'hd-file-btn dash-style-btn'; + styleButton.setAttribute('aria-haspopup', 'menu'); + styleButton.setAttribute('aria-expanded', 'false'); + styleButton.setAttribute('aria-label', 'Dashboard style: Report'); + styleButton.innerHTML = 'Report'; + styleButton.value = 'report'; + let styleHandle = null; + const styles = [ + ['grafana-grid', 'Grid Tiles'], ['full', 'Full view'], ['report', 'Report'], + ['columns-2', '2 columns'], ['columns-3', '3 columns'], + ]; + styleButton.onclick = () => { + if (styleHandle) { + styleHandle.close(); + styleButton.focus(); + return; + } + styleHandle = openMenu({ + document, + trigger: styleButton, + menuClass: 'dash-file-menu dash-style-menu', + rows: styles.map(([value, label]) => ({ + kind: 'item', + label, + meta: value === styleButton.value ? 'Current' : null, + onClick: () => { + styleButton.value = value; + styleButton.querySelector('span').textContent = label; + styleButton.setAttribute('aria-label', `Dashboard style: ${label}`); + }, + })), + onClose: () => { styleHandle = null; }, + }); + }; + styleWrap.append(styleButton); + const dashboardTitle = document.createElement('div'); + dashboardTitle.className = 'dash-title'; + dashboardTitle.textContent = 'A deliberately long production operations Dashboard name'; + const updated = document.createElement('span'); + updated.className = 'dash-updated'; + updated.textContent = 'Updated 12:34'; + const refresh = document.createElement('button'); + refresh.className = 'editor-mode-btn dash-refresh'; + refresh.title = 'Re-run all tiles'; + refresh.setAttribute('aria-label', 'Refresh dashboard'); + refresh.textContent = 'Refresh'; + const refreshControl = document.createElement('div'); + refreshControl.className = 'editor-mode-switch dash-refresh-wrap'; + refreshControl.append(refresh); + const headerApp = { + document, + dom: {}, + state: { + serverVersion: '26.3.1', + theme: 'dark', + workspaceKey: 'production_operations', + libraryName: { value: 'Production operations' }, + libraryDirty: { value: false }, + }, + currentWorkspace: { + key: 'production_operations', + }, + sqlRoute: { + surface: 'dashboard', + workspaceKey: 'production_operations', + mode: 'edit', + }, + conn: { + host: () => 'clickhouse.example.internal', + email: () => 'me@example.com', + }, + actions: { + openUserMenu: () => {}, + openShortcuts: () => {}, + openDashboard: () => {}, + }, + toggleTheme: () => {}, + navigateSqlRoute: async () => {}, + editingLibrary: false, + }; + document.querySelector('#shared-header').replaceWith( + buildAppHeader(headerApp, { + dashboardControls: { + tileCount, + fileButton: dashboardFileButton, + style: styleWrap, + title: dashboardTitle, + updated, + refresh: refreshControl, + }, + }), + ); const filters = document.querySelector('.dash-filters'); // 'version' is the one optional field here — real coverage for the bold @@ -133,7 +218,6 @@ }; window.__refreshCount = 0; - const refresh = document.querySelector('.dash-refresh'); refresh.addEventListener('click', () => { refresh.disabled = true; window.__refreshCount++; diff --git a/tests/e2e/dashboard-mobile.spec.js b/tests/e2e/dashboard-mobile.spec.js index cf9a556f..f9a11210 100644 --- a/tests/e2e/dashboard-mobile.spec.js +++ b/tests/e2e/dashboard-mobile.spec.js @@ -7,39 +7,30 @@ async function openAt(page, width, height = 844) { } test.describe('Dashboard mobile layout', () => { - test('keeps the accessible header on one line and truncates its title at phone widths', async ({ page }) => { + test('keeps the shared application header on one line at phone widths', async ({ page }) => { await openAt(page, 390); - const header = page.locator('.dash-header'); - const title = page.locator('.dash-title'); + const header = page.locator('.app-header'); - await expect(page.getByRole('link', { name: 'Back to SQL Browser' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'Application surface' })).toBeVisible(); + await expect(page.getByRole('group', { name: 'Dashboard mode' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Toggle theme' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Refresh dashboard' })).toBeVisible(); - await expect(page.locator('.dash-back-label')).toBeHidden(); - await expect(page.locator('.dash-refresh-label')).toBeHidden(); - for (const selector of ['.dash-fav', '.dash-src', '.dash-updated', '.dash-layout-wrap']) { + await expect(page.locator('.dash-refresh-label')).toHaveCount(0); + for (const selector of ['.dash-fav', '.dash-updated', '.dash-layout-wrap']) { await expect(page.locator(selector)).toBeHidden(); } const geometry = await header.evaluate((node) => { const visible = [...node.children].filter((child) => getComputedStyle(child).display !== 'none'); const rects = visible.map((child) => child.getBoundingClientRect()); - const title = node.querySelector('.dash-title'); return { header: node.getBoundingClientRect(), centers: rects.map((rect) => rect.top + rect.height / 2), - titleClientWidth: title.clientWidth, - titleScrollWidth: title.scrollWidth, - whiteSpace: getComputedStyle(title).whiteSpace, - textOverflow: getComputedStyle(title).textOverflow, pageOverflow: document.documentElement.scrollWidth - innerWidth, }; }); expect(Math.max(...geometry.centers) - Math.min(...geometry.centers)).toBeLessThan(2); - expect(geometry.header.height).toBeLessThanOrEqual(47); - expect(geometry.titleClientWidth).toBeLessThan(geometry.titleScrollWidth); - expect(geometry.whiteSpace).toBe('nowrap'); - expect(geometry.textOverflow).toBe('ellipsis'); + expect(geometry.header.height).toBe(44); expect(geometry.pageOverflow).toBeLessThanOrEqual(0); const refresh = page.getByRole('button', { name: 'Refresh dashboard' }); @@ -51,14 +42,12 @@ test.describe('Dashboard mobile layout', () => { test('keeps title and actions reachable without viewport overflow at 360px', async ({ page }) => { await openAt(page, 360, 800); - const result = await page.locator('.dash-header').evaluate((header) => { + const result = await page.locator('.dashboard-app-header').evaluate((header) => { const title = header.querySelector('.dash-title').getBoundingClientRect(); - const theme = header.querySelector('.dash-icobtn').getBoundingClientRect(); const refresh = header.querySelector('.dash-refresh').getBoundingClientRect(); return { - wraps: Math.abs((title.top + title.height / 2) - (theme.top + theme.height / 2)) > 2 - || Math.abs((theme.top + theme.height / 2) - (refresh.top + refresh.height / 2)) > 2, - titleBeforeActions: title.right <= theme.left, + wraps: Math.abs((title.top + title.height / 2) - (refresh.top + refresh.height / 2)) > 2, + titleBeforeActions: title.right <= refresh.left, actionsInside: refresh.right <= innerWidth, pageOverflow: document.documentElement.scrollWidth - innerWidth, }; @@ -66,6 +55,25 @@ test.describe('Dashboard mobile layout', () => { expect(result).toEqual({ wraps: false, titleBeforeActions: true, actionsInside: true, pageOverflow: 0 }); }); + test('keeps every visible Dashboard header control reachable just above the mobile breakpoint', async ({ page }) => { + await openAt(page, 820, 700); + const result = await page.locator('.dashboard-app-header').evaluate((header) => { + const visible = [...header.children].filter((child) => getComputedStyle(child).display !== 'none'); + const rects = visible.map((child) => child.getBoundingClientRect()); + return { + oneRow: Math.max(...rects.map((rect) => rect.top + rect.height / 2)) + - Math.min(...rects.map((rect) => rect.top + rect.height / 2)) < 2, + inside: rects.every((rect) => rect.left >= 0 && rect.right <= innerWidth), + pageOverflow: document.documentElement.scrollWidth - innerWidth, + outside: visible.flatMap((child, index) => ( + rects[index].left < 0 || rects[index].right > innerWidth + ? [`${child.className}:${rects[index].left}-${rects[index].right}`] : [] + )), + }; + }); + expect(result).toEqual({ oneRow: true, inside: true, pageOverflow: 0, outside: [] }); + }); + test('visually normalizes every saved layout on mobile and restores desktop CSS on resize', async ({ page }) => { await openAt(page, 390); // 'wide'/'full-width' removed (#321) — every remaining flow preset still @@ -273,23 +281,35 @@ test.describe('Dashboard mobile layout', () => { await expect(lastField).toBeInViewport(); }); - test('the layout switcher is a compact select in the header, right after the tile-count chip, matching the workbench panel-picker style (2026-07-18)', async ({ page }) => { + test('the File-style layout picker follows File in the one-row Dashboard header', async ({ page }) => { await openAt(page, 1100, 800); - const select = page.locator('.dash-layout-select'); - await expect(select).toBeVisible(); - await expect(select).toHaveClass(/result-panel-select/); + const style = page.locator('.dash-style-btn'); + await expect(style).toBeVisible(); + await expect(style).toHaveClass(/hd-file-btn/); + await expect(style).toHaveText(/Report/); + await expect(page.locator('.dashboard-app-header')).not.toContainText('Style'); // No more four-button segmented control. await expect(page.locator('.dash-seg-layout')).toHaveCount(0); + await expect(page.locator('.dash-contextbar')).toHaveCount(0); + const controlSizes = await page.evaluate(() => { + const edit = [...document.querySelectorAll('.dashboard-mode-switch .editor-mode-btn')] + .find((button) => button.textContent === 'Edit').getBoundingClientRect(); + const refresh = document.querySelector('.dash-refresh').getBoundingClientRect(); + return { edit: [edit.width, edit.height], refresh: [refresh.width, refresh.height] }; + }); + expect(controlSizes.refresh).toEqual(controlSizes.edit); const geometry = await page.evaluate(() => { - const header = document.querySelector('.dash-header'); + const header = document.querySelector('.dashboard-app-header'); const children = [...header.children]; const tileCountIndex = children.findIndex((c) => c.classList.contains('dash-fav')); + const fileIndex = children.findIndex((c) => c.classList.contains('dash-file-btn')); const layoutWrapIndex = children.findIndex((c) => c.classList.contains('dash-layout-wrap')); const layoutWrap = children[layoutWrapIndex]; const tileCount = children[tileCountIndex]; return { - layoutRightAfterTileCount: layoutWrapIndex === tileCountIndex + 1, + tileCountBeforeFile: fileIndex === tileCountIndex + 1, + styleRightAfterFile: layoutWrapIndex === fileIndex + 1, inHeaderNotToolbar: !document.querySelector('.dash-toolbar .dash-layout-wrap'), sameRow: Math.abs( (layoutWrap.getBoundingClientRect().top + layoutWrap.getBoundingClientRect().height / 2) @@ -297,9 +317,19 @@ test.describe('Dashboard mobile layout', () => { ) < 2, }; }); - expect(geometry).toEqual({ layoutRightAfterTileCount: true, inHeaderNotToolbar: true, sameRow: true }); + expect(geometry).toEqual({ + tileCountBeforeFile: true, styleRightAfterFile: true, + inHeaderNotToolbar: true, sameRow: true, + }); - await select.selectOption('columns-2'); - expect(await select.inputValue()).toBe('columns-2'); + await style.click(); + const menu = page.locator('.dash-style-menu'); + await expect(menu).toHaveClass(/file-menu/); + await expect(menu.locator('.fm-label')).toHaveText( + ['Grid Tiles', 'Full view', 'Report', '2 columns', '3 columns'], + ); + await menu.getByRole('menuitem', { name: '2 columns' }).click(); + await expect(style).toHaveText(/2 columns/); + expect(await style.evaluate((button) => button.value)).toBe('columns-2'); }); }); diff --git a/tests/helpers/auth-fixtures.ts b/tests/helpers/auth-fixtures.ts index 2d027e89..d571f6f2 100644 --- a/tests/helpers/auth-fixtures.ts +++ b/tests/helpers/auth-fixtures.ts @@ -14,7 +14,7 @@ export function jwt(payload: Record): string { /** A Map-backed sessionStorage fake — the three methods the auth code uses, * plus the backing `_map` for direct assertions. Structurally satisfies * `connection-session.ts`'s `SessionStorageLike` (and the narrower - * `core/auth-handoff.js` `StorageLike`). */ + * the browser session-storage contract). */ export interface MemStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index 7a89e90a..11607064 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -240,8 +240,6 @@ const connDefaults: ConnectionSession = { connectBasic: async () => {}, signOut: () => {}, ensureFreshToken: async () => true, - grantHandoffTo: () => {}, - receiveAuthHandoff: async () => false, }; // A stand-in for the Chart.js constructor: records its canvas + config and @@ -431,23 +429,18 @@ const appDefaults: App = { resolveImplicit: async () => ({ status: 'empty' }), markOpened: async () => ({ ok: true }), }, - // #288 Phase 6 — Dashboard viewing seams. In-memory no-op stores by default; - // a test exercising the handoff/detached-view flow overrides these. - handoff: { - put: async () => {}, - take: async () => null, - }, - detachedViews: { - put: async () => {}, - get: async () => null, - getByKey: async () => null, - }, - dashboardOpenSource: null, - dashboardRoute: false, - dashboardReadOnly: false, + sqlRoute: { surface: 'workspace', workspaceKey: null }, + currentWorkspace: null, + workspaceRouteStatus: 'ready', + captureSurfaceGeneration: () => 0, + isSurfaceGenerationCurrent: (generation) => generation === 0, + refreshCurrentSurfaceAfterStale: (generation) => generation === 0, + navigateSqlRoute: async () => {}, + handleSqlPopState: async () => {}, + syncSqlRoute: () => {}, + rewriteWorkspaceRoute: () => {}, + renderCurrentSurface: () => {}, reloadDashboardRoute: () => {}, - consumeDashboardHandoff: async () => null, - loadDashboardWorkspace: async () => null, loadWorkspaceOnBoot: async () => null, // Inert placeholders — `base` below overrides both with real, state-backed // implementations (mirroring app.ts's own #287 W5 wiring) so a file-menu @@ -554,7 +547,6 @@ const appDefaults: App = { renderApp: () => {}, renderDashboard: () => {}, openDashboard: () => {}, - openDashboardForViewing: () => {}, actions: {} as ActionsRegistry, }; @@ -575,9 +567,9 @@ const appDefaults: App = { // re-declaring a narrower `Partial` that would reject it. export type MakeAppOverrides = AppOverrides; type AppOverrides = Partial> & { - /** Partial like the rest (#286 Phase 4) — the Dashboard viewer reads a - * StoredWorkspaceV2 through `loadDashboardWorkspace`/`workspace.loadById`; - * a dashboard test overrides just the method(s) it drives. */ + /** Partial like the rest (#286 Phase 4) — Dashboard mutations read a + * StoredWorkspaceV2 through `workspace.loadById`; a test overrides only + * the repository methods it drives. */ workspace?: Partial; dom?: Partial; chCtx?: Partial; @@ -635,6 +627,7 @@ export function makeApp>(override // this exact object, not a fresh merge (`overrides.workspace` layers under // `appDefaults.workspace`, same three-way convention as `dom`/`conn`/etc.). const workspaceRepo: WorkspaceRepository = { ...appDefaults.workspace, ...(overrides.workspace ?? {}) }; + let projectedApp: App | null = null; // #287 W5 / #343 §2: the one state-backed projection both `applyCommittedWorkspace` // and the `mutateWorkspace` success path share (mirrors app.ts, where the // primitive projects exactly once so callers no longer do). @@ -648,9 +641,14 @@ export function makeApp>(override state.workspaceKey = workspace.key; state.libraryName.value = workspace.name; state.libraryDirty.value = false; + if (projectedApp) { + projectedApp.currentWorkspace = workspace; + projectedApp.workspaceRouteStatus = 'ready'; + } }; const base = { state, + sqlRoute: { surface: 'workspace', workspaceKey: state.workspaceKey } as App['sqlRoute'], root, document, Chart: FakeChart, @@ -827,7 +825,6 @@ export function makeApp>(override openCreateInNewTab: vi.fn(), openShortcuts: vi.fn(), openDashboard: vi.fn(), - openDashboardForViewing: vi.fn(), exportDashboard: vi.fn(), importDashboard: vi.fn(), openUserMenu: vi.fn(), @@ -866,6 +863,7 @@ export function makeApp>(override // (every field's REAL, often Mock-typed, shape) is what callers actually // get back, not this widened annotation. const asApp: App = merged; + projectedApp = asApp; void asApp; return merged; } diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 1c8a0bd4..2b508160 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -9,15 +9,18 @@ import type { EditorPort } from '../../src/editor/editor-port.types.js'; import type { CodeViewerOptions } from '../../src/editor/code-viewer.types.js'; import { AST_PROGRESSIVE_THRESHOLD } from '../../src/net/ch-client.js'; import { libraryControls, openFileMenu } from '../../src/ui/file-menu.js'; +import { handleKeydown } from '../../src/ui/shortcuts.js'; import { emptyRecentMap, recordRecent } from '../../src/core/recent-values.js'; import { queryDescription } from '../../src/core/saved-query.js'; import { createSpecValidatorRegistry } from '../../src/core/spec-draft.js'; import { savedQuery } from '../helpers/saved-query.js'; import { fakeIndexedDbFactory } from '../helpers/fake-idb.js'; import { fakeBroadcastBus } from '../helpers/fake-broadcast.js'; -import { encodePortableBundleJson } from '../../src/dashboard/model/portable-bundle-codec.js'; import { decodeShare } from '../../src/core/share.js'; import type { CreateAppEnv, BroadcastChannelPort } from '../../src/env.types.js'; +import type { + WorkspaceCommitResult, WorkspaceLoadResult, WorkspaceMarkOpenedResult, +} from '../../src/workspace/workspace-repository.js'; import type { App, WorkspaceChangedMessage } from '../../src/ui/app.types.js'; import type { AppState, QueryTab } from '../../src/state.js'; import { renameSaved, savedForTab } from '../../src/state.js'; @@ -524,11 +527,9 @@ describe('createApp basics', () => { await expect(app.flushWorkspaceWrites()).resolves.toBeUndefined(); const dashboardApp = createApp(env()); - await dashboardApp.loadDashboardWorkspace(); - await expect(dashboardApp.renderDashboard()).resolves.toBeUndefined(); - dashboardApp.dashboardReadOnly = true; + await dashboardApp.loadWorkspaceOnBoot(); + expect(dashboardApp.renderCurrentSurface()).toBeUndefined(); await expect(dashboardApp.refreshWorkspaceFromStore()).resolves.toBeUndefined(); - dashboardApp.dashboardReadOnly = false; const change = { type: 'workspace-changed' as const, sourceTabId: 'other', workspaceId: 'w1' }; dashboardApp.onExternalWorkspaceChange(change); dashboardApp.onExternalWorkspaceChange(change); // coalesced while the first refresh is queued @@ -963,6 +964,16 @@ describe('renderApp shell', () => { it('builds header + sidebar + workbench and mounts the editor', async () => { const { app } = rendered(); expect(qs(app.root, '.app-header')).not.toBeNull(); + expect(qs(app.root, '.logo-name').textContent).toBe('Altinity®'); + expect(qsa(app.root, '.app-surface-switch .editor-mode-btn').map((button) => button.textContent)) + .toEqual(['SQL Browser', 'Dashboard']); + expect(qs(app.root, '.dashboard-mode-switch')).toBeNull(); + app.navigateSqlRoute = vi.fn(async () => {}); + qsa(app.root, '.app-surface-switch .editor-mode-btn') + .find((button) => button.textContent === 'Dashboard')!.click(); + expect(app.navigateSqlRoute).toHaveBeenCalledWith({ + surface: 'dashboard', workspaceKey: app.state.workspaceKey, mode: 'edit', + }, 'push'); expect(qs(app.root, '.sidebar')).not.toBeNull(); expect(qs(app.root, '.cm-editor')).not.toBeNull(); // user control shows the short name (local-part) + full email on hover @@ -970,6 +981,13 @@ describe('renderApp shell', () => { expect(app.dom.userBtn!.getAttribute('title')).toBe('me@example.com'); await Promise.resolve(); }); + it('renders an already-probed ClickHouse version on the Workbench header', () => { + const app = createApp(env()); + app.state.serverVersion = '26.7.1.42'; + app.renderApp(); + expect(qs(app.root, '.conn-status').textContent).toBe('ClickHouse 26.7.1'); + expect(qs(app.root, '.conn-status').getAttribute('title')).toBe('ClickHouse 26.7.1.42'); + }); it('toggles theme via the header button', () => { const { app } = rendered(); app.dom.themeBtn!.dispatchEvent(new Event('click')); // default light → dark @@ -1034,7 +1052,7 @@ describe('loadVersion / loadSchema', () => { const e = env({ fetch: makeFetch([[(u, sql) => /version/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })]]) }); const app = createApp(e); app.renderApp(); - await new Promise((r) => setTimeout(r)); + await app.catalog.loadVersion(); expect(app.state.serverVersion).toBe('26.3.1'); expect(app.dom.connStatus!.textContent).toContain('26.3.1'); }); @@ -1042,7 +1060,7 @@ describe('loadVersion / loadSchema', () => { const e = env({ fetch: makeFetch([[(u, sql) => /version/.test(sql), resp({ ok: false, status: 500, text: 'err' })]]) }); const app = createApp(e); app.renderApp(); - await new Promise((r) => setTimeout(r)); + await app.catalog.loadVersion(); expect(app.dom.connStatus!.textContent).toContain('offline'); }); it('records a schema error', async () => { @@ -1342,7 +1360,7 @@ describe('query run', () => { }); it('run() while already running is a no-op (cancel is separate)', async () => { const { app } = appForRun([]); - await new Promise((r) => setTimeout(r)); // let mount-time loadVersion/loadSchema/loadReference settle + await new Promise((r) => setTimeout(r)); // let mount-time loadSchema/loadReference settle app.state.running.value = true; const before = asMock(app.conn.chCtx.fetch).mock.calls.length; await app.actions.run(); @@ -2119,7 +2137,7 @@ describe('query run', () => { [(u, sql) => /version\(\)/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })], [(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'plan' })], ]); - await new Promise((r) => setTimeout(r)); // let app.catalog.loadVersion() resolve + await app.catalog.loadVersion(); app.activeTab().sqlDraft = 'SELECT 1'; await app.actions.explainQuery(); expect(sentExplains(e)).toContain('EXPLAIN pretty = 1, compact = 1 SELECT 1'); @@ -2132,7 +2150,7 @@ describe('query run', () => { [(u, sql) => /version\(\)/.test(sql), resp({ json: { data: [{ v: '26.3.1' }] } })], [(u, sql) => /EXPLAIN/.test(sql), resp({ text: 'plan' })], ]); - await new Promise((r) => setTimeout(r)); // let app.catalog.loadVersion() resolve + await app.catalog.loadVersion(); app.activeTab().sqlDraft = 'EXPLAIN SELECT 1'; await app.actions.run(); expect(sentExplains(e)).toContain('EXPLAIN SELECT 1'); // verbatim, no decoration @@ -2909,7 +2927,7 @@ describe('formatQuery', () => { }); it('no-ops on empty SQL', async () => { const { app, e } = appFor([]); - await Promise.resolve(); // let render's loadVersion/loadSchema settle + await Promise.resolve(); // let render's loadSchema settle asMock(e.fetch!).mockClear(); app.activeTab().sqlDraft = ' '; await app.actions.formatQuery(); @@ -2981,7 +2999,7 @@ describe('formatQuery', () => { }); it('optional blocks (#165): a single statement with a block is skipped with a notice — no server call', async () => { const { app, e } = appFor([]); - await Promise.resolve(); // let render's loadVersion/loadSchema settle + await Promise.resolve(); // let render's loadSchema settle asMock(e.fetch!).mockClear(); app.activeTab().sqlDraft = 'select 1 /*[ AND d = {d:String} ]*/'; app.sqlEditor.replaceDocument('select 1 /*[ AND d = {d:String} ]*/'); @@ -3273,8 +3291,10 @@ describe('credentials (basic) sign-in', () => { fetch: makeFetch([[(u, sql) => /SELECT 1/.test(sql), resp({ json: { data: [{ '1': 1 }] } })]]), }); const app = createApp(e); + const loadVersion = vi.spyOn(app.catalog, 'loadVersion').mockResolvedValue(); expect(app.conn.authMode()).toBe('oauth'); await app.actions.connect({ username: 'demo', password: 'demo', host: '' }); + expect(loadVersion).toHaveBeenCalledOnce(); expect(app.conn.authMode()).toBe('basic'); expect(e.sessionStorage!.getItem('ch_basic_auth')).toBe(creds); expect(e.sessionStorage!.getItem('ch_basic_user')).toBe('demo'); @@ -3455,6 +3475,7 @@ describe('share + star + columns', () => { }); it('#287 W4: the Save popover surfaces a toast (and mutates nothing) when the aggregate commit is rejected', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); const diagnostics = [{ path: [], severity: 'error' as const, code: 'test-fail', message: 'boom' }]; app.workspace.commit = vi.fn(async () => ({ ok: false as const, diagnostics })); @@ -3469,8 +3490,12 @@ describe('share + star + columns', () => { }); it('#287 W4: linked Save surfaces a toast (and mutates nothing) when the aggregate commit is rejected', async () => { const app = createApp(env()); + await app.loadWorkspaceOnBoot(); app.renderApp(); - app.state.savedQueries = [savedQueryFixture({ id: 's9', name: 'Fav', sql: 'SELECT 9' })]; + const linked = savedQueryFixture({ id: 's9', name: 'Fav', sql: 'SELECT 9' }); + await app.mutateWorkspace((latest) => ({ + candidate: { ...latest!, queries: [linked] }, + })); app.actions.loadIntoNewTab(asQueryOrName(app.state.savedQueries[0])); const diagnostics = [{ path: [], severity: 'error' as const, code: 'test-fail', message: 'boom' }]; app.workspace.commit = vi.fn(async () => ({ ok: false as const, diagnostics })); @@ -3536,32 +3561,33 @@ describe('share + star + columns', () => { expect(qs(document, '.share-toast').textContent) .toBe('Workspace opened, but its last-used timestamp could not be saved.'); }); - it('returns null for an explicit Dashboard workspace key that does not exist', async () => { - const app = createApp(env()); - await expect(app.loadDashboardWorkspace('missing_workspace')).resolves.toBeNull(); + it('boot provisions an empty collection and rewrites /sql with its canonical key', async () => { + const replaceState = vi.fn(); + const app = createApp(env({ + window: asWindow({ history: { replaceState }, navigator: {} }), + })); + const workspace = await app.loadWorkspaceOnBoot(); + expect(workspace).not.toBeNull(); + expect(replaceState).toHaveBeenCalledWith( + null, '', `/sql?ws=${encodeURIComponent(workspace!.key)}`, + ); }); - it('marks a keyed Dashboard workspace opened only after its Dashboard id resolves', async () => { - const app = createApp(env()); - const workspace: StoredWorkspaceV2 = { - storageVersion: 2, id: 'dashboard-workspace-id', key: 'dashboard_workspace', - name: 'Dashboard workspace', queries: [], - dashboard: { - documentVersion: 1, id: 'dashboard-id', title: 'Dashboard', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: {} }, - filters: [], tiles: [], - }, + it('boot resolves a pre-existing last-used workspace and rewrites /sql', async () => { + const store = fakeIndexedDbFactory(); + const seed = createApp(env({ indexedDB: store })); + const alpha: StoredWorkspaceV2 = { + storageVersion: 2, id: 'alpha-id', key: 'alpha', name: 'Alpha', + queries: [], dashboard: null, }; - expect((await app.workspace.create(workspace)).ok).toBe(true); - const markOpened = vi.spyOn(app.workspace, 'markOpened'); - - await expect(app.loadDashboardWorkspace(workspace.key, 'missing-dashboard')).resolves.toBeNull(); - expect(markOpened).not.toHaveBeenCalled(); - - await expect(app.loadDashboardWorkspace(workspace.key, 'dashboard-id')).resolves.toMatchObject({ - id: workspace.id, - }); - expect(markOpened).toHaveBeenCalledOnce(); - expect(markOpened).toHaveBeenCalledWith(workspace.key); + expect((await seed.workspace.create(alpha)).ok).toBe(true); + expect((await seed.workspace.markOpened('alpha')).ok).toBe(true); + const replaceState = vi.fn(); + const app = createApp(env({ + indexedDB: store, + window: asWindow({ history: { replaceState }, navigator: {} }), + })); + await expect(app.loadWorkspaceOnBoot()).resolves.toMatchObject({ key: 'alpha' }); + expect(replaceState).toHaveBeenCalledWith(null, '', '/sql?ws=alpha'); }); it('boot resolves an explicit Workbench ws key without falling back or provisioning', async () => { const location = { @@ -5131,149 +5157,732 @@ describe('mobile best-effort mode (#126)', () => { }); }); -// ── #288 / #302: Dashboard viewing seams on the App controller ──────────────── -describe('Dashboard viewing (open-source, handoff, actions) — #288/#302', () => { - const vdash = () => ({ - documentVersion: 1, id: 'd', title: 'My View', revision: 1, - layout: { type: 'flow', version: 1, preset: 'report', items: { t1: {} } }, - filters: [], tiles: [{ id: 't1', queryId: 'q1' }], - }); - const vquery = () => savedQuery({ id: 'q1', name: 'q1', sql: 'SELECT 1' }); - const bundleText = (): string => { - const enc = encodePortableBundleJson({ queries: [vquery()], dashboards: [vdash()] as never, nowISO: '2026-07-18T00:00:00.000Z' }); - if (!enc.ok) throw new Error('fixture failed to encode'); - return enc.value; +// ── #407: unified route coordinator ────────────────────────────────────────── +describe('unified /sql routing', () => { + const dashboardWorkspace = (queries: SavedQueryV2[] = []): StoredWorkspaceV2 => ({ + storageVersion: 2, + id: 'w', + key: 'w', + name: 'Workspace', + queries, + dashboard: { + documentVersion: 1, + id: 'dash', + title: 'Operations', + revision: 1, + layout: { type: 'flow', version: 1, preset: 'report', items: {} }, + filters: [], + tiles: [], + }, + }); + + const deferNextCommit = (app: App) => { + const realCommit = app.workspace.commit.bind(app.workspace); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + const commit = vi.spyOn(app.workspace, 'commit').mockImplementation(async (candidate) => { + await gate; + return realCommit(candidate); + }); + return { commit, release }; }; - const child = () => ({ postMessage: vi.fn(), closed: false }); - it('createApp parses the tab open-source + route flag from the location', () => { - const editTab = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?ws=w1&dash=d1', host: 'ch.example' } as Location })); - expect(editTab.dashboardOpenSource).toEqual({ - kind: 'current-workspace', workspaceKey: 'w1', dashboardId: 'd1', + const pickDashboardStyle = (app: App, label: string): void => { + qs(app.root, '.dash-style-btn').click(); + qsa(document.body, '.dash-style-menu .fm-item') + .find((item) => item.querySelector('.fm-label')?.textContent === label)!.click(); + }; + + it('tracks mounted renderer lifetime independently from workspace loading', () => { + const app = createApp(env()); + const before = app.captureSurfaceGeneration(); + expect(app.isSurfaceGenerationCurrent(before)).toBe(true); + app.renderApp(); + expect(app.isSurfaceGenerationCurrent(before)).toBe(false); + expect(app.refreshCurrentSurfaceAfterStale(app.captureSurfaceGeneration())).toBe(true); + }); + + it('parses dashboard view mode from query parameters', () => { + const app = createApp(env({ location: { + origin: 'https://ch.example', pathname: '/sql', + search: '?ws=w1&surface=dashboard&mode=view', host: 'ch.example', + } as Location })); + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'w1', mode: 'view', + }); + }); + + it('resynchronizes route state after bootstrap cleans an OAuth callback URL', () => { + const app = createApp(env()); + app.syncSqlRoute('?ws=ops&surface=dashboard&mode=view'); + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'ops', mode: 'view', }); - expect(editTab.dashboardRoute).toBe(true); - const workbenchTab = createApp(env()); - expect(workbenchTab.dashboardOpenSource).toBeNull(); - expect(workbenchTab.dashboardRoute).toBe(false); }); - it('openDashboard opens ?ws=&dash= when the workspace has a dashboard, else the bare route; grants the handoff', () => { - const opened: (string | undefined)[] = []; - const c = child(); - const app = createApp(env({ openWindow: asOpenWindow((url?: string) => { opened.push(url); return c; }) })); + it('Workbench to Dashboard uses pushState in the same tab', async () => { + const pushState = vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); + const app = createApp(env()); app.state.workspaceKey = 'workspace_nine'; - app.state.dashboard = vdash() as never; - app.openDashboard(); - expect(opened[0]).toBe('https://ch.example/sql/dashboard?ws=workspace_nine&dash=d'); - expect(c.postMessage).not.toBe(undefined); // grantHandoffTo ran against the child - // No dashboard → bare route. - app.state.dashboard = null; + app.currentWorkspace = { + storageVersion: 2, id: 'w', key: 'workspace_nine', name: 'W', queries: [], dashboard: null, + }; + app.workspaceRouteStatus = 'ready'; + app.renderCurrentSurface = vi.fn(); app.openDashboard(); - expect(opened[1]).toBe('https://ch.example/sql/dashboard'); - }); - - it('openDashboardForViewing writes the one-time token then opens ?st=; toasts when there is no dashboard', async () => { - const opened: (string | undefined)[] = []; - const put = vi.fn(async () => {}); - const app = createApp(env({ openWindow: asOpenWindow((url?: string) => { opened.push(url); return child(); }) })); - app.handoff = { put, take: vi.fn(async () => null) }; - app.state.savedQueries = [vquery()] as never; - app.state.dashboard = vdash() as never; - app.openDashboardForViewing(); - expect(opened[0]).toMatch(/\/dashboard\?st=[0-9a-f]{64}&dash=d$/); - expect(put).toHaveBeenCalledOnce(); - // No dashboard → toast, no window. - app.state.dashboard = null; - app.openDashboardForViewing(); - expect(opened.length).toBe(1); - // Blocked popup (openWindow → null) → no orphan token is written. - const put2 = vi.fn(async () => {}); - const blocked = createApp(env({ openWindow: asOpenWindow(() => null) })); - blocked.handoff = { put: put2, take: vi.fn(async () => null) }; - blocked.state.savedQueries = [vquery()] as never; - blocked.state.dashboard = vdash() as never; - blocked.openDashboardForViewing(); - expect(put2).not.toHaveBeenCalled(); - }); - - it('openDashboardForViewing toasts on an unencodable dashboard and on a failed token write', async () => { - const opened: unknown[] = []; - // Unencodable dashboard (bad preset) → build fails before opening a window. - const app = createApp(env({ openWindow: asOpenWindow((url?: string) => { opened.push(url); return child(); }) })); - app.handoff = { put: vi.fn(async () => {}), take: vi.fn(async () => null) }; - app.state.savedQueries = [vquery()] as never; - app.state.dashboard = { ...vdash(), layout: { type: 'flow', version: 1, preset: 'nope', items: {} } } as never; - app.openDashboardForViewing(); - expect(opened.length).toBe(0); - // Valid dashboard but the token write rejects → the window still opened, the - // rejection is caught (toast), never thrown. - const app2 = createApp(env({ openWindow: asOpenWindow(() => child()) })); - app2.handoff = { put: vi.fn(async () => { throw new Error('idb down'); }), take: vi.fn(async () => null) }; - app2.state.savedQueries = [vquery()] as never; - app2.state.dashboard = vdash() as never; - app2.openDashboardForViewing(); - await new Promise((r) => setTimeout(r, 0)); // let the rejected put settle into .catch - }); - - it('consumeDashboardHandoff atomically consumes the token, materializes a detached view, and rewrites the URL', async () => { - const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?st=tok&dash=d', host: 'ch.example' } as Location })); - const put = vi.fn(async () => {}); - app.handoff = { take: vi.fn(async () => ({ text: bundleText(), dashboardId: 'd', detachedWorkspaceId: 'wsview-1', expiresAt: 9e12 })), put: vi.fn(async () => {}) }; - app.detachedViews = { - get: vi.fn(async () => null), - getByKey: vi.fn(async () => null), - put, + await Promise.resolve(); + expect(pushState).toHaveBeenCalledWith( + null, '', '/sql?ws=workspace_nine&surface=dashboard', + ); + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'workspace_nine', mode: 'edit', + }); + pushState.mockRestore(); + }); + + it('view/edit uses replaceState and canonicalizes edit mode away', async () => { + const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + const app = createApp(env({ location: { + origin: 'https://ch.example', pathname: '/sql', + search: '?ws=w&surface=dashboard&mode=view', host: 'ch.example', + } as Location })); + app.currentWorkspace = { + storageVersion: 2, id: 'w', key: 'w', name: 'W', queries: [], dashboard: null, }; - // happy-dom's real replaceState rejects a cross-origin URL from the test's - // blob: document — stub the impl so we observe the call without it throwing. + app.workspaceRouteStatus = 'ready'; + app.renderCurrentSurface = vi.fn(); + await app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }, 'replace'); + expect(replaceState).toHaveBeenCalledWith( + null, '', '/sql?ws=w&surface=dashboard', + ); + replaceState.mockRestore(); + }); + + it('rewrites a newly created workspace key without changing the active surface', () => { const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); - const ws = await app.consumeDashboardHandoff(); - expect(ws?.id).toBe('wsview-1'); - expect(put).toHaveBeenCalledOnce(); - expect(replaceState).toHaveBeenCalled(); - expect(app.dashboardOpenSource).toEqual({ - kind: 'current-workspace', workspaceKey: 'wsview-1', dashboardId: 'd', + const app = createApp(env({ location: { + origin: 'https://ch.example', pathname: '/sql', + search: '?ws=old&surface=dashboard&mode=view', hash: '', host: 'ch.example', + } as Location })); + app.rewriteWorkspaceRoute('new_workspace'); + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'new_workspace', mode: 'view', }); + expect(replaceState).toHaveBeenCalledWith( + null, '', '/sql?ws=new_workspace&surface=dashboard&mode=view', + ); + replaceState.mockRestore(); + }); + + it('canonicalizes an unresolved explicit route without falling back', async () => { + const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + const app = createApp(env({ location: { + origin: 'https://ch.example', pathname: '/sql', + search: '?ws=missing&surface=workspace&mode=view', hash: '#keep', host: 'ch.example', + } as Location })); + app.workspace.loadByKey = vi.fn(async () => ({ status: 'empty' as const })); + app.workspace.resolveImplicit = vi.fn(); + await expect(app.loadWorkspaceOnBoot()).resolves.toBeNull(); + expect(app.workspaceRouteStatus).toBe('not-found'); + expect(app.workspace.resolveImplicit).not.toHaveBeenCalled(); + expect(replaceState).toHaveBeenCalledWith(null, '', '/sql?ws=missing#keep'); replaceState.mockRestore(); }); - it('consumeDashboardHandoff returns null for a non-bundle route, a spent token, and an undecodable record', async () => { - const plain = createApp(env()); // no ?st → not a session-bundle route - expect(await plain.consumeDashboardHandoff()).toBeNull(); - const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?st=tok&dash=d', host: 'ch.example' } as Location })); - app.handoff = { take: vi.fn(async () => null), put: vi.fn(async () => {}) }; // spent/expired - expect(await app.consumeDashboardHandoff()).toBeNull(); - app.handoff = { take: vi.fn(async () => ({ text: '{bad', dashboardId: 'd', detachedWorkspaceId: 'x', expiresAt: 9e12 })), put: vi.fn(async () => {}) }; - expect(await app.consumeDashboardHandoff()).toBeNull(); + it('popstate reparses, reloads, and renders the destination workspace', async () => { + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '?ws=first', + hash: '', host: 'ch.example', href: 'https://ch.example/sql?ws=first', + } as Location; + const app = createApp(env({ location })); + const second: StoredWorkspaceV2 = { + storageVersion: 2, id: 'second-id', key: 'second', name: 'Second', + queries: [], dashboard: null, + }; + app.workspace.loadByKey = vi.fn(async () => ({ status: 'ok' as const, workspace: second })); + app.workspace.markOpened = vi.fn(async () => ({ ok: true as const, workspace: second })); + app.renderCurrentSurface = vi.fn(); + location.search = '?ws=second&surface=dashboard&mode=view'; + await app.handleSqlPopState(); + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'second', mode: 'view', + }); + expect(app.currentWorkspace).toBe(second); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); }); - it('reloadDashboardRoute repoints the URL at the current dashboard and re-renders (URL skipped when absent)', () => { - const app = createApp(env({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '?ws=w&dash=old', host: 'ch.example' } as Location })); + it('reloadDashboardRoute also handles a missing current projection', () => { + const app = createApp(env()); + app.currentWorkspace = null; app.renderDashboard = vi.fn(); - const replaceState = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); - app.state.workspaceKey = 'workspace'; - app.state.dashboard = { ...vdash(), id: 'dNew' } as never; app.reloadDashboardRoute(); - expect(replaceState).toHaveBeenCalledWith( - null, '', 'https://ch.example/sql/dashboard?ws=workspace&dash=dNew', - ); - expect(app.dashboardOpenSource).toEqual({ - kind: 'current-workspace', workspaceKey: 'workspace', dashboardId: 'dNew', - }); + expect(app.currentWorkspace).toBeNull(); expect(app.renderDashboard).toHaveBeenCalledOnce(); - // No dashboard → re-render only, no URL rewrite. - replaceState.mockClear(); - app.state.dashboard = null; - app.reloadDashboardRoute(); - expect(replaceState).not.toHaveBeenCalled(); - expect(app.renderDashboard).toHaveBeenCalledTimes(2); - replaceState.mockRestore(); }); - it('the Dashboard export/import actions delegate to their file-menu flows', () => { + it('renderCurrentSurface dispatches a ready dashboard route to its renderer', () => { + const app = createApp(env()); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'view' }; + app.currentWorkspace = { + storageVersion: 2, id: 'w', key: 'w', name: 'W', queries: [], dashboard: null, + }; + app.workspaceRouteStatus = 'ready'; + app.renderDashboard = vi.fn(); + app.renderCurrentSurface(); + expect(app.renderDashboard).toHaveBeenCalledOnce(); + }); + + it('renderCurrentSurface dispatches an absent-surface route to Workbench', () => { + const app = createApp(env()); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + app.currentWorkspace = { + storageVersion: 2, id: 'w', key: 'w', name: 'W', queries: [], dashboard: null, + }; + app.workspaceRouteStatus = 'ready'; + app.renderApp = vi.fn(); + app.renderCurrentSurface(); + expect(app.renderApp).toHaveBeenCalledOnce(); + }); + + it('renders the dedicated not-found surface for an explicit missing key', () => { + const app = createApp(env()); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'gone' }; + app.currentWorkspace = null; + app.workspaceRouteStatus = 'not-found'; + app.renderCurrentSurface(); + expect(app.root!.querySelector('.workspace-not-found')).not.toBeNull(); + expect(app.root!.textContent).toContain('No local workspace exists for “gone”'); + }); + + it('disposes a mounted Workbench before rendering not-found', async () => { + const app = createApp(env()); + await app.loadWorkspaceOnBoot(); + app.renderApp(); + const remove = vi.spyOn(document, 'removeEventListener'); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'gone' }; + app.currentWorkspace = null; + app.workspaceRouteStatus = 'not-found'; + app.renderCurrentSurface(); + expect(remove.mock.calls.some(([type]) => type === 'selectionchange')).toBe(true); + remove.mockRestore(); + }); + + it('synchronously replaces and inerts old Workbench controls during workspace navigation', async () => { + const app = createApp(env()); + const a: StoredWorkspaceV2 = { + storageVersion: 2, id: 'a', key: 'a', name: 'A', queries: [], dashboard: null, + }; + const b: StoredWorkspaceV2 = { + storageVersion: 2, id: 'b', key: 'b', name: 'B', queries: [], dashboard: null, + }; + app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + app.applyCommittedWorkspace(a); + app.renderApp(); + const save = qs(app.root, '.save-btn'); + const run = qs(app.root, '.run-btn'); + qs(app.root, '.user-btn').click(); + expect(document.querySelector('.user-menu')).not.toBeNull(); + save.disabled = false; + const saveAction = vi.fn(); + const runAction = vi.fn(); + app.actions.save = saveAction; + app.actions.run = runAction; + let resolveB!: (result: WorkspaceLoadResult) => void; + app.workspace.loadByKey = vi.fn(() => + new Promise((resolve) => { resolveB = resolve; })); + app.workspace.markOpened = vi.fn(async () => ({ ok: true as const })); + + const navigation = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); + + expect(app.workspaceRouteStatus).toBe('loading'); + expect(qs(app.root, '.workspace-loading').getAttribute('aria-busy')).toBe('true'); + expect(save.isConnected).toBe(false); + expect(run.isConnected).toBe(false); + expect(save.disabled).toBe(true); + expect(run.disabled).toBe(true); + expect(document.querySelector('.user-menu')).toBeNull(); + save.click(); + run.click(); + expect(saveAction).not.toHaveBeenCalled(); + expect(runAction).not.toHaveBeenCalled(); + const shortcut = (over: Record) => ({ + key: '', metaKey: true, ctrlKey: false, shiftKey: false, altKey: false, + preventDefault: vi.fn(), target: {}, ...over, + }); + const shareAction = vi.fn(); + const formatQueryAction = vi.fn(); + const formatSpecAction = vi.fn(); + const setEditorModeAction = vi.fn(); + app.actions.share = shareAction; + app.actions.formatQuery = formatQueryAction; + app.actions.formatSpec = formatSpecAction; + app.actions.setEditorMode = setEditorModeAction; + expect(handleKeydown(shortcut({ key: 'Enter' }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: 's' }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: 's', shiftKey: true }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: 'Enter', shiftKey: true }), app)).toBeNull(); + app.activeTab().editorMode = 'spec'; + expect(handleKeydown(shortcut({ key: 'Enter', shiftKey: true }), app)).toBeNull(); + expect(handleKeydown(shortcut({ key: '1', altKey: true }), app)).toBeNull(); + expect(runAction).not.toHaveBeenCalled(); + expect(saveAction).not.toHaveBeenCalled(); + expect(shareAction).not.toHaveBeenCalled(); + expect(formatQueryAction).not.toHaveBeenCalled(); + expect(formatSpecAction).not.toHaveBeenCalled(); + expect(setEditorModeAction).not.toHaveBeenCalled(); + app.renderCurrentSurface(); + + resolveB({ status: 'ok', workspace: b }); + await navigation; + expect(app.currentWorkspace).toBe(b); + }); + + it('synchronously inerts a stale Create dashboard control during workspace navigation', async () => { + const app = createApp(env()); + const a: StoredWorkspaceV2 = { + storageVersion: 2, id: 'a', key: 'a', name: 'A', queries: [], dashboard: null, + }; + const b: StoredWorkspaceV2 = { + storageVersion: 2, id: 'b', key: 'b', name: 'B', queries: [], dashboard: null, + }; + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'a', mode: 'edit' }; + app.applyCommittedWorkspace(a); + app.renderDashboard(); + const create = qs(app.root, '.dash-create'); + const mutate = vi.fn(); + app.mutateWorkspace = mutate; + let resolveB!: (result: WorkspaceLoadResult) => void; + app.workspace.loadByKey = vi.fn(() => + new Promise((resolve) => { resolveB = resolve; })); + app.workspace.markOpened = vi.fn(async () => ({ ok: true as const })); + + const navigation = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); + expect(create.isConnected).toBe(false); + expect(create.disabled).toBe(true); + create.click(); + expect(mutate).not.toHaveBeenCalled(); + + resolveB({ status: 'ok', workspace: b }); + await navigation; + }); + + it('an in-flight Create dashboard completion cannot repaint after switching to Workbench', async () => { + const app = createApp(env()); + const workspace: StoredWorkspaceV2 = { + storageVersion: 2, id: 'w', key: 'w', name: 'W', queries: [], dashboard: null, + }; + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + const { commit, release } = deferNextCommit(app); + qs(app.root, '.dash-create').click(); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => expect(app.currentWorkspace!.dashboard).not.toBeNull()); + + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(qs(app.root, '.dash-page')).toBeNull(); + }); + + it('discards a slower workspace navigation after a newer destination wins', async () => { + const app = createApp(env()); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + app.currentWorkspace = { + storageVersion: 2, id: 'a', key: 'a', name: 'A', queries: [], dashboard: null, + }; + app.workspaceRouteStatus = 'ready'; + const resolvers = new Map void>(); + app.workspace.loadByKey = vi.fn((key: string) => + new Promise((resolve) => resolvers.set(key, resolve))); + app.workspace.markOpened = vi.fn(async (key) => ({ + ok: true as const, + workspace: { + storageVersion: 2 as const, id: key, key, name: key.toUpperCase(), + queries: [], dashboard: null, + }, + })); + app.renderCurrentSurface = vi.fn(); + const toB = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); + const toC = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'c' }, 'push'); + const c: StoredWorkspaceV2 = { + storageVersion: 2, id: 'c', key: 'c', name: 'C', queries: [], dashboard: null, + }; + resolvers.get('c')!({ status: 'ok', workspace: c }); + await toC; + const b: StoredWorkspaceV2 = { + storageVersion: 2, id: 'b', key: 'b', name: 'B', queries: [], dashboard: null, + }; + resolvers.get('b')!({ status: 'ok', workspace: b }); + await toB; + expect(app.sqlRoute.workspaceKey).toBe('c'); + expect(app.currentWorkspace).toBe(c); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it('discards a route whose last-used write finishes after a newer navigation', async () => { + const app = createApp(env()); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + app.currentWorkspace = { + storageVersion: 2, id: 'a', key: 'a', name: 'A', queries: [], dashboard: null, + }; + const workspace = (key: string): StoredWorkspaceV2 => ({ + storageVersion: 2, id: key, key, name: key.toUpperCase(), queries: [], dashboard: null, + }); + app.workspace.loadByKey = vi.fn(async (key) => ({ status: 'ok' as const, workspace: workspace(key) })); + let releaseB!: () => void; + app.workspace.markOpened = vi.fn((key: string): Promise => key === 'b' + ? new Promise((resolve) => { + releaseB = () => resolve({ ok: true as const }); + }) + : Promise.resolve({ ok: true as const })); + app.renderCurrentSurface = vi.fn(); + const toB = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); + await Promise.resolve(); + await Promise.resolve(); + const toC = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'c' }, 'push'); + await toC; + releaseB(); + await toB; + expect(app.currentWorkspace?.key).toBe('c'); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it('a refresh started for one workspace cannot overwrite a newer routed workspace', async () => { + const app = createApp(env()); + const workspace = (key: string): StoredWorkspaceV2 => ({ + storageVersion: 2, id: key, key, name: key.toUpperCase(), queries: [], dashboard: null, + }); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + app.applyCommittedWorkspace(workspace('a')); + let resolveRefresh!: (result: WorkspaceLoadResult) => void; + app.workspace.loadById = vi.fn(() => + new Promise((resolve) => { resolveRefresh = resolve; })); + app.workspace.loadByKey = vi.fn(async (key) => ({ + status: 'ok' as const, workspace: workspace(key), + })); + app.workspace.markOpened = vi.fn(async () => ({ ok: true as const })); + app.renderCurrentSurface = vi.fn(); + + const refreshA = app.refreshWorkspaceFromStore(); + await Promise.resolve(); + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); + resolveRefresh({ status: 'ok', workspace: workspace('a') }); + await refreshA; + + expect(app.sqlRoute.workspaceKey).toBe('b'); + expect(app.currentWorkspace?.key).toBe('b'); + expect(app.state.workspaceKey).toBe('b'); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it('a stale popstate load never repaints over a newer Back/Forward destination', async () => { + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '?ws=a', + hash: '', host: 'ch.example', href: 'https://ch.example/sql?ws=a', + } as Location; + const app = createApp(env({ location })); + const resolvers = new Map void>(); + app.workspace.loadByKey = vi.fn((key: string) => + new Promise((resolve) => resolvers.set(key, resolve))); + app.workspace.markOpened = vi.fn(async (key) => ({ + ok: true as const, + workspace: { + storageVersion: 2 as const, id: key, key, name: key.toUpperCase(), + queries: [], dashboard: null, + }, + })); + app.renderCurrentSurface = vi.fn(); + location.search = '?ws=b'; + const backToB = app.handleSqlPopState(); + location.search = '?ws=c'; + const forwardToC = app.handleSqlPopState(); + const c: StoredWorkspaceV2 = { + storageVersion: 2, id: 'c', key: 'c', name: 'C', queries: [], dashboard: null, + }; + resolvers.get('c')!({ status: 'ok', workspace: c }); + await forwardToC; + const b: StoredWorkspaceV2 = { + storageVersion: 2, id: 'b', key: 'b', name: 'B', queries: [], dashboard: null, + }; + resolvers.get('b')!({ status: 'ok', workspace: b }); + await backToB; + expect(app.currentWorkspace).toBe(c); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it('same-workspace popstate switches surface immediately without reloading', async () => { + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '?ws=a', + hash: '', host: 'ch.example', href: 'https://ch.example/sql?ws=a', + } as Location; + const app = createApp(env({ location })); + const a: StoredWorkspaceV2 = { + storageVersion: 2, id: 'a', key: 'a', name: 'A', queries: [], dashboard: null, + }; + app.applyCommittedWorkspace(a); + app.renderApp(); + const loadByKey = vi.spyOn(app.workspace, 'loadByKey'); + location.search = '?ws=a&surface=dashboard&mode=view'; + + await app.handleSqlPopState(); + + expect(loadByKey).not.toHaveBeenCalled(); + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'a', mode: 'view', + }); + expect(qs(app.root, '.dash-page')).not.toBeNull(); + expect(qs(app.root, '.workspace-loading')).toBeNull(); + }); + + it('a Dashboard edit commit that settles after switching to View refreshes View only', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + const { commit, release } = deferNextCommit(app); + pickDashboardStyle(app, '2 columns'); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: 'w', mode: 'view', + }, 'replace'); + expect(qsa(app.root, '.dashboard-mode-switch .editor-mode-btn') + .find((button) => button.textContent === 'View')!.disabled).toBe(true); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => { + expect((app.currentWorkspace!.dashboard!.layout as { preset?: string }).preset).toBe('columns-2'); + }); + + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'w', mode: 'view', + }); + expect(qs(app.root, '.dash-page')).not.toBeNull(); + expect(qs(app.root, '.workbench')).toBeNull(); + expect(qs(app.root, '.dash-gg-del')).toBeNull(); + }); + + it('a Dashboard edit commit that settles after switching to Workbench cannot replace Workbench', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + const { commit, release } = deferNextCommit(app); + pickDashboardStyle(app, '3 columns'); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); + expect(qs(app.root, '.workbench')).not.toBeNull(); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => { + expect((app.currentWorkspace!.dashboard!.layout as { preset?: string }).preset).toBe('columns-3'); + }); + + expect(app.sqlRoute).toEqual({ surface: 'workspace', workspaceKey: 'w' }); + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(qs(app.root, '.dash-page')).toBeNull(); + }); + + it('a stale Dashboard rollback callback never replaces the current Workbench DOM', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'dashboard', workspaceKey: 'w', mode: 'edit' }; + app.renderDashboard(); + let rejectCommit!: () => void; + const commit = vi.spyOn(app.workspace, 'commit').mockImplementation(() => + new Promise((_resolve, reject) => { + rejectCommit = () => reject(new Error('boom')); + })); + pickDashboardStyle(app, '2 columns'); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'w' }, 'push'); + rejectCommit(); + await app.flushWorkspaceWrites(); + await Promise.resolve(); + + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(qs(app.root, '.dash-page')).toBeNull(); + expect(document.querySelector('.share-toast')).toBeNull(); + }); + + it('a linked Workbench Save that settles after switching to Dashboard refreshes Dashboard only', async () => { + const linked = savedQueryFixture({ id: 'q1', name: 'Query 1', sql: 'SELECT 1' }); + const app = createApp(env()); + const workspace = dashboardWorkspace([linked]); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + app.renderApp(); + app.actions.loadIntoNewTab(asQueryOrName(linked)); + app.sqlEditor.replaceDocument('SELECT 2'); + const { commit, release } = deferNextCommit(app); + + const save = app.actions.save(); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + await app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: 'w', mode: 'edit', + }, 'push'); + release(); + await save; + await app.flushWorkspaceWrites(); + await vi.waitFor(() => { + expect(app.currentWorkspace!.queries[0].sql).toBe('SELECT 2'); + }); + + expect(app.sqlRoute).toEqual({ + surface: 'dashboard', workspaceKey: 'w', mode: 'edit', + }); + expect(qs(app.root, '.dash-page')).not.toBeNull(); + expect(qs(app.root, '.workbench')).toBeNull(); + }); + + it('a Workbench Save-as completion cannot settle its removed popover after switching to Dashboard', async () => { + const app = createApp(env()); + const workspace = dashboardWorkspace(); + await seedActiveWorkspace(app, workspace); + app.sqlRoute = { surface: 'workspace', workspaceKey: 'w' }; + app.renderApp(); + app.sqlEditor.replaceDocument('SELECT 42'); + const { commit, release } = deferNextCommit(app); + await app.actions.save(); + qs(document, '.save-popover .sp-input').value = 'Saved later'; + qs(document, '.save-popover .sp-save').click(); + await vi.waitFor(() => expect(commit).toHaveBeenCalledOnce()); + + await app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: 'w', mode: 'edit', + }, 'push'); + release(); + await app.flushWorkspaceWrites(); + await vi.waitFor(() => expect(app.currentWorkspace!.queries).toHaveLength(1)); + + expect(app.currentWorkspace!.queries[0].sql).toBe('SELECT 42'); + expect(qs(app.root, '.dash-page')).not.toBeNull(); + expect(qs(app.root, '.workbench')).toBeNull(); + expect(document.querySelector('.save-popover')).toBeNull(); + }); + + it('a mutation that observes external deletion transitions to not-found', async () => { + const app = createApp(env()); + app.currentWorkspace = { + storageVersion: 2, id: app.state.workspaceId, key: 'w', name: 'W', + queries: [], dashboard: null, + }; + app.workspaceRouteStatus = 'ready'; + app.workspace.loadById = vi.fn(async () => ({ status: 'empty' as const })); + app.renderCurrentSurface = vi.fn(); + const transform = vi.fn(); + await expect(app.mutateWorkspace(transform)).resolves.toMatchObject({ + ok: false, aborted: true, + }); + expect(transform).not.toHaveBeenCalled(); + expect(app.currentWorkspace).toBeNull(); + expect(app.workspaceRouteStatus).toBe('not-found'); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it('a mutation whose transform settles after route loading begins aborts before commit', async () => { + const app = createApp(env()); + const a: StoredWorkspaceV2 = { + storageVersion: 2, id: app.state.workspaceId, key: 'a', name: 'A', + queries: [], dashboard: null, + }; + app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + app.applyCommittedWorkspace(a); + app.workspace.loadById = vi.fn(async () => ({ status: 'ok' as const, workspace: a })); + app.workspace.commit = vi.fn(); + let finishTransform!: (value: { candidate: StoredWorkspaceV2 }) => void; + const mutation = app.mutateWorkspace(() => + new Promise<{ candidate: StoredWorkspaceV2 }>((resolve) => { finishTransform = resolve; })); + await Promise.resolve(); + await Promise.resolve(); + app.workspaceRouteStatus = 'loading'; + app.sqlRoute = { surface: 'workspace', workspaceKey: 'b' }; + finishTransform({ candidate: a }); + + await expect(mutation).resolves.toMatchObject({ ok: false, aborted: true }); + expect(app.workspace.commit).not.toHaveBeenCalled(); + const blockedTransform = vi.fn(); + await expect(app.mutateWorkspace(blockedTransform)).resolves.toMatchObject({ + ok: false, aborted: true, + }); + expect(blockedTransform).not.toHaveBeenCalled(); + }); + + it('a queued mutation aborts if route loading begins before it dequeues', async () => { + const app = createApp(env()); + const a: StoredWorkspaceV2 = { + storageVersion: 2, id: app.state.workspaceId, key: 'a', name: 'A', + queries: [], dashboard: null, + }; + app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + app.applyCommittedWorkspace(a); + let releaseQueue!: () => void; + const queuedAhead = app.serializeWrite(() => + new Promise((resolve) => { releaseQueue = resolve; })); + await Promise.resolve(); + const transform = vi.fn(); + const mutation = app.mutateWorkspace(transform); + app.workspaceRouteStatus = 'loading'; + releaseQueue(); + await queuedAhead; + + await expect(mutation).resolves.toMatchObject({ ok: false, aborted: true }); + expect(transform).not.toHaveBeenCalled(); + }); + + it('a commit that finishes after navigation stays durable but cannot repaint the new route', async () => { + const app = createApp(env()); + const a: StoredWorkspaceV2 = { + storageVersion: 2, id: app.state.workspaceId, key: 'a', name: 'A', + queries: [], dashboard: null, + }; + const changed = { ...a, name: 'Changed A' }; + app.sqlRoute = { surface: 'workspace', workspaceKey: 'a' }; + app.applyCommittedWorkspace(a); + app.workspace.loadById = vi.fn(async () => ({ status: 'ok' as const, workspace: a })); + let finishCommit!: () => void; + app.workspace.commit = vi.fn(() => new Promise((resolve) => { + finishCommit = () => resolve({ + ok: true as const, workspace: changed, dashboardRevision: null, + }); + })); + const mutation = app.mutateWorkspace(async () => ({ candidate: changed })); + await vi.waitFor(() => expect(app.workspace.commit).toHaveBeenCalledOnce()); + app.workspaceRouteStatus = 'loading'; + app.sqlRoute = { surface: 'workspace', workspaceKey: 'b' }; + finishCommit(); + + await expect(mutation).resolves.toMatchObject({ ok: false, aborted: true }); + expect(app.currentWorkspace).toBe(a); + expect(app.workspace.commit).toHaveBeenCalledWith(changed); + }); + + it('the registered popstate listener delegates to the route coordinator', async () => { + let listener: EventListener | null = null; + const addEventListener = vi.spyOn(window, 'addEventListener').mockImplementation( + ((type: string, callback: EventListenerOrEventListenerObject) => { + if (type === 'popstate' && typeof callback === 'function') listener = callback; + }) as typeof window.addEventListener, + ); + const app = createApp(env()); + app.handleSqlPopState = vi.fn(async () => {}); + expect(listener).not.toBeNull(); + listener!(new PopStateEvent('popstate')); + await Promise.resolve(); + expect(app.handleSqlPopState).toHaveBeenCalledOnce(); + addEventListener.mockRestore(); + }); + + it('the Dashboard export/import actions remain available without snapshot handoff', () => { const app = createApp(env()); - // exportDashboard with no dashboard just toasts (no throw); importDashboard - // opens a picker input on the page — both exercise the action arrow bodies. app.state.dashboard = null; expect(() => app.actions.exportDashboard()).not.toThrow(); expect(() => app.actions.importDashboard()).not.toThrow(); diff --git a/tests/unit/connection-session.test.ts b/tests/unit/connection-session.test.ts index 9a104477..716ec854 100644 --- a/tests/unit/connection-session.test.ts +++ b/tests/unit/connection-session.test.ts @@ -19,34 +19,6 @@ const expiredToken = jwt({ email: 'gone@example.com', exp: nowSec() - 10 }); const _storageTypeCheck: SessionStorageLike = memStorage(); void _storageTypeCheck; -/** A test-only `MessageEvent`-shaped object — the session only ever reads - * `data`/`origin`/`source` off a dispatched message (matches - * `core/auth-handoff.ts`'s own `AuthMessageEvent` contract). */ -interface FakeMessageEvent { data: unknown; origin: string; source: unknown } - -/** A fake `win` seam: captures `message` listeners (so tests can dispatch - * synthetic events at them) and queued `setTimeout` callbacks (so tests can - * fire the handoff timeout deterministically instead of sleeping). Cast to - * the narrow `Pick` the session declares — the same "widen the - * fake, single cast" idiom `tests/unit/dashboard.test.ts`'s own `asWindow` - * helper uses for the same reason (a plain object only ever needs the few - * members real code reads, not the whole `Window` interface). */ -function makeWin() { - const listeners = new Set<(e: MessageEvent) => void>(); - const timeouts: (() => void)[] = []; - const raw = { - addEventListener: (_type: string, fn: (e: MessageEvent) => void) => { listeners.add(fn); }, - removeEventListener: (_type: string, fn: (e: MessageEvent) => void) => { listeners.delete(fn); }, - setTimeout: (fn: () => void, _ms?: number): number => { timeouts.push(fn); return timeouts.length; }, - }; - return { - win: raw as unknown as Pick, - dispatch: (e: FakeMessageEvent): void => { for (const fn of [...listeners]) fn(e as unknown as MessageEvent); }, - listenerCount: (): number => listeners.size, - fireTimeouts: (): void => { const t = timeouts.splice(0); for (const fn of t) fn(); }, - }; -} - interface FakeResponse { ok: boolean; status: number; json(): Promise; text(): Promise } function jsonResponse(status: number, body: unknown): FakeResponse { return { ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) }; @@ -97,12 +69,9 @@ interface SetupOpts { location?: { origin: string; pathname: string; search: string; href: string }; routes?: RouteFn[]; queryJson?: QueryJsonFn; - handoffMs?: number; - handoffListenMs?: number; onAuthLost?: (detail?: string) => void; } function setup(opts: SetupOpts = {}) { - const win = makeWin(); const fetchMock = makeFetch(opts.routes || []); const storage = opts.storage || memStorage(); const onAuthLost = opts.onAuthLost || vi.fn(); @@ -115,13 +84,10 @@ function setup(opts: SetupOpts = {}) { // sibling spec stubbing the global (or a differently-ordered aggregation // where it's undefined at setup() time) must not break PKCE here. crypto: webcrypto, - win: win.win, queryJson: opts.queryJson || fakeQueryJson(async () => ({ data: [{ 1: 1 }] })), - handoffMs: opts.handoffMs ?? 50, - handoffListenMs: opts.handoffListenMs ?? 200, onAuthLost, }; - return { deps, storage, location, win, fetchMock, onAuthLost, session: createConnectionSession(deps) }; + return { deps, storage, location, fetchMock, onAuthLost, session: createConnectionSession(deps) }; } // ── construction seeding ───────────────────────────────────────────────────── @@ -162,8 +128,8 @@ describe('construction seeding', () => { expect(session.idpId()).toBe('g'); }); - it('resolves basePath from /sql/dashboard via configBase', () => { - const { session } = setup({ location: { origin: 'https://ch.example', pathname: '/sql/dashboard', search: '', href: '' } }); + it('uses the unified /sql pathname as basePath', () => { + const { session } = setup({ location: { origin: 'https://ch.example', pathname: '/sql/', search: '?surface=dashboard', href: '' } }); expect(session.basePath).toBe('/sql'); }); @@ -333,6 +299,9 @@ describe('beginOAuth', () => { expect(storage.getItem('oauth_origin')).toBe('https://cluster.example'); expect(storage.getItem('oauth_verifier')).toBeTruthy(); expect(storage.getItem('oauth_state')).toBeTruthy(); + expect(JSON.parse(storage.getItem('oauth_return_route')!)).toEqual({ + state: storage.getItem('oauth_state'), search: '', + }); expect(location.href).not.toBe(''); const url = new URL(location.href); expect(url.origin + url.pathname).toBe('https://issuer.example/authorize'); @@ -348,6 +317,21 @@ describe('beginOAuth', () => { expect(storage.getItem('oauth_origin')).toBeNull(); expect(session.idpId()).toBe('g'); }); + + it('associates the pre-login application route with the OAuth state', async () => { + const { session, storage } = setup({ + location: { + origin: 'https://ch.example', pathname: '/sql', + search: '?ws=ops&surface=dashboard&mode=view&code=stale&keep=1', + href: 'https://ch.example/sql?ws=ops&surface=dashboard&mode=view&code=stale&keep=1', + }, + }); + await session.beginOAuth('g'); + expect(JSON.parse(storage.getItem('oauth_return_route')!)).toEqual({ + state: storage.getItem('oauth_state'), + search: '?ws=ops&surface=dashboard&mode=view&keep=1', + }); + }); }); // ── config ─────────────────────────────────────────────────────────────────── @@ -518,133 +502,6 @@ describe('ensureFreshToken', () => { }); }); -// ── cross-tab auth handoff ─────────────────────────────────────────────────── - -describe('grantHandoffTo', () => { - it('answers a pinned request with a snapshot, only when it holds credentials, then stops listening', () => { - const { session, win, location } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); - const child = { postMessage: vi.fn() }; - session.grantHandoffTo(child as unknown as Window); - win.dispatch({ data: { type: 'asb-auth-request' }, origin: location.origin, source: child }); - expect(child.postMessage).toHaveBeenCalledTimes(1); - const [payload, origin] = child.postMessage.mock.calls[0]; - expect(payload.type).toBe('asb-auth-grant'); - expect(payload.creds.oauth_id_token).toBe(validToken); - expect(origin).toBe(location.origin); - expect(win.listenerCount()).toBe(0); - }); - - it('ignores a request from the wrong origin or the wrong source window', () => { - const { session, win, location } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); - const child = { postMessage: vi.fn() }; - const stranger = { postMessage: vi.fn() }; - session.grantHandoffTo(child as unknown as Window); - win.dispatch({ data: { type: 'asb-auth-request' }, origin: 'https://evil.example', source: child }); - win.dispatch({ data: { type: 'asb-auth-request' }, origin: location.origin, source: stranger }); - expect(child.postMessage).not.toHaveBeenCalled(); - expect(win.listenerCount()).toBe(1); - }); - - it('grants nothing (but still stops listening) when it holds no credentials', () => { - const { session, win, location } = setup(); - const child = { postMessage: vi.fn() }; - session.grantHandoffTo(child as unknown as Window); - win.dispatch({ data: { type: 'asb-auth-request' }, origin: location.origin, source: child }); - expect(child.postMessage).not.toHaveBeenCalled(); - expect(win.listenerCount()).toBe(0); - }); - - it('stops listening after handoffListenMs even if the child never asks', () => { - const { session, win, location } = setup({ storage: memStorage({ oauth_id_token: validToken }) }); - const child = { postMessage: vi.fn() }; - session.grantHandoffTo(child as unknown as Window); - win.fireTimeouts(); - win.dispatch({ data: { type: 'asb-auth-request' }, origin: location.origin, source: child }); - expect(child.postMessage).not.toHaveBeenCalled(); - }); -}); - -describe('receiveAuthHandoff', () => { - it('resolves false immediately with no opener', async () => { - const { session } = setup(); - await expect(session.receiveAuthHandoff({})).resolves.toBe(false); - }); - - it('requests, then applies a full OAuth grant (token/refresh/idp/origin all updated)', async () => { - const { session, win, storage, location } = setup(); - const opener = { postMessage: vi.fn() }; - const newTok = jwt({ email: 'x@y.com', exp: nowSec() + 3600 }); - const p = session.receiveAuthHandoff({ opener: opener as unknown as Window }); - expect(opener.postMessage).toHaveBeenCalledWith({ type: 'asb-auth-request' }, location.origin); - win.dispatch({ data: { type: 'other' }, origin: location.origin, source: opener }); // ignored: wrong type - win.dispatch({ - data: { type: 'asb-auth-grant', creds: { oauth_id_token: newTok, oauth_refresh_token: 'r9', oauth_idp: 'zz', oauth_origin: 'https://cluster.example' } }, - origin: location.origin, source: opener, - }); - await expect(p).resolves.toBe(true); - expect(session.token()).toBe(newTok); - expect(session.refreshToken()).toBe('r9'); - expect(session.idpId()).toBe('zz'); - expect(session.chCtx.origin).toBe('https://cluster.example'); - expect(storage.getItem('oauth_id_token')).toBe(newTok); - }); - - it('applies a partial OAuth grant, falling back to the serving origin with no idp change', async () => { - const { session, win, location } = setup(); - const opener = { postMessage: vi.fn() }; - const newTok = jwt({ email: 'x@y.com', exp: nowSec() + 3600 }); - const p = session.receiveAuthHandoff({ opener: opener as unknown as Window }); - win.dispatch({ data: { type: 'asb-auth-grant', creds: { oauth_id_token: newTok } }, origin: location.origin, source: opener }); - await expect(p).resolves.toBe(true); - expect(session.token()).toBe(newTok); - expect(session.idpId()).toBeNull(); - expect(session.chCtx.origin).toBe(location.origin); - }); - - it('applies a basic grant', async () => { - const { session, win, location } = setup(); - const opener = { postMessage: vi.fn() }; - const p = session.receiveAuthHandoff({ opener: opener as unknown as Window }); - win.dispatch({ data: { type: 'asb-auth-grant', creds: { ch_basic_auth: 'YWJj', ch_basic_origin: 'https://other.example' } }, origin: location.origin, source: opener }); - await expect(p).resolves.toBe(true); - expect(session.authMode()).toBe('basic'); - expect(session.chCtx.origin).toBe('https://other.example'); - }); - - it('ignores an empty grant and times out into false', async () => { - const { session, win, location } = setup(); - const opener = { postMessage: vi.fn() }; - const p = session.receiveAuthHandoff({ opener: opener as unknown as Window }); - win.dispatch({ data: { type: 'asb-auth-grant', creds: {} }, origin: location.origin, source: opener }); - win.fireTimeouts(); - await expect(p).resolves.toBe(false); - }); - - it('ignores a grant from the wrong source window', async () => { - const { session, win, location } = setup(); - const opener = { postMessage: vi.fn() }; - const stranger = { postMessage: vi.fn() }; - const newTok = jwt({ email: 'x@y.com', exp: nowSec() + 3600 }); - const p = session.receiveAuthHandoff({ opener: opener as unknown as Window }); - win.dispatch({ data: { type: 'asb-auth-grant', creds: { oauth_id_token: 'from-stranger' } }, origin: location.origin, source: stranger }); - win.dispatch({ data: { type: 'asb-auth-grant', creds: { oauth_id_token: newTok } }, origin: location.origin, source: opener }); - await expect(p).resolves.toBe(true); - expect(session.token()).toBe(newTok); - }); - - it('resolves once — a second grant after the first is ignored', async () => { - const { session, win, location } = setup(); - const opener = { postMessage: vi.fn() }; - const tokA = jwt({ email: 'a@x.com', exp: nowSec() + 3600 }); - const tokB = jwt({ email: 'b@x.com', exp: nowSec() + 3600 }); - const p = session.receiveAuthHandoff({ opener: opener as unknown as Window }); - win.dispatch({ data: { type: 'asb-auth-grant', creds: { oauth_id_token: tokA } }, origin: location.origin, source: opener }); - await expect(p).resolves.toBe(true); - win.dispatch({ data: { type: 'asb-auth-grant', creds: { oauth_id_token: tokB } }, origin: location.origin, source: opener }); - expect(session.token()).toBe(tokA); - }); -}); - // ── host() ─────────────────────────────────────────────────────────────────── describe('host()', () => { diff --git a/tests/unit/cross-tab-consistency.test.ts b/tests/unit/cross-tab-consistency.test.ts index fa48ce83..5c971de1 100644 --- a/tests/unit/cross-tab-consistency.test.ts +++ b/tests/unit/cross-tab-consistency.test.ts @@ -385,17 +385,14 @@ describe('cross-tab refresh + linked-tab reconcile (#343)', () => { void a; }); - it('a detached read-only Dashboard tab ignores primary-workspace invalidation at the app level', async () => { + it('a live read-only Dashboard tab refreshes from the primary workspace', async () => { const store = fakeIndexedDbFactory(); const a = tab(store); const b = tab(store); await seed(a, b, oneQuery()); - // Simulate this tab being a detached/read-only Dashboard route (renders from - // a detached snapshot, not the primary workspace). - a.dashboardReadOnly = true; - const before = a.state.savedQueries; + // #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(); // guard: a read-only route projects nothing - expect(a.state.savedQueries).toBe(before); // same reference — never reprojected + await a.refreshWorkspaceFromStore(); + expect(a.state.savedQueries[0].spec.name).toBe('Changed by B'); }); it('works without a BroadcastChannel (null factory): refresh still projects', async () => { diff --git a/tests/unit/dashboard-boundaries.test.js b/tests/unit/dashboard-boundaries.test.js index 35a351f7..6c031767 100644 --- a/tests/unit/dashboard-boundaries.test.js +++ b/tests/unit/dashboard-boundaries.test.js @@ -84,11 +84,8 @@ describe('dashboard dependency boundaries', () => { expect(specs.some((spec) => FORBIDDEN.some((f) => spec === f || spec.startsWith(`${f}/`)))).toBe(false); }); - // #288 Phase 6: the workspace aggregate + Dashboard-viewing stores (the - // one-time handoff transport and the persistent detached-views store) are the - // read-only viewer path's persistence layer — they must stay constructible - // without any UI/App/editor/service/net dependency, so the viewer bundles no - // Workbench/editor construction. + // #407: the live workspace aggregate is the Dashboard viewer's persistence + // layer. It remains constructible without UI/App/editor/service/net modules. it('src/workspace imports no Workbench UI / App / AppState / editor / service / net modules', () => { expect(violations('src/workspace')).toEqual([]); }); diff --git a/tests/unit/dashboard-open-source.test.ts b/tests/unit/dashboard-open-source.test.ts deleted file mode 100644 index 2b2160e8..00000000 --- a/tests/unit/dashboard-open-source.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parseDashboardOpenSource, buildDashboardSearch, -} from '../../src/dashboard/application/dashboard-open-source.js'; -import type { DashboardOpenSource } from '../../src/dashboard/application/dashboard-open-source.js'; - -describe('parseDashboardOpenSource', () => { - it('resolves a current-workspace source from ?ws=&dash=', () => { - expect(parseDashboardOpenSource('?ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', - }); - }); - - it('resolves a session-bundle source from ?st=&dash=', () => { - expect(parseDashboardOpenSource('?st=tok123&dash=def')).toEqual({ - kind: 'session-bundle', token: 'tok123', dashboardId: 'def', - }); - }); - - it('prefers st over ws when both are present', () => { - expect(parseDashboardOpenSource('?st=tok&ws=abc&dash=def')).toEqual({ - kind: 'session-bundle', token: 'tok', dashboardId: 'def', - }); - }); - - it('defaults a missing dash to the empty string', () => { - expect(parseDashboardOpenSource('?ws=abc')).toEqual({ - kind: 'current-workspace', workspaceKey: 'abc', dashboardId: '', - }); - expect(parseDashboardOpenSource('?st=tok')).toEqual({ - kind: 'session-bundle', token: 'tok', dashboardId: '', - }); - }); - - it('returns null for the legacy bare /dashboard open (no ws, no st)', () => { - expect(parseDashboardOpenSource('?dash=def')).toBeNull(); - }); - - it('returns null for empty, ?-only, or garbage search strings', () => { - expect(parseDashboardOpenSource('')).toBeNull(); - expect(parseDashboardOpenSource('?')).toBeNull(); - expect(parseDashboardOpenSource('garbage')).toBeNull(); - }); - - it('treats an empty ws or st value as absent (falls through)', () => { - expect(parseDashboardOpenSource('?ws=&dash=def')).toBeNull(); - expect(parseDashboardOpenSource('?st=&ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', - }); - }); - - it('URL-decodes ws, st, and dash', () => { - expect(parseDashboardOpenSource('?ws=a%20b&dash=c%2Fd')).toEqual({ - kind: 'current-workspace', workspaceKey: 'a b', dashboardId: 'c/d', - }); - expect(parseDashboardOpenSource('?st=tok%2B1&dash=x')).toEqual({ - kind: 'session-bundle', token: 'tok+1', dashboardId: 'x', - }); - }); - - it('ignores the OAuth CSRF `state` param — never treated as a token', () => { - expect(parseDashboardOpenSource('?state=csrf-value&dash=def')).toBeNull(); - // `state` present alongside a real source must not interfere. - expect(parseDashboardOpenSource('?state=csrf-value&ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', - }); - }); - - it('accepts a search string without a leading ?', () => { - expect(parseDashboardOpenSource('ws=abc&dash=def')).toEqual({ - kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def', - }); - }); -}); - -describe('buildDashboardSearch', () => { - it('builds ?ws=&dash= for a current-workspace source', () => { - expect(buildDashboardSearch({ kind: 'current-workspace', workspaceKey: 'abc', dashboardId: 'def' })) - .toBe('?ws=abc&dash=def'); - }); - - it('builds ?st=&dash= for a session-bundle source', () => { - expect(buildDashboardSearch({ kind: 'session-bundle', token: 'tok123', dashboardId: 'def' })) - .toBe('?st=tok123&dash=def'); - }); - - it('URL-encodes values that need it', () => { - expect(buildDashboardSearch({ kind: 'current-workspace', workspaceKey: 'a b', dashboardId: 'c/d' })) - .toBe('?ws=a+b&dash=c%2Fd'); - }); - - it('round-trips both kinds through parse', () => { - const sources: DashboardOpenSource[] = [ - { kind: 'current-workspace', workspaceKey: 'ws-1', dashboardId: 'dash-1' }, - { kind: 'session-bundle', token: 'tok-1', dashboardId: 'dash-1' }, - ]; - for (const source of sources) { - expect(parseDashboardOpenSource(buildDashboardSearch(source))).toEqual(source); - } - }); -}); diff --git a/tests/unit/dashboard.test.ts b/tests/unit/dashboard.test.ts index 6572bfbb..9559dfff 100644 --- a/tests/unit/dashboard.test.ts +++ b/tests/unit/dashboard.test.ts @@ -1,16 +1,11 @@ import { describe, it, expect, vi, afterEach, type Mock } from 'vitest'; import { - isDashboardRoute, configBase, normalizeDashLayout, normalizeDashCols, DASH_TILE_ROW_CAP, DASH_TILE_BYTE_CAP, DASH_TABLE_DISPLAY_CAP, activeDashboardView, dashboardViewSelection, partitionKpiBands, } from '../../src/core/dashboard.js'; import { KEYS } from '../../src/state.js'; import * as storage from '../../src/core/storage.js'; import { CHART_ROW_CAPS } from '../../src/core/chart-data.js'; -import { - AUTH_SS_KEYS, AUTH_REQUEST, AUTH_GRANT, - snapshotAuth, restoreAuth, hasAuth, isAuthRequest, isAuthGrant, -} from '../../src/core/auth-handoff.js'; import { renderDashboard } from '../../src/ui/dashboard.js'; import { applyCommand } from '../../src/dashboard/application/dashboard-commands.js'; import { createQueryResolver } from '../../src/dashboard/application/dashboard-query-resolver.js'; @@ -50,27 +45,6 @@ const runOnclick = (el: HTMLElement | null): unknown => ((el as HTMLElement).onc * attached div for every fixture this file builds. */ const rootEl = (app: App): HTMLElement => app.root as HTMLElement; -// ── core/dashboard.js ─────────────────────────────────────────────────────── -describe('isDashboardRoute', () => { - it('matches the dashboard path (with or without a trailing slash), nothing else', () => { - expect(isDashboardRoute('/sql/dashboard')).toBe(true); - expect(isDashboardRoute('/sql/dashboard/')).toBe(true); - expect(isDashboardRoute('/tools/sql/dashboard')).toBe(true); // mount-agnostic (matches configBase) - expect(isDashboardRoute('/sql')).toBe(false); - expect(isDashboardRoute('/sql/config.json')).toBe(false); - expect(isDashboardRoute(undefined)).toBe(false); - }); -}); - -describe('configBase', () => { - it('strips a trailing /dashboard so config resolves from the SPA base', () => { - expect(configBase('/sql/dashboard')).toBe('/sql'); - expect(configBase('/sql/dashboard/')).toBe('/sql'); - expect(configBase('/sql')).toBe('/sql'); - expect(configBase(undefined)).toBe(''); - }); -}); - // (dashboardTileSql + parseJsonResult were retired in #193 — the tiles stream // through the shared app.exec.executeRead seam, so SQL prep is now just the shared // materialization (#165) and the client row bound is newResult's trim + `capped` @@ -159,7 +133,7 @@ describe('partitionKpiBands (#240)', () => { // field discovery is now `fieldControls(analysis)`, tested with the pipeline in // param-pipeline.test.js and end-to-end in the filter-bar suite below.) -// ── core/auth-handoff.js ───────────────────────────────────────────────────── +// ── auth/window fixture helpers ────────────────────────────────────────────── /** A minimal sessionStorage-like stub — a real `Storage` structurally * (length/key/clear included), so it plugs straight into `env.sessionStorage` * (`CreateAppEnv`) with no cast. */ @@ -186,57 +160,11 @@ function memSession(initial: Record = {}): MemSession { }; } -describe('auth-handoff snapshot/restore', () => { - it('snapshots only the present auth keys', () => { - const ss = memSession({ oauth_id_token: 't', oauth_idp: 'g', unrelated: 'x' }); - expect(snapshotAuth(ss)).toEqual({ oauth_id_token: 't', oauth_idp: 'g' }); - }); - it('restores present keys and ignores absent ones (and a null snapshot)', () => { - const ss = memSession(); - restoreAuth(ss, { oauth_id_token: 't', ch_basic_auth: 'b' }); - expect(ss.getItem('oauth_id_token')).toBe('t'); - expect(ss.getItem('ch_basic_auth')).toBe('b'); - expect(ss.getItem('oauth_idp')).toBeNull(); - expect(() => restoreAuth(ss, null)).not.toThrow(); - }); - it('AUTH_SS_KEYS covers both OAuth and basic sessions', () => { - expect(AUTH_SS_KEYS).toContain('oauth_id_token'); - expect(AUTH_SS_KEYS).toContain('ch_basic_auth'); - }); - it('hasAuth is true only with a token or basic creds', () => { - expect(hasAuth({ oauth_id_token: 't' })).toBe(true); - expect(hasAuth({ ch_basic_auth: 'b' })).toBe(true); - expect(hasAuth({})).toBe(false); - expect(hasAuth(null)).toBe(false); - }); -}); - -describe('auth-handoff message predicates', () => { - const src = {}; - const ok = (type: string) => ({ origin: 'https://o', source: src, data: { type } }); - it('isAuthRequest accepts a matching request only', () => { - expect(isAuthRequest(ok(AUTH_REQUEST), 'https://o', src)).toBe(true); - expect(isAuthRequest(null, 'https://o', src)).toBe(false); - expect(isAuthRequest({ ...ok(AUTH_REQUEST), origin: 'https://evil' }, 'https://o', src)).toBe(false); - expect(isAuthRequest({ ...ok(AUTH_REQUEST), source: {} }, 'https://o', src)).toBe(false); - expect(isAuthRequest({ origin: 'https://o', source: src }, 'https://o', src)).toBe(false); // no data - expect(isAuthRequest(ok('other'), 'https://o', src)).toBe(false); - }); - it('isAuthGrant accepts a matching grant only', () => { - expect(isAuthGrant(ok(AUTH_GRANT), 'https://o', src)).toBe(true); - expect(isAuthGrant(null, 'https://o', src)).toBe(false); - expect(isAuthGrant({ ...ok(AUTH_GRANT), origin: 'https://evil' }, 'https://o', src)).toBe(false); - expect(isAuthGrant({ ...ok(AUTH_GRANT), source: {} }, 'https://o', src)).toBe(false); - expect(isAuthGrant({ origin: 'https://o', source: src }, 'https://o', src)).toBe(false); - expect(isAuthGrant(ok('other'), 'https://o', src)).toBe(false); - }); -}); - // ── ui/dashboard.js (viewer-driven render, #286 — reads dashboard.tiles[]) ──── // The favorites-derived render was replaced by a DashboardViewerSession bound // to the persisted StoredWorkspaceV2; these tests drive renderDashboard through -// a fake `loadDashboardWorkspace` (a controlled workspace) + a fake streaming -// `executeRead`, exactly as the app wires the real repository + exec seam. +// a controlled current workspace + a fake streaming `executeRead`, exactly as +// the app wires the real repository projection + exec seam. type ExecuteReadResult = Parameters[0]; type ExecuteReadOpts = Parameters[1]; @@ -331,12 +259,18 @@ function dashApp(opts: { : { status: 'empty' as const } ), } as Partial, - // Mirrors production (`app.loadDashboardWorkspace` resolves the route key): - // reads the fixture's stateful `current`, so a route REBUILD (#350 — - // membership-restoring rollback) re-renders from committed truth, not the - // initially-loaded snapshot. - loadDashboardWorkspace: async () => current as never, + currentWorkspace: current, + workspaceRouteStatus: current ? 'ready' : 'not-found', + sqlRoute: { surface: 'dashboard', workspaceKey: current?.key ?? 'workspace', mode: 'edit' }, }) as TestApp; + let surfaceGeneration = 0; + app.captureSurfaceGeneration = () => surfaceGeneration; + app.isSurfaceGenerationCurrent = (generation) => generation === surfaceGeneration; + app.renderDashboard = () => { + surfaceGeneration += 1; + void renderDashboard(app as unknown as Parameters[0]); + }; + if (current) app.applyCommittedWorkspace(current); if (opts.savedQueries) app.state.savedQueries = opts.savedQueries as AppState['savedQueries']; const loadActive = async (): Promise => { const loaded = await app.workspace.loadById(app.state.workspaceId); @@ -353,13 +287,25 @@ const render = (app: TestApp): Promise => renderDashboard(app as unknown a // observe `commit` having been called; a macrotask flush lets every pending // microtask (the queue + the commit promise + its projection callback) run. const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); -/** The flow preset switcher (2026-07-18: a `