feat: add SQLite database browser in FileNav

- New SqliteView component with table tabs, paginated data view
  (100 rows/page), SQL query editor (Cmd+Enter), NULL/BLOB formatting,
  sticky column headers, and dark mode
- Supports .db, .sqlite, .sqlite3, .db3 extensions
- Uses sql.js WASM served locally from /sql.js/sql-wasm.wasm
- Also fixes display_file handling when another file is already open
This commit is contained in:
Timothy Jaeryang Baek
2026-03-05 13:34:21 -06:00
parent 114f709337
commit a181b4a731
6 changed files with 350 additions and 0 deletions
+7
View File
@@ -93,6 +93,7 @@
"shiki": "^4.0.1", "shiki": "^4.0.1",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"sql.js": "^1.14.1",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
@@ -12363,6 +12364,12 @@
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
"dev": true "dev": true
}, },
"node_modules/sql.js": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
"integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
"license": "MIT"
},
"node_modules/ssf": { "node_modules/ssf": {
"version": "0.11.2", "version": "0.11.2",
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
+1
View File
@@ -137,6 +137,7 @@
"shiki": "^4.0.1", "shiki": "^4.0.1",
"socket.io-client": "^4.2.0", "socket.io-client": "^4.2.0",
"sortablejs": "^1.15.6", "sortablejs": "^1.15.6",
"sql.js": "^1.14.1",
"svelte-sonner": "^0.3.19", "svelte-sonner": "^0.3.19",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"turndown": "^7.2.0", "turndown": "^7.2.0",
+8
View File
@@ -94,6 +94,7 @@
let fileVideoUrl: string | null = null; let fileVideoUrl: string | null = null;
let fileAudioUrl: string | null = null; let fileAudioUrl: string | null = null;
let filePdfData: ArrayBuffer | null = null; let filePdfData: ArrayBuffer | null = null;
let fileSqliteData: ArrayBuffer | null = null;
let fileLoading = false; let fileLoading = false;
let filePreviewRef: FilePreview; let filePreviewRef: FilePreview;
@@ -188,9 +189,11 @@
const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']); const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'avif']);
const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv']); const VIDEO_EXTS = new Set(['mp4', 'webm', 'mov', 'ogv', 'avi', 'mkv']);
const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus']); const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac', 'wma', 'opus']);
const SQLITE_EXTS = new Set(['db', 'sqlite', 'sqlite3', 'db3']);
const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? ''); const isImage = (path: string) => IMAGE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isVideo = (path: string) => VIDEO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? ''); const isVideo = (path: string) => VIDEO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isAudio = (path: string) => AUDIO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? ''); const isAudio = (path: string) => AUDIO_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isSqlite = (path: string) => SQLITE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf'; const isPdf = (path: string) => path.split('.').pop()?.toLowerCase() === 'pdf';
const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? ''); const isOffice = (path: string) => OFFICE_EXTS.has(path.split('.').pop()?.toLowerCase() ?? '');
@@ -224,6 +227,7 @@
fileAudioUrl = null; fileAudioUrl = null;
} }
filePdfData = null; filePdfData = null;
fileSqliteData = null;
fileOfficeHtml = null; fileOfficeHtml = null;
fileOfficeSlides = null; fileOfficeSlides = null;
currentSlide = 0; currentSlide = 0;
@@ -288,6 +292,9 @@
} else if (isPdf(filePath)) { } else if (isPdf(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath); const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) filePdfData = await result.blob.arrayBuffer(); if (result) filePdfData = await result.blob.arrayBuffer();
} else if (isSqlite(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) fileSqliteData = await result.blob.arrayBuffer();
} else if (isOffice(filePath)) { } else if (isOffice(filePath)) {
const result = await downloadFileBlob(terminal.url, terminal.key, filePath); const result = await downloadFileBlob(terminal.url, terminal.key, filePath);
if (result) { if (result) {
@@ -794,6 +801,7 @@
{fileVideoUrl} {fileVideoUrl}
{fileAudioUrl} {fileAudioUrl}
{filePdfData} {filePdfData}
{fileSqliteData}
{fileContent} {fileContent}
{fileOfficeHtml} {fileOfficeHtml}
{fileOfficeSlides} {fileOfficeSlides}
@@ -9,6 +9,7 @@
import PDFViewer from '../../common/PDFViewer.svelte'; import PDFViewer from '../../common/PDFViewer.svelte';
import JsonTreeView from './JsonTreeView.svelte'; import JsonTreeView from './JsonTreeView.svelte';
import NotebookView from './NotebookView.svelte'; import NotebookView from './NotebookView.svelte';
import SqliteView from './SqliteView.svelte';
let pdfViewerRef: PDFViewer; let pdfViewerRef: PDFViewer;
@@ -20,6 +21,7 @@
export let fileVideoUrl: string | null = null; export let fileVideoUrl: string | null = null;
export let fileAudioUrl: string | null = null; export let fileAudioUrl: string | null = null;
export let filePdfData: ArrayBuffer | null = null; export let filePdfData: ArrayBuffer | null = null;
export let fileSqliteData: ArrayBuffer | null = null;
export let fileContent: string | null = null; export let fileContent: string | null = null;
// Office preview props // Office preview props
@@ -268,6 +270,8 @@
</div> </div>
{:else if filePdfData !== null} {:else if filePdfData !== null}
<PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" /> <PDFViewer bind:this={pdfViewerRef} data={filePdfData} className="w-full h-full" />
{:else if fileSqliteData !== null}
<SqliteView data={fileSqliteData} />
{:else if fileOfficeHtml !== null} {:else if fileOfficeHtml !== null}
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<div class="office-preview overflow-auto flex-1 min-h-0"> <div class="office-preview overflow-auto flex-1 min-h-0">
@@ -0,0 +1,330 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import Spinner from '../../common/Spinner.svelte';
const i18n = getContext('i18n');
export let data: ArrayBuffer;
let db: any = null;
let loading = true;
let error: string | null = null;
let tables: string[] = [];
let selectedTable = '';
let columns: string[] = [];
let rows: string[][] = [];
let totalRows = 0;
let page = 0;
const pageSize = 100;
// Custom query
let queryMode = false;
let queryText = '';
let queryColumns: string[] = [];
let queryRows: string[][] = [];
let queryError: string | null = null;
const init = async () => {
try {
const initSqlJs = (await import('sql.js')).default;
const SQL = await initSqlJs({
locateFile: () => '/sql.js/sql-wasm.wasm'
});
db = new SQL.Database(new Uint8Array(data));
// Get table list
const result = db.exec(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
);
if (result.length > 0) {
tables = result[0].values.map((r: string[]) => r[0]);
if (tables.length > 0) {
selectTable(tables[0]);
}
}
loading = false;
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : 'Failed to open database';
}
};
const selectTable = (table: string) => {
if (!db) return;
selectedTable = table;
page = 0;
queryMode = false;
loadPage();
};
const loadPage = () => {
if (!db || !selectedTable) return;
try {
// Get columns
const info = db.exec(`PRAGMA table_info("${selectedTable}")`);
if (info.length > 0) {
columns = info[0].values.map((r: any[]) => r[1] as string);
}
// Get total count
const countResult = db.exec(`SELECT COUNT(*) FROM "${selectedTable}"`);
if (countResult.length > 0) {
totalRows = countResult[0].values[0][0] as number;
}
// Get page data
const offset = page * pageSize;
const result = db.exec(
`SELECT * FROM "${selectedTable}" LIMIT ${pageSize} OFFSET ${offset}`
);
if (result.length > 0) {
rows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
} else {
rows = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Query failed';
}
};
const runQuery = () => {
if (!db || !queryText.trim()) return;
queryError = null;
try {
const result = db.exec(queryText);
if (result.length > 0) {
queryColumns = result[0].columns;
queryRows = result[0].values.map((r: any[]) => r.map((v: any) => formatValue(v)));
} else {
queryColumns = [];
queryRows = [];
}
} catch (e) {
queryError = e instanceof Error ? e.message : 'Query failed';
queryColumns = [];
queryRows = [];
}
};
const formatValue = (v: any): string => {
if (v === null) return 'NULL';
if (v instanceof Uint8Array) return `[BLOB ${v.length}B]`;
return String(v);
};
$: totalPages = Math.ceil(totalRows / pageSize);
$: data && init();
onDestroy(() => {
if (db) {
try { db.close(); } catch {}
db = null;
}
});
</script>
<div class="sqlite-view flex flex-col h-full">
{#if loading}
<div class="flex items-center justify-center h-full"><Spinner className="size-4" /></div>
{:else if error}
<div class="p-3 text-xs text-red-500">{error}</div>
{:else}
<!-- Table tabs + query toggle -->
<div class="flex items-center gap-1 px-2 py-1.5 border-b border-gray-100 dark:border-gray-800 overflow-x-auto scrollbar-none shrink-0">
{#each tables as table}
<button
class="shrink-0 px-2 py-1 text-xs rounded transition
{table === selectedTable && !queryMode
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => selectTable(table)}
>
{table}
</button>
{/each}
<div class="flex-1"></div>
<button
class="shrink-0 px-2 py-1 text-xs rounded transition
{queryMode
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300 font-medium'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}"
on:click={() => { queryMode = !queryMode; }}
>
SQL
</button>
</div>
{#if queryMode}
<!-- Query editor -->
<div class="p-2 border-b border-gray-100 dark:border-gray-800 shrink-0">
<div class="flex gap-1.5">
<textarea
bind:value={queryText}
placeholder="SELECT * FROM ..."
class="flex-1 text-xs font-mono bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-2 py-1.5 outline-none resize-none min-h-[2.5rem]"
rows="2"
on:keydown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
runQuery();
}
}}
></textarea>
<button
class="shrink-0 px-3 py-1 text-xs bg-blue-500 hover:bg-blue-600 text-white rounded transition"
on:click={runQuery}
>
{$i18n.t('Run')}
</button>
</div>
{#if queryError}
<div class="mt-1 text-xs text-red-500">{queryError}</div>
{/if}
</div>
{#if queryColumns.length > 0}
<div class="flex-1 overflow-auto min-h-0">
<table class="sqlite-table w-full text-xs font-mono border-collapse">
<thead>
<tr>
<th class="sqlite-row-num">#</th>
{#each queryColumns as col}
<th>{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each queryRows as row, i}
<tr>
<td class="sqlite-row-num">{i + 1}</td>
{#each row as cell}
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{:else}
<!-- Table data -->
<div class="flex-1 overflow-auto min-h-0">
{#if columns.length > 0}
<table class="sqlite-table w-full text-xs font-mono border-collapse">
<thead>
<tr>
<th class="sqlite-row-num">#</th>
{#each columns as col}
<th>{col}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as row, i}
<tr>
<td class="sqlite-row-num">{page * pageSize + i + 1}</td>
{#each row as cell}
<td class:sqlite-null={cell === 'NULL'}>{cell}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="text-xs text-gray-400 text-center pt-6">{$i18n.t('No data')}</div>
{/if}
</div>
<!-- Pagination -->
{#if totalPages > 1}
<div class="flex items-center justify-center gap-3 py-1.5 px-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-500 shrink-0">
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={page === 0}
on:click={() => { page--; loadPage(); }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
</svg>
</button>
<span>{page + 1} / {totalPages} ({totalRows.toLocaleString()} rows)</span>
<button
class="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-30"
disabled={page >= totalPages - 1}
on:click={() => { page++; loadPage(); }}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4">
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
{/if}
{/if}
{/if}
</div>
<style>
.sqlite-table {
font-size: 0.7rem;
line-height: 1.4;
}
.sqlite-table th,
.sqlite-table td {
padding: 4px 8px;
text-align: left;
white-space: nowrap;
border: 1px solid rgba(128, 128, 128, 0.15);
}
.sqlite-table thead th {
position: sticky;
top: 0;
background: rgba(243, 244, 246, 0.95);
backdrop-filter: blur(4px);
font-weight: 600;
color: #374151;
border-bottom: 2px solid rgba(128, 128, 128, 0.25);
z-index: 1;
}
:global(.dark) .sqlite-table thead th {
background: rgba(31, 41, 55, 0.95);
color: #d1d5db;
}
.sqlite-table tbody tr:nth-child(even) {
background: rgba(128, 128, 128, 0.04);
}
.sqlite-table tbody tr:hover {
background: rgba(59, 130, 246, 0.06);
}
:global(.dark) .sqlite-table tbody tr:hover {
background: rgba(59, 130, 246, 0.1);
}
.sqlite-table td {
color: #374151;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
:global(.dark) .sqlite-table td {
color: #d1d5db;
}
.sqlite-row-num {
color: #9ca3af;
font-size: 0.6rem;
text-align: right !important;
user-select: none;
width: 1px;
padding-right: 6px !important;
}
:global(.dark) .sqlite-row-num {
color: #6b7280;
}
.sqlite-null {
color: #9ca3af !important;
font-style: italic;
}
:global(.dark) .sqlite-null {
color: #6b7280 !important;
}
</style>
BIN
View File
Binary file not shown.