Merge pull request #53 from agnosticeng/tabs

This commit is contained in:
Yann Amsellem
2025-01-14 17:53:17 +01:00
committed by GitHub
9 changed files with 344 additions and 55 deletions

View File

@@ -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%);
}
</style>

View File

@@ -41,6 +41,10 @@
e.currentTarget.blur();
}
}}
ontouchend={(e) => {
onHistoryClick?.(entry);
e.currentTarget.blur();
}}
>
<div class="content">{entry.content}</div>
<span class="time">{dayjs(entry.timestamp).fromNow()}</span>

View File

@@ -85,6 +85,10 @@
e.currentTarget.blur();
}
}}
ontouchend={async (e) => {
await onopen?.(query);
e.currentTarget.blur();
}}
>
<div>
{#if editing_id === query.id}

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import XMark from '$lib/icons/XMark.svelte';
interface Props {
label: string;
onSelect: () => void;
onClose: () => void;
active: boolean;
'close-hidden': boolean;
}
let { active, label, onClose, onSelect, 'close-hidden': hide_close }: Props = $props();
</script>
<div class="tab" class:active role="button" onclick={onSelect} tabindex="0" onkeyup={() => {}}>
<span>{label}</span>
{#if !hide_close}
<button
onclick={(e) => {
e.stopPropagation();
onClose();
}}
>
<XMark size="10" />
</button>
{/if}
</div>
<style>
div {
height: 100%;
font-size: 11px;
border-right: 1px solid hsl(0deg 0% 20%);
padding: 0 16px;
display: inline-flex;
align-items: center;
height: 100%;
color: hsl(0deg 0% 70%);
position: relative;
user-select: none;
-webkit-user-select: none;
&:hover {
cursor: pointer;
}
&.active {
background-color: hsl(0deg 0% 5%);
color: hsl(0deg 0% 100%);
z-index: 2;
}
& > button {
appearance: none;
outline: none;
border: none;
position: absolute;
display: flex;
place-items: center;
background-color: transparent;
right: 2px;
padding: 1px;
opacity: 0;
cursor: inherit;
border-radius: 3px;
&:hover {
background-color: hsl(0deg 0% 20%);
}
}
&:hover > button {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { SvelteHTMLElements } from 'svelte/elements';
interface Props extends Omit<SvelteHTMLElements['svg'], 'width' | 'height'> {
size?: string | number | null;
}
let { size = 24, ...rest }: Props = $props();
</script>
<svg viewBox="0 0 24 24" aria-hidden="true" width={size} height={size} {...rest} data-name="x-mark">
<path
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
</svg>

View File

@@ -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
);

View File

@@ -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 }
];

View File

@@ -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<void>;
}
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<void> {
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<ReturnType<Database['exec']>>[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);

View File

@@ -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<OLAPResponse>();
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<Table[]>([]);
@@ -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<ReturnType<typeof SaveQueryModal>>();
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<NonNullable<ComponentProps<typeof SaveQueryModal>['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<Tab[]>([]);
$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));
</script>
<svelte:window onkeydown={handleKeyDown} bind:innerWidth={screen_width} />
@@ -162,24 +234,45 @@
<SplitPane type="vertical" min="20%" max="80%" --color="hsl(0deg 0% 12%)">
{#snippet a()}
<div>
<nav class="Tabs">
<div class="left">
<nav class="navigation">
<div class="tabs-container">
{#if is_mobile}
<button onclick={() => (open_drawer = true)}>
<button class="action" onclick={() => (open_drawer = true)}>
<Bars3 size="12" />
</button>
{/if}
{#each tabs as tab, i}
<TabComponent
close-hidden={tabs.length === 1}
active={i === selectedTabIndex}
label={tab.name}
onClose={() => closeTab(i)}
onSelect={() => (selectedTabIndex = i)}
/>
{/each}
<button
onclick={addNewTab}
class="add-new"
aria-label="Open new tab"
title="Open new tab"
>
<Plus size="14" />
</button>
</div>
<div class="right">
<button onclick={() => save_query_modal?.show()} disabled={!query}>
<div class="workspace-actions">
<button class="action" onclick={handleSaveQuery} disabled={!canSave}>
<Save size="12" />
</button>
<button onclick={handleExec} disabled={loading}><Play size="12" /></button>
<button class="action" onclick={handleExec} disabled={loading}>
<Play size="12" />
</button>
</div>
</nav>
<div>
<Editor bind:value={query} onExec={handleExec} {tables} />
</div>
{#each tabs as tab, i (tab.id)}
<div style:display={selectedTabIndex == i ? 'block' : 'none'}>
<Editor bind:value={tab.contents} {tables} />
</div>
{/each}
</div>
{/snippet}
{#snippet b()}
@@ -193,24 +286,45 @@
<SaveQueryModal bind:this={save_query_modal} onCreate={handleCreateQuery} />
<style>
.Tabs {
.navigation {
height: 28px;
display: flex;
border-bottom: 1px solid hsl(0deg 0% 20%);
& > .left {
flex: 1;
position: relative;
&::before {
content: '';
position: absolute;
width: 100%;
height: 1px;
bottom: 0px;
left: 0;
background-color: hsl(0deg 0% 20%);
z-index: 1;
}
& > .right {
& > .tabs-container {
flex: 1;
display: flex;
align-items: center;
height: 100%;
overflow-x: hidden;
}
& > .workspace-actions {
display: flex;
gap: 0px;
}
& button {
& button.action {
height: 100%;
aspect-ratio: 1;
background-color: transparent;
border-radius: 0;
&:is(:hover, :focus-within):not(:disabled) {
background: hsl(0deg 0% 10%);
}
}
& ~ div {
@@ -218,19 +332,36 @@
}
}
.add-new {
height: calc(100% - 8px);
aspect-ratio: 1;
display: flex;
place-items: center;
justify-content: center;
padding: 0;
margin-left: 4px;
border-radius: 4px;
background-color: transparent;
&:hover {
cursor: pointer;
background-color: hsl(0deg 0% 17%);
}
&:active {
background-color: hsl(0deg 0% 20%);
}
}
button {
appearance: none;
outline: none;
border: none;
font-size: 10px;
font-weight: 500;
background-color: hsl(0deg 0% 9%);
padding: 4px 2;
border-radius: 3px;
&:is(:hover, :focus-within):not(:disabled) {
cursor: pointer;
background: hsl(0deg 0% 10%);
}
}