@walkthru-earth/objex 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +11 -2
  2. package/dist/components/browser/FileBrowser.svelte +41 -54
  3. package/dist/components/browser/FileTreeSidebar.svelte +43 -7
  4. package/dist/components/layout/ConnectionDialog.svelte +100 -1
  5. package/dist/components/layout/Sidebar.svelte +43 -25
  6. package/dist/components/viewers/CodeViewer.svelte +23 -0
  7. package/dist/components/viewers/CogControls.svelte +208 -0
  8. package/dist/components/viewers/CogControls.svelte.d.ts +12 -0
  9. package/dist/components/viewers/CogViewer.svelte +353 -1160
  10. package/dist/components/viewers/CogViewer.svelte.d.ts +1 -1
  11. package/dist/components/viewers/DatabaseViewer.svelte +345 -37
  12. package/dist/components/viewers/MarkdownViewer.svelte +1 -1
  13. package/dist/components/viewers/TableViewer.svelte +123 -41
  14. package/dist/components/viewers/ZarrMapViewer.svelte +29 -0
  15. package/dist/components/viewers/ZarrViewer.svelte +1 -4
  16. package/dist/constants.d.ts +6 -2
  17. package/dist/constants.js +6 -2
  18. package/dist/file-icons/index.d.ts +1 -1
  19. package/dist/file-icons/index.js +12 -2
  20. package/dist/i18n/ar.js +24 -0
  21. package/dist/i18n/en.js +24 -0
  22. package/dist/i18n/index.svelte.d.ts +0 -1
  23. package/dist/i18n/index.svelte.js +0 -3
  24. package/dist/index.d.ts +11 -0
  25. package/dist/index.js +10 -0
  26. package/dist/query/engine.d.ts +20 -4
  27. package/dist/query/index.d.ts +2 -1
  28. package/dist/query/index.js +1 -0
  29. package/dist/query/source.d.ts +30 -0
  30. package/dist/query/source.js +37 -0
  31. package/dist/query/wasm.d.ts +7 -5
  32. package/dist/query/wasm.js +138 -85
  33. package/dist/storage/providers.d.ts +47 -0
  34. package/dist/storage/providers.js +160 -0
  35. package/dist/stores/connections.svelte.js +5 -31
  36. package/dist/stores/files.svelte.d.ts +2 -8
  37. package/dist/stores/files.svelte.js +5 -38
  38. package/dist/stores/query-history.svelte.js +3 -25
  39. package/dist/stores/settings.svelte.d.ts +1 -0
  40. package/dist/stores/settings.svelte.js +10 -30
  41. package/dist/stores/tabs.svelte.d.ts +9 -2
  42. package/dist/stores/tabs.svelte.js +11 -2
  43. package/dist/types.d.ts +11 -0
  44. package/dist/utils/cloud-url.d.ts +27 -0
  45. package/dist/utils/cloud-url.js +61 -0
  46. package/dist/utils/cog.d.ts +244 -0
  47. package/dist/utils/cog.js +1039 -0
  48. package/dist/utils/deck.d.ts +0 -18
  49. package/dist/utils/deck.js +0 -36
  50. package/dist/utils/export.d.ts +22 -2
  51. package/dist/utils/export.js +35 -10
  52. package/dist/utils/file-sort.d.ts +20 -0
  53. package/dist/utils/file-sort.js +41 -0
  54. package/dist/utils/geometry-type.d.ts +52 -0
  55. package/dist/utils/geometry-type.js +76 -0
  56. package/dist/utils/local-storage.d.ts +16 -0
  57. package/dist/utils/local-storage.js +37 -0
  58. package/dist/utils/markdown-sql.d.ts +1 -1
  59. package/dist/utils/markdown-sql.js +3 -4
  60. package/dist/utils/pmtiles-tile.d.ts +0 -2
  61. package/dist/utils/pmtiles-tile.js +0 -8
  62. package/dist/utils/url-state.d.ts +6 -0
  63. package/dist/utils/url-state.js +34 -26
  64. package/dist/utils/url.d.ts +13 -25
  65. package/dist/utils/url.js +17 -78
  66. package/dist/utils/zarr-tab.d.ts +22 -0
  67. package/dist/utils/zarr-tab.js +30 -0
  68. package/dist/utils/zarr.d.ts +0 -2
  69. package/dist/utils/zarr.js +73 -44
  70. package/package.json +50 -46
  71. package/dist/components/ui/tabs/index.d.ts +0 -5
  72. package/dist/components/ui/tabs/index.js +0 -7
  73. package/dist/components/ui/tabs/tabs-content.svelte +0 -17
  74. package/dist/components/ui/tabs/tabs-content.svelte.d.ts +0 -4
  75. package/dist/components/ui/tabs/tabs-list.svelte +0 -16
  76. package/dist/components/ui/tabs/tabs-list.svelte.d.ts +0 -4
  77. package/dist/components/ui/tabs/tabs-trigger.svelte +0 -20
  78. package/dist/components/ui/tabs/tabs-trigger.svelte.d.ts +0 -4
  79. package/dist/components/ui/tabs/tabs.svelte +0 -19
  80. package/dist/components/ui/tabs/tabs.svelte.d.ts +0 -4
  81. package/dist/components/viewers/MapViewer.svelte +0 -234
  82. package/dist/components/viewers/MapViewer.svelte.d.ts +0 -7
  83. package/dist/components/viewers/StyleEditorOverlay.svelte +0 -27
  84. package/dist/components/viewers/StyleEditorOverlay.svelte.d.ts +0 -7
@@ -1,4 +1,4 @@
1
- import type { Tab } from '../../types';
1
+ import type { Tab } from '../../types.js';
2
2
  type $$ComponentProps = {
3
3
  tab: Tab;
4
4
  };
@@ -1,4 +1,5 @@
1
1
  <script lang="ts">
2
+ import ClockIcon from '@lucide/svelte/icons/clock';
2
3
  import { onDestroy } from 'svelte';
3
4
  import SqlEditor from '../editor/SqlEditor.svelte';
4
5
  import { Badge } from '../ui/badge/index.js';
@@ -8,19 +9,40 @@ import { getQueryEngine } from '../../query/index.js';
8
9
  import { getAdapter } from '../../storage/index.js';
9
10
  import { tabResources } from '../../stores/tab-resources.svelte.js';
10
11
  import type { Tab } from '../../types';
11
- import TableGrid from './TableGrid.svelte';
12
+ import TableViewer from './TableViewer.svelte';
12
13
 
13
14
  let { tab }: { tab: Tab } = $props();
14
15
 
15
16
  let loading = $state(true);
16
17
  let error = $state<string | null>(null);
17
18
  let tables = $state<string[]>([]);
19
+ let schemas = $state<string[]>([]);
20
+ let selectedSchema = $state<string>('main');
18
21
  let selectedTable = $state<string | null>(null);
19
- let columns = $state<string[]>([]);
20
- let rows = $state<Record<string, any>[]>([]);
21
- let tableLoading = $state(false);
22
22
  let showSql = $state(false);
23
23
 
24
+ /**
25
+ * Synthetic tab for the embedded TableViewer. Rebuilt whenever the user
26
+ * picks a different table, so TableViewer sees a new `tab.id` and runs its
27
+ * normal load lifecycle (including tabResources cleanup of the previous).
28
+ */
29
+ let childTab = $state<Tab | null>(null);
30
+
31
+ // DuckLake state: true for .ducklake files, auto-detected for .duckdb files
32
+ let isDuckLake = $state(false);
33
+ let snapshots = $state.raw<Array<{ id: number; timeMs: number | null }>>([]);
34
+ let snapshotVersion = $state<number | null>(null);
35
+ let snapshotTimeMs = $state<number | null>(null);
36
+ let switchingSnapshot = $state(false);
37
+
38
+ const ATTACH_ALIAS = '__objex_db__';
39
+
40
+ // Virtual filesystem path for the downloaded catalog file
41
+ const VFS_PATH = `/${ATTACH_ALIAS}.duckdb`;
42
+
43
+ // DuckLake catalog tables used for auto-detection
44
+ const DUCKLAKE_MARKER_TABLES = ['ducklake_table', 'ducklake_schema', 'ducklake_snapshot'];
45
+
24
46
  $effect(() => {
25
47
  if (!tab) return;
26
48
  loadDatabase();
@@ -28,9 +50,14 @@ $effect(() => {
28
50
 
29
51
  function cleanup() {
30
52
  tables = [];
31
- rows = [];
32
- columns = [];
53
+ schemas = [];
33
54
  selectedTable = null;
55
+ childTab = null;
56
+ isDuckLake = false;
57
+ snapshots = [];
58
+ snapshotVersion = null;
59
+ snapshotTimeMs = null;
60
+ switchingSnapshot = false;
34
61
  }
35
62
 
36
63
  $effect(() => {
@@ -41,27 +68,56 @@ $effect(() => {
41
68
 
42
69
  onDestroy(cleanup);
43
70
 
71
+ /**
72
+ * Download the database file using the authenticated storage adapter
73
+ * and register it in DuckDB-WASM's virtual filesystem.
74
+ * DuckDB's ATTACH doesn't use httpfs S3 config, so we must download first.
75
+ */
76
+ async function downloadAndRegister(engine: any): Promise<void> {
77
+ const adapter = getAdapter(tab.source, tab.connectionId);
78
+ const buffer = await adapter.read(tab.path);
79
+ if (engine.registerFileBuffer) {
80
+ // Drop any previously registered file with the same VFS path
81
+ if (engine.dropFile) {
82
+ await engine.dropFile(VFS_PATH);
83
+ }
84
+ await engine.registerFileBuffer(VFS_PATH, buffer);
85
+ }
86
+ }
87
+
44
88
  async function loadDatabase() {
45
89
  loading = true;
46
90
  error = null;
47
91
 
48
92
  try {
49
- // For DuckDB/SQLite files, we load them into DuckDB-WASM
50
93
  const engine = await getQueryEngine();
51
94
  const connId = tab.connectionId ?? '';
52
-
53
- // Register the database file with DuckDB
54
- // For .duckdb files, attach directly
55
- // For .sqlite files, use sqlite scanner
56
95
  const ext = tab.extension.toLowerCase();
57
96
 
58
- if (ext === 'duckdb') {
59
- // DuckDB native file query via path
60
- const result = await engine.query(
61
- connId,
62
- `ATTACH '${tab.path}' AS db (READ_ONLY); SHOW TABLES;`
63
- );
64
- tables = (result.rows ?? []).map((row) => row.name).filter((name): name is string => !!name);
97
+ if (ext === 'ducklake' || ext === 'duckdb') {
98
+ // Download and register in DuckDB-WASM's VFS for ATTACH
99
+ await downloadAndRegister(engine);
100
+
101
+ if (ext === 'ducklake') {
102
+ isDuckLake = true;
103
+ await loadDuckLake(engine, connId);
104
+ } else {
105
+ // .duckdb: auto-detect if it's a DuckLake catalog
106
+ const detected = await tryDetectDuckLake(engine, connId);
107
+ if (detected) {
108
+ isDuckLake = true;
109
+ await loadDuckLake(engine, connId);
110
+ } else {
111
+ isDuckLake = false;
112
+ const result = await engine.query(
113
+ connId,
114
+ `ATTACH '${VFS_PATH}' AS ${ATTACH_ALIAS} (READ_ONLY); SHOW TABLES;`
115
+ );
116
+ tables = (result.rows ?? [])
117
+ .map((row) => row.name)
118
+ .filter((name): name is string => !!name);
119
+ }
120
+ }
65
121
  } else {
66
122
  // SQLite via DuckDB's sqlite scanner
67
123
  const result = await engine.query(
@@ -77,22 +133,226 @@ async function loadDatabase() {
77
133
  }
78
134
  }
79
135
 
80
- async function selectTable(tableName: string) {
81
- selectedTable = tableName;
82
- tableLoading = true;
136
+ /**
137
+ * Probe a .duckdb file to check if it's a DuckLake catalog.
138
+ * Attaches as regular DuckDB, checks for DuckLake system tables,
139
+ * then detaches so loadDuckLake can re-attach with TYPE ducklake.
140
+ */
141
+ async function tryDetectDuckLake(engine: any, connId: string): Promise<boolean> {
142
+ try {
143
+ await engine.query(connId, `ATTACH '${VFS_PATH}' AS ${ATTACH_ALIAS} (READ_ONLY);`);
144
+ const result = await engine.query(
145
+ connId,
146
+ `SELECT table_name FROM information_schema.tables WHERE table_catalog = '${ATTACH_ALIAS}' AND table_name IN ('${DUCKLAKE_MARKER_TABLES.join("','")}');`
147
+ );
148
+ // Detach before re-attaching as DuckLake
149
+ try {
150
+ await engine.query(connId, `DETACH ${ATTACH_ALIAS};`);
151
+ } catch {
152
+ // ignore
153
+ }
154
+ const found = (result.rows ?? []).map((r: any) => r.table_name);
155
+ // If at least 2 DuckLake system tables are present, it's a DuckLake catalog
156
+ return found.length >= 2;
157
+ } catch {
158
+ // If attach fails or query fails, not a DuckLake catalog
159
+ try {
160
+ await engine.query(connId, `DETACH ${ATTACH_ALIAS};`);
161
+ } catch {
162
+ // ignore
163
+ }
164
+ return false;
165
+ }
166
+ }
83
167
 
168
+ async function loadDuckLake(engine: any, connId: string, snapshotId: number | null = null) {
169
+ // Detach any previous catalog (ignore errors)
170
+ try {
171
+ await engine.query(connId, `DETACH ${ATTACH_ALIAS};`);
172
+ } catch {
173
+ // ignore
174
+ }
175
+
176
+ // When snapshotId is given, attach at that snapshot for time travel; otherwise
177
+ // DuckLake attaches at the latest snapshot.
178
+ const snapshotClause = snapshotId !== null ? `, SNAPSHOT_VERSION ${snapshotId}` : '';
179
+ await engine.query(
180
+ connId,
181
+ `ATTACH '${VFS_PATH}' AS ${ATTACH_ALIAS} (TYPE ducklake, READ_ONLY${snapshotClause});`
182
+ );
183
+
184
+ // Snapshot history is immutable for a read-only attached catalog, so only
185
+ // query it on first load; switching snapshots reuses the cached list.
186
+ if (snapshots.length === 0) {
187
+ try {
188
+ const snapResult = await engine.query(
189
+ connId,
190
+ `SELECT snapshot_id, snapshot_time FROM ducklake_snapshots('${ATTACH_ALIAS}') ORDER BY snapshot_id DESC;`
191
+ );
192
+ const rows = snapResult.rows ?? [];
193
+ snapshots = rows.map((r: any) => ({
194
+ id: Number(r.snapshot_id),
195
+ timeMs: coerceTimestampMs(r.snapshot_time)
196
+ }));
197
+ } catch {
198
+ // Snapshot queries may fail on very old DuckLake specs; fall back silently.
199
+ snapshots = [];
200
+ }
201
+ }
202
+ const current = snapshotId !== null ? snapshots.find((s) => s.id === snapshotId) : snapshots[0];
203
+ if (current) {
204
+ snapshotVersion = current.id;
205
+ snapshotTimeMs = current.timeMs;
206
+ }
207
+
208
+ // Discover schemas
209
+ try {
210
+ const schemaResult = await engine.query(
211
+ connId,
212
+ `SELECT DISTINCT "schema" AS schema_name FROM (DESCRIBE) WHERE database = '${ATTACH_ALIAS}' ORDER BY "schema";`
213
+ );
214
+ schemas = (schemaResult.rows ?? [])
215
+ .map((r: any) => r.schema_name)
216
+ .filter((s: any): s is string => !!s);
217
+ if (schemas.length === 0) schemas = ['main'];
218
+ } catch {
219
+ schemas = ['main'];
220
+ }
221
+
222
+ // Align selectedSchema with the discovered list. DuckLake catalogs often
223
+ // use 'default' or a user-named schema, not 'main', so the initial value
224
+ // would otherwise produce an empty table list until the user manually
225
+ // switches via the dropdown.
226
+ if (!schemas.includes(selectedSchema)) {
227
+ selectedSchema = schemas[0];
228
+ }
229
+
230
+ // Load tables for selected schema
231
+ await loadDuckLakeTables(engine, connId);
232
+ }
233
+
234
+ async function loadDuckLakeTables(engine: any, connId: string) {
235
+ const result = await engine.query(
236
+ connId,
237
+ `SELECT "name" AS table_name FROM (DESCRIBE) WHERE database = '${ATTACH_ALIAS}' AND "schema" = '${selectedSchema}' ORDER BY "name";`
238
+ );
239
+ tables = (result.rows ?? [])
240
+ .map((r: any) => r.table_name)
241
+ .filter((name: any): name is string => !!name);
242
+ }
243
+
244
+ async function switchSnapshot(id: number) {
245
+ if (id === snapshotVersion) return;
246
+ switchingSnapshot = true;
247
+ error = null;
248
+ // Clear the currently-open table so TableViewer tears down and re-renders
249
+ // against the new catalog state.
250
+ selectedTable = null;
251
+ childTab = null;
84
252
  try {
85
253
  const engine = await getQueryEngine();
86
254
  const connId = tab.connectionId ?? '';
87
- const result = await engine.query(connId, `SELECT * FROM "${tableName}" LIMIT 1000`);
88
- columns = result.columns;
89
- rows = result.rows ?? [];
255
+ await loadDuckLake(engine, connId, id);
90
256
  } catch (err) {
91
257
  error = err instanceof Error ? err.message : String(err);
92
258
  } finally {
93
- tableLoading = false;
259
+ switchingSnapshot = false;
94
260
  }
95
261
  }
262
+
263
+ /**
264
+ * DuckDB-WASM returns TIMESTAMP in different shapes depending on the driver
265
+ * path: BigInt microseconds (Arrow int64), Number milliseconds, Date object,
266
+ * or ISO string. Normalize all of them to epoch milliseconds so downstream
267
+ * formatting has a single code path.
268
+ */
269
+ function coerceTimestampMs(raw: unknown): number | null {
270
+ if (raw === null || raw === undefined) return null;
271
+ if (raw instanceof Date) {
272
+ const t = raw.getTime();
273
+ return Number.isNaN(t) ? null : t;
274
+ }
275
+ if (typeof raw === 'bigint') {
276
+ // DuckDB TIMESTAMP is microseconds since epoch.
277
+ return Number(raw / 1000n);
278
+ }
279
+ // Heuristic: > 1e14 is microseconds (any year past 5138 in ms is unrealistic)
280
+ const msFromEpochNumber = (n: number) => (n > 1e14 ? Math.floor(n / 1000) : n);
281
+ if (typeof raw === 'number') {
282
+ return Number.isFinite(raw) ? msFromEpochNumber(raw) : null;
283
+ }
284
+ if (typeof raw === 'string') {
285
+ const trimmed = raw.trim();
286
+ if (/^\d+$/.test(trimmed)) {
287
+ const n = Number(trimmed);
288
+ return Number.isFinite(n) ? msFromEpochNumber(n) : null;
289
+ }
290
+ const parsed = Date.parse(trimmed);
291
+ return Number.isNaN(parsed) ? null : parsed;
292
+ }
293
+ return null;
294
+ }
295
+
296
+ function formatSnapshotTime(ms: number | null): string | null {
297
+ if (ms === null) return null;
298
+ const d = new Date(ms);
299
+ if (Number.isNaN(d.getTime())) return null;
300
+ return `${d
301
+ .toISOString()
302
+ .replace('T', ' ')
303
+ .replace(/\.\d+Z$/, '')} UTC`;
304
+ }
305
+
306
+ function formatSnapshotLabel(s: { id: number; timeMs: number | null }): string {
307
+ const time = formatSnapshotTime(s.timeMs);
308
+ return time ? `v${s.id} (${time})` : `v${s.id}`;
309
+ }
310
+
311
+ async function switchSchema(schema: string) {
312
+ selectedSchema = schema;
313
+ selectedTable = null;
314
+ childTab = null;
315
+
316
+ try {
317
+ const engine = await getQueryEngine();
318
+ const connId = tab.connectionId ?? '';
319
+ await loadDuckLakeTables(engine, connId);
320
+ } catch (err) {
321
+ error = err instanceof Error ? err.message : String(err);
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Build the FROM-clause target for a table inside the attached database.
327
+ * For DuckLake/DuckDB we fully-qualify via the attach alias and schema;
328
+ * for SQLite, the scanner exposes tables by bare name in the main catalog.
329
+ */
330
+ function buildSourceRef(tableName: string): string {
331
+ // DuckLake / .duckdb tables live inside the attach alias; SQLite's scanner
332
+ // exposes tables by bare name in the main catalog.
333
+ const ext = tab.extension.toLowerCase();
334
+ if (isDuckLake || ext === 'duckdb') {
335
+ return `${ATTACH_ALIAS}."${selectedSchema}"."${tableName}"`;
336
+ }
337
+ return `"${tableName}"`;
338
+ }
339
+
340
+ function selectTable(tableName: string) {
341
+ selectedTable = tableName;
342
+ const ref = buildSourceRef(tableName);
343
+ // Synthetic tab: unique id per (db-tab, schema, table) so TableViewer
344
+ // re-runs its $effect-driven load and tabResources cleanup fires on
345
+ // the previous selection.
346
+ childTab = {
347
+ id: `${tab.id}::${selectedSchema}.${tableName}`,
348
+ name: `${selectedSchema}.${tableName}`,
349
+ path: `${tab.path}#${selectedSchema}.${tableName}`,
350
+ source: tab.source,
351
+ connectionId: tab.connectionId,
352
+ extension: 'parquet',
353
+ sourceRef: ref
354
+ };
355
+ }
96
356
  </script>
97
357
 
98
358
  <div class="flex h-full flex-col">
@@ -100,10 +360,36 @@ async function selectTable(tableName: string) {
100
360
  class="flex items-center gap-1 border-b border-zinc-200 px-2 py-1.5 sm:gap-2 sm:px-4 dark:border-zinc-800"
101
361
  >
102
362
  <span class="truncate max-w-[120px] text-sm font-medium text-zinc-700 sm:max-w-none dark:text-zinc-300">{tab.name}</span>
103
- <Badge variant="secondary">{t('database.badge')}</Badge>
363
+ {#if isDuckLake}
364
+ <Badge variant="secondary" class="bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200">DuckLake</Badge>
365
+ {:else}
366
+ <Badge variant="secondary">{t('database.badge')}</Badge>
367
+ {/if}
104
368
  {#if tables.length > 0}
105
369
  <span class="hidden text-xs text-zinc-400 sm:inline">{tables.length} {t('database.tables')}</span>
106
370
  {/if}
371
+ {#if isDuckLake && snapshotVersion !== null}
372
+ <div class="hidden items-center gap-1 text-xs text-zinc-400 sm:inline-flex">
373
+ <ClockIcon class="h-3 w-3" />
374
+ {#if snapshots.length > 1}
375
+ <select
376
+ class="rounded bg-white px-1.5 py-0.5 text-xs text-zinc-700 disabled:opacity-60 dark:bg-zinc-800 dark:text-zinc-300"
377
+ disabled={switchingSnapshot}
378
+ title={t('ducklake.snapshot')}
379
+ value={snapshotVersion}
380
+ onchange={(e) => switchSnapshot(Number(e.currentTarget.value))}
381
+ >
382
+ {#each snapshots as snap (snap.id)}
383
+ <option value={snap.id}>{formatSnapshotLabel(snap)}</option>
384
+ {/each}
385
+ </select>
386
+ <span class="text-zinc-400">({snapshots.length} {t('ducklake.snapshots')})</span>
387
+ {:else}
388
+ {@const formatted = formatSnapshotTime(snapshotTimeMs)}
389
+ <span>v{snapshotVersion}{#if formatted}&nbsp;({formatted}){/if}</span>
390
+ {/if}
391
+ </div>
392
+ {/if}
107
393
 
108
394
  <div class="ms-auto">
109
395
  <Button
@@ -120,17 +406,34 @@ async function selectTable(tableName: string) {
120
406
  <div class="flex flex-1 overflow-hidden">
121
407
  {#if loading}
122
408
  <div class="flex flex-1 items-center justify-center">
123
- <p class="text-sm text-zinc-400">{t('database.loading')}</p>
409
+ <p class="text-sm text-zinc-400">{isDuckLake ? t('ducklake.loading') : t('database.loading')}</p>
124
410
  </div>
125
411
  {:else if error}
126
- <div class="flex flex-1 items-center justify-center">
127
- <p class="text-sm text-red-400">{error}</p>
412
+ <div class="flex flex-1 items-center justify-center p-4">
413
+ <div class="max-w-md text-center">
414
+ <p class="text-sm text-red-400">{error}</p>
415
+ {#if isDuckLake && error.includes('ducklake')}
416
+ <p class="mt-2 text-xs text-zinc-500">{t('ducklake.extensionHint')}</p>
417
+ {/if}
418
+ </div>
128
419
  </div>
129
420
  {:else}
130
- <!-- Table list -->
421
+ <!-- Table list sidebar -->
131
422
  <div
132
423
  class="w-56 shrink-0 overflow-auto border-e border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900"
133
424
  >
425
+ {#if isDuckLake && schemas.length > 1}
426
+ <div class="border-b border-zinc-200 px-3 py-2 dark:border-zinc-800">
427
+ <select
428
+ class="w-full rounded bg-white px-2 py-1 text-xs text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
429
+ onchange={(e) => switchSchema(e.currentTarget.value)}
430
+ >
431
+ {#each schemas as schema}
432
+ <option value={schema} selected={schema === selectedSchema}>{schema}</option>
433
+ {/each}
434
+ </select>
435
+ </div>
436
+ {/if}
134
437
  <div class="border-b border-zinc-200 px-3 py-2 dark:border-zinc-800">
135
438
  <h3 class="text-xs font-medium text-zinc-500 dark:text-zinc-400">{t('database.tablesHeader')}</h3>
136
439
  </div>
@@ -144,20 +447,25 @@ async function selectTable(tableName: string) {
144
447
  <span class="text-zinc-700 dark:text-zinc-300">{tableName}</span>
145
448
  </button>
146
449
  {/each}
450
+ {#if tables.length === 0}
451
+ <div class="px-3 py-4 text-center text-xs text-zinc-400">
452
+ {isDuckLake ? t('ducklake.noTables') : t('database.selectTable')}
453
+ </div>
454
+ {/if}
147
455
  </div>
148
456
 
149
- <!-- Content -->
457
+ <!-- Content: embed TableViewer with a synthetic tab pointed at the
458
+ attached table. TableViewer handles SQL editing, CRS detection
459
+ from DuckDB v1.5 GEOMETRY types, and zero-copy WKB → map. -->
150
460
  <div class="flex flex-1 flex-col overflow-hidden">
151
461
  {#if showSql}
152
462
  <div class="flex-1">
153
463
  <SqlEditor connId={tab.connectionId ?? ''} />
154
464
  </div>
155
- {:else if tableLoading}
156
- <div class="flex flex-1 items-center justify-center">
157
- <p class="text-sm text-zinc-400">{t('database.loadingTable')}</p>
158
- </div>
159
- {:else if selectedTable && columns.length > 0}
160
- <TableGrid {columns} {rows} />
465
+ {:else if childTab}
466
+ {#key childTab.id}
467
+ <TableViewer tab={childTab} />
468
+ {/key}
161
469
  {:else}
162
470
  <div class="flex flex-1 items-center justify-center">
163
471
  <p class="text-sm text-zinc-400">{t('database.selectTable')}</p>
@@ -73,7 +73,7 @@ async function loadMarkdown() {
73
73
  contentDir = isRTL ? 'rtl' : 'ltr';
74
74
 
75
75
  // Parse for SQL blocks
76
- const parsed = parseMarkdownDocument(rawMarkdown);
76
+ const parsed = await parseMarkdownDocument(rawMarkdown);
77
77
  hasSqlBlocks = parsed.sqlBlocks.length > 0;
78
78
 
79
79
  if (parsed.sqlBlocks.length > 0) {