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 () => {