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
30 changes: 30 additions & 0 deletions src/core/export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Pure serializers turning result data (columns + rows) into TSV / CSV text.
// Used by the results pane's Copy (TSV — pastes into spreadsheets) and Export
// (CSV — opens in Excel) actions. No DOM, no globals.

function cell(v) {
return v == null ? '' : String(v);
}

/**
* TabSeparated text: a header row of column names + one line per data row.
* Backslashes, tabs and newlines are escaped ClickHouse-TSV style so embedded
* whitespace can't break the column/row grid when pasted.
*/
export function toTSV(columns, rows) {
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
const head = columns.map((c) => esc(c.name)).join('\t');
const body = rows.map((row) => row.map((v) => esc(cell(v))).join('\t')).join('\n');
return rows.length ? head + '\n' + body : head;
}

/**
* RFC-4180 CSV: a header row + one line per data row. A field is quoted only
* when it contains a comma, double-quote, or CR/LF; internal quotes are doubled.
*/
export function toCSV(columns, rows) {
const q = (s) => (/[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s);
const head = columns.map((c) => q(c.name)).join(',');
const body = rows.map((row) => row.map((v) => q(cell(v))).join(',')).join('\n');
return rows.length ? head + '\n' + body : head;
}
65 changes: 48 additions & 17 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// is injected as a `save(key, value)` function (defaulting to storage.js), so
// every operation is unit-testable with a spy and no real localStorage.

import { clamp, inferQueryName } from './core/format.js';
import { clamp } from './core/format.js';
import { loadJSON, saveJSON, loadStr } from './core/storage.js';

export const KEYS = {
Expand Down Expand Up @@ -65,28 +65,59 @@ export function allocTabId(state) {

const rnd = () => Math.random().toString(36).slice(2, 6);

/** Find a saved query whose SQL matches (trimmed). */
export function findSavedBySql(state, sql) {
const s = String(sql || '').trim();
return state.savedQueries.find((q) => q.sql.trim() === s) || null;
/** The saved query a tab is linked to (via tab.savedId), or null. */
export function savedForTab(state, tab) {
return (tab && tab.savedId && state.savedQueries.find((q) => q.id === tab.savedId)) || null;
}

/**
* Toggle the active SQL in/out of saved queries. Returns { saved } reflecting
* the new state, or { saved: false, noop: true } for empty SQL.
* Save the tab's SQL under `name`. If the tab is already linked to a saved
* entry, update that entry in place; otherwise create a new one (newest first)
* and link the tab to it. The tab's name mirrors the saved name. Returns the
* saved entry, or null for empty SQL/name.
*/
export function toggleSaved(state, sql, save = saveJSON, now = Date.now()) {
const s = String(sql || '').trim();
if (!s) return { saved: false, noop: true };
const existing = findSavedBySql(state, s);
if (existing) {
state.savedQueries = state.savedQueries.filter((q) => q.id !== existing.id);
save(KEYS.saved, state.savedQueries);
return { saved: false };
export function saveQuery(state, tab, name, save = saveJSON, now = Date.now()) {
const sql = String(tab.sql || '').trim();
const nm = String(name || '').trim();
if (!sql || !nm) return null;
let entry = savedForTab(state, tab);
if (entry) {
entry.name = nm;
entry.sql = sql;
} else {
entry = { id: 's' + now + rnd(), name: nm, sql, favorite: false };
state.savedQueries.unshift(entry);
tab.savedId = entry.id;
}
state.savedQueries.unshift({ id: 's' + now + rnd(), name: inferQueryName(s), sql: s, starred: true });
tab.name = nm;
save(KEYS.saved, state.savedQueries);
return { saved: true };
return entry;
}

/** Rename a saved query, keeping any linked tab's name in sync. */
export function renameSaved(state, id, name, save = saveJSON) {
const nm = String(name || '').trim();
const entry = state.savedQueries.find((q) => q.id === id);
if (!entry || !nm) return;
entry.name = nm;
for (const t of state.tabs) if (t.savedId === id) t.name = nm;
save(KEYS.saved, state.savedQueries);
}

/** Toggle a saved query's favorite flag. */
export function toggleFavorite(state, id, save = saveJSON) {
const entry = state.savedQueries.find((q) => q.id === id);
if (!entry) return;
entry.favorite = !entry.favorite;
save(KEYS.saved, state.savedQueries);
}

/** Saved queries with favorites first (stable within each group). */
export function sortedSaved(state) {
return state.savedQueries
.map((q, i) => [q, i])
.sort((a, b) => (b[0].favorite ? 1 : 0) - (a[0].favorite ? 1 : 0) || a[1] - b[1])
.map(([q]) => q);
}

/** Delete a saved query by id and clear any tab pointer to it. */
Expand Down
69 changes: 53 additions & 16 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -248,35 +248,40 @@ body {
flex: 1; font-size: 12px; font-weight: 500; color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.saved-row .star { color: var(--accent); display: flex; flex-shrink: 0; }
.saved-row .star.unstar { color: var(--fg-faint); }
.saved-row .sv-star {
width: 18px; height: 18px; border: none; background: transparent;
color: var(--fg-faint); cursor: pointer; border-radius: 3px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center; padding: 0;
}
.saved-row .sv-star.on { color: var(--accent); }
.saved-row .sv-star:hover { color: var(--fg); }
.saved-row .sv-edit {
flex: 1; min-width: 0; font: inherit; font-size: 12px; font-weight: 500;
color: var(--fg); background: var(--bg-editor);
border: 1px solid var(--accent); border-radius: 4px; padding: 1px 5px; outline: none;
}
.saved-row .preview {
font-size: 10.5px; font-family: var(--mono); color: var(--fg-faint);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
padding-left: 18px;
}
.saved-row .del {
.sv-act {
width: 18px; height: 18px; border: none; background: transparent;
color: var(--fg-faint); cursor: pointer; border-radius: 3px;
color: var(--fg-faint); cursor: pointer; border-radius: 3px; flex-shrink: 0;
display: none; align-items: center; justify-content: center; padding: 0;
}
.saved-row:hover .del { display: inline-flex; }
.saved-row .del:hover { color: var(--fg); background: var(--bg-hover); }
.saved-row:hover .sv-act { display: inline-flex; }
.sv-act:hover { color: var(--fg); background: var(--bg-hover); }
.side-count { color: var(--fg-faint); font-weight: 400; }
.history-row {
position: relative;
padding: 8px 10px; cursor: pointer; user-select: none;
border-bottom: 1px solid var(--border-faint);
display: flex; flex-direction: column; gap: 3px;
}
.history-row:hover { background: var(--bg-hover); }
.history-row .del {
position: absolute; top: 6px; right: 8px;
width: 18px; height: 18px; border: none; background: transparent;
color: var(--fg-faint); cursor: pointer; border-radius: 3px;
display: none; align-items: center; justify-content: center; padding: 0;
}
.history-row:hover .del { display: inline-flex; }
.history-row .del:hover { color: var(--fg); background: var(--bg-hover); }
.history-row .sv-act.del { position: absolute; top: 6px; right: 8px; }
.history-row:hover .sv-act.del { display: inline-flex; }
.history-row .sql {
font-size: 11px; font-family: var(--mono); color: var(--fg);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
Expand All @@ -286,7 +291,32 @@ body {
display: flex; gap: 10px;
font-size: 10px; color: var(--fg-faint); font-family: var(--mono);
}
.star-btn .star-on { color: var(--accent); }
.save-btn.saved, .save-btn.saved svg { color: var(--accent); }
.save-popover {
z-index: 50; width: 240px; padding: 12px;
background: var(--bg-panel, var(--bg-editor)); border: 1px solid var(--border);
border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.4);
display: flex; flex-direction: column; gap: 9px;
}
.save-popover .sp-label {
font-size: 10px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
color: var(--fg-faint);
}
.save-popover .sp-input {
font: inherit; font-size: 13px; color: var(--fg);
background: var(--bg-editor); border: 1px solid var(--border);
border-radius: 5px; padding: 6px 8px; outline: none;
}
.save-popover .sp-input:focus { border-color: var(--accent); }
.save-popover .sp-actions { display: flex; justify-content: flex-end; gap: 8px; }
.save-popover .sp-cancel, .save-popover .sp-save {
font: inherit; font-size: 12px; cursor: pointer;
padding: 5px 12px; border-radius: 5px; border: 1px solid var(--border);
}
.save-popover .sp-cancel { background: transparent; color: var(--fg-mute); }
.save-popover .sp-cancel:hover { color: var(--fg); background: var(--bg-hover); }
.save-popover .sp-save { background: var(--accent); border-color: var(--accent); color: #fff; }
.save-popover .sp-save:hover { filter: brightness(1.08); }

/* ------------ share toast ------------ */
.share-toast {
Expand Down Expand Up @@ -650,6 +680,14 @@ body {
padding: 0 8px; border-right: 1px solid var(--border-faint);
}
.stat:last-of-type { border-right: none; }
.res-act {
display: flex; align-items: center; gap: 5px;
height: 24px; padding: 0 9px;
border: 1px solid transparent; border-radius: 5px;
background: transparent; color: var(--fg-mute);
font-size: 11px; font-family: inherit; cursor: pointer;
}
.res-act:hover { background: var(--bg-hover); color: var(--fg); border-color: var(--border); }
.stat .ic { color: var(--fg-faint); display: flex; }
.stat .v { color: var(--fg); }

Expand Down Expand Up @@ -714,7 +752,6 @@ table.res-table.fixed { table-layout: fixed; }
table.res-table.fixed th, table.res-table.fixed td { overflow: hidden; text-overflow: ellipsis; }
table.res-table th .h-inner { display: flex; align-items: center; gap: 5px; }
table.res-table th .h-name { color: var(--fg); }
table.res-table th .h-type { font-size: 9.5px; color: var(--fg-faint); font-weight: 400; }
table.res-table th .h-sort { color: var(--accent); display: flex; }
table.res-table td {
border-bottom: 1px solid var(--border-faint);
Expand Down
Loading
Loading