diff --git a/CHANGELOG.md b/CHANGELOG.md index df881644..e1681fbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,25 @@ auto-generated per-PR notes; this file is the curated, human-readable history. the file (in file order) instead of asking which single one to keep. ### Added +- **A Dashboard is now a full-size main work surface, selected by stable id** + (#425). Opening one replaces the complete SQL editor and result/data-drawer + area — the left sidebar stays visible — and a new toolbar carries + **Back to query**, the Dashboard title, and the View/Edit switch. Returning + finds the Query surface exactly as it was: the same editor contents, + selection, scroll, active tab, result view, and editor/results split, because + the surface is hidden rather than rebuilt. A surface change no longer cancels + the query running in the editor, and Back/Forward between surfaces of one + workspace no longer tears the editor down. Any Dashboard in the workspace can + be opened by its stable id in View or Edit mode, with an optional navigation + target that focuses, scrolls to, and briefly highlights one panel tile (by + tile id, never query id) or one curated filter (by filter id). Selection is + session state — never persisted, cleared on sign-out, and re-validated against + every committed workspace, so a deleted or ambiguous selection falls back to + Query mode instead of silently switching to another Dashboard; URLs are + unchanged. Export Dashboard and Import Dashboard now address the selected + Dashboard rather than the workspace's first one; the favourite star, which + still drives panel membership through the first Dashboard, declines with an + explanation while a different one is open (it is rewired in #427). - Surface-aware keyboard shortcuts for SQL Browser and Dashboard (#417). The shared, platform-aware shortcut catalog now drives both help and dispatch; Dashboard gains refresh, View/Edit, and `G` navigation commands while stale diff --git a/docs/ADR-0003-dashboard-viewing.md b/docs/ADR-0003-dashboard-viewing.md index 3b5870b9..c60c172c 100644 --- a/docs/ADR-0003-dashboard-viewing.md +++ b/docs/ADR-0003-dashboard-viewing.md @@ -1,9 +1,9 @@ # ADR-0003: Dashboard viewing and unified `/sql` routes - **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 + 2026-07-23; surface lifecycle amended by #425 on 2026-07-25 (see the addendum) +- **Date:** 2026-07-18; revised 2026-07-23, 2026-07-25 +- **Context tracking:** roadmap #68; #288, #302, #406, #407, #425 ## Context @@ -89,6 +89,56 @@ records. - OAuth uses one `/sql` redirect URI. Callback cleanup retains route parameters while removing only OAuth callback parameters. +## Addendum (#425, 2026-07-25): surfaces are hosts in one persistent shell + +The consequence above — "Dashboard route resources are disposed when switching +surfaces or rebuilding the current surface; the Workbench shell likewise disposes +signal and media listeners before remounting" — described a model where each +surface owned the whole page and every switch was a dispose-and-remount. #425 +amends it, because a Dashboard must own the complete editor-plus-results area +*while the left sidebar stays visible*, and returning to the Query surface must +not reconstruct it. + +What changes: + +- One persistent shell (`ui/app-shell.ts`) owns `#root`: a header slot, the + sidebar, the mobile nav, and two sibling hosts — the query column + (`ui/workbench/workbench-shell.ts`) and the Dashboard. Exactly one host is + exposed; the hidden one keeps its DOM and its state and contributes no layout. +- The query column is mounted once per signed-in workspace. A surface switch no + longer disposes it and no longer calls `workbench.destroy()` — that aborts the + in-flight request and issues `KILL QUERY`, and a presentation change must never + cancel the query in the editor. Real end-of-life events (a workspace switch, + workspace-not-found/loading, sign-out) still tear everything down, and every + path that replaces `#root` wholesale must forget the shell handle so the next + render re-mounts. +- The Dashboard surface is still disposed when left: its viewer session, window + listeners, and pending focus work go, and its host is emptied — the host + outlives the surface, so a disposed Dashboard must not leave DOM behind. +- Each surface still builds its own header, now into the shell's slot, so only + one header is ever mounted. + +Selected-Dashboard state (`application/main-surface.ts`) is session state: which +Dashboard, in which mode, with an optional focus target, identified only by +`DashboardDocumentV1.id` and never by collection position. It is not persisted — +`StoredWorkspaceV3` gains no `activeDashboardId`/`defaultDashboardId` — is cleared +on sign-out, and is re-validated against every committed workspace, falling back +to Query mode rather than silently retargeting another Dashboard. It is also the +single writer of the route's `surface`/`mode`, so the URL is always derived from +it. **Routes are unchanged:** the URL still carries only `ws`, `surface`, and +`mode`, which is why Back/Forward inside the Dashboard surface deliberately +preserves the explicit selection instead of re-deriving one from the +compatibility selector. + +Two consequences worth recording: + +- Edit mode renders the same single filter bar as View, so #425's "focus the + filter editor/control in Edit mode" collapses to one control per filter — not a + dropped requirement. +- The schema tree is no longer refetched on a Dashboard→Workbench round trip + (`catalog.loadSchema()` moved to the shell's one-time mount). The #343 + external-change refresh path still covers staleness. + ## Alternatives considered - **Durable detached snapshots:** rejected because they silently diverge from diff --git a/src/application/main-surface.ts b/src/application/main-surface.ts new file mode 100644 index 00000000..aae05e19 --- /dev/null +++ b/src/application/main-surface.ts @@ -0,0 +1,137 @@ +// The main work surface's SESSION state (#425): which of the two mutually +// exclusive surfaces — Query (SQL editor + result/data drawer) or Dashboard — +// owns the right-hand work area, and, for a Dashboard, WHICH stored Dashboard +// is selected, in which presentation mode, with an optional navigation focus +// target. +// +// Deliberately session state, never persisted workspace content: +// `StoredWorkspaceV3` carries no `activeDashboardId`/`defaultDashboardId`, and +// a Dashboard is identified ONLY by its stable `DashboardDocumentV1.id` — +// never by its position in `dashboards[]`. Sign-out and a workspace switch +// therefore clear or re-validate the selection rather than migrating it. +// +// Pure: no DOM, no persistence, no globals. Lives in `src/application/` (not +// `src/core/`) because it resolves against the workspace aggregate, and the +// dependency direction is `workspace <- application <- UI`; `src/core/` must +// never import `src/workspace/` (build/check-boundaries.mjs). + +import { findDashboardStrict, type WorkspaceDashboards } from '../workspace/workspace-dashboards.js'; +import type { SqlRoute } from '../core/sql-route.js'; + +/** Where a caller wants navigation to land INSIDE the opened Dashboard. A tile + * is addressed by its Dashboard-local TILE id (never the saved-query id it + * renders); a curated filter by its filter-definition id. */ +export type DashboardFocusTarget = + | { kind: 'tile'; id: string } + | { kind: 'filter'; id: string }; + +/** View is a presentation choice over the same live document, not an + * authorization boundary (ADR-0003). */ +export type DashboardSurfaceMode = 'view' | 'edit'; + +export type MainSurfaceState = + | { kind: 'query' } + | { + kind: 'dashboard'; + dashboardId: string; + mode: DashboardSurfaceMode; + focus: DashboardFocusTarget | null; + }; + +/** The one application-level Dashboard navigation request (#425). */ +export interface OpenDashboardRequest { + dashboardId: string; + mode: DashboardSurfaceMode; + focus?: DashboardFocusTarget; +} + +/** The Query surface carries no parameters, so one frozen value serves every + * transition to it — and identity comparison is a legitimate test for "we + * fell back to Query mode". */ +export const QUERY_SURFACE: MainSurfaceState = Object.freeze({ kind: 'query' as const }); + +/** `resolveOpenDashboard`'s outcome. `missing`/`duplicate` are reported through + * the caller's diagnostic path and change NO state: opening a Dashboard must + * never mutate anything, and an ambiguous id must never be resolved by a + * guess. */ +export type OpenDashboardResolution = + | { status: 'ok'; surface: MainSurfaceState } + | { status: 'missing' } + | { status: 'duplicate' }; + +/** + * Resolve an open request against the ACTIVE workspace's Dashboard collection, + * by exact id. A `null` workspace (none loaded, or a corrupt/not-found route) + * resolves as `missing` — there is nothing to address an id against. + */ +export function resolveOpenDashboard( + workspace: WorkspaceDashboards | null, request: OpenDashboardRequest, +): OpenDashboardResolution { + if (!workspace) return { status: 'missing' }; + const lookup = findDashboardStrict(workspace, request.dashboardId); + if (lookup.status !== 'ok') return { status: lookup.status }; + return { + status: 'ok', + surface: { + kind: 'dashboard', + dashboardId: request.dashboardId, + mode: request.mode, + focus: request.focus ?? null, + }, + }; +} + +/** + * Re-validate a selection against committed truth. A selected Dashboard that + * was removed — or whose id became ambiguous — falls back to **Query** mode + * rather than silently retargeting to another Dashboard. Called after every + * committed workspace projection and after a workspace switch, which is exactly + * what makes "switching workspaces clears the selection unless the new + * workspace contains the same explicitly selected id" fall out for free. + */ +export function reconcileMainSurface( + surface: MainSurfaceState, workspace: WorkspaceDashboards | null, +): MainSurfaceState { + if (surface.kind === 'query') return surface; + if (workspace && findDashboardStrict(workspace, surface.dashboardId).status === 'ok') return surface; + return QUERY_SURFACE; +} + +/** The canonical `/sql` route for a surface. #425 leaves URLs unchanged: the + * selected Dashboard id is session state and never appears in the URL, so the + * route still carries only workspace + surface + mode. */ +export function mainSurfaceRoute( + surface: MainSurfaceState, workspaceKey: string | null, +): SqlRoute { + return surface.kind === 'dashboard' + ? { surface: 'dashboard', workspaceKey, mode: surface.mode } + : { surface: 'workspace', workspaceKey }; +} + +/** The selected Dashboard id, or `null` in Query mode — the render target's + * `dashboardId`, where `null` also covers "this workspace has no Dashboard + * yet" and lands on the Create-dashboard placeholder. */ +export function selectedDashboardId(surface: MainSurfaceState): string | null { + return surface.kind === 'dashboard' ? surface.dashboardId : null; +} + +/** True when an open request targets the ALREADY-selected Dashboard in the + * already-active mode — the caller then keeps the live viewer session and only + * applies the new focus target, so a repeated open never builds a duplicate + * Dashboard session. */ +export function isSameDashboardSelection( + surface: MainSurfaceState, request: OpenDashboardRequest, +): boolean { + return surface.kind === 'dashboard' + && surface.dashboardId === request.dashboardId + && surface.mode === request.mode; +} + +/** Drop a consumed focus target, keeping the selection. Applied once the focus + * has been delivered (or reported missing) so a later repaint — an external + * workspace change, a style switch — cannot re-focus and re-highlight a tile + * the user has since navigated away from. */ +export function withoutFocus(surface: MainSurfaceState): MainSurfaceState { + if (surface.kind !== 'dashboard' || surface.focus === null) return surface; + return { ...surface, focus: null }; +} diff --git a/src/styles.css b/src/styles.css index ad6bf967..f87fd805 100644 --- a/src/styles.css +++ b/src/styles.css @@ -500,6 +500,22 @@ body { instead of a brittle hard-coded viewport `top`. No effect on the desktop flex layout. */ .main-row { flex: 1; display: flex; min-height: 0; overflow: hidden; position: relative; } +/* #425: the persistent shell mounts the surface-specific header into a stable + slot so it can be replaced without rebuilding the sidebar around it. + `display: contents` keeps `.app-header` ITSELF the direct child of `#root`'s + flex column — an ordinary block wrapper would become the flex item instead and + lose the header's own `flex-shrink: 0`, letting it squash. */ +.app-header-slot { display: contents; } +/* #425: the two mutually exclusive main work-surface hosts — the Query surface + (SQL editor + result/data drawer) and the Dashboard — as siblings of the + sidebar, so a Dashboard owns the whole right-hand work area while the sidebar + stays visible. Exactly one is exposed at a time; `[hidden]` needs the explicit + override because a class rule's `display: flex` beats the UA stylesheet's bare + `[hidden]` (the same reason `.document-editor[hidden]` carries one). */ +.query-host, .dashboard-host { + flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; +} +.query-host[hidden], .dashboard-host[hidden] { display: none !important; } .sidebar { display: flex; flex-direction: column; background: var(--bg-side); @@ -2474,11 +2490,23 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } /* ---- Full-screen view switching (main-row[data-mobile-view]) ---- */ /* Tables → sidebar fills; workbench hidden. Sidebar is a normal flow child here (not the desktop resizable column), full width. */ - .main-row[data-mobile-view="tables"] .workbench { display: none; } + /* #425: hide the HOST, not just `.workbench` inside it — the host is the + flex:1 sibling of the sidebar now, so leaving it laid out would fight the + full-width sidebar below. */ + .main-row[data-mobile-view="tables"] .query-host { display: none; } .main-row[data-mobile-view="tables"] .sidebar { display: flex; width: 100% !important; } /* Editor / Results → sidebar hidden, workbench fills. */ .main-row[data-mobile-view="editor"] .sidebar, .main-row[data-mobile-view="results"] .sidebar { display: none; } + /* #425: a Dashboard is full-bleed on mobile (#248) — the sidebar and the + bottom Tables/Editor/Results nav belong to the Query surface, and its three + `data-mobile-view` values say nothing about a Dashboard. Hiding both keeps + the pre-#425 mobile Dashboard presentation exactly as it was, and takes + precedence over the `[data-mobile-view="tables"]` sidebar rule above by + being later with equal specificity. */ + .main-row[data-surface="dashboard"] .sidebar, + .main-row[data-surface="dashboard"] .col-resize { display: none; } + .main-row[data-surface="dashboard"] ~ .mobile-nav { display: none; } /* Editor view: query tabs + toolbar + editor; hide the results half. */ .main-row[data-mobile-view="editor"] .results-region, .main-row[data-mobile-view="editor"] .editor-results-split { display: none; } @@ -2536,11 +2564,36 @@ table.res-table tbody tr:hover td.idx { background: var(--bg-hover); } .docs-panel { width: 100vw !important; min-width: 0; } } -/* ── 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. */ +/* ── Dashboard (#149 D1 / #407 / #425) ────────────────────────────────────── + The Dashboard main work surface: sticky toolbars + responsive tiles. */ +/* The dashboard's own scroll container. #425: it now fills `.dashboard-host` — + the flex:1 sibling of the sidebar inside `.main-row` — rather than all of + `#root`, so a Dashboard owns the whole editor-plus-results area while the + sidebar stays visible. The host is itself a `min-height:0` flex column, so + `height:100%` here still resolves and the page still scrolls itself. */ .dash-page { height: 100%; overflow-y: auto; overflow-x: hidden; background: var(--bg); } +/* #425 — the Dashboard surface's own toolbar row: Back to query, the Dashboard + title, then the View/Edit switch pushed right by the shared spacer. */ +.dash-surface-toolbar { gap: 8px; } +.dash-surface-title { + margin: 0; font-size: 13px; font-weight: 600; color: var(--fg); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +/* The title is the ONLY control in this row allowed to shrink, so a long + Dashboard name ellipsizes instead of pushing View/Edit off a narrow screen. + Two-class specificity is required to beat `.dash-toolbar > * { flex-shrink: 0 }` + below, which correctly pins every other toolbar child. */ +.dash-surface-toolbar .dash-surface-title { flex: 0 1 auto; min-width: 0; } +.dash-back-to-query { flex-shrink: 0; } +/* #425 — the temporary navigation highlight marking the tile or filter a caller + navigated to, cleared after a bounded interval or on the next user interaction. + A `box-shadow` ring, NOT an outline: an element has exactly one outline, so an + outline here would replace the focus ring rather than sit alongside it, and + #425 asks for a highlight in ADDITION to the normal focus indicator. */ +.is-nav-target { + box-shadow: 0 0 0 2px var(--accent), 0 0 0 5px color-mix(in srgb, var(--accent) 25%, transparent); + border-radius: 6px; +} /* 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); } diff --git a/src/ui/app-header.ts b/src/ui/app-header.ts index 88613f8a..43f081b3 100644 --- a/src/ui/app-header.ts +++ b/src/ui/app-header.ts @@ -26,20 +26,17 @@ export function routeButton( function surfaceSwitch(app: App): HTMLElement { const dashboard = app.sqlRoute.surface === 'dashboard'; - // The header stays mounted when File → New workspace swaps the active - // aggregate. Resolve at click time so its route never retains the workspace - // key from the header's original render. - const workspaceKey = (): string => app.currentWorkspace?.key ?? app.state.workspaceKey; + // #425: both controls go through the main-surface navigation API rather than + // writing a route themselves, so the session surface stays the ONE writer of + // the URL. The Dashboard side has no chooser yet (#426 adds the tree), so it + // opens the compatibility Dashboard by id. Resolved at click time — the header + // stays mounted when File → New workspace swaps the active aggregate. 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: workspaceKey() }, 'push'); - }), - routeButton('Dashboard', dashboard, () => { - void app.navigateSqlRoute({ surface: 'dashboard', workspaceKey: workspaceKey(), mode: 'edit' }, 'push'); - })); + routeButton('SQL Browser', !dashboard, () => { app.showQuerySurface(); }), + routeButton('Dashboard', dashboard, () => { app.showDashboardSurface('edit'); })); } /** The one application header used by both Workbench and Dashboard. */ diff --git a/src/ui/app-shell.ts b/src/ui/app-shell.ts new file mode 100644 index 00000000..69cfbbad --- /dev/null +++ b/src/ui/app-shell.ts @@ -0,0 +1,252 @@ +// The persistent application frame (#425 follow-up prep) — the header slot, +// the sidebar (schema + saved/library panes and their splitters), and the +// mobile bottom-nav, plus the reactive effects that repaint them and the +// catalog bootstrap-load tail. Split out of `ui/workbench/workbench-shell.ts`'s +// former `mountWorkbenchShell` body so a later commit can keep this frame +// mounted while swapping only the workbench column for a Dashboard host — +// `queryHost`/`dashboardHost` below are the two swappable slots. Every line +// is moved byte-identically (ported originally from `app.ts`'s own +// `renderApp` — #276 Phase 5); see the individual comments for their +// original rationale, carried over unchanged. +// +// This module does NOT build the header itself: `ui/app.ts`'s `renderApp` +// calls `buildAppHeader(app)` and hands the result to `setHeader()` AFTER +// both this shell and the workbench shell are mounted. That means the +// `app.dom = {}` reset below still happens exactly once, before any header +// exists (satisfying every other module that reaches into `app.dom.*` +// directly) — but it also means the `libraryName`/`libraryDirty` effect +// below can observe a null `app.dom.libraryTitle`/`dashboardNav` on its +// first, registration-time run, well before `setHeader` ever populates them. +// `renderLibraryTitle`/`renderDashboardNav` (file-menu.ts) are already +// null-safe for exactly this reason; the effect re-runs (and paints for +// real) on the next `libraryName`/`libraryDirty` change, by which point +// `setHeader` has long since run. +// +// `deps.app` is kept for the same reasons `mountWorkbenchShell` keeps it +// (see that module's own header comment): the render-module pass-through +// (renderSchema/renderSavedHistory/renderLibraryTitle/renderDashboardNav all +// still take the full `App`), and the `app.dom` reset + population other +// modules read `app.dom.*` off of directly. + +import { h } from './dom.js'; +import { Icon } from './icons.js'; +import { MOBILE_BREAKPOINT_PX } from '../state.js'; +import type { AppState as State } from '../state.js'; +import { effect } from '@preact/signals-core'; +import { renderSchema } from './schema.js'; +import { renderSavedHistory } from './saved-history.js'; +import { renderLibraryTitle, renderDashboardNav } from './file-menu.js'; +import type { DragCtx, DragRect, DragStartEvent, SplitterAxis } from './splitters.js'; +import { startDrag } from './splitters.js'; +import type { App } from './app.types.js'; +import type { SchemaCatalogService } from '../application/schema-catalog-service.js'; +import type { AppPreferences, PreferenceKey } from '../application/app-preferences.js'; + +/** `mountAppShell`'s dependency bag. See this file's header comment for the + * `app` field's rationale — every other field is read directly by this + * shell's own logic, never through `app.*`. */ +export interface AppShellDeps { + /** Kept ONLY for: the render-module pass-through (renderSchema/ + * renderSavedHistory/renderLibraryTitle/renderDashboardNav), and the + * `app.dom` reset + population (other modules read `app.dom.*` + * directly — see the header comment). */ + app: App; + root: Element | null; + document: Document; + state: State; + catalog: Pick; + prefs: Pick; + matchMedia: ((query: string) => MediaQueryList) | null; + updateBanner(): void; + startDrag: typeof startDrag; +} + +/** Which main work surface owns the right-hand work area. */ +export type SurfaceHostKind = 'query' | 'dashboard'; + +/** `mountAppShell`'s return value — the two swappable hosts, the header slot's + * setter, and the visibility switch between them. */ +export interface AppShellHandle { + /** Replace the header slot's content (each surface builds its own header). */ + setHeader(header: Element): void; + /** Host the workbench column (SQL editor + result/data drawer) mounts into. */ + queryHost: HTMLElement; + /** Host a Dashboard mounts into. */ + dashboardHost: HTMLElement; + /** + * Expose exactly one host (#425). The hidden one keeps its DOM and its state — + * that is what preserves editor contents, selection, scroll, the active tab, + * the result view, and the result-drawer size across a Dashboard round trip — + * but contributes no layout, so a Dashboard genuinely owns the whole + * right-hand work area and no invisible result drawer consumes space. + * + * Also mirrored onto `.main-row[data-surface]` for the mobile rules, which + * need to drop the sidebar and the bottom nav for a full-bleed Dashboard. + */ + showHost(kind: SurfaceHostKind): void; + dispose(): void; +} + +/** Build the persistent frame (header slot, sidebar, mobile nav) and mount + * it. Ported byte-identically from `mountWorkbenchShell`'s former body + * (#276 Phase 5 → this split) — every ordering comment below is original. */ +export function mountAppShell(deps: AppShellDeps): AppShellHandle { + const { + app, root, document: doc, state, catalog, prefs, matchMedia, updateBanner, + startDrag: doStartDrag, + } = deps; + doc.documentElement.setAttribute('data-theme', state.theme); + doc.documentElement.setAttribute('data-density', state.density); + + app.dom = {}; + // The header itself is built by the caller (`ui/app.ts`'s `renderApp`) and + // spliced in via `setHeader()` below — this slot is the stable mount point + // so the header can be replaced without rebuilding the sidebar around it. + const headerSlot = h('div', { class: 'app-header-slot' }); + + app.dom.schemaSearchInput = h('input', { + type: 'text', placeholder: 'Search tables, columns…', + oninput: (e: Event) => { state.schemaFilter.value = (e.target as HTMLInputElement).value; }, + }); + app.dom.schemaList = h('div', { class: 'schema-list' }); + const schemaPane = h('div', { class: 'side-pane schema-pane', style: { height: state.sideSplitPct + '%', flexShrink: '0', minHeight: '0' } }, + h('div', { class: 'schema-search' }, h('div', { class: 'search-wrap' }, Icon.search(), app.dom.schemaSearchInput)), + app.dom.schemaList); + + app.dom.savedTabsRow = h('div', { class: 'side-tabs' }); + app.dom.savedSearch = h('div', { class: 'saved-search' }); + app.dom.savedList = h('div', { class: 'saved-list' }); + const savedPane = h('div', { class: 'side-pane saved-pane', style: { flex: '1', minHeight: '0' } }, app.dom.savedTabsRow, app.dom.savedSearch, app.dom.savedList); + + const sidebar = h('div', { class: 'sidebar', style: { width: state.sidebarPx + 'px' } }); + // Only 'col' (sidebar width) and 'sideRow' (schema/saved split) run through + // this ctx — the editor/results 'row' splitter is workbench-shell's own, + // over elements this shell has no business touching (a Dashboard-only + // surface may one day mount here with neither `editorRegion` nor + // `resultsRegion` present at all). + const rectFor = (axis: SplitterAxis): DragRect => (axis === 'sideRow' ? sidebar.getBoundingClientRect() : {}); + const dragCtx: DragCtx = { + state, + rectFor, + apply: (axis, value) => { + if (axis === 'col') sidebar.style.width = value + 'px'; + else schemaPane.style.height = value + '%'; + }, + save: (name, value) => prefs.save(name as PreferenceKey, value), + }; + app.dom.sideSplit = h('div', { class: 'row-resize side-split', onmousedown: (e: DragStartEvent) => doStartDrag(e, 'sideRow', dragCtx) }); + // Mobile Tables view (#126): a Schema | Library segmented control at the top of + // the sidebar. CSS hides it above the breakpoint; below it, it swaps which pane + // shows (the sidebar's data-mobile-tab drives both the active-button style and + // the pane visibility — no JS effect needed for the active state). + app.dom.mobileSegmented = h('div', { class: 'mobile-segmented' }, + h('button', { class: 'mseg-btn', 'data-seg': 'schema', onclick: () => { state.mobileTab.value = 'schema'; } }, Icon.database(), h('span', null, 'Schema')), + h('button', { class: 'mseg-btn', 'data-seg': 'library', onclick: () => { state.mobileTab.value = 'library'; } }, Icon.layers(), h('span', null, 'Queries'))); + sidebar.append(app.dom.mobileSegmented, schemaPane, app.dom.sideSplit, savedPane); + const sideHandle = h('div', { class: 'col-resize', onmousedown: (e: DragStartEvent) => doStartDrag(e, 'col', dragCtx) }); + + app.dom.banner = h('div', { class: 'auth-banner', style: { display: 'none' } }); + // The workbench column's mount point (#425 follow-up prep). Its sizing lives + // in styles.css alongside `.dashboard-host` (static layout, not state-driven + // like `sidebar.style.width`), including the `[hidden]` override a + // `display: flex` class rule needs to actually hide. + const queryHost = h('div', { class: 'query-host' }); + // The Dashboard host (#425) — a SIBLING of `queryHost`, so switching surfaces + // toggles which of the two is exposed without rebuilding the sidebar (or the + // query surface's own state) around them. + const dashboardHost = h('div', { class: 'dashboard-host', hidden: true }); + const mainRow = h('div', { class: 'main-row' }, sidebar, sideHandle, queryHost, dashboardHost); + + // Mobile bottom-tab nav (#126): one full-screen panel at a time. CSS hides it + // above the breakpoint; below it, `mainRow[data-mobile-view]` (set by the + // effect below) picks which of sidebar / editor / results fills the screen. + // The Results tab carries a live badge (row count, or ● while a query streams). + // `mobileBadge` crosses shells deliberately: this element is app-shell-owned, + // but its text is written by a workbench-owned effect (`attachShell`'s + // `setMobileBadge`, over in workbench-shell.ts) — the mobile nav and the + // query results it summarizes are both singletons of the same render pass, + // so the badge stays here rather than duplicating the mobile-nav markup. + app.dom.mobileBadge = h('span', { class: 'mnav-badge' }); + const navBtn = (view: string, icon: SVGElement, label: string, extra?: HTMLElement): HTMLButtonElement => h('button', { + class: 'mobile-nav-btn', 'data-view': view, onclick: () => { state.mobileView.value = view as 'tables' | 'editor' | 'results'; }, + }, h('span', { class: 'mnav-ic' }, icon, extra || null), h('span', { class: 'mnav-label' }, label)); + app.dom.mobileNav = h('div', { class: 'mobile-nav' }, + navBtn('tables', Icon.database(), 'Tables'), + navBtn('editor', Icon.code(), 'Editor'), + navBtn('results', Icon.table2(), 'Results', app.dom.mobileBadge)); + + root!.replaceChildren(headerSlot, app.dom.banner, mainRow, app.dom.mobileNav); + + const disposers: (() => void)[] = []; + // Reactive repaint of the schema tree — replaces the scattered renderSchema() + // calls: re-runs on schema load, load error, filter text, or expand/collapse. + // Registered here (post-mount) so app.dom.schemaList already exists; the effect + // also runs once now for the initial paint. + disposers.push(effect(() => { + state.schema.value; + state.schemaError.value; + state.schemaFilter.value; + state.expanded.value; + // Crossing the mobile breakpoint (#126) adds/removes each row's drag source + // and hover title, so repaint the tree when isMobile flips. + state.isMobile.value; + renderSchema(app); + })); + // The schema/auth-failure banner reflects schemaError (a separate surface). + disposers.push(effect(() => { + state.schemaError.value; + updateBanner(); + })); + // Reactive repaint of the side panel: re-runs when the active panel changes + // (Library ↔ History). Data-driven repaints (savedQueries/history mutations) + // still call renderSavedHistory directly until those slices are signals too. + disposers.push(effect(() => { + state.sidePanel.value; + renderSavedHistory(app); + })); + // Reactive repaint of the header library title (name + unsaved-changes dot): + // re-runs when the name or dirty flag changes. The edit-mode toggle is driven + // separately (editingLibrary is not a signal — file-menu.js renders it directly). + disposers.push(effect(() => { + state.libraryName.value; + state.libraryDirty.value; + renderLibraryTitle(app); + // #302: the "Dashboard →" control's visibility tracks Dashboard presence, + // which changes alongside these signals (star toggle / import / replace all + // flip libraryDirty on their way through a commit). + renderDashboardNav(app); + })); + // Mobile mode (#126): mirror the viewport width into `isMobile` (drives the + // schema tree's drag/hover affordances, the results drop target, and the + // auto-navigation in the action wrappers) via the injected matchMedia seam. + // When the platform has no matchMedia the app stays in desktop JS mode — the + // mobile CSS still applies, just without JS branching. + const mq = matchMedia && matchMedia('(max-width: ' + MOBILE_BREAKPOINT_PX + 'px)'); + const onMobileChange = (e: MediaQueryListEvent): void => { state.isMobile.value = e.matches; }; + if (mq) { + state.isMobile.value = mq.matches; + mq.addEventListener('change', onMobileChange); + } + // Bottom-nav view switching: reflect the active mobile panel + Tables segmented + // choice onto data-attributes the mobile CSS keys off (a no-op above the + // breakpoint). Each runs once now for the initial paint. + disposers.push(effect(() => { mainRow.dataset.mobileView = state.mobileView.value; })); + disposers.push(effect(() => { sidebar.dataset.mobileTab = state.mobileTab.value; })); + catalog.loadSchema(); + catalog.loadReference(); + + return { + setHeader: (header: Element) => { headerSlot.replaceChildren(header); }, + queryHost, + dashboardHost, + showHost: (kind) => { + queryHost.hidden = kind !== 'query'; + dashboardHost.hidden = kind !== 'dashboard'; + mainRow.dataset.surface = kind; + }, + dispose: () => { + for (const dispose of disposers) dispose(); + mq?.removeEventListener('change', onMobileChange); + }, + }; +} diff --git a/src/ui/app.ts b/src/ui/app.ts index 1dff6df8..ba332c80 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -14,7 +14,7 @@ import { } from '../state.js'; import type { QueryTab, AppState, SpecValidationService } from '../state.js'; import { - resolveCompatibilityDashboard, withCompatibilityDashboard, + findDashboard, replaceDashboard, resolveCompatibilityDashboard, withCompatibilityDashboard, } from '../workspace/workspace-dashboards.js'; import type { SavedQueryV2, StoredWorkspaceV3 } from '../generated/json-schema.types.js'; import { splitStatements } from '../core/sql-split.js'; @@ -43,6 +43,7 @@ import { batch } from '@preact/signals-core'; import { renderResults } from './results.js'; import type { Result, QueryResult, ScriptResult, ScriptEntry } from './results.js'; import { disposeDashboardSurface, renderDashboard } from './dashboard.js'; +import type { DashboardRenderTarget } from './dashboard.js'; import { toggleThemeDom } from './theme-toggle.js'; import { openSchemaView } from './explain-graph.js'; import type { SchemaLineageNode, DetachedGraphApp } from './explain-graph.js'; @@ -77,6 +78,11 @@ import { createExportService } from '../application/export-service.js'; import type { ExportSink, FileHandleLike, DirectoryHandleLike } from '../application/export-service.js'; import { createSchemaGraphSession, SchemaGraphAuthRequiredError } from '../application/schema-graph-session.js'; import { createAppPreferences } from '../application/app-preferences.js'; +import { + QUERY_SURFACE, isSameDashboardSelection, mainSurfaceRoute, reconcileMainSurface, + resolveOpenDashboard, selectedDashboardId, withoutFocus, +} from '../application/main-surface.js'; +import type { DashboardSurfaceMode, MainSurfaceState } from '../application/main-surface.js'; import { createWorkspaceRepository } from '../workspace/workspace-repository.js'; import type { WorkspaceLoadResult } from '../workspace/workspace-repository.js'; import { createIndexedDbWorkspaceStore } from '../workspace/indexeddb-workspace-store.js'; @@ -95,6 +101,9 @@ import { createWorkbenchSession } from './workbench/workbench-session.js'; import { createQueryDocumentSession } from '../application/query-document-session.js'; import { createSavedQueryService } from '../application/saved-query-service.js'; import { mountWorkbenchShell } from './workbench/workbench-shell.js'; +import { mountAppShell } from './app-shell.js'; +import type { AppShellHandle } from './app-shell.js'; +import { buildAppHeader } from './app-header.js'; /** Optional globals a plain browser page (or the CM6/Chart/dagre UMD bundles a * ` diff --git a/tests/e2e/dashboard-membership.html b/tests/e2e/dashboard-membership.html index a7cffb4b..00e99cd4 100644 --- a/tests/e2e/dashboard-membership.html +++ b/tests/e2e/dashboard-membership.html @@ -55,14 +55,27 @@ return result; }; + // #425: the application shell owns the surface hosts and the header slot and + // hands the Dashboard a render target. This harness plays that role with two + // plain divs, so the fixture keeps exercising the real `renderDashboard`. + const headerSlot = document.createElement('div'); + const dashboardHost = document.createElement('div'); + const dashboardTarget = () => ({ + host: dashboardHost, + dashboardId: (app.currentWorkspace?.dashboards[0] ?? null)?.id ?? null, + mode: 'edit', + focus: null, + setHeader: (header) => headerSlot.replaceChildren(header), + }); + function renderWorkbench() { const tabs = document.createElement('div'); const search = document.createElement('div'); const list = document.createElement('div'); const open = document.createElement('button'); open.textContent = 'Open Dashboard'; - open.onclick = () => { void renderDashboard(app); }; - root.replaceChildren(tabs, search, list, open); + open.onclick = () => { void renderDashboard(app, dashboardTarget()); }; + root.replaceChildren(tabs, search, list, open, headerSlot, dashboardHost); app.dom.savedTabsRow = tabs; app.dom.savedSearch = search; app.dom.savedList = list; diff --git a/tests/e2e/dashboard-mobile.html b/tests/e2e/dashboard-mobile.html index 1b4987fe..33ce829e 100644 --- a/tests/e2e/dashboard-mobile.html +++ b/tests/e2e/dashboard-mobile.html @@ -14,6 +14,18 @@
+ +
+ + +
+
@@ -31,6 +43,11 @@
Panel four
+
+
+ +