Merge pull request #46 from agnosticeng/feat/handle-history
feat: manage query history
This commit is contained in:
7
package-lock.json
generated
7
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
77
src/lib/components/History.svelte
Normal file
77
src/lib/components/History.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
5
src/lib/migrations/001_create_history_table.sql
Normal file
5
src/lib/migrations/001_create_history_table.sql
Normal 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
|
||||
);
|
||||
7
src/lib/migrations/index.ts
Normal file
7
src/lib/migrations/index.ts
Normal 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 }
|
||||
];
|
||||
41
src/lib/repositories/history.ts
Normal file
41
src/lib/repositories/history.ts
Normal 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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%)">
|
||||
|
||||
Reference in New Issue
Block a user