diff --git a/src/lib/components/Editor/Editor.svelte b/src/lib/components/Editor/Editor.svelte index d7f7134..9935e43 100644 --- a/src/lib/components/Editor/Editor.svelte +++ b/src/lib/components/Editor/Editor.svelte @@ -2,7 +2,7 @@ import type { Table } from '$lib/olap-engine'; import { sql } from '@codemirror/lang-sql'; import { Compartment, EditorState } from '@codemirror/state'; - import { EditorView, keymap, placeholder } from '@codemirror/view'; + import { EditorView, placeholder } from '@codemirror/view'; import { untrack } from 'svelte'; import './codemirror.css'; import { default_extensions, default_keymaps } from './extensions'; @@ -11,11 +11,10 @@ type Props = { value: string; - onExec?: () => unknown; tables?: Table[]; }; - let { value = $bindable(''), onExec, tables = [] }: Props = $props(); + let { value = $bindable(''), tables = [] }: Props = $props(); let container: HTMLDivElement; let editor_view: EditorView; @@ -37,16 +36,7 @@ value = update.state.doc.toString(); } }), - placeholder('Enter a query...'), - keymap.of([ - { - key: 'Mod-Enter', - run: () => { - onExec?.(); - return true; - } - } - ]) + placeholder('Type your query...') ] }); @@ -78,7 +68,6 @@ div { width: 100%; height: 100%; - padding: 7px 2px; background-color: hsl(0deg 0% 5%); } diff --git a/src/lib/components/History.svelte b/src/lib/components/History.svelte index 0e7d92e..b641b66 100644 --- a/src/lib/components/History.svelte +++ b/src/lib/components/History.svelte @@ -41,6 +41,10 @@ e.currentTarget.blur(); } }} + ontouchend={(e) => { + onHistoryClick?.(entry); + e.currentTarget.blur(); + }} >
{entry.content}
{dayjs(entry.timestamp).fromNow()} diff --git a/src/lib/components/Queries/Queries.svelte b/src/lib/components/Queries/Queries.svelte index 7ea2694..64816a6 100644 --- a/src/lib/components/Queries/Queries.svelte +++ b/src/lib/components/Queries/Queries.svelte @@ -85,6 +85,10 @@ e.currentTarget.blur(); } }} + ontouchend={async (e) => { + await onopen?.(query); + e.currentTarget.blur(); + }} >
{#if editing_id === query.id} diff --git a/src/lib/components/Tab.svelte b/src/lib/components/Tab.svelte new file mode 100644 index 0000000..6fa8d02 --- /dev/null +++ b/src/lib/components/Tab.svelte @@ -0,0 +1,78 @@ + + +
{}}> + {label} + + {#if !hide_close} + + {/if} +
+ + diff --git a/src/lib/icons/XMark.svelte b/src/lib/icons/XMark.svelte new file mode 100644 index 0000000..18c0cab --- /dev/null +++ b/src/lib/icons/XMark.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/migrations/003_create_tabs_table.sql b/src/lib/migrations/003_create_tabs_table.sql new file mode 100644 index 0000000..35fcf01 --- /dev/null +++ b/src/lib/migrations/003_create_tabs_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS tabs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + contents TEXT NOT NULL, + query_id INTEGER, + tab_index INTEGER NOT NULL, + active BOOL DEFAULT FALSE UNIQUE +); diff --git a/src/lib/migrations/index.ts b/src/lib/migrations/index.ts index cdbdb19..52aad25 100644 --- a/src/lib/migrations/index.ts +++ b/src/lib/migrations/index.ts @@ -2,8 +2,10 @@ import type { Migration } from '@agnosticeng/migrate'; import CREATE_HISTORY_TABLE_SCRIPT from './001_create_history_table.sql?raw'; import CREATE_QUERIES_TABLE_SCRIPT from './002_create_queries_table.sql?raw'; +import CREATE_TABS_TABLE_SCRIPT from './003_create_tabs_table.sql?raw'; export const MIGRATIONS: Migration[] = [ { name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT }, - { name: 'create_queries_table', script: CREATE_QUERIES_TABLE_SCRIPT } + { name: 'create_queries_table', script: CREATE_QUERIES_TABLE_SCRIPT }, + { name: 'create_tabs_table', script: CREATE_TABS_TABLE_SCRIPT } ]; diff --git a/src/lib/repositories/tabs.ts b/src/lib/repositories/tabs.ts new file mode 100644 index 0000000..e5c2a11 --- /dev/null +++ b/src/lib/repositories/tabs.ts @@ -0,0 +1,55 @@ +import { db, type Database } from '$lib/database'; + +export interface Tab { + id: string; + name: string; + contents: string; + query_id?: number; +} + +export interface TabRepository { + get(): Promise<[tabs: Tab[], activeIndex: number]>; + save(tabs: Tab[], activeIndex: number): Promise; +} + +class SQLiteTabRepository implements TabRepository { + constructor(private db: Database) {} + + async get(): Promise<[tabs: Tab[], activeIndex: number]> { + const rows = await this.db.exec('select * from tabs order by tab_index'); + let index = rows.findIndex((r) => r.active); + return [rows.map(row_to_tab), Math.max(0, index)]; + } + + async save(tabs: Tab[], activeIndex: number): Promise { + const rows = tabs.map((tab, tab_index) => ({ ...tab, tab_index })); + + await this.db.exec( + `DELETE FROM tabs; +INSERT INTO tabs (id, name, contents, query_id, tab_index, active) +VALUES ${Array.from({ length: rows.length }).fill('(?,?,?,?,?, ?)').join(',\n')} +`, + rows + .map((r) => [ + r.id, + r.name, + r.contents, + r.query_id ?? null, + r.tab_index, + r.tab_index === activeIndex || null + ]) + .flat() + ); + } +} + +function row_to_tab(row: Awaited>[number]): Tab { + return { + id: row.id as string, + contents: row.contents as string, + name: row.name as string, + query_id: row.query_id as number | undefined + }; +} + +export const tab_repository: TabRepository = new SQLiteTabRepository(db); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 245541d..079cc25 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -6,36 +6,37 @@ import { SaveQueryModal } from '$lib/components/Queries'; import Result from '$lib/components/Result.svelte'; import SideBar from '$lib/components/SideBar.svelte'; + import TabComponent from '$lib/components/Tab.svelte'; import { set_app_context } from '$lib/context'; import Bars3 from '$lib/icons/Bars3.svelte'; import Play from '$lib/icons/Play.svelte'; + import Plus from '$lib/icons/Plus.svelte'; import Save from '$lib/icons/Save.svelte'; import type { Table } from '$lib/olap-engine'; import { engine, type OLAPResponse } from '$lib/olap-engine'; import { history_repository, type HistoryEntry } from '$lib/repositories/history'; import { query_repository, type Query } from '$lib/repositories/queries'; + import { tab_repository, type Tab } from '$lib/repositories/tabs'; import { SplitPane } from '@rich_harris/svelte-split-pane'; + import debounce from 'p-debounce'; import type { ComponentProps } from 'svelte'; let response = $state.raw(); - let query = $state(''); let loading = $state(false); async function handleExec() { + const query = currentTab.contents; if (loading || !query) { return; } loading = true; - const query_to_execute = query; - response = await engine.exec(query_to_execute).finally(() => (loading = false)); + response = await engine.exec(query).finally(() => (loading = false)); const last = await history_repository.getLast(); - if (response && last?.content !== query_to_execute) { - await addHistoryEntry(query_to_execute); - } + if (response && last?.content !== query) await addHistoryEntry(query); } let tables = $state.raw([]); @@ -64,7 +65,10 @@ } function handleHistoryClick(entry: HistoryEntry) { - query = entry.content; + if (currentTab.contents) { + selectedTabIndex = + tabs.push({ id: crypto.randomUUID(), contents: entry.content, name: 'Untitled' }) - 1; + } else tabs[selectedTabIndex] = { ...currentTab, contents: entry.content }; if (is_mobile) open_drawer = false; } @@ -74,41 +78,72 @@ let save_query_modal = $state>(); - function handleKeyDown(event: KeyboardEvent) { + async function handleKeyDown(event: KeyboardEvent) { if (event.key === 's' && event.metaKey) { - if (query) { - event.preventDefault(); - save_query_modal?.show(); - } + event.preventDefault(); + handleSaveQuery(); } + + if (event.key === 'Enter' && event.metaKey) handleExec(); } async function handleCreateQuery({ name }: Parameters['onCreate']>>['0']) { - const q = await query_repository.create(name, query); + const q = await query_repository.create(name, currentTab.contents); queries = queries.concat(q); + tabs[selectedTabIndex] = { ...currentTab, name, query_id: q.id }; } async function handleDeleteQuery(query: Query) { await query_repository.delete(query.id); - const index = queries.indexOf(query); - queries = queries.slice(0, index).concat(queries.slice(index + 1)); + queries = queries.toSpliced(queries.indexOf(query), 1); } - function handleQueryOpen(_query: Query) { - query = _query.sql; + function handleQueryOpen(query: Query) { + const index = tabs.findIndex((t) => t.query_id === query.id); + if (index === -1) { + if (currentTab.contents) { + selectedTabIndex = + tabs.push({ + id: crypto.randomUUID(), + contents: query.sql, + name: query.name, + query_id: query.id + }) - 1; + } else + tabs[selectedTabIndex] = { + ...currentTab, + contents: query.sql, + name: query.name, + query_id: query.id + }; + } else selectedTabIndex = index; + if (is_mobile) open_drawer = false; } async function handleQueryRename(query: Query) { const updated = await query_repository.update(query); const index = queries.findIndex((query) => query.id === updated.id); + if (index !== -1) queries = queries.with(index, updated); + + const tab_index = tabs.findIndex((t) => t.query_id === updated.id); + if (tab_index !== -1) { + tabs[tab_index] = { ...tabs[tab_index], name: updated.name, contents: updated.sql }; + } + } + + async function handleSaveQuery() { + const { contents, query_id: query_id } = currentTab; + if (contents && !query_id) { + return save_query_modal?.show(); + } + + const index = queries.findIndex((q) => q.id === query_id); if (index !== -1) { - queries = queries - .slice(0, index) - .concat(updated) - .concat(queries.slice(index + 1)); + const updated = await query_repository.update({ ...queries[index], sql: contents }); + queries = queries.with(index, updated); } } @@ -122,6 +157,43 @@ $effect(() => { if (!is_mobile) open_drawer = false; }); + + let tabs = $state([]); + $effect( + () => + void tab_repository.get().then(([t, active]) => { + if (t.length) (tabs = t), (selectedTabIndex = active); + else tabs.push({ id: crypto.randomUUID(), contents: '', name: 'Untitled' }); + }) + ); + + const saveTabs = debounce( + (tabs: Tab[], activeIndex: number) => tab_repository.save(tabs, activeIndex), + 2_000 + ); + + let selectedTabIndex = $state(0); + const currentTab = $derived(tabs[selectedTabIndex]); + const canSave = $derived.by(() => { + if (!tabs.length) return false; + if (currentTab.query_id) { + const query = queries.find((q) => q.id === currentTab.query_id); + return query?.sql !== currentTab.contents; + } + + return !!currentTab.contents; + }); + + function addNewTab() { + selectedTabIndex = tabs.push({ id: crypto.randomUUID(), name: 'Untitled', contents: '' }) - 1; + } + + function closeTab(index: number) { + tabs.splice(index, 1); + selectedTabIndex = Math.max(0, selectedTabIndex - 1); + } + + $effect(() => void saveTabs($state.snapshot(tabs), selectedTabIndex).catch(console.error)); @@ -162,24 +234,45 @@ {#snippet a()}
-