Skip to content

Commit 92cb36b

Browse files
Merge pull request #574 from Altinity/feat/left-rail-focused-drawer-487p3
feat(#487): rail, focused drawer and resize separator (phase 3)
2 parents 464597b + d9f6ac5 commit 92cb36b

38 files changed

Lines changed: 6195 additions & 328 deletions

‎CHANGELOG.md‎

Lines changed: 247 additions & 4 deletions
Large diffs are not rendered by default.

‎docs/ADR-0001-reactivity.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,8 +175,10 @@ forgettable as the old manual `renderSchema` calls, revisit via a fresh ADR.
175175
`state.shortcutsOpen`, `state.editingSavedId`, and `state.bannerDismissedFor`
176176
(previously bare fields — the latter two lived on `app` directly, not
177177
`app.state`) were converted to `signal(...)` and consolidated into `state.js`
178-
alongside the other session-only, non-persisted fields (`libraryFilter`,
179-
`resultSort`). None had a reactive reader before or after — each site that sets
178+
alongside the other session-only, non-persisted fields (`lowerNavigationFilters`
179+
— renamed from `libraryFilter` by #487 phase 3, which split the one field into
180+
per-section search text — and `resultSort`). None had a reactive reader before
181+
or after — each site that sets
180182
one already calls its own repaint (`renderSavedHistory`, `updateBanner`,
181183
`openShortcuts`'s own mount/unmount) — so this is a pure `.value` mechanical
182184
edit, not a new `effect()`. Housing them in `state.js` rather than on `app`

‎src/application/left-nav.ts‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// #487 phase 3 — the desktop left navigation's controller seam. The pure mode
2+
// machine lives in `core/left-nav-layout.ts`; this module is the thin async/
3+
// stateful glue that reads `AppState`'s scattered fields into one
4+
// `LeftNavigationLayout`, drives the reducers, and writes the result back —
5+
// plus the one persistence gap phase 3 exists to close (see
6+
// `selectSectionInExistingPane` below).
7+
//
8+
// Typed against a narrow structural interface, not `App`/`AppState` from
9+
// `src/ui/`: `src/application/**` must never import `src/ui/**` or
10+
// `src/editor/**` (build/check-boundaries.mjs), and a real `App` satisfies the
11+
// shape below directly — the same convention `library-assignment-service.ts`
12+
// and `app-preferences.ts` use.
13+
14+
import { batch } from '@preact/signals-core';
15+
import type { Signal } from '@preact/signals-core';
16+
import {
17+
resolveRailActivation, resolveRailOpen, sidePanelKeyFor,
18+
} from '../core/left-nav-layout.js';
19+
import type {
20+
LeftNavigationLayout, LeftNavigationMode, LeftNavigationSection, SidePanelKey,
21+
} from '../core/left-nav-layout.js';
22+
23+
/** The `AppState` fields the left navigation reads and writes, named exactly as
24+
* `state.ts` names them. Some are signals (repainted/observed reactively),
25+
* some are plain numbers (written like any other splitter width, persisted
26+
* only on a later resize-session commit) — that split matches `state.ts`
27+
* exactly and matters for every write below. */
28+
export interface LeftNavStateSlice {
29+
sidebarPx: number;
30+
leftNavDrawerPx: number;
31+
readonly leftNavMode: Signal<LeftNavigationMode>;
32+
readonly leftNavSection: Signal<LeftNavigationSection | null>;
33+
readonly upperRole: Signal<'databases' | 'dashboards'>;
34+
readonly sidePanel: Signal<SidePanelKey>;
35+
}
36+
37+
/** The one persistence call this module makes — `app.prefs.save`'s real
38+
* signature (`AppPreferences.save`) takes any `PreferenceKey`; this module
39+
* only ever names `'sidePanel'`, so the seam is narrowed to that one key
40+
* rather than importing the full `PreferenceKey` union from
41+
* `application/app-preferences.ts`. */
42+
export interface LeftNavApp {
43+
readonly state: LeftNavStateSlice;
44+
readonly prefs: { save(name: 'sidePanel', value: SidePanelKey): void };
45+
/**
46+
* #487 phase-3 review, major issue 2 — called BEFORE either function below
47+
* runs its own batched write. An active pointer resize session
48+
* (`ui/left-nav-separator.ts`) keeps its own uncommitted layout snapshot
49+
* independent of `state`; without this, a semantic command that runs while
50+
* a drag is still live (Escape closing a drawer, a rail click, a
51+
* programmatic reveal) writes `state` correctly, but the drag's eventual
52+
* `mouseup`/`blur` commit still fires from its OWN stale snapshot and can
53+
* silently overwrite (or resurrect) exactly what this command just did.
54+
* `app-shell.ts` wires this to cancel the active session (no commit) and
55+
* repaint from the now-current committed layout, so there is nothing left
56+
* to fight this write once it runs. This is the ONE choke point every
57+
* caller of `openFocusedSection`/`toggleFocusedSection` gets it through —
58+
* optional, so a caller with no separator session to preempt (a test, or a
59+
* call before the shell has mounted one) simply omits it and gets a no-op.
60+
*/
61+
preemptActiveResize?(): void;
62+
}
63+
64+
/**
65+
* Project the scattered `AppState` fields this module reads into one
66+
* `LeftNavigationLayout` — the shape every reducer in `core/left-nav-layout.ts`
67+
* takes and returns. Exported: later phase-3 steps (the resize separator, the
68+
* app-shell repaint effect) need the same projection.
69+
*/
70+
export function readLeftNavigationLayout(state: LeftNavStateSlice): LeftNavigationLayout {
71+
return {
72+
mode: state.leftNavMode.value,
73+
wideWidthPx: state.sidebarPx,
74+
drawerWidthPx: state.leftNavDrawerPx,
75+
focusedSection: state.leftNavSection.value,
76+
};
77+
}
78+
79+
/**
80+
* Drive the pane that ALREADY shows this section when there is no drawer to
81+
* open for it — the wide sidebar's upper role switch, or its lower
82+
* library/history tab. Not exported: it is only ever the one-signal half of
83+
* `openFocusedSection`/`toggleFocusedSection`'s single batched write, never a
84+
* standalone operation (see those functions' own comments for why they must
85+
* not each get their own `batch()`).
86+
*
87+
* The lower-section branch is the fix phase 3 exists to make: today only the
88+
* wide sidebar's own tab click (`ui/saved-history.ts`'s `switchTo`) persists
89+
* `sidePanel`, so a rail/drawer selection of the same section left the
90+
* signal's NEW value unpersisted — a reload would silently revert to
91+
* whichever pane was last chosen through the wide tabs. Persisting BEFORE
92+
* writing the signal mirrors `switchTo` exactly. Per-section filters
93+
* (`state.lowerNavigationFilters`, phase 3 step 3) are already implemented
94+
* elsewhere and this module still correctly never touches them.
95+
*
96+
* Both branches are guarded to a no-op when the target value is already
97+
* current: `openFocusedSection`'s documented caller is #428's bounded
98+
* drag-hover, which can re-assert the SAME section repeatedly on every hover
99+
* notification, and `sidePanel`'s write has a real synchronous side effect
100+
* (`app.prefs.save`) that must not fire on every one of those.
101+
*/
102+
function selectSectionInExistingPane(app: LeftNavApp, section: LeftNavigationSection): void {
103+
if (section === 'databases' || section === 'dashboards') {
104+
// Session-only, like `switchTo`'s counterpart for the upper pane: `upperRole`
105+
// is never persisted (state.ts), so there is nothing to save here.
106+
if (app.state.upperRole.value !== section) app.state.upperRole.value = section;
107+
return;
108+
}
109+
const panel = sidePanelKeyFor(section);
110+
if (app.state.sidePanel.value !== panel) {
111+
app.prefs.save('sidePanel', panel);
112+
app.state.sidePanel.value = panel;
113+
}
114+
}
115+
116+
/**
117+
* Write a resolved `LeftNavigationLayout` back onto the scattered signals/
118+
* fields it was read from. Not exported, for the same reason as
119+
* `selectSectionInExistingPane`: it is only ever the other half of one batched
120+
* write.
121+
*
122+
* Every caller only ever hands this the result of `resolveRailOpen` /
123+
* `resolveRailActivation`, and neither reducer changes `mode` or either width
124+
* in practice (rail-only reducers) — so writing all four fields
125+
* unconditionally is harmless, and simpler than special-casing which changed.
126+
* It does NOT call `prefs.save` for `leftNavMode`/`leftNavDrawerPx`: that
127+
* persistence belongs to a later step's resize-session commit, not to opening
128+
* a section.
129+
*/
130+
function writeLeftNavigationLayout(state: LeftNavStateSlice, layout: LeftNavigationLayout): void {
131+
state.leftNavMode.value = layout.mode;
132+
state.sidebarPx = layout.wideWidthPx;
133+
state.leftNavDrawerPx = layout.drawerWidthPx;
134+
state.leftNavSection.value = layout.focusedSection;
135+
}
136+
137+
/**
138+
* Open a section IDEMPOTENTLY (`core/left-nav-layout.ts`'s `resolveRailOpen`) —
139+
* the deterministic seam #487 asks the left navigation to expose for #428's
140+
* bounded drag-hover, and the one a plain rail-icon click also uses.
141+
*
142+
* In rail mode this opens (or keeps open) the focused drawer; in wide mode
143+
* `resolveRailOpen` returns the layout unchanged (both panes already show, so
144+
* `selectSectionInExistingPane` is the only effect), and for a lower section it
145+
* ALSO drives the existing wide-mode pane switch, so calling this while wide
146+
* still selects the right lower tab. Both halves are wrapped in exactly ONE
147+
* `batch()` call: composing two independently-`batch()`-wrapped functions is
148+
* NOT one atomic transition, since Preact signals' `batch()` flushes on each
149+
* top-level call's own exit — two separate calls would let an effect observe
150+
* an intermediate, mismatched combination (e.g. `sidePanel` updated but
151+
* `leftNavSection` still stale) on the frame between them.
152+
*/
153+
export function openFocusedSection(app: LeftNavApp, section: LeftNavigationSection): void {
154+
app.preemptActiveResize?.();
155+
batch(() => {
156+
selectSectionInExistingPane(app, section);
157+
writeLeftNavigationLayout(app.state, resolveRailOpen(readLeftNavigationLayout(app.state), section));
158+
});
159+
}
160+
161+
/**
162+
* Toggle a section (`core/left-nav-layout.ts`'s `resolveRailActivation`) —
163+
* the same shape as `openFocusedSection`, but a second activation of the SAME
164+
* already-open section closes the drawer instead of re-asserting it open.
165+
* Same single-`batch()` requirement and rationale as `openFocusedSection`.
166+
*/
167+
export function toggleFocusedSection(app: LeftNavApp, section: LeftNavigationSection): void {
168+
app.preemptActiveResize?.();
169+
batch(() => {
170+
selectSectionInExistingPane(app, section);
171+
writeLeftNavigationLayout(
172+
app.state, resolveRailActivation(readLeftNavigationLayout(app.state), section),
173+
);
174+
});
175+
}

‎src/application/saved-query-service.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,10 @@ export interface SavedQueryService {
146146
commit(tab: QueryTab, evaluated: { parsed: unknown; diagnostics: SpecValidationDiagnostic[] }): Promise<CommitLinkedResult>;
147147
/** Record a successful run in history (state.ts's own `recordHistory`) —
148148
* never touches rendering; app.ts's own `app.recordHistory` delegate
149-
* conditionally repaints the History side panel itself after calling
150-
* this. */
149+
* unconditionally repaints History's own content after calling this
150+
* (#487 phase 3 removed the `sidePanel === 'history'` guard, since
151+
* History's content must stay current regardless of which lower-navigation
152+
* section is currently exposed). */
151153
recordHistory(tab: QueryTab, sqlText?: string): void;
152154
/** Build the shareable URL for an already-evaluated Spec, or a typed
153155
* rejection reason — never writes `location`/clipboard itself. */

‎src/core/dashboard-tree-ui-state.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// Keyed by `StoredWorkspaceV5.id` — the immutable opaque application identity
1515
// (#406) — never by the mutable `name` or the rewritable URL `key`.
1616
//
17-
// Deliberately NOT a signal, matching `state.libraryFilter`'s precedent
17+
// Deliberately NOT a signal, matching `state.lowerNavigationFilters`'s precedent
1818
// (`src/state.ts`): if a repaint effect observed this state, every keystroke in
1919
// the search box and every scroll frame would repaint the tree — losing the caret
2020
// on the first and doing pointless work on the second. The tree's ONE reactive

0 commit comments

Comments
 (0)