Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,28 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **A navigation section registry behind the left sidebar** (#487, phase 2 of 4).
Each of the four navigation sections — Databases, Dashboards, Library, History —
is now addressable through one registry (`src/ui/nav-sections.ts`) that owns its
label, its icon, its accessible label and its single *persistent* host element,
and both sidebar panes are composed out of those hosts. The lower pane's Library
and History sections gained separate persistent search/list element pairs; before
this they shared one pair that a tab switch repainted, which meant neither
section's live DOM could be handed to another container without taking the
other's content along. #426 established the pattern for the upper pane's two
sections; this generalizes it to all four, so the hosts all carry one
`.nav-section-host` class (it was `.upper-role-host`) and one `data-section`
attribute. The `'library' ↔ 'saved'` vocabulary bridge — `AppState.sidePanel`
still persists `'saved'` for the section #427 relabelled "Library" — now lives in
exactly one place instead of being re-derived per caller, and both wide switchers
take their label and icon from the registry so the two presentations of a section
cannot drift. **No user-visible change**: the same two panes, the same switchers,
the same splitters, and a section switch still clears the search exactly as
before. What it buys is
structural: phase 3's compact rail and docked focused drawer can *move* a
section's live DOM instead of rebuilding it, which is what makes "wide and
focused presentations share and preserve all navigation state" true by
construction rather than by save/restore logic.
- **The desktop left navigation's layout core and preferences** (#487, phase 1 of
4). A new pure `src/core/left-nav-layout.ts` owns every layout decision the
foldable left navigation needs: the named constants and thresholds, the
Expand Down Expand Up @@ -38,12 +60,12 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
invariant is an unconditional postcondition rather than a precondition the
caller has to honour. **No user-visible change yet**: the rail, the docked
focused drawer and the resize separator arrive in phase 3.

### Changed
- The sidebar's `'col'` drag axis now clamps through the same
`LEFT_PANEL_MIN_PX`/`LEFT_PANEL_MAX_PX` constants as the load path, instead of
repeating `180`/`420` as literals (#487). Behaviour is unchanged; it removes
the second owner of a range whose whole point is having one.

### Changed
- **`VariableBarApp`'s shared activation port is now caller-neutral** (#478).
`state.filterActive`/`params.saveFilterActive` — named after Workbench
persistence even though Dashboard's own caller uses them for an unpersisted
Expand All @@ -59,6 +81,15 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
adapter refactor — no user-visible behavior changes.

### Fixed
- **A corrupt `asb:sidePanel` decodes to the Library instead of propagating**
(#487). The lower pane's active section was read from localStorage undecoded,
and every reader compared `=== 'saved'`, so an unrecognized or obsolete value
fell through to the History branch — neither the documented default nor the
value's own meaning. It is now decoded once at load, like the `leftNavMode` and
width preferences beside it. This became load-bearing rather than cosmetic in
phase 2: with the pane's two sections on separate hosts, two readers resolving
one value differently expose one section's host while painting into the other's,
which renders as a blank pane.
- **A corrupt `asb:sidebarPx` decodes to the default width instead of `NaN`**
(#487). The width decoded through a bare `clamp(parseInt(stored), 180, 420)`,
and `clamp` is not NaN-safe (`Math.max(180, NaN)` is `NaN`), so a non-numeric
Expand Down
48 changes: 43 additions & 5 deletions src/core/left-nav-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,12 @@ export type LeftNavigationMode = 'wide' | 'rail';
* "Library" and deliberately left the stored value alone, since migrating it
* would discard every user's persisted lower-pane choice for no behavioural gain.
*
* So the vocabularies genuinely differ, and phase 2's navigation section registry
* owns the mapping in exactly one place — `'library' ↔ 'saved'`, with the other
* three sections identical. That mapping is deliberately NOT written here yet: it
* has no caller until the registry exists, and a second copy of it is precisely
* the duplication phase 2 is meant to prevent.
* So the vocabularies genuinely differ, and the mapping lives in exactly one
* place — `sectionForSidePanelKey` / `sidePanelKeyFor` below, added in phase 2.
* `ui/nav-sections.ts` (the navigation section registry) is its UI-side owner and
* only consumer of the section half; the *decode* half belongs here beside
* `decodeLeftNavigationMode`, because `state.ts` applies it at the load boundary
* and must not import from `src/ui/`.
*/
export type LeftNavigationSection = 'databases' | 'dashboards' | 'library' | 'history';

Expand All @@ -87,6 +88,43 @@ export type LeftNavigationSection = 'databases' | 'dashboards' | 'library' | 'hi
export const LEFT_NAV_SECTIONS: readonly LeftNavigationSection[] =
['databases', 'dashboards', 'library', 'history'];

/** The lower sidebar pane's two sections, in the registry's vocabulary. Derived
* from `LeftNavigationSection` so the section names have exactly one source. */
export type LowerNavigationSection = Extract<LeftNavigationSection, 'library' | 'history'>;

/**
* What `AppState.sidePanel` actually stores. `'saved'` is the Library section:
* #427 renamed the visible label but deliberately left the persisted value at
* `asb:sidePanel` alone, since migrating it would discard every user's lower-pane
* choice for no behavioural gain.
*/
export type SidePanelKey = 'saved' | 'history';

/** Section → stored value. */
export function sidePanelKeyFor(section: LowerNavigationSection): SidePanelKey {
return section === 'library' ? 'saved' : 'history';
}

/**
* Stored value → section, and the DECODER for `asb:sidePanel`: a missing, invalid
* or obsolete stored value resolves to the Library section, which is that
* preference's documented default (`state.ts` reads it with `'saved'` as the
* fallback), rather than propagating an unrecognized string.
*
* That fallback direction matters, and it is a deliberate fix rather than
* preserved behaviour. Before phase 2 the lower pane's two sections shared one
* search/list pair and every reader compared `=== 'saved'`, so an unrecognized
* value fell through to the History branch — i.e. to neither the default nor the
* value's own meaning. With two hosts, two readers disagreeing about the fallback
* exposes one section's host while painting into the other's, which renders as a
* blank pane. `state.ts` now decodes once at load, so the signal only ever holds
* a `SidePanelKey` and the disagreement is unreachable rather than merely
* avoided by discipline.
*/
export function sectionForSidePanelKey(key: unknown): LowerNavigationSection {
return key === 'history' ? 'history' : 'library';
}

/**
* The complete left-navigation layout. `wideWidthPx` is deliberately the SAME
* value `AppState.sidebarPx` persists at `asb:sidebarPx` — #487 suggests a new
Expand Down
27 changes: 22 additions & 5 deletions src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,11 @@ import { deriveWorkspaceKey } from './core/workspace-key.js';
import {
LEFT_DRAWER_DEFAULT_PX, LEFT_WIDE_DEFAULT_PX,
clampDrawerWidthPx, clampWideWidthPx, decodeLeftNavigationMode, decodeStoredPx,
sectionForSidePanelKey, sidePanelKeyFor,
} from './core/left-nav-layout.js';
import type {
LeftNavigationMode, LeftNavigationSection, SidePanelKey,
} from './core/left-nav-layout.js';
import type { LeftNavigationMode, LeftNavigationSection } from './core/left-nav-layout.js';

// ── Persisted-data types (schema-generated) ─────────────────────────────────

Expand Down Expand Up @@ -383,9 +386,17 @@ export interface AppState {
filterActive: Record<string, boolean>;
varRecent: RecentMap;
varRecentDisabled: boolean;
/** 'saved' | 'history' at every write site; typed string because the
* initial value is an undecoded localStorage read (`asb:sidePanel`). */
sidePanel: Signal<string>;
/**
* The lower sidebar pane's active section, as `asb:sidePanel` stores it —
* `'saved'` is the Library (#427 relabelled it without migrating the value).
*
* #487 phase 2 narrowed this from `Signal<string>`: the stored value is now
* DECODED at load (below), so an obsolete or corrupt string can never reach a
* reader. It had to be, once the pane's two sections gained separate hosts —
* two readers disagreeing about what an unrecognized value means exposes one
* section while painting into the other, i.e. a blank pane.
*/
sidePanel: Signal<SidePanelKey>;
/**
* #426 — the UPPER sidebar pane's role. Deliberately NOT persisted (unlike
* `sidePanel`): the issue specifies "default to Databases for a fresh session",
Expand Down Expand Up @@ -763,7 +774,13 @@ export function createState(read: StateReader = { loadJSON, loadStr }): AppState
// cleared (Clear all recent values / per-field Clear recent).
// The `as` trusts the localStorage shape verbatim — no decoder exists today.
varRecentDisabled: read.loadJSON(KEYS.varRecentDisabled, false) as boolean,
sidePanel: signal(read.loadStr(KEYS.sidePanel, 'saved')),
// Decoded, not passed through (#487 phase 2) — the same discipline
// `leftNavMode` below has, and for the same reason: an unknown stored string
// is not a third section. Round-tripping through the section vocabulary is
// what makes the fallback the documented default rather than whichever branch
// an `=== 'saved'` comparison happens to take.
sidePanel: signal(sidePanelKeyFor(
sectionForSidePanelKey(read.loadStr(KEYS.sidePanel, 'saved')))),
upperRole: signal<'databases' | 'dashboards'>('databases'),
dashboardTreeRevision: signal(0),
dashboardTreeUi: new Map(),
Expand Down
31 changes: 23 additions & 8 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1704,18 +1704,33 @@ body.detached-tab .graph-overlay-panel {
.schema-empty {
padding: 24px 14px; color: var(--fg-faint); font-size: var(--text-body); text-align: center;
}
/* ------------ upper sidebar roles (#426) ------------
Databases | Dashboards over two persistent hosts, exactly one exposed. The tab
row reuses .side-tabs/.side-tab/.side-count from the lower switcher verbatim —
DESIGN.md asks for one tab vocabulary across the app. */
.upper-role-host {
/* ------------ navigation section hosts (#426, generalized in #487) ------------
One host per navigation section, exactly one exposed per pane: Databases |
Dashboards above, Library | History below. Each pane's tab row reuses
.side-tabs/.side-tab/.side-count verbatim — DESIGN.md asks for one tab
vocabulary across the app.

#487 phase 2 gave all four sections this one class (it was .upper-role-host,
for the upper pane's two) so that phase 3's focused drawer can host any of them
with no per-section layout rule. */
.nav-section-host {
flex: 1; min-height: 0;
display: flex; flex-direction: column;
}
/* A hidden host contributes no layout, so the exposed one owns the whole pane —
but it keeps its DOM, which is what preserves search text, expansion, lazily
loaded columns and scroll across a role switch. */
.upper-role-host[hidden] { display: none; }
but it keeps its DOM. What that preserves differs by pane, and the distinction
matters to #487 phase 3:

- UPPER (Databases | Dashboards): everything, across a role switch. Each role
owns its own search and expansion state, so switching only flips `hidden` and
the schema search text, lazily loaded columns and scroll all survive (#426).
- LOWER (Library | History): NOT across a section switch. The switcher clears
the shared `libraryFilter`, and activating the destination repaints its search
box and list. Per-section lower search state is phase 3's, not solved here.
- BOTH, across a MOVE between containers (phase 3's wide sidebar ↔ focused
drawer): everything, because moving an element repaints nothing. That is the
guarantee the persistent hosts exist for. */
.nav-section-host[hidden] { display: none; }

/* ------------ Dashboard hierarchy tree (#426) ------------ */
.dash-tree-row { position: relative; }
Expand Down
46 changes: 34 additions & 12 deletions src/ui/app-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import type { AppState as State } from '../state.js';
import { effect } from '@preact/signals-core';
import { renderSchema } from './schema.js';
import { buildSidebarUpper, renderUpperRoleTabs } from './sidebar-upper.js';
import { buildNavSectionRegistry, sectionForSidePanelKey } from './nav-sections.js';
import type { NavSectionPane } from './nav-sections.js';
import { renderDashboardTree, cancelDashboardTreeClicks } from './dashboard-tree.js';
import { renderSavedHistory } from './saved-history.js';
import { renderLibraryTitle } from './file-menu.js';
Expand Down Expand Up @@ -126,21 +128,32 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
oninput: (e: Event) => { state.schemaFilter.value = (e.target as HTMLInputElement).value; },
});
app.dom.schemaList = h('div', { class: 'schema-list' });
// #426: the upper pane now hosts TWO roles. The Databases content is built here
// exactly as before and handed to the role host, which only ever toggles
// #426: the upper pane hosts TWO sections. The Databases content is built here
// exactly as before and handed to the section host, which only ever toggles
// `hidden` — so schema search text/focus, expansion, lazily-loaded columns and
// scroll all survive a trip through the Dashboards role by construction.
const upper = buildSidebarUpper(app, [
// scroll all survive a trip through the Dashboards section by construction.
//
// #487 phase 2: both panes are now composed out of the SAME registry, which owns
// all four sections' persistent hosts, their labels and their icons. This shell
// no longer builds the lower pane's search/list elements, and it does not name
// which sections belong to which pane — it asks the registry, which is the only
// way the claim stays true when phase 3 adds a third container. Phase 3's rail
// and focused drawer address exactly the same four hosts, which is what makes a
// mode change a MOVE of live DOM rather than a rebuild.
const registry = buildNavSectionRegistry(app, buildSidebarUpper(app, [
h('div', { class: 'schema-search' }, h('div', { class: 'search-wrap' }, Icon.search(), app.dom.schemaSearchInput)),
app.dom.schemaList,
]);
]));
// `entries` is in rail order, so each pane's hosts come out in the order its
// switcher presents them (Databases | Dashboards above, Library | History below).
const hostsIn = (pane: NavSectionPane): HTMLElement[] =>
registry.entries.filter((entry) => entry.pane === pane).map((entry) => entry.host);
const schemaPane = h('div', { class: 'side-pane schema-pane', style: { height: state.sideSplitPct + '%', flexShrink: '0', minHeight: '0' } },
app.dom.upperRoleTabs!, upper.databasesHost, upper.dashboardsHost);
app.dom.upperRoleTabs!, ...hostsIn('upper'));

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 savedPane = h('div', { class: 'side-pane saved-pane', style: { flex: '1', minHeight: '0' } },
app.dom.savedTabsRow, ...hostsIn('lower'));

const sidebar = h('div', { class: 'sidebar', style: { width: state.sidebarPx + 'px' } });
// Only 'col' (sidebar width) and 'sideRow' (schema/saved split) run through
Expand Down Expand Up @@ -244,10 +257,19 @@ export function mountAppShell(deps: AppShellDeps): AppShellHandle {
state.dashboardTreeRevision.value;
renderUpperRoleTabs(app);
}));
// #426: expose exactly one role host, and repaint the Dashboard tree. Kept
// separate from the tab effect so a schema load does not rebuild the tree.
// #426: expose exactly one upper section host, and repaint the Dashboard tree.
// Kept separate from the tab effect so a schema load does not rebuild the tree.
disposers.push(effect(() => {
registry.showSection(state.upperRole.value);
}));
// #487 phase 2: the same rule for the lower pane, which until now had no
// exposure step at all — its two sections shared one search/list pair that the
// repaint below simply overwrote. Subscribed to `sidePanel` ALONE (unlike the
// repaint effect, which also tracks the projection revision): a Dashboard
// mutation changes which rows the Library shows, never which section is
// exposed.
disposers.push(effect(() => {
upper.showRole(state.upperRole.value);
registry.showSection(sectionForSidePanelKey(state.sidePanel.value));
}));
disposers.push(effect(() => {
// The ONE reactive input the tree has: every trigger #426 lists (workspace
Expand Down
9 changes: 9 additions & 0 deletions src/ui/app.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,17 @@ export interface AppDom {
qtabsInner?: HTMLElement;
resultsRegion?: HTMLElement;
runElapsedEl?: HTMLElement;
/** The LIBRARY section's list and search box. Since #487 phase 2 the lower
* pane's two sections own separate, persistent element pairs (see
* `historyList`/`historySearch`) — before that both rendered through this one
* pair, which a section switch repainted. */
savedList?: HTMLElement;
savedSearch?: HTMLElement;
/** #487 phase 2 — the HISTORY section's own list and search box, so each lower
* section has a persistent host a container can move without dragging the
* other section's content along. */
historyList?: HTMLElement;
historySearch?: HTMLElement;
savedTabsRow?: HTMLElement;
schemaList?: HTMLElement;
specEditorView?: EditorView;
Expand Down
Loading