Merge pull request #48 from agnosticeng/feat/save-queries

feat: save queries
This commit is contained in:
Didier Franc
2025-01-08 17:05:13 +01:00
committed by GitHub
18 changed files with 666 additions and 22 deletions

View File

@@ -0,0 +1,111 @@
<script lang="ts">
import type { ContextMenuState } from './ContextMenuState.svelte';
interface Props {
state: ContextMenuState;
}
let { state }: Props = $props();
</script>
{#if state.open}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="backdrop"
onclick={state.close}
oncontextmenu={(e) => {
e.preventDefault();
state.close();
}}
></div>
<div
style:--x="{state.position.x}px"
style:--y="{state.position.y}px"
oncontextmenu={(e) => e.preventDefault()}
role="menu"
tabindex="-1"
>
<ul>
{#each state.items as item}
{#if 'is_separator' in item}
<li>
<hr />
</li>
{:else}
<li>
<button
disabled={item.disabled}
onclick={async (e) => {
await item.onClick?.(e);
state.close();
}}
>
{item.label}
</button>
</li>
{/if}
{/each}
</ul>
</div>
{/if}
<style>
div.backdrop {
position: fixed;
inset: 0;
z-index: 9998;
}
div[role='menu'] {
position: fixed;
left: var(--x);
top: var(--y);
z-index: 9999;
min-width: 120px;
border: 1px solid hsl(0deg 0% 29%);
border-radius: 6px;
background-color: hsl(0deg 0% 18%);
padding: 5px;
user-select: none;
-webkit-user-select: none;
}
ul {
list-style: none;
margin: 0;
padding: 0;
}
button {
appearance: none;
outline: none;
border: none;
width: 100%;
background-color: transparent;
text-align: start;
padding: 4px 8px;
border-radius: 4px;
&:hover:not(:disabled) {
cursor: pointer;
background-color: hsl(0deg 0% 29%);
}
&:disabled {
color: hsl(0deg 0% 50%);
}
}
li:has(> hr) {
padding: 0 8px;
}
hr {
border: none;
border-top: 1px solid hsl(0deg 0% 29%);
}
</style>

View File

@@ -0,0 +1,32 @@
import type { ContextMenuItem, ContextMenuOptions } from './types';
export class ContextMenuState {
#items = $state.raw<ContextMenuItem[]>([]);
#is_open = $derived(!!this.#items.length);
#position = $state.raw<{ x: number; y: number }>({ x: 0, y: 0 });
get items() {
return this.#items;
}
get open() {
return this.#is_open;
}
get position() {
return this.#position;
}
constructor() {
this.close = this.close.bind(this);
}
show(options: ContextMenuOptions) {
this.#items = options.items;
this.#position = options.position;
}
close() {
this.#items = [];
}
}

View File

@@ -0,0 +1,3 @@
export { default as ContextMenu } from './ContextMenu.svelte';
export { ContextMenuState } from './ContextMenuState.svelte';
export type { ContextMenuOptions } from './types';

View File

@@ -0,0 +1,16 @@
export type ContextMenuItem = ContextMenuAction | ContextMenuSeparator;
export interface ContextMenuAction {
label: string;
disabled?: boolean;
onClick?: (e: MouseEvent) => MaybePromise<any>;
}
export interface ContextMenuSeparator {
is_separator: true;
}
export interface ContextMenuOptions {
position: { x: number; y: number };
items: ContextMenuItem[];
}

View File

@@ -34,8 +34,8 @@
}
}}
>
<span class="time">{dayjs(entry.timestamp).fromNow()}</span>
<div class="content">{entry.content}</div>
<span class="time">{dayjs(entry.timestamp).fromNow()}</span>
</li>
{/each}
</ol>
@@ -63,13 +63,17 @@
user-select: none;
-webkit-user-select: none;
&:focus {
&:is(:focus-within) {
outline: none;
background-color: hsl(210deg 100% 52%);
background-color: hsl(0deg 0% 19% / 100%);
}
}
.content {
height: 18px;
padding: 3px 0;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -57,8 +57,8 @@
max-block-size: min(80vh, 100%);
margin-top: 0;
overflow: hidden;
transition: opacity 0.5s;
animation: slide-out-up 0.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
transition: opacity 0.3s;
animation: slide-out-up 0.3s cubic-bezier(0.25, 0, 0.3, 1) forwards;
filter: drop-shadow(3px 5px 10px hsla(0deg 0% 0% / 10%));
z-index: 2147483647;
padding: 0;
@@ -70,11 +70,11 @@
border-top-right-radius: 0;
width: 100%;
max-width: 580px;
max-width: 420px;
}
dialog[open] {
animation: slide-in-down 0.5s cubic-bezier(0.25, 0, 0.3, 1) forwards;
animation: slide-in-down 0.3s cubic-bezier(0.25, 0, 0.3, 1) forwards;
}
dialog:not([open]) {

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { get_app_context } from '$lib/context';
import type { Query } from '$lib/repositories/queries';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
interface Props {
queries?: Query[];
onopen?: (query: Query) => MaybePromise<void>;
ondelete?: (query: Query) => MaybePromise<void>;
onrename?: (query: Query) => MaybePromise<void>;
}
let { queries = [], onopen, ondelete, onrename }: Props = $props();
const { context_menu } = get_app_context();
let editing_id = $state<Query['id']>();
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
editing_id = undefined;
}
}
let input = $state.raw<HTMLInputElement>();
$effect(() => input?.focus());
</script>
<ol role="menu">
{#each queries as query (query.id)}
<li
tabindex="-1"
oncontextmenu={(e) => {
e.preventDefault();
context_menu.show({
items: [
{
label: 'Open',
onClick: () => onopen?.(query)
},
{
label: 'Rename',
onClick: () => (editing_id = query.id)
},
{ is_separator: true },
{
label: 'Delete',
onClick: () => ondelete?.(query)
}
],
position: { x: e.clientX, y: e.clientY }
});
}}
role="menuitem"
onkeydown={(e) => {
if (e.key === 'Enter' && !editing_id) {
e.preventDefault();
editing_id = query.id;
}
}}
onclick={async (e) => {
if (e.detail >= 2) {
await onopen?.(query);
e.currentTarget.blur();
}
}}
>
<div>
{#if editing_id === query.id}
<form
onsubmit={async (e) => {
e.preventDefault();
const form_data = new FormData(e.currentTarget);
const name = form_data.get('name') as string;
if (name && name !== query.name) {
await onrename?.({ ...query, name });
}
editing_id = undefined;
}}
>
<input
bind:this={input}
type="text"
name="name"
value={query.name}
onkeydown={handleKeydown}
onblur={() => (editing_id = undefined)}
autocomplete="off"
/>
</form>
{:else}
<span class="name">{query.name}</span>
{/if}
<span class="time">{dayjs(query.createdAt).fromNow()}</span>
</div>
</li>
{/each}
</ol>
<style>
ol {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
overflow-y: auto;
}
li {
padding: 3px 5px;
border-radius: 3px;
cursor: default;
user-select: none;
-webkit-user-select: none;
display: flex;
align-items: center;
gap: 5px;
& > div {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
& > form {
width: 100%;
& > input {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 18px;
outline: none;
margin: 0;
padding: 0;
border: none;
}
}
& > span.name {
display: block;
height: 18px;
padding: 3px 0;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
& > span.time {
font-size: 10px;
color: hsl(0deg 0% 96%);
}
}
&:is(:focus-within) {
outline: none;
background-color: hsl(0deg 0% 19% / 100%);
}
}
</style>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import type { FormEventHandler } from 'svelte/elements';
import Modal from '../Modal.svelte';
interface Props {
onCreate?: (query: { name: string }) => MaybePromise<unknown>;
}
let { onCreate }: Props = $props();
let modal = $state<ReturnType<typeof Modal>>();
let open = $state(false);
async function handleSubmit(e: Parameters<FormEventHandler<HTMLFormElement>>[0]) {
e.preventDefault();
const form_data = new FormData(e.currentTarget);
const name = form_data.get('name') as string;
try {
await onCreate?.({ name });
modal?.close();
} catch (e) {
console.error(e);
}
}
let name_value = $state('');
$effect(() => {
open;
name_value = '';
});
export function show() {
open = true;
}
</script>
{#if open}
<Modal onclose={() => (open = false)} bind:this={modal}>
<form onsubmit={handleSubmit}>
<label>
<span>Name</span>
<input
type="text"
placeholder="Price"
spellcheck="false"
autocomplete="off"
name="name"
bind:value={name_value}
required
/>
</label>
<div class="Actions">
<button type="button" onclick={() => modal!.close()}>Cancel</button>
<button type="submit">Save</button>
</div>
</form>
</Modal>
{/if}
<style>
form {
padding: 2rem;
}
form label {
display: block;
& > span {
display: block;
margin-bottom: 8px;
}
}
input[type='text'] {
width: 100%;
background-color: hsl(0deg 0% 3%);
border: 1px solid hsl(0deg 0% 3%);
border-radius: 3px;
padding: 5px 10px;
outline: none;
caret-color: currentcolor;
&:not(:read-only):is(:focus-within, :hover) {
border-color: hsl(0deg 0% 34%);
}
}
button {
appearance: none;
outline: none;
border: none;
text-wrap: nowrap;
background-color: hsl(0deg 0% 33%);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
&:is(:active) {
background-color: hsl(0deg 0% 52%);
}
}
div.Actions {
display: flex;
justify-content: flex-end;
gap: 12px;
height: 28px;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,2 @@
export { default as Queries } from './Queries.svelte';
export { default as SaveQueryModal } from './SaveQueryModal.svelte';

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import type { Table } from '$lib/olap-engine';
import type { HistoryEntry } from '$lib/repositories/history';
import type { Query } from '$lib/repositories/queries';
import Datasets from './Datasets/Datasets.svelte';
import History from './History.svelte';
import Queries from './Queries/Queries.svelte';
type Tab = 'sources' | 'queries' | 'history';
@@ -13,11 +15,25 @@
type Props = {
tables?: Table[];
history?: HistoryEntry[];
onHistoryClick?: (entry: HistoryEntry) => void;
queries?: Query[];
onQueryOpen?: (query: Query) => MaybePromise<void>;
onQueryRename?: (query: Query) => MaybePromise<void>;
onQueryDelete?: (query: Query) => MaybePromise<void>;
};
let { tables = [], history = [], onHistoryClick }: Props = $props();
let {
tables = [],
history = [],
onHistoryClick,
queries = [],
onQueryDelete,
onQueryOpen,
onQueryRename
}: Props = $props();
</script>
<section>
@@ -29,6 +45,9 @@
{#if tab === 'sources'}
<Datasets {tables} />
{/if}
{#if tab === 'queries'}
<Queries {queries} ondelete={onQueryDelete} onopen={onQueryOpen} onrename={onQueryRename} />
{/if}
{#if tab === 'history'}
<History {history} {onHistoryClick} />
{/if}

View File

@@ -1,3 +1,4 @@
import { dev } from '$app/environment';
import { MIGRATIONS } from '$lib/migrations';
import { IndexedDBCache } from '@agnosticeng/cache';
import { MigrationManager } from '@agnosticeng/migrate';
@@ -41,3 +42,8 @@ class Database {
export type { Database };
export const db = new Database();
if (dev) {
// @ts-ignore
window.db = db;
}

View File

@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS queries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
sql TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TRIGGER IF NOT EXISTS update_queries_updated_at
AFTER UPDATE ON queries
FOR EACH ROW
BEGIN
UPDATE queries
SET updated_at = datetime('now')
WHERE id = OLD.id;
END;

View File

@@ -1,7 +1,9 @@
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';
export const MIGRATIONS: Migration[] = [
{ name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT }
{ name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT },
{ name: 'create_queries_table', script: CREATE_QUERIES_TABLE_SCRIPT }
];

View File

@@ -1,4 +1,8 @@
import { db, type Database } from '$lib/database';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
export interface HistoryEntry {
id: number;
@@ -7,19 +11,21 @@ export interface HistoryEntry {
}
export interface HistoryRepository {
get_all(): Promise<HistoryEntry[]>;
getAll(): Promise<HistoryEntry[]>;
add(content: string): Promise<HistoryEntry>;
}
class SQLiteHistoryRepository implements HistoryRepository {
constructor(private db: Database) {}
async get_all(): Promise<HistoryEntry[]> {
async getAll(): Promise<HistoryEntry[]> {
const rows = await this.db.exec('SELECT * FROM history ORDER BY timestamp DESC');
return rows.map((row) => ({
id: row.id as number,
content: row.content as string,
timestamp: new Date(row.timestamp as string)
timestamp: dayjs(row.timestamp as string)
.utc(true)
.toDate()
}));
}
@@ -33,7 +39,9 @@ class SQLiteHistoryRepository implements HistoryRepository {
return {
id: row.id as number,
content: row.content as string,
timestamp: new Date(row.timestamp as string)
timestamp: dayjs(row.timestamp as string)
.utc(true)
.toDate()
};
}
}

View File

@@ -0,0 +1,79 @@
import { db, type Database } from '$lib/database';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
dayjs.extend(utc);
export interface Query {
id: number;
name: string;
sql: string;
createdAt: Date;
updatedAt: Date;
}
export interface QueryRepository {
getAll(): Promise<Query[]>;
getById(id: number): Promise<Query>;
create(name: string, sql: string): Promise<Query>;
update(query: Query): Promise<Query>;
delete(id: number): Promise<void>;
}
class SQLiteQueryRepository implements QueryRepository {
constructor(private db: Database) {}
async getAll(): Promise<Query[]> {
const rows = await this.db.exec('SELECT * FROM queries');
return rows.map((row) => row_to_query(row));
}
async getById(id: number): Promise<Query> {
const [row] = await this.db.exec('SELECT * FROM queries WHERE id = ?', [id]);
if (!row) throw Error('Query not found');
return row_to_query(row);
}
async create(name: string, sql: string): Promise<Query> {
const [row] = await this.db.exec('INSERT INTO queries (name, sql) VALUES (?, ?) RETURNING *', [
name,
sql
]);
if (!row) throw Error('Failed to insert query');
return row_to_query(row);
}
async update(query: Query): Promise<Query> {
const [row] = await this.db.exec(
'UPDATE queries SET name = ?, sql = ? WHERE id = ? RETURNING *',
[query.name, query.sql, query.id]
);
if (!row) throw Error('Failed to update query');
return row_to_query(row);
}
async delete(id: number): Promise<void> {
await this.db.exec('DELETE FROM queries WHERE id = ?', [id]);
}
}
function row_to_query(row: Awaited<ReturnType<Database['exec']>>[number]): Query {
return {
id: row.id as number,
name: row.name as string,
sql: row.sql as string,
createdAt: dayjs(row.created_at as string)
.utc(true)
.toDate(),
updatedAt: dayjs(row.updated_at as string)
.utc(true)
.toDate()
};
}
export const query_repository: QueryRepository = new SQLiteQueryRepository(db);

4
src/lib/types.d.ts vendored
View File

@@ -1,5 +1,5 @@
type MaybePromise<T> = T | Promise<T>;
import type { ContextMenuState } from './components/ContextMenu';
export type AppContext = {
tables: Table[];
context_menu: ContextMenuState;
};

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import { ContextMenuState } from '$lib/components/ContextMenu';
import ContextMenu from '$lib/components/ContextMenu/ContextMenu.svelte';
import { Editor } from '$lib/components/Editor';
import { SaveQueryModal } from '$lib/components/Queries';
import Result from '$lib/components/Result.svelte';
import SideBar from '$lib/components/SideBar.svelte';
import { SplitPane } from '$lib/components/SplitPane';
import WindowTitleBar from '$lib/components/WindowTitleBar.svelte';
import { set_app_context } from '$lib/context';
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 type { PageData } from './$types';
let response = $state.raw<OLAPResponse>();
@@ -23,8 +28,9 @@
if (response) await addHistoryEntry();
}
let tables = $state<Table[]>([]);
let history = $state<HistoryEntry[]>([]);
let tables = $state.raw<Table[]>([]);
let history = $state.raw<HistoryEntry[]>([]);
let queries = $state.raw<Query[]>([]);
$effect(() => {
engine.getSchema().then((t) => {
@@ -33,7 +39,7 @@
});
$effect(() => {
history_repository.get_all().then((entries) => {
history_repository.getAll().then((entries) => {
history = entries;
});
});
@@ -50,10 +56,33 @@
function handleHistoryClick(entry: HistoryEntry) {
query = entry.content;
}
$effect(() => {
query_repository.getAll().then((q) => (queries = q));
});
let save_query_modal = $state<ReturnType<typeof SaveQueryModal>>();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 's' && event.metaKey) {
if (query) {
event.preventDefault();
save_query_modal?.show();
}
}
}
const context_menu = new ContextMenuState();
set_app_context({ context_menu });
</script>
<svelte:window onkeydown={handleKeyDown} />
<ContextMenu state={context_menu} />
<WindowTitleBar>
{#snippet actions()}
<button onclick={() => save_query_modal?.show()} disabled={!query}>Save</button>
<button onclick={handleExec} disabled={loading}>Run</button>
{/snippet}
</WindowTitleBar>
@@ -61,7 +90,30 @@
<section class="screen">
<SplitPane orientation="horizontal" position="242px" min="242px" max="40%">
{#snippet a()}
<SideBar {tables} {history} onHistoryClick={handleHistoryClick} />
<SideBar
{tables}
{history}
onHistoryClick={handleHistoryClick}
{queries}
onQueryDelete={async (query) => {
await query_repository.delete(query.id);
const index = queries.indexOf(query);
queries = queries.slice(0, index).concat(queries.slice(index + 1));
}}
onQueryOpen={(q) => {
query = q.sql;
}}
onQueryRename={async (q) => {
const updated = await query_repository.update(q);
const index = queries.findIndex((_q) => _q.id === updated.id);
if (index !== -1) {
queries = queries
.slice(0, index)
.concat(updated)
.concat(queries.slice(index + 1));
}
}}
/>
{/snippet}
{#snippet b()}
<SplitPane orientation="vertical" min="20%" max="80%" --color="hsl(0deg 0% 12%)">
@@ -76,6 +128,14 @@
</SplitPane>
</section>
<SaveQueryModal
bind:this={save_query_modal}
onCreate={async ({ name }) => {
const q = await query_repository.create(name, query);
queries = queries.concat(q);
}}
/>
<style>
button {
appearance: none;
@@ -87,9 +147,8 @@
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
&:is(:hover, :focus-within) {
&:is(:hover, :focus-within):not(:disabled) {
cursor: pointer;
background-color: hsl(0deg 0% 15%);
}
}

2
src/vite-env.d.ts vendored
View File

@@ -1 +1,3 @@
declare const FORCE_REMOTE_ENGINE: string;
type MaybePromise<T> = T | Promise<T>;