Merge pull request #46 from agnosticeng/feat/handle-history

feat: manage query history
This commit is contained in:
Didier Franc
2025-01-07 17:38:38 +01:00
committed by GitHub
11 changed files with 174 additions and 94 deletions

7
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"@lezer/highlight": "^1.2.1",
"@tauri-apps/api": "^2.2.0",
"d3": "^7.9.0",
"dayjs": "^1.11.13",
"normalize.css": "^8.0.1",
"p-debounce": "^4.0.0",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1"
@@ -2017,6 +2018,12 @@
"node": ">=12"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",

View File

@@ -26,6 +26,7 @@
"@lezer/highlight": "^1.2.1",
"@tauri-apps/api": "^2.2.0",
"d3": "^7.9.0",
"dayjs": "^1.11.13",
"normalize.css": "^8.0.1",
"p-debounce": "^4.0.0",
"tauri-plugin-store-api": "github:tauri-apps/tauri-plugin-store#v1"

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import type { HistoryEntry } from '$lib/repositories/history';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
interface Props {
history: HistoryEntry[];
onHistoryClick?: (entry: HistoryEntry) => void;
}
let { history: entries, onHistoryClick }: Props = $props();
</script>
<ol role="menu">
{#each entries as entry}
<li
tabindex="-1"
oncontextmenu={(e) => {
e.preventDefault();
}}
role="menuitem"
onkeydown={(e) => {
if (e.key === 'Enter') {
onHistoryClick?.(entry);
e.currentTarget.blur();
}
}}
onclick={(e) => {
if (e.detail >= 2) {
onHistoryClick?.(entry);
e.currentTarget.blur();
}
}}
>
<span class="time">{dayjs(entry.timestamp).fromNow()}</span>
<div class="content">{entry.content}</div>
</li>
{/each}
</ol>
<style>
ol {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
overflow-y: auto;
}
.time {
font-size: 10px;
color: hsl(0deg 0% 96%);
}
li {
padding: 3px 5px;
border-radius: 3px;
cursor: default;
user-select: none;
-webkit-user-select: none;
&:focus {
outline: none;
background-color: hsl(210deg 100% 52%);
}
}
.content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

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

View File

@@ -1,4 +1,4 @@
import { format_date } from '$lib/utils/date';
import dayjs from 'dayjs';
export { default as LineChart } from './Chart.svelte';
@@ -34,7 +34,7 @@ export function applyType(value: any, type: string) {
export function formatValue(value: any, type: string) {
const normalized = remove_nullable(type);
if (isDateRegExp.test(normalized)) return format_date(new Date(value), "dd MMM 'yy");
if (isDateRegExp.test(normalized)) return dayjs(new Date(value)).format("DD MMM [']YY");
if (isIntegerRegExp.test(normalized)) return Math.round(value).toLocaleString('en');
return Number(value).toLocaleString('en');
}

View File

@@ -1,3 +1,4 @@
import { MIGRATIONS } from '$lib/migrations';
import { IndexedDBCache } from '@agnosticeng/cache';
import { MigrationManager } from '@agnosticeng/migrate';
import { SQLite } from '@agnosticeng/sqlite';
@@ -25,7 +26,7 @@ class Database {
this.db.on('exec', debounce(this.snapshot.bind(this), 1000));
await this.migration.migrate([]);
await this.migration.migrate(MIGRATIONS);
}
private async snapshot() {

View File

@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
content TEXT NOT NULL
);

View File

@@ -0,0 +1,7 @@
import type { Migration } from '@agnosticeng/migrate';
import CREATE_HISTORY_TABLE_SCRIPT from './001_create_history_table.sql?raw';
export const MIGRATIONS: Migration[] = [
{ name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT }
];

View File

@@ -0,0 +1,41 @@
import { db, type Database } from '$lib/database';
export interface HistoryEntry {
id: number;
content: string;
timestamp: Date;
}
export interface HistoryRepository {
get_all(): Promise<HistoryEntry[]>;
add(content: string): Promise<HistoryEntry>;
}
class SQLiteHistoryRepository implements HistoryRepository {
constructor(private db: Database) {}
async get_all(): 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)
}));
}
async add(content: string): Promise<HistoryEntry> {
const [row] = await this.db.exec('INSERT INTO history (content) VALUES (?) RETURNING *', [
content
]);
if (!row) throw Error('Failed to insert history entry');
return {
id: row.id as number,
content: row.content as string,
timestamp: new Date(row.timestamp as string)
};
}
}
export const history_repository: HistoryRepository = new SQLiteHistoryRepository(db);

View File

@@ -1,75 +0,0 @@
function number_with_leading_zero(n: number, length: number = 2) {
if (!isFinite(n)) return 'n/a';
return n.toString().padStart(length, '0');
}
const getMonth = (date: Date) => date.getUTCMonth() + 1;
const getDay = (date: Date) => date.getUTCDate();
const getYear = (date: Date) => date.getUTCFullYear();
const dd = (date: Date) => number_with_leading_zero(getDay(date), 2);
const MMMM = (date: Date, locale: string) =>
new Date(date.getUTCFullYear(), date.getUTCMonth(), 1).toLocaleString(locale, { month: 'long' });
const MMM = (date: Date, locale: string) =>
new Date(date.getUTCFullYear(), date.getUTCMonth(), 1).toLocaleString(locale, { month: 'short' });
const MM = (date: Date) => number_with_leading_zero(getMonth(date), 2);
const yy = (date: Date) => number_with_leading_zero(getYear(date) % 100, 2);
const yyyy = (date: Date) => number_with_leading_zero(getYear(date), 4);
const hh = (date: Date) => number_with_leading_zero(date.getUTCHours(), 2);
const mm = (date: Date) => number_with_leading_zero(date.getUTCMinutes(), 2);
const ss = (date: Date) => number_with_leading_zero(date.getUTCSeconds(), 2);
export function format_date(
date: Date,
format: string = 'yyyy-MM-dd',
locale: string = 'default'
): string {
return format
.replace(/yyyy/g, yyyy(date))
.replace(/yy/g, yy(date))
.replace(/MMMM/g, MMMM(date, locale))
.replace(/MMM/g, MMM(date, locale))
.replace(/MM/g, MM(date))
.replace(/dd/g, dd(date));
}
export function format_time(date: Date, format: string = '%h:%m:%s'): string {
return format.replace('%h', hh(date)).replace('%m', mm(date)).replace('%s', ss(date));
}
export enum TimeInterval {
Second = 0,
Minute,
Hour,
Day,
Week,
Month,
Quarter,
Year
}
const ONE_SECOND = 1_000;
const ONE_MINUTE = 60 * ONE_SECOND;
const ONE_HOUR = 60 * ONE_MINUTE;
const ONE_DAY = 24 * ONE_HOUR;
const ONE_WEEK = 7 * ONE_DAY;
export function get_interval(a: Date, b: Date): TimeInterval {
const delta = Math.abs(a.getTime() - b.getTime());
if (delta <= ONE_SECOND) return TimeInterval.Second;
if (delta <= ONE_MINUTE) return TimeInterval.Minute;
if (delta <= ONE_HOUR) return TimeInterval.Hour;
if (delta <= ONE_DAY) return TimeInterval.Day;
if (delta <= ONE_WEEK) return TimeInterval.Week;
const months = Math.abs(
b.getUTCMonth() - a.getUTCMonth() - 12 * Math.abs(a.getUTCFullYear() - b.getUTCFullYear())
);
if (months < 3) return TimeInterval.Month;
if (months >= 3 && months < 6) return TimeInterval.Quarter;
return TimeInterval.Year;
}

View File

@@ -1,14 +1,12 @@
<script lang="ts">
import type { Table } from '$lib/olap-engine';
import { engine, type OLAPResponse } from '$lib/olap-engine';
import { Editor } from '$lib/components/Editor';
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 { db } from '$lib/database';
import { onMount } from '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 type { PageData } from './$types';
let response = $state.raw<OLAPResponse>();
@@ -22,9 +20,11 @@
if (loading) return;
loading = true;
response = await engine.exec(query).finally(() => (loading = false));
if (response) await addHistoryEntry();
}
let tables = $state<Table[]>([]);
let history = $state<HistoryEntry[]>([]);
$effect(() => {
engine.getSchema().then((t) => {
@@ -32,15 +32,24 @@
});
});
onMount(() => {
db.exec('SELECT * FROM sqlite_master')
.then((res) => {
console.log(res);
})
.catch((err) => {
console.error(err);
});
$effect(() => {
history_repository.get_all().then((entries) => {
history = entries;
});
});
async function addHistoryEntry() {
try {
const entry = await history_repository.add(query);
history = [entry, ...history];
} catch (e) {
console.error(e);
}
}
function handleHistoryClick(entry: HistoryEntry) {
query = entry.content;
}
</script>
<WindowTitleBar>
@@ -52,7 +61,7 @@
<section class="screen">
<SplitPane orientation="horizontal" position="242px" min="242px" max="40%">
{#snippet a()}
<SideBar {tables} />
<SideBar {tables} {history} onHistoryClick={handleHistoryClick} />
{/snippet}
{#snippet b()}
<SplitPane orientation="vertical" min="20%" max="80%" --color="hsl(0deg 0% 12%)">