From 3cab75f19da406187f11a6f0d5e9630592691742 Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Mon, 20 Jul 2026 20:53:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(#342):=20reorganize=20the=20Workbench=20Fi?= =?UTF-8?q?le=20menu,=20rename=20Replace=E2=86=92Open=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first four rows are now one unlabeled primary-workspace-action group (New workspace…, Open workspace…, Export workspace…, Import queries…), dropping the `IMPORT / REPLACE` and `EXPORT` section headings. Share / Publish now comes before Variable history; the query-count footer stays last. "Replace workspace…" is renamed to "Open workspace…" — a copy-only change (dialog title, picker title, success toast) over the same destructive whole-workspace-replace pipeline (picker → planner → confirm → atomic commit). New Icon.folderOpen() replaces the reused refresh glyph on that row. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 14 ++++++ README.md | 48 ++++++++++-------- src/ui/file-menu.ts | 70 +++++++++++++++------------ src/ui/icons.ts | 3 ++ src/workspace/import-planner.ts | 3 +- src/workspace/workspace-operations.ts | 3 +- tests/unit/file-menu.test.ts | 46 ++++++++++++------ 7 files changed, 121 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c5079e..6c108530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Changed +- **Workbench File menu reorganized around primary workspace actions, with + `Replace workspace…` renamed to `Open workspace…`** (#342). The first four + rows are now one unlabeled group in a fixed order — New workspace…, Open + workspace…, Export workspace… (`.json`), Import queries… — with the + `IMPORT / REPLACE` and `EXPORT` section headings removed. Below a + separator, `Share / Publish` (Download Markdown/SQL) now comes before + `Variable history` (recent-values toggle + clear-all); the query-count + footer stays last. `Open workspace…` is a pure rename — the picker, + transactional planner (`planReplaceWorkspace`), conflict resolution, and + destructive-replace confirmation are unchanged, with the confirm dialog and + success toast copy updated to match ("Open workspace?" / "Opened + workspace"). New `Icon.folderOpen()` (`ui/icons.ts`) replaces the reused + `refresh` glyph on that row. The standalone Dashboard's own File menu + (#302) is untouched. - **Compact, type-aware widths for every `{name:Type}` variable/filter input** (#345). `.var-input` no longer reserves a flat 150px for every field regardless of its declared ClickHouse type — a new pure diff --git a/README.md b/README.md index 0be226f7..4669066f 100644 --- a/README.md +++ b/README.md @@ -437,33 +437,43 @@ publishes once persistence succeeds. The header **File ▾** menu carries a name unsaved-changes dot (changes since the last export or import), and these resource-oriented operations (#287): +The first four rows are one unlabeled primary-workspace-action group, in this +order (#342): + - **New workspace…** — clears to an empty, default-named workspace (confirms first when non-empty). Open editor tabs are unaffected. +- **Open workspace…** — atomically replace **both** the query collection and + the Dashboard from a file (confirms first). A multi-Dashboard file asks which + Dashboard to adopt (or none). +- **Export workspace…** (`.json`) — write the one canonical + **`altinity-sql-browser/portable-bundle`** interchange format: every saved + query (catalog order) plus the zero-or-one Dashboard. Uses the deterministic + canonical encoder and never mutates the workspace's identity or Dashboard + revision. - **Import queries…** — merge a file's queries into the current collection. When an incoming query's id collides with an existing one, a **conflict dialog** offers a default action plus per-row overrides — *use existing*, *copy* (import under a fresh id), *replace*, or *skip*; a byte-identical incoming query is reused automatically. The Dashboard is untouched (imported favorite flags never add tiles). -- **Import Dashboard…** — replace the current Dashboard with one from a file - (confirms first when a Dashboard already exists), importing only its referenced - queries. A multi-Dashboard file asks which one. -- **Replace workspace…** — atomically replace **both** the query collection and - the Dashboard from a file (confirms first). A multi-Dashboard file asks which - Dashboard to adopt (or none). -- **Export Dashboard… / Export workspace…** (`.json`) — write the one canonical - **`altinity-sql-browser/portable-bundle`** interchange format. *Export Dashboard* - emits the selected Dashboard plus exactly its dependency-closure of queries; - *Export workspace* emits every saved query (catalog order) plus the zero-or-one - Dashboard. Both use the deterministic canonical encoder and never mutate the - workspace's identity or Dashboard revision. Legacy Library v1/v2 files remain - **importable** (decoded to an in-memory bundle); no new Library-only JSON is - written. See the [schema contracts](docs/library-json-schema.md). Imported SQL - is never run automatically. -- **Share / publish** — **Download Markdown** (`.md`, a `### heading` + fenced - ` ```sql ` cookbook) and **Download SQL** (`.sql`, `/* name + description */` - comment blocks, `;`-delimited). Both are **one-way** — lossy by design (no ids - or Spec metadata), so the portable bundle stays the canonical round-trip format. + +Below a separator, **Share / Publish** — **Download Markdown** (`.md`, a +`### heading` + fenced ` ```sql ` cookbook) and **Download SQL** (`.sql`, +`/* name + description */` comment blocks, `;`-delimited). Both are **one-way** +— lossy by design (no ids or Spec metadata), so the portable bundle stays the +canonical round-trip format. **Variable history** (the recent-values toggle + +clear-all) follows below its own separator. + +The standalone Dashboard has its own **File ▾** menu (#302) for **Import +Dashboard…** / **Export Dashboard…** — importing replaces the current +Dashboard with one from a file (confirms first when a Dashboard already +exists, importing only its referenced queries; a multi-Dashboard file asks +which one), and exporting emits the selected Dashboard plus exactly its +dependency-closure of queries. Legacy Library v1/v2 files remain +**importable** everywhere (decoded to an in-memory bundle); no new +Library-only JSON is written. See the [schema +contracts](docs/library-json-schema.md). Imported SQL is never run +automatically. The workspace name is editable inline (click it in the header). The **•** dot appears after any change not yet written to a file and clears on export / import / diff --git a/src/ui/file-menu.ts b/src/ui/file-menu.ts index 687e88aa..044d4a69 100644 --- a/src/ui/file-menu.ts +++ b/src/ui/file-menu.ts @@ -1,8 +1,13 @@ // The header "File ▾" menu (#287 W5): resource-oriented portable-bundle // workspace operations — New workspace / Import queries / Import Dashboard / -// Replace workspace / Export Dashboard / Export workspace — plus the kept +// Open workspace / Export Dashboard / Export workspace — plus the kept // "Open as dashboard" (#149), the Variable history section, and the one-way // Markdown/SQL "share" downloads (buildMarkdownDoc/buildSqlDoc, unchanged). +// #342 reordered the Workbench menu around an unlabeled primary group (New / +// Open / Export workspace / Import queries), renamed "Replace workspace…" to +// "Open workspace…" (same destructive whole-workspace replace semantics — +// see `startOpenWorkspace`/`confirmOpenWorkspace` below), and moved Share/ +// Publish above Variable history. // The legacy Library New/Save-JSON/Open-replace/Append ops are gone; every // write here resolves a `PortableBundleImportPlan` (workspace/import-planner.js) // or a repository-level primitive (workspace/workspace-operations.js), then @@ -137,7 +142,7 @@ export function openFileMenu(app: App): void { // (`libraryControls`), and Dashboard import/export moved to the Dashboard // page's own File menu — none of them appear here anymore. const importQueriesInput = pickerInput(app, (f) => onImportQueriesFile(app, f)); - const replaceWorkspaceInput = pickerInput(app, (f) => onReplaceWorkspaceFile(app, f)); + const openWorkspaceInput = pickerInput(app, (f) => onOpenWorkspaceFile(app, f)); // Variable recent-value history (#171): this is the closest thing the app // has to a "settings" surface today (no dedicated preferences panel exists @@ -158,8 +163,18 @@ export function openFileMenu(app: App): void { h('span', { class: 'fm-label' }, 'Remember recent variable values')); const countRow = h('div', { class: 'fm-count' }, empty ? 'Workspace is empty' : queries(list.length) + ' in workspace'); + // #342: the first four rows are one unlabeled primary-workspace-action + // group (no heading) in this exact order, followed by Share / Publish, + // then Variable history — the query-count footer stays last. const rows: MenuRow[] = [ { kind: 'item', icon: Icon.plus(), label: 'New workspace…', onClick: () => newWorkspaceAction(app) }, + { kind: 'item', icon: Icon.folderOpen(), label: 'Open workspace…', onClick: () => openWorkspaceInput.click() }, + { kind: 'item', icon: Icon.download(), label: 'Export workspace…', meta: '.json', onClick: () => exportWorkspaceAction(app) }, + { kind: 'item', icon: Icon.upload(), label: 'Import queries…', onClick: () => importQueriesInput.click() }, + { kind: 'sep' }, + { kind: 'section', label: 'Share / Publish' }, + { kind: 'item', icon: Icon.download(), label: 'Download Markdown', meta: '.md', onClick: () => downloadAction(app, 'md') }, + { kind: 'item', icon: Icon.download(), label: 'Download SQL', meta: '.sql', onClick: () => downloadAction(app, 'sql') }, { kind: 'sep' }, { kind: 'section', label: 'Variable history' }, { kind: 'custom', node: historyToggle, focusable: true }, @@ -170,24 +185,13 @@ export function openFileMenu(app: App): void { flashToast('Cleared recent variable values', { document: app.document }); }, }, - { kind: 'sep' }, - { kind: 'section', label: 'Import / replace' }, - { kind: 'item', icon: Icon.upload(), label: 'Import queries…', onClick: () => importQueriesInput.click() }, - { kind: 'item', icon: Icon.refresh(), label: 'Replace workspace…', onClick: () => replaceWorkspaceInput.click() }, - { kind: 'sep' }, - { kind: 'section', label: 'Export' }, - { kind: 'item', icon: Icon.download(), label: 'Export workspace…', meta: '.json', onClick: () => exportWorkspaceAction(app) }, - { kind: 'sep' }, - { kind: 'section', label: 'Share / publish' }, - { kind: 'item', icon: Icon.download(), label: 'Download Markdown', meta: '.md', onClick: () => downloadAction(app, 'md') }, - { kind: 'item', icon: Icon.download(), label: 'Download SQL', meta: '.sql', onClick: () => downloadAction(app, 'sql') }, { kind: 'custom', node: countRow }, ]; const handle = openMenu({ document: doc, trigger: app.dom.fileBtn!, rows }); // The hidden file pickers aren't menu ROWS (no label/click chrome of their // own) — they're display:none inputs `.click()`-triggered by the Import - // queries / Replace workspace items above. Parent them to the mounted menu + // queries / Open workspace items above. Parent them to the mounted menu // so they're torn down with it on close (no leak) and `picker(i)`-style // lookups (`.file-menu input[type=file]`) keep finding them. The item click // closes the menu (detaching these) BEFORE running its onClick, so the @@ -196,7 +200,7 @@ export function openFileMenu(app: App): void { // the document (the standard detached-input pattern), and it still runs // synchronously inside the original user gesture. handle.el.appendChild(importQueriesInput); - handle.el.appendChild(replaceWorkspaceInput); + handle.el.appendChild(openWorkspaceInput); } // ── file pickers + bundle decode ──────────────────────────────────────────── @@ -286,7 +290,7 @@ function afterLibraryChange(app: App): void { app.updateEditorModeUi!(); renderSavedHistory(app); // Keep the header "Dashboard →" control in sync when a commit adds/removes - // the workspace's Dashboard (e.g. Replace workspace / Import queries). + // the workspace's Dashboard (e.g. Open workspace / Import queries). renderDashboardNav(app); } @@ -398,7 +402,7 @@ function openConflictDialog( // ── multi-dashboard picker ─────────────────────────────────────────────────── /** Show a picker over `dashboards` (bundle array order — presentation order, - * not re-sorted); `allowNone` adds a "No dashboard" row (Replace workspace's + * not re-sorted); `allowNone` adds a "No dashboard" row (Open workspace's * own owner decision — Import Dashboard never offers it, since it must * import exactly one). Cancelling calls neither branch of `onPick`. */ function openDashboardPicker( @@ -498,8 +502,8 @@ function startImportDashboard(app: App, bundle: PortableBundleV1): void { function runImportDashboard(app: App, bundle: PortableBundleV1, dashboardId: string): void { // v1 holds at most one Dashboard, so importing one REPLACES the current // Dashboard (its tiles/layout/filters). Confirm first when that would discard - // an existing Dashboard — matching New/Replace-workspace, which also gate - // destructive commits (#287; flagged in review — silent, unrecoverable loss). + // an existing Dashboard — matching New workspace/Open workspace, which also + // gate destructive commits (#287; flagged in review — silent, unrecoverable loss). if (app.state.dashboard) { openConfirm(app, { title: 'Import and replace current Dashboard?', @@ -529,37 +533,41 @@ function doImportDashboard(app: App, bundle: PortableBundleV1, dashboardId: stri }); } -// ── actions: Replace workspace ─────────────────────────────────────────────── +// ── actions: Open workspace ────────────────────────────────────────────────── +// #342: user-facing rename of "Replace workspace…" — same destructive +// whole-workspace replace semantics (picker → plan → confirm → commit), just +// relabeled. `planReplaceWorkspace` (import-planner.js) keeps its own name; +// it's an internal primitive, not user-facing copy. -function onReplaceWorkspaceFile(app: App, file: File): void { - readBundleFile(app, file, (bundle) => startReplaceWorkspace(app, bundle)); +function onOpenWorkspaceFile(app: App, file: File): void { + readBundleFile(app, file, (bundle) => startOpenWorkspace(app, bundle)); } -function startReplaceWorkspace(app: App, bundle: PortableBundleV1): void { +function startOpenWorkspace(app: App, bundle: PortableBundleV1): void { const dashboards = listBundleDashboards(bundle); if (dashboards.length > 1) { - openDashboardPicker(app, 'Replace workspace — which dashboard?', dashboards, true, (id) => { - confirmReplaceWorkspace(app, bundle, id === null ? undefined : id); + openDashboardPicker(app, 'Open workspace — which dashboard?', dashboards, true, (id) => { + confirmOpenWorkspace(app, bundle, id === null ? undefined : id); }); return; } - confirmReplaceWorkspace(app, bundle, dashboards[0]?.id); + confirmOpenWorkspace(app, bundle, dashboards[0]?.id); } -function confirmReplaceWorkspace(app: App, bundle: PortableBundleV1, sourceDashboardId: string | undefined): void { +function confirmOpenWorkspace(app: App, bundle: PortableBundleV1, sourceDashboardId: string | undefined): void { const cur = currentWorkspace(app); openConfirm(app, { - title: 'Replace workspace?', - body: ['This replaces your current ', h('b', null, String(cur.queries.length)), ' saved ', + title: 'Open workspace?', + body: ['Opening this file replaces your current ', h('b', null, String(cur.queries.length)), ' saved ', cur.queries.length === 1 ? 'query' : 'queries', cur.dashboard ? ' and your Dashboard' : '', ' with ', h('b', null, String(bundle.queries.length)), ' ', queries(bundle.queries.length), sourceDashboardId ? ' and the selected Dashboard' : '', ' from the file. Open editor tabs are unaffected.'], - confirmLabel: 'Replace', + confirmLabel: 'Open workspace', onConfirm: () => { const workspace = currentWorkspace(app); withQueryDecisions(app, workspace.queries, bundle.queries, (decisions) => { const plan = planReplaceWorkspace(workspace, bundle, sourceDashboardId, decisions, app.genId); - void commitPlan(app, plan, 'Replaced workspace'); + void commitPlan(app, plan, 'Opened workspace'); }); }, }); diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 988a82b0..b4a77782 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -106,6 +106,9 @@ export const Icon = { undo: () => svg('M4.5 3.5 2 6l2.5 2.5M2 6h5a2.5 2.5 0 0 1 2.5 2.5', 12, 12), redo: () => svg('M7.5 3.5 10 6l-2.5 2.5M10 6H5a2.5 2.5 0 0 0-2.5 2.5', 12, 12), bookmark: () => iconEl('', 12, 12, 1.3), + // File menu "Open workspace…" (#342): an open folder, distinct from the + // closed-loop `refresh` glyph it replaces there. + folderOpen: () => iconEl('', 12, 12, 1.3), // #314 — the schema-surface "Open engine/type reference" actions' icon (an // open book), distinct from `shortcuts` (a keyboard glyph) above. book: () => iconEl('', 12, 12, 1.2), diff --git a/src/workspace/import-planner.ts b/src/workspace/import-planner.ts index f65c81e5..09dfee4e 100644 --- a/src/workspace/import-planner.ts +++ b/src/workspace/import-planner.ts @@ -272,7 +272,8 @@ function mergeIncomingQueries( return [...merged, ...additions]; } -/** REPLACE the query catalog wholesale (Replace workspace): only queries +/** REPLACE the query catalog wholesale (File menu's "Open workspace…", #342): + * only queries * reachable from the incoming bundle survive, in bundle order; 'use-existing' * keeps the existing query's own content (still under the shared id) rather * than the incoming content. */ diff --git a/src/workspace/workspace-operations.ts b/src/workspace/workspace-operations.ts index 6c3553f4..f78951b7 100644 --- a/src/workspace/workspace-operations.ts +++ b/src/workspace/workspace-operations.ts @@ -65,7 +65,8 @@ export function importQueries( /** Replace the workspace's queries AND Dashboard atomically while preserving * its identity (`id`/`name`) — the repository-level primitive behind "Replace - * from bundle" and "Replace workspace" (#280). The parsing/validation of an + * from bundle" and the File menu's "Open workspace…" (#280; user-facing + * label renamed by #342). The parsing/validation of an * external bundle and the dependency-closure/ID-remapping that select the * `queries`/`dashboard` passed here are the Phase-5 import planner's job; this * only assembles the candidate the repository then commits in one transaction. */ diff --git a/tests/unit/file-menu.test.ts b/tests/unit/file-menu.test.ts index fe4b8e9c..fe10e565 100644 --- a/tests/unit/file-menu.test.ts +++ b/tests/unit/file-menu.test.ts @@ -81,8 +81,9 @@ function mount>(over: O = {} for (const node of libraryControls(app)) document.body.appendChild(node); return app; } -// #302: the File menu now only appends `importQueriesInput, replaceWorkspaceInput` -// (in that order) — the old `importDashboardInput` between them is gone. +// #302: the File menu now only appends `importQueriesInput, openWorkspaceInput` +// (in that order) — the old `importDashboardInput` between them is gone. #342 +// renamed the picker/action to "Open workspace…" without changing append order. const picker = (i: number): HTMLInputElement => document.querySelectorAll('.file-menu input[type=file]')[i]; const pickFile = (input: HTMLInputElement, name = 'file.json'): void => { Object.defineProperty(input, 'files', { configurable: true, value: [{ name }] }); @@ -216,12 +217,12 @@ describe('file menu structure', () => { app.state.savedQueries = [panelQuery('s1', 'A'), panelQuery('s2', 'B')]; openFileMenu(app); expect([...document.querySelectorAll('.fm-label')].map((l) => l.textContent)).toEqual([ - 'New workspace…', 'Remember recent variable values', 'Clear all recent values', - 'Import queries…', 'Replace workspace…', - 'Export workspace…', 'Download Markdown', 'Download SQL', + 'New workspace…', 'Open workspace…', 'Export workspace…', 'Import queries…', + 'Download Markdown', 'Download SQL', + 'Remember recent variable values', 'Clear all recent values', ]); expect([...document.querySelectorAll('.fm-section')].map((s) => s.textContent)).toEqual( - ['Variable history', 'Import / replace', 'Export', 'Share / publish']); + ['Share / Publish', 'Variable history']); expect(document.querySelector('.fm-count')!.textContent).toBe('2 queries in workspace'); openFileMenu(app); expect(document.querySelectorAll('.file-menu')).toHaveLength(1); @@ -237,6 +238,23 @@ describe('file menu structure', () => { expect(document.activeElement).toBe(item(/New workspace/)); }); + it('#342: the first four rows are an unlabeled primary group (no heading before or inside it), followed by a separator', async () => { + const app = mount(); + openFileMenu(app); + const menu = document.querySelector('.file-menu')!; + const rows = [...menu.children]; + const primary = rows.slice(0, 4); + expect(primary.every((r) => r.classList.contains('fm-item'))).toBe(true); + expect(primary.map((r) => r.querySelector('.fm-label')!.textContent)).toEqual([ + 'New workspace…', 'Open workspace…', 'Export workspace…', 'Import queries…', + ]); + expect(rows[4].classList.contains('fm-sep')).toBe(true); + // Keyboard focus order matches the visual row order exactly. + await flush(); + key(document, 'ArrowDown'); + expect(document.activeElement).toBe(item(/Open workspace/)); + }); + it('footer shows the empty state when there are no queries', () => { const app = mount(); openFileMenu(app); @@ -588,7 +606,7 @@ describe('afterLibraryChange — dashboard route (#302)', () => { }); }); -describe('Replace workspace', () => { +describe('Open workspace (#342 rename of "Replace workspace…")', () => { it('the menu item closes the menu, opens the picker, and — after confirming — replaces the catalog + Dashboard', async () => { const dep = panelQuery('p1', 'Panel'); const dash = dashboardDoc({ id: 'd1', title: 'Ops', tiles: [{ id: 't1', queryId: 'p1' }] }); @@ -597,19 +615,19 @@ describe('Replace workspace', () => { openFileMenu(app); const input = picker(1); input.click = vi.fn(); - click(item(/Replace workspace/)!); + click(item(/Open workspace/)!); expect(document.querySelector('.file-menu')).toBeNull(); expect(input.click).toHaveBeenCalled(); pickFile(input); const dialog = document.querySelector('.fm-dialog-card')!; - expect(dialog.textContent).toContain('Replace workspace?'); + expect(dialog.textContent).toContain('Open workspace?'); expect(dialog.textContent).toContain('current 1 saved query'); expect(dialog.textContent).toContain('and the selected Dashboard'); click(document.querySelector('.fm-dialog-confirm')!); await flush(); expect(app.state.savedQueries.map((q) => q.id)).toEqual(['p1']); - expect(app.state.dashboard!.id).toBe('d1'); // Replace keeps the bundle Dashboard's own id/revision - expect(toast()).toBe('Replaced workspace'); + expect(app.state.dashboard!.id).toBe('d1'); // Open workspace keeps the bundle Dashboard's own id/revision + expect(toast()).toBe('Opened workspace'); }); it('cancelling the confirm dialog leaves the workspace untouched', () => { @@ -630,10 +648,10 @@ describe('Replace workspace', () => { openFileMenu(app); pickFile(picker(1)); const dialog = document.querySelector('.fm-dialog-card')!; - expect(dialog.textContent).toContain('Replace workspace — which dashboard?'); + expect(dialog.textContent).toContain('Open workspace — which dashboard?'); const noneRow = [...dialog.querySelectorAll('.fm-item')].find((b) => (b.textContent || '').includes('No dashboard'))!; click(noneRow); - expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Replace workspace?'); + expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Open workspace?'); click(document.querySelector('.fm-dialog-confirm')!); await flush(); expect(app.state.dashboard).toBeNull(); @@ -644,7 +662,7 @@ describe('Replace workspace', () => { const app = mount({ FileReader: fakeReader(bundleText({ dashboards: [dash] })) }); openFileMenu(app); pickFile(picker(1)); - expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Replace workspace?'); + expect(document.querySelector('.fm-dialog-card')!.textContent).toContain('Open workspace?'); }); it('a queries-only bundle (no Dashboard) clears an existing Dashboard', async () => {