| <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; |
|
|
| |
| 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)); |
|
|
| |
| 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 { |
| |
| const info = db.exec(`PRAGMA table_info("${selectedTable}")`); |
| if (info.length > 0) { |
| columns = info[0].values.map((r: any[]) => r[1] as string); |
| } |
|
|
| |
| const countResult = db.exec(`SELECT COUNT(*) FROM "${selectedTable}"`); |
| if (countResult.length > 0) { |
| totalRows = countResult[0].values[0][0] as number; |
| } |
|
|
| |
| 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} |
| |
| <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-lg transition |
| {table === selectedTable && !queryMode |
| ? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 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-lg transition font-mono |
| {queryMode |
| ? 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-200 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} |
| |
| <div class="shrink-0"> |
| <textarea |
| bind:value={queryText} |
| placeholder="SELECT * FROM ..." |
| class="query-editor w-full text-xs font-mono bg-transparent text-gray-800 dark:text-gray-200 px-3 py-2 outline-none resize-none" |
| rows="3" |
| spellcheck="false" |
| on:keydown={(e) => { |
| if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { |
| e.preventDefault(); |
| runQuery(); |
| } |
| }} |
| ></textarea> |
| <div |
| class="flex items-center justify-between px-2 py-1 bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800" |
| > |
| {#if queryError} |
| <span class="text-[0.65rem] text-red-500 dark:text-red-400 truncate mr-2" |
| >{queryError}</span |
| > |
| {:else} |
| <span class="text-[0.6rem] text-gray-400 dark:text-gray-600 select-none">⌘+Enter</span> |
| {/if} |
| <button |
| class="shrink-0 px-2.5 py-0.5 text-[0.65rem] font-medium rounded hover:bg-gray-200 dark:hover:bg-gray-800 transition text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300" |
| on:click={runQuery} |
| > |
| {$i18n.t('Run')} |
| </button> |
| </div> |
| </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} |
| |
| <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> |
| |
| |
| {#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; |
| } |
| .query-editor::placeholder { |
| color: #9ca3af; |
| } |
| :global(.dark) .query-editor::placeholder { |
| color: #6b7280; |
| } |
| </style> |
| |