feat(library): document-style File menu for the saved-query Library (… · Altinity/altinity-sql-browser@e8370f4 · GitHub
Skip to content

Commit e8370f4

Browse files
feat(library): document-style File menu for the saved-query Library (#29)
Reframes the saved-query collection as a named, savable document — "the Library" — with a header File ▾ menu, an editable name, and an unsaved-changes dot, replacing the Export/Import row hidden at the bottom of the Saved panel. State (src/state.js): - libraryName (persisted, key asb:libraryName) + libraryDirty (session-only). - ops renameLibrary / newLibrary / replaceLibrary / appendLibrary / markLibrarySaved; the existing saved-query mutators now mark the Library dirty. New/Replace prune now-dangling tab→saved links; Replace adopts the loaded file's base name and keeps original ids (lossless). Serializers (src/core/saved-io.js): - buildMarkdownDoc / buildSqlDoc — one-way "share" exports (### + fenced sql; /* name + description */ comment + ;-delimited sql). JSON stays canonical. UI: - New src/ui/file-menu.js: the header File ▾ menu (New / Save JSON / Replace… / Append… / Download Markdown / Download SQL + "N in Library" footer), the inline-editable library title with a dirty dot, and Replace/New confirm dialogs. JSON is the only importable format; imported SQL is never run. - app.js mounts the controls in the header and exposes the download / saveStr / FileReader / library-title seams; the obsolete exportSaved/importSavedFile actions are removed. saved-history.js drops savedActions() and relabels the tab "Library". Tests stay at the per-file 100% statements/lines gate (branches ≥90, functions ≥95); adds tests/unit/file-menu.test.js. README documents the Library menu. Builds on the descriptions pre-phase (#29 phase 1, PR #30). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AX4YR7RHKj7JfmS2AqnRrm
1 parent 73f8182 commit e8370f4

13 files changed

Lines changed: 874 additions & 122 deletions

README.md

Lines changed: 32 additions & 0 deletions

src/core/saved-io.js

1.51 KB
Binary file not shown.

src/state.js

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import { clamp } from './core/format.js';
66
import { mergeSaved } from './core/saved-io.js';
77
import { cloneChartCfg } from './core/chart-data.js';
8-
import { loadJSON, saveJSON, loadStr } from './core/storage.js';
8+
import { loadJSON, saveJSON, loadStr, saveStr } from './core/storage.js';
99

1010
/** A tab's chart state as a persistable payload `{ cfg, key }`, or null. */
1111
export function tabChart(tab) {
@@ -24,8 +24,12 @@ export const KEYS = {
2424
sidePanel: 'asb:sidePanel',
2525
saved: 'asb:saved',
2626
history: 'asb:history',
27+
libraryName: 'asb:libraryName',
2728
};
2829

30+
/** Default name for a fresh / unnamed saved-query library. */
31+
export const DEFAULT_LIBRARY_NAME = 'SQL Library';
32+
2933
/** A blank query tab. `chartCfg`/`chartKey` hold the per-tab chart config and
3034
* the schema signature it was derived for (re-derived when the schema changes). */
3135
export function newTabObj(id) {
@@ -60,6 +64,11 @@ export function createState(read = { loadJSON, loadStr }) {
6064
sidePanel: read.loadStr(KEYS.sidePanel, 'saved'),
6165
savedQueries: read.loadJSON(KEYS.saved, []),
6266
history: read.loadJSON(KEYS.history, []),
67+
// The saved-query collection treated as a named document ("the Library").
68+
// `libraryName` is persisted; `libraryDirty` (unsaved changes since the last
69+
// file Save/Replace/New) is session-only and resets on reload.
70+
libraryName: read.loadStr(KEYS.libraryName, DEFAULT_LIBRARY_NAME),
71+
libraryDirty: false,
6372
shortcutsOpen: false,
6473
};
6574
}
@@ -115,6 +124,7 @@ export function saveQuery(state, tab, name, description, save = saveJSON, now =
115124
tab.savedId = entry.id;
116125
}
117126
tab.name = nm;
127+
state.libraryDirty = true;
118128
save(KEYS.saved, state.savedQueries);
119129
return entry;
120130
}
@@ -134,6 +144,7 @@ export function renameSaved(state, id, name, description, save = saveJSON) {
134144
if (desc) entry.description = desc; else delete entry.description;
135145
}
136146
for (const t of tabsForSaved(state, id)) t.name = nm;
147+
state.libraryDirty = true;
137148
save(KEYS.saved, state.savedQueries);
138149
}
139150

@@ -142,6 +153,7 @@ export function toggleFavorite(state, id, save = saveJSON) {
142153
const entry = state.savedQueries.find((q) => q.id === id);
143154
if (!entry) return;
144155
entry.favorite = !entry.favorite;
156+
state.libraryDirty = true;
145157
save(KEYS.saved, state.savedQueries);
146158
}
147159

@@ -160,6 +172,7 @@ export function sortedSaved(state) {
160172
export function importSaved(state, queries, save = saveJSON, genId = () => makeId('s', Date.now())) {
161173
const { merged, added, updated, skipped } = mergeSaved(state.savedQueries, queries, genId);
162174
state.savedQueries = merged;
175+
state.libraryDirty = true;
163176
save(KEYS.saved, state.savedQueries);
164177
return { added, updated, skipped };
165178
}
@@ -168,7 +181,66 @@ export function importSaved(state, queries, save = saveJSON, genId = () => makeI
168181
export function deleteSaved(state, id, save = saveJSON) {
169182
state.savedQueries = state.savedQueries.filter((q) => q.id !== id);
170183
for (const t of tabsForSaved(state, id)) t.savedId = null;
184+
state.libraryDirty = true;
185+
save(KEYS.saved, state.savedQueries);
186+
}
187+
188+
// ── Library document ops ────────────────────────────────────────────────────
189+
// The saved-query collection is a named, savable document. These ops back the
190+
// header File menu (New / Save / Replace / Append) and the editable library
191+
// name + unsaved-changes dot.
192+
193+
/** Clear tab→saved links whose entry no longer exists (after New/Replace), so a
194+
* kept tab doesn't show "Saved" against a query that's gone. */
195+
function pruneTabLinks(state) {
196+
const ids = new Set(state.savedQueries.map((q) => q.id));
197+
for (const t of state.tabs) if (t.savedId && !ids.has(t.savedId)) t.savedId = null;
198+
}
199+
200+
/** Rename the library (blank → the default name). Marks dirty; persists name. */
201+
export function renameLibrary(state, name, saveName = saveStr) {
202+
state.libraryName = String(name || '').trim() || DEFAULT_LIBRARY_NAME;
203+
state.libraryDirty = true;
204+
saveName(KEYS.libraryName, state.libraryName);
205+
}
206+
207+
/** Start an empty, default-named library. Clears dirty; open tabs are kept
208+
* (their now-dangling saved links are pruned). */
209+
export function newLibrary(state, save = saveJSON, saveName = saveStr) {
210+
state.savedQueries = [];
211+
pruneTabLinks(state);
212+
state.libraryName = DEFAULT_LIBRARY_NAME;
213+
state.libraryDirty = false;
214+
save(KEYS.saved, state.savedQueries);
215+
saveName(KEYS.libraryName, state.libraryName);
216+
}
217+
218+
/** Replace the library with `queries`, adopting the loaded file's base name.
219+
* Original ids are kept (lossless round-trip); id-less entries get a fresh id.
220+
* Clears dirty; open tabs are kept (dangling links pruned). */
221+
export function replaceLibrary(state, queries, fileName, save = saveJSON, saveName = saveStr, genId = () => makeId('s', Date.now())) {
222+
state.savedQueries = queries.map((q) => ({
223+
id: q.id || genId(), name: q.name, sql: q.sql, favorite: !!q.favorite,
224+
...(q.description ? { description: q.description } : {}),
225+
...(q.chart ? { chart: q.chart } : {}), ...(q.view ? { view: q.view } : {}),
226+
}));
227+
pruneTabLinks(state);
228+
const base = String(fileName || '').replace(/\.[^.]+$/, '').trim();
229+
state.libraryName = base || DEFAULT_LIBRARY_NAME;
230+
state.libraryDirty = false;
171231
save(KEYS.saved, state.savedQueries);
232+
saveName(KEYS.libraryName, state.libraryName);
233+
}
234+
235+
/** Append `queries` into the library via the standard merge dedupe (sets dirty
236+
* through importSaved). Returns { added, updated, skipped }. */
237+
export function appendLibrary(state, queries, save = saveJSON, genId = () => makeId('s', Date.now())) {
238+
return importSaved(state, queries, save, genId);
239+
}
240+
241+
/** Mark the library as saved to a file (clears the unsaved-changes dot). */
242+
export function markLibrarySaved(state) {
243+
state.libraryDirty = false;
172244
}
173245

174246
/** Record a successful run in history (most-recent first, capped at 50). */

src/styles.css

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,79 @@ body {
284284
.user-menu .um-item.danger { color: #ef4444; }
285285
.user-menu .um-item.danger:hover { background: color-mix(in oklab, #ef4444 12%, transparent); }
286286

287+
/* ------------ header File menu + library title ------------ */
288+
.hd-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }
289+
.hd-file-btn {
290+
display: flex; align-items: center; gap: 5px; height: 26px; padding: 0 8px;
291+
border: none; border-radius: 6px; background: transparent; color: var(--fg-mute);
292+
cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 500; flex-shrink: 0;
293+
}
294+
.hd-file-btn:hover { background: var(--bg-hover); color: var(--fg); }
295+
.lib-title { display: flex; align-items: center; min-width: 0; }
296+
.lib-name {
297+
display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 7px;
298+
border: none; border-radius: 6px; background: transparent; cursor: pointer;
299+
font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--fg);
300+
max-width: 280px; min-width: 0;
301+
}
302+
.lib-name:hover { background: var(--bg-hover); }
303+
.lib-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
304+
.lib-dirty { width: 6px; height: 6px; border-radius: 3px; background: var(--accent); flex-shrink: 0; }
305+
.lib-name-input {
306+
height: 24px; min-width: 120px; max-width: 280px; padding: 0 8px;
307+
background: var(--bg-input); border: 1px solid var(--accent); border-radius: 6px;
308+
color: var(--fg); font-family: inherit; font-size: 12.5px; outline: none;
309+
}
310+
/* File dropdown */
311+
.fm-overlay { position: fixed; inset: 0; z-index: 60; }
312+
.file-menu {
313+
z-index: 61; width: 252px; padding-bottom: 4px;
314+
background: var(--bg-modal); border: 1px solid var(--border);
315+
border-radius: 9px; box-shadow: 0 12px 36px rgba(0,0,0,.35); overflow: hidden;
316+
}
317+
.fm-item {
318+
width: 100%; display: flex; align-items: center; gap: 9px; padding: 7px 12px;
319+
border: none; background: transparent; color: var(--fg); font-family: inherit;
320+
font-size: 12.5px; cursor: pointer; text-align: left;
321+
}
322+
.fm-item:hover { background: var(--bg-hover); }
323+
.fm-icon { display: flex; width: 15px; justify-content: center; color: var(--fg-mute); flex-shrink: 0; }
324+
.fm-label { flex: 1; white-space: nowrap; }
325+
.fm-meta { font-size: 10px; color: var(--fg-faint); font-family: var(--mono); flex-shrink: 0; }
326+
.fm-sep { height: 1px; background: var(--border-faint); margin: 5px 0; }
327+
.fm-section {
328+
font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
329+
color: var(--fg-faint); padding: 9px 12px 4px;
330+
}
331+
.fm-count {
332+
margin-top: 4px; padding: 8px 12px; border-top: 1px solid var(--border-faint);
333+
font-size: 10.5px; color: var(--fg-faint); font-family: var(--mono);
334+
}
335+
/* Replace / New confirm dialog */
336+
.fm-dialog-backdrop {
337+
position: fixed; inset: 0; z-index: 130; background: rgba(0,0,0,.5);
338+
backdrop-filter: blur(4px);
339+
display: flex; align-items: center; justify-content: center;
340+
}
341+
.fm-dialog-card {
342+
width: 392px; max-width: calc(100vw - 32px);
343+
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 11px;
344+
box-shadow: 0 20px 60px rgba(0,0,0,.45); padding: 20px 22px;
345+
}
346+
.fm-dialog-title { font-size: 14.5px; font-weight: 600; color: var(--fg); margin-bottom: 6px; }
347+
.fm-dialog-body { font-size: 12.5px; color: var(--fg-mute); line-height: 1.55; margin-bottom: 18px; }
348+
.fm-dialog-body b { color: var(--fg); }
349+
.fm-mono { color: var(--fg); font-family: var(--mono); }
350+
.fm-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
351+
.fm-dialog-cancel, .fm-dialog-confirm {
352+
height: 30px; padding: 0 14px; border-radius: 6px; font-family: inherit;
353+
font-size: 12px; font-weight: 500; cursor: pointer;
354+
}
355+
.fm-dialog-cancel { border: 1px solid var(--border); background: transparent; color: var(--fg); }
356+
.fm-dialog-cancel:hover { background: var(--bg-hover); }
357+
.fm-dialog-confirm { border: none; background: var(--accent); color: #fff; font-weight: 600; }
358+
.fm-dialog-confirm:hover { filter: brightness(1.08); }
359+
287360
/* ------------ main row ------------ */
288361
.main-row { flex: 1; display: flex; min-height: 0; overflow: hidden; }
289362
.sidebar {

src/ui/app.js

Lines changed: 12 additions & 30 deletions

0 commit comments

Comments
 (0)