@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
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # objex
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@walkthru-earth/objex?label=%40walkthru-earth%2Fobjex&color=cb3837)](https://www.npmjs.com/package/@walkthru-earth/objex)
4
+ [![npm](https://img.shields.io/npm/v/@walkthru-earth/objex-utils?label=%40walkthru-earth%2Fobjex-utils&color=cb3837)](https://www.npmjs.com/package/@walkthru-earth/objex-utils)
5
+ [![CI](https://github.com/walkthru-earth/objex/actions/workflows/ci.yml/badge.svg)](https://github.com/walkthru-earth/objex/actions/workflows/ci.yml)
6
+ [![License: CC BY 4.0](https://img.shields.io/badge/license-CC%20BY%204.0-lightgrey.svg)](https://creativecommons.org/licenses/by/4.0/)
7
+
3
8
  Cloud storage explorer that runs entirely in the browser. Connect to S3, Azure, GCS, R2, MinIO -- browse files, query data with SQL, and visualize geospatial formats on interactive maps. No backend required.
4
9
 
5
10
  ```mermaid
@@ -58,7 +63,7 @@ import { readParquetMetadata } from '@walkthru-earth/objex/utils/parquet-metadat
58
63
  import { getFileTypeInfo } from '@walkthru-earth/objex/file-icons';
59
64
  ```
60
65
 
61
- Requires `svelte ^5` and `@sveltejs/kit ^2` as peer dependencies. Heavy deps (DuckDB, deck.gl, MapLibre, Arrow, hyparquet) are optional peers -- only install what you need.
66
+ Requires `svelte ^5` and `@sveltejs/kit ^2` as peer dependencies. Heavy deps (DuckDB, deck.gl, MapLibre, Arrow, hyparquet, hyparquet-compressors, yaml) are optional peers -- only install what you need.
62
67
 
63
68
  ### `@walkthru-earth/objex-utils` -- Pure TypeScript Utilities
64
69
 
@@ -81,6 +86,8 @@ import {
81
86
  } from '@walkthru-earth/objex-utils';
82
87
  ```
83
88
 
89
+ Full per-module reference docs: [`packages/objex-utils/docs/`](packages/objex-utils/docs/README.md).
90
+
84
91
  ### Exports
85
92
 
86
93
  | Export path | What |
@@ -92,12 +99,14 @@ import {
92
99
  | `./utils/geoarrow` | `buildGeoArrowTables`, `normalizeGeomType` |
93
100
  | `./utils/storage-url` | `parseStorageUrl`, `looksLikeUrl` |
94
101
  | `./utils/parquet-metadata` | `readParquetMetadata`, `extractEpsgFromGeoMeta` |
95
- | `./utils/format` | `formatFileSize`, `formatDate`, `getFileExtension` |
102
+ | `./utils/format` | `formatFileSize`, `formatDate`, `formatValue`, `getFileExtension`, `jsonReplacerBigInt` |
96
103
  | `./utils/hex` | `generateHexDump` |
97
104
  | `./utils/column-types` | `classifyType`, `typeColor`, `typeBadgeClass` |
98
105
  | `./file-icons` | `getFileTypeInfo`, `getDuckDbReadFn`, `getViewerKind` |
99
106
  | `./types` | `FileEntry`, `Connection`, `Tab`, `WriteResult`, `Theme` |
100
107
 
108
+ The main export also includes `copyToClipboard`, `handleLoadError`, and shared constants (`WGS84_CODES`, `STORAGE_KEYS`, `DEFAULT_TARGET_CRS`, etc.).
109
+
101
110
  ## Quick Start (Development)
102
111
 
103
112
  ```bash
@@ -8,7 +8,14 @@ import { browser } from '../../stores/browser.svelte.js';
8
8
  import { safeLock } from '../../stores/safelock.svelte.js';
9
9
  import { tabs } from '../../stores/tabs.svelte.js';
10
10
  import type { FileEntry } from '../../types.js';
11
+ import {
12
+ type SortConfig,
13
+ type SortField,
14
+ sortFileEntries,
15
+ toggleSortField
16
+ } from '../../utils/file-sort.js';
11
17
  import { detectZarrMarkers } from '../../utils/zarr.js';
18
+ import { openZarrTab } from '../../utils/zarr-tab.js';
12
19
  import Breadcrumb from './Breadcrumb.svelte';
13
20
  import CreateFolderDialog from './CreateFolderDialog.svelte';
14
21
  import DeleteConfirmDialog from './DeleteConfirmDialog.svelte';
@@ -18,12 +25,8 @@ import RenameDialog from './RenameDialog.svelte';
18
25
  import SearchBar from './SearchBar.svelte';
19
26
  import UploadButton from './UploadButton.svelte';
20
27
 
21
- type SortField = 'name' | 'size' | 'modified' | 'extension';
22
- type SortDirection = 'asc' | 'desc';
23
-
24
28
  let filterQuery = $state('');
25
- let sortField = $state<SortField>('name');
26
- let sortDirection = $state<SortDirection>('asc');
29
+ let sortConfig = $state<SortConfig>({ field: 'name', direction: 'asc' });
27
30
 
28
31
  let deleteDialogOpen = $state(false);
29
32
  let deleteTarget = $state<FileEntry | null>(null);
@@ -35,22 +38,25 @@ let showWriteActions = $derived(browser.canWrite && !safeLock.locked);
35
38
 
36
39
  const zarrDetection = $derived(detectZarrMarkers(browser.entries.map((e: FileEntry) => e.name)));
37
40
 
38
- function openAsZarr() {
39
- if (!browser.activeConnection) return;
40
- const prefix = browser.currentPrefix.replace(/\/+$/, '');
41
- const name = prefix.split('/').pop() || browser.activeConnection.bucket;
42
- tabs.open({
43
- id: `${browser.activeConnection.id}:${prefix}/`,
44
- name,
45
- path: `${prefix}/`,
46
- source: 'remote',
47
- connectionId: browser.activeConnection.id,
48
- extension: 'zarr'
49
- });
50
- }
41
+ // Auto-open Zarr viewer when markers are detected in the current directory.
42
+ // Uses a Set to prevent re-triggering when navigating back to a previously opened store.
43
+ const autoOpenedPrefixes = new Set<string>();
44
+ $effect(() => {
45
+ if (zarrDetection.detected && browser.activeConnection) {
46
+ const prefix = browser.currentPrefix;
47
+ if (!autoOpenedPrefixes.has(prefix)) {
48
+ autoOpenedPrefixes.add(prefix);
49
+ openZarrTab(prefix, {
50
+ source: 'remote',
51
+ connectionId: browser.activeConnection.id,
52
+ bucketFallback: browser.activeConnection.bucket
53
+ });
54
+ }
55
+ }
56
+ });
51
57
 
52
58
  const sortedAndFilteredEntries = $derived.by(() => {
53
- let result = [...browser.entries];
59
+ let result = browser.entries;
54
60
 
55
61
  // Filter
56
62
  if (filterQuery) {
@@ -58,28 +64,7 @@ const sortedAndFilteredEntries = $derived.by(() => {
58
64
  result = result.filter((entry: FileEntry) => entry.name.toLowerCase().includes(q));
59
65
  }
60
66
 
61
- // Sort
62
- const dir = sortDirection === 'asc' ? 1 : -1;
63
- result.sort((a, b) => {
64
- // Directories always come first
65
- if (a.is_dir && !b.is_dir) return -1;
66
- if (!a.is_dir && b.is_dir) return 1;
67
-
68
- switch (sortField) {
69
- case 'name':
70
- return dir * a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
71
- case 'size':
72
- return dir * (a.size - b.size);
73
- case 'modified':
74
- return dir * (a.modified - b.modified);
75
- case 'extension':
76
- return dir * a.extension.localeCompare(b.extension, undefined, { sensitivity: 'base' });
77
- default:
78
- return 0;
79
- }
80
- });
81
-
82
- return result;
67
+ return sortFileEntries(result, sortConfig);
83
68
  });
84
69
 
85
70
  function handleFilter(query: string) {
@@ -91,12 +76,7 @@ function handleNavigate(path: string) {
91
76
  }
92
77
 
93
78
  function handleSort(field: SortField) {
94
- if (sortField === field) {
95
- sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
96
- } else {
97
- sortField = field;
98
- sortDirection = 'asc';
99
- }
79
+ sortConfig = toggleSortField(sortConfig, field);
100
80
  }
101
81
 
102
82
  function handleDelete(entry: FileEntry) {
@@ -152,8 +132,8 @@ function handleRename(entry: FileEntry) {
152
132
  onclick={() => handleSort('name')}
153
133
  >
154
134
  {t('fileBrowser.name')}
155
- {#if sortField === 'name'}
156
- {#if sortDirection === 'asc'}
135
+ {#if sortConfig.field === 'name'}
136
+ {#if sortConfig.direction === 'asc'}
157
137
  <ArrowUp class="size-3" />
158
138
  {:else}
159
139
  <ArrowDown class="size-3" />
@@ -166,8 +146,8 @@ function handleRename(entry: FileEntry) {
166
146
  class="text-muted-foreground hover:text-foreground flex w-20 shrink-0 items-center justify-end gap-1 transition-colors"
167
147
  onclick={() => handleSort('size')}
168
148
  >
169
- {#if sortField === 'size'}
170
- {#if sortDirection === 'asc'}
149
+ {#if sortConfig.field === 'size'}
150
+ {#if sortConfig.direction === 'asc'}
171
151
  <ArrowUp class="size-3" />
172
152
  {:else}
173
153
  <ArrowDown class="size-3" />
@@ -179,8 +159,8 @@ function handleRename(entry: FileEntry) {
179
159
  class="text-muted-foreground hover:text-foreground flex w-24 shrink-0 items-center justify-end gap-1 transition-colors"
180
160
  onclick={() => handleSort('modified')}
181
161
  >
182
- {#if sortField === 'modified'}
183
- {#if sortDirection === 'asc'}
162
+ {#if sortConfig.field === 'modified'}
163
+ {#if sortConfig.direction === 'asc'}
184
164
  <ArrowUp class="size-3" />
185
165
  {:else}
186
166
  <ArrowDown class="size-3" />
@@ -201,7 +181,14 @@ function handleRename(entry: FileEntry) {
201
181
  variant="outline"
202
182
  size="sm"
203
183
  class="h-6 gap-1 px-2 text-[11px]"
204
- onclick={openAsZarr}
184
+ onclick={() => {
185
+ if (!browser.activeConnection) return;
186
+ openZarrTab(browser.currentPrefix, {
187
+ source: 'remote',
188
+ connectionId: browser.activeConnection.id,
189
+ bucketFallback: browser.activeConnection.bucket
190
+ });
191
+ }}
205
192
  >
206
193
  {t('fileBrowser.openAsZarr')}
207
194
  </Button>
@@ -17,8 +17,10 @@ import { getAdapter } from '../../storage/index.js';
17
17
  import { browser } from '../../stores/browser.svelte.js';
18
18
  import { tabs } from '../../stores/tabs.svelte.js';
19
19
  import type { Connection, FileEntry } from '../../types.js';
20
- import { getNativeScheme } from '../../utils/url.js';
20
+ import { getNativeScheme } from '../../utils/cloud-url.js';
21
21
  import { syncUrlParam } from '../../utils/url-state.js';
22
+ import { detectZarrMarkers } from '../../utils/zarr.js';
23
+ import { openZarrTab } from '../../utils/zarr-tab.js';
22
24
 
23
25
  let {
24
26
  connection,
@@ -48,6 +50,8 @@ let rootContinuationToken = $state<string | undefined>();
48
50
  let rootHasMore = $state(false);
49
51
  let filterQuery = $state('');
50
52
  let scrollEl = $state<HTMLElement>();
53
+ /** Paths of directories detected as Zarr stores (by marker files in children). */
54
+ let detectedZarrPaths = $state(new Set<string>());
51
55
 
52
56
  const filteredNodes = $derived(
53
57
  filterQuery ? filterTree(rootNodes, filterQuery.toLowerCase()) : rootNodes
@@ -156,6 +160,16 @@ async function toggleFolder(node: TreeNode) {
156
160
  node.expanded = !node.expanded;
157
161
  }
158
162
 
163
+ function openAsZarr(dirPath: string) {
164
+ const path = dirPath.endsWith('/') ? dirPath : `${dirPath}/`;
165
+ openZarrTab(path, {
166
+ source: 'remote',
167
+ connectionId: connection.id,
168
+ bucketFallback: connection.bucket
169
+ });
170
+ syncUrlParam(connection, path);
171
+ }
172
+
159
173
  function openFile(entry: FileEntry) {
160
174
  tabs.open({
161
175
  id: `${connection.id}:${entry.path}`,
@@ -175,12 +189,27 @@ function isViewerDir(entry: FileEntry): boolean {
175
189
  return entry.is_dir && VIEWER_DIR_EXTENSIONS.has(entry.extension);
176
190
  }
177
191
 
178
- function handleNodeClick(node: TreeNode) {
192
+ /** Whether a directory should render with the Zarr icon (either by extension or marker detection). */
193
+ function isZarrDir(entry: FileEntry): boolean {
194
+ return isViewerDir(entry) || detectedZarrPaths.has(entry.path);
195
+ }
196
+
197
+ async function handleNodeClick(node: TreeNode) {
179
198
  if (isViewerDir(node.entry)) {
180
- // .zarr directories open in the viewer (clicking chevron expands)
181
- openFile(node.entry);
199
+ // .zarr / .zr3 directories open in the viewer (clicking chevron expands)
200
+ openAsZarr(node.entry.path);
182
201
  } else if (node.entry.is_dir) {
183
- toggleFolder(node);
202
+ // Load children if needed, then check for zarr markers
203
+ if (node.children.length === 0) {
204
+ await loadChildren(node);
205
+ }
206
+ const zarrCheck = detectZarrMarkers(node.children.map((c) => c.entry.name));
207
+ if (zarrCheck.detected) {
208
+ detectedZarrPaths.add(node.entry.path);
209
+ openAsZarr(node.entry.path);
210
+ }
211
+ // Always expand so user can also browse contents
212
+ node.expanded = !node.expanded;
184
213
  } else {
185
214
  openFile(node.entry);
186
215
  }
@@ -349,6 +378,7 @@ async function loadRoot() {
349
378
  rootLoading = true;
350
379
  rootContinuationToken = undefined;
351
380
  rootHasMore = false;
381
+ detectedZarrPaths = new Set();
352
382
  try {
353
383
  const adapter = getAdapter('remote', connection.id);
354
384
  const prefix = connection.rootPrefix ?? '';
@@ -488,8 +518,8 @@ async function loadMoreRoot() {
488
518
  onclick={() => handleNodeClick(node)}
489
519
  >
490
520
  <FileTypeIcon
491
- extension={entry.extension}
492
- isDir={entry.is_dir && !isViewerDir(entry)}
521
+ extension={isZarrDir(entry) ? 'zarr' : entry.extension}
522
+ isDir={entry.is_dir && !isZarrDir(entry)}
493
523
  isOpen={node.expanded}
494
524
  class="size-3.5 shrink-0"
495
525
  />
@@ -510,6 +540,12 @@ async function loadMoreRoot() {
510
540
  {t('fileTree.open')}
511
541
  </ContextMenu.Item>
512
542
  <ContextMenu.Separator />
543
+ {:else if detectedZarrPaths.has(entry.path)}
544
+ <ContextMenu.Item onclick={() => openAsZarr(entry.path)}>
545
+ <ExternalLinkIcon class="me-2 size-3.5" />
546
+ {t('fileBrowser.openAsZarr')}
547
+ </ContextMenu.Item>
548
+ <ContextMenu.Separator />
513
549
  {/if}
514
550
 
515
551
  <ContextMenu.Item onclick={() => copyToClipboard(buildHttpUrl(entry.path))}>
@@ -1,10 +1,14 @@
1
1
  <script lang="ts">
2
2
  import CheckIcon from '@lucide/svelte/icons/check';
3
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
3
4
  import CloudIcon from '@lucide/svelte/icons/cloud';
5
+ import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
6
+ import GlobeIcon from '@lucide/svelte/icons/globe';
4
7
  import LinkIcon from '@lucide/svelte/icons/link';
5
8
  import Loader2Icon from '@lucide/svelte/icons/loader-2';
6
9
  import LockIcon from '@lucide/svelte/icons/lock';
7
10
  import PlugZapIcon from '@lucide/svelte/icons/plug-zap';
11
+ import ShieldIcon from '@lucide/svelte/icons/shield';
8
12
  import XIcon from '@lucide/svelte/icons/x';
9
13
  import { Button } from '../ui/button/index.js';
10
14
  import { Input } from '../ui/input/index.js';
@@ -20,10 +24,12 @@ import { Switch } from '../ui/switch/index.js';
20
24
  import { t } from '../../i18n/index.svelte.js';
21
25
  import {
22
26
  buildEndpointFromTemplate,
27
+ CORS_HELP,
23
28
  getProvider,
24
29
  PROVIDER_IDS,
25
30
  PROVIDERS,
26
- type ProviderId
31
+ type ProviderId,
32
+ READ_ONLY_HELP
27
33
  } from '../../storage/providers.js';
28
34
  import { connections } from '../../stores/connections.svelte.js';
29
35
  import type { Connection, ConnectionConfig } from '../../types.js';
@@ -71,6 +77,8 @@ let isAzure = $derived(provider === 'azure');
71
77
  let hasRegions = $derived(providerDef.regions.length > 0);
72
78
  let needsRegion = $derived(providerDef.needsRegion);
73
79
  let bucketLabel = $derived(providerDef.bucketLabel ?? t('connection.bucket'));
80
+ let corsHelp = $derived(CORS_HELP[provider]);
81
+ let readOnlyHelp = $derived(READ_ONLY_HELP[provider]);
74
82
 
75
83
  let isEditMode = $derived(editConnection !== null && editConnection !== undefined);
76
84
  let title = $derived(isEditMode ? t('connection.editTitle') : t('connection.newTitle'));
@@ -433,6 +441,97 @@ async function handleTestConnection() {
433
441
  </form>
434
442
  {/if}
435
443
 
444
+ <!-- CORS Help -->
445
+ {#if corsHelp}
446
+ <details class="group rounded-md border border-border">
447
+ <summary class="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
448
+ <ChevronRightIcon class="size-3.5 shrink-0 transition-transform group-open:rotate-90" />
449
+ <GlobeIcon class="size-3.5 shrink-0" />
450
+ {t('connection.corsTitle')}
451
+ </summary>
452
+ <div class="flex flex-col gap-2.5 border-t border-border px-3 py-2.5">
453
+ {#if corsHelp.defaultEnabled}
454
+ <div class="flex items-center gap-1.5 text-xs text-green-700 dark:text-green-400">
455
+ <CheckIcon class="size-3 shrink-0" />
456
+ <span>{t('connection.corsDefault')}</span>
457
+ </div>
458
+ {:else}
459
+ <p class="text-xs text-muted-foreground">{t('connection.corsRequired')}</p>
460
+ {/if}
461
+
462
+ {#if corsHelp.note}
463
+ <p class="text-xs text-muted-foreground">{corsHelp.note}</p>
464
+ {/if}
465
+
466
+ {#if corsHelp.docsUrl}
467
+ <a
468
+ href={corsHelp.docsUrl}
469
+ target="_blank"
470
+ rel="noopener noreferrer"
471
+ class="inline-flex items-center gap-1 text-xs text-primary hover:underline"
472
+ >
473
+ <ExternalLinkIcon class="size-3 shrink-0" />
474
+ {t('connection.corsDocs')}
475
+ </a>
476
+ {/if}
477
+
478
+ {#if corsHelp.cliSteps && corsHelp.cliSteps.length > 0}
479
+ <details class="group/cli">
480
+ <summary class="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
481
+ <ChevronRightIcon class="size-3 shrink-0 transition-transform group-open/cli:rotate-90" />
482
+ {t('connection.corsCliTitle')}
483
+ </summary>
484
+ <div class="mt-1.5 flex flex-col gap-1.5">
485
+ {#each corsHelp.cliSteps as step, i}
486
+ <pre class="overflow-x-auto rounded bg-muted px-2.5 py-2 text-[11px] leading-relaxed">{step}</pre>
487
+ {/each}
488
+ </div>
489
+ </details>
490
+ {/if}
491
+ </div>
492
+ </details>
493
+ {/if}
494
+
495
+ <!-- Read-Only Access Help -->
496
+ {#if readOnlyHelp}
497
+ <details class="group rounded-md border border-border">
498
+ <summary class="flex cursor-pointer list-none items-center gap-2 px-3 py-2 text-sm font-medium text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
499
+ <ChevronRightIcon class="size-3.5 shrink-0 transition-transform group-open:rotate-90" />
500
+ <ShieldIcon class="size-3.5 shrink-0" />
501
+ {t('connection.readOnlyTitle')}
502
+ </summary>
503
+ <div class="flex flex-col gap-2.5 border-t border-border px-3 py-2.5">
504
+ <p class="text-xs text-muted-foreground">{readOnlyHelp.note}</p>
505
+
506
+ {#if readOnlyHelp.docsUrl}
507
+ <a
508
+ href={readOnlyHelp.docsUrl}
509
+ target="_blank"
510
+ rel="noopener noreferrer"
511
+ class="inline-flex items-center gap-1 text-xs text-primary hover:underline"
512
+ >
513
+ <ExternalLinkIcon class="size-3 shrink-0" />
514
+ {t('connection.readOnlyDocs')}
515
+ </a>
516
+ {/if}
517
+
518
+ {#if readOnlyHelp.cliSteps && readOnlyHelp.cliSteps.length > 0}
519
+ <details class="group/ro">
520
+ <summary class="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
521
+ <ChevronRightIcon class="size-3 shrink-0 transition-transform group-open/ro:rotate-90" />
522
+ {t('connection.readOnlyCliTitle')}
523
+ </summary>
524
+ <div class="mt-1.5 flex flex-col gap-1.5">
525
+ {#each readOnlyHelp.cliSteps as step, i}
526
+ <pre class="overflow-x-auto rounded bg-muted px-2.5 py-2 text-[11px] leading-relaxed">{step}</pre>
527
+ {/each}
528
+ </div>
529
+ </details>
530
+ {/if}
531
+ </div>
532
+ </details>
533
+ {/if}
534
+
436
535
  <!-- Test Connection Result -->
437
536
  {#if testResult === 'success'}
438
537
  <div class="flex items-center gap-2 rounded-md border border-green-500/30 bg-green-500/10 px-3 py-2 text-sm text-green-700 dark:text-green-400">
@@ -23,7 +23,7 @@ import { t } from '../../i18n/index.svelte.js';
23
23
  import { browser } from '../../stores/browser.svelte.js';
24
24
  import { connections } from '../../stores/connections.svelte.js';
25
25
  import { credentialStore, loadFromNative } from '../../stores/credentials.svelte.js';
26
- import { tabs } from '../../stores/tabs.svelte.js';
26
+ import { eagerUrlTabId, tabs } from '../../stores/tabs.svelte.js';
27
27
  import type { Connection } from '../../types.js';
28
28
  import { type DetectedHost, detectHostBucket } from '../../utils/host-detection.js';
29
29
  import { parseStorageUrl } from '../../utils/storage-url.js';
@@ -58,16 +58,25 @@ $effect(() => {
58
58
  });
59
59
 
60
60
  async function handleAutoDetection() {
61
- // Direct file URLs (e.g. ?url=https://...file.parquet) are opened eagerly
62
- // in +page.svelte so they work on mobile. Skip if tab already exists.
63
61
  const url = new URL(window.location.href);
64
62
  const rawUrl = url.searchParams.get('url');
65
- if (rawUrl && tabs.items.some((t) => t.id === `url:${rawUrl}`)) {
63
+
64
+ const detected = detectHostBucket();
65
+ if (!detected) {
66
+ // No recognizable host — let the eager URL tab handle it
66
67
  return;
67
68
  }
68
69
 
69
- const detected = detectHostBucket();
70
- if (!detected) return;
70
+ // A recognizable storage provider was detected. Close the eagerly-opened
71
+ // URL tab (if any) so we can re-open it with a proper connectionId that
72
+ // provides S3 credentials and endpoint config for DuckDB httpfs.
73
+ if (rawUrl) {
74
+ const eagerTabId = eagerUrlTabId(rawUrl);
75
+ const eagerTab = tabs.items.find((t) => t.id === eagerTabId);
76
+ if (eagerTab) {
77
+ tabs.close(eagerTabId);
78
+ }
79
+ }
71
80
 
72
81
  const hasUrlParam = url.searchParams.has('url');
73
82
 
@@ -85,27 +94,36 @@ async function handleAutoDetection() {
85
94
  const prefixParam = parsed.prefix;
86
95
 
87
96
  if (prefixParam && !prefixParam.endsWith('/')) {
88
- // It's a file — browse to its parent folder and open it
89
- const parentPrefix = prefixParam.includes('/') ? prefixParam.replace(/\/[^/]*$/, '/') : '';
90
- browser.browse(conn, parentPrefix || undefined);
91
97
  const fileName = prefixParam.split('/').pop() || prefixParam;
92
98
  const ext = fileName.includes('.') ? fileName.split('.').pop()!.toLowerCase() : '';
93
- const tabId = `${conn.id}:${prefixParam}`;
94
- tabs.open({
95
- id: tabId,
96
- name: fileName,
97
- path: prefixParam,
98
- source: 'remote',
99
- connectionId: conn.id,
100
- extension: ext
101
- });
102
- // Fire-and-forget: fetch file size via HEAD request
103
- fetch(url.searchParams.get('url')!, { method: 'HEAD' })
104
- .then((res) => {
105
- const cl = res.headers.get('content-length');
106
- if (cl) tabs.update(tabId, { size: Number(cl) });
107
- })
108
- .catch(() => {});
99
+ if (ext) {
100
+ // It's a file — browse to its parent folder and open it
101
+ const parentPrefix = prefixParam.includes('/')
102
+ ? prefixParam.replace(/\/[^/]*$/, '/')
103
+ : '';
104
+ browser.browse(conn, parentPrefix || undefined);
105
+ const tabId = `${conn.id}:${prefixParam}`;
106
+ tabs.open({
107
+ id: tabId,
108
+ name: fileName,
109
+ path: prefixParam,
110
+ source: 'remote',
111
+ connectionId: conn.id,
112
+ extension: ext
113
+ });
114
+ // Fire-and-forget: fetch file size via HEAD request
115
+ fetch(url.searchParams.get('url')!, { method: 'HEAD' })
116
+ .then((res) => {
117
+ const cl = res.headers.get('content-length');
118
+ if (cl) tabs.update(tabId, { size: Number(cl) });
119
+ })
120
+ .catch(() => {});
121
+ } else {
122
+ // No extension — likely a directory (e.g. Zarr store without .zarr suffix).
123
+ // Browse into it and let FileBrowser's auto-detection handle Zarr/etc.
124
+ const dirPrefix = `${prefixParam}/`;
125
+ browser.browse(conn, dirPrefix);
126
+ }
109
127
  } else if (prefixParam) {
110
128
  // It's a directory prefix
111
129
  browser.browse(conn, prefixParam);
@@ -13,6 +13,7 @@ import { handleLoadError } from '../../utils/error.js';
13
13
  import { extensionToShikiLang, highlightCode } from '../../utils/shiki';
14
14
  import { buildHttpsUrl } from '../../utils/url.js';
15
15
  import { getUrlView, updateUrlView } from '../../utils/url-state.js';
16
+ import { openZarrTab } from '../../utils/zarr-tab.js';
16
17
 
17
18
  let { tab }: { tab: Tab } = $props();
18
19
 
@@ -49,6 +50,8 @@ type JsonKind =
49
50
  | 'stac-collection'
50
51
  | 'stac-item'
51
52
  | 'kepler'
53
+ | 'zarr-v2'
54
+ | 'zarr-v3'
52
55
  | null;
53
56
 
54
57
  /** Detect if a .py file is a marimo notebook (first 512 bytes contain both markers) */
@@ -74,6 +77,8 @@ function detectJsonKind(code: string): JsonKind {
74
77
  if (obj.type === 'Collection' && obj.stac_version) return 'stac-collection';
75
78
  if (obj.type === 'Feature' && obj.stac_version) return 'stac-item';
76
79
  if (obj.info?.app === 'kepler.gl' && obj.config) return 'kepler';
80
+ if (obj.zarr_format === 3) return 'zarr-v3';
81
+ if (obj.zarr_format === 2) return 'zarr-v2';
77
82
  }
78
83
  } catch {
79
84
  // not valid JSON
@@ -353,6 +358,24 @@ async function copyCode() {
353
358
  >
354
359
  {viewMode === 'kepler' ? t('code.code') : t('code.openKepler')}
355
360
  </Button>
361
+ {:else if jsonKind === 'zarr-v3' || jsonKind === 'zarr-v2'}
362
+ <Badge variant="outline" class="hidden border-purple-200 text-purple-600 sm:inline-flex dark:border-purple-800 dark:text-purple-300">
363
+ {jsonKind === 'zarr-v3' ? 'Zarr v3' : 'Zarr v2'}
364
+ </Badge>
365
+ <Button
366
+ variant="outline"
367
+ size="sm"
368
+ class="h-7 gap-1 px-2 text-xs border-purple-300 text-purple-600 hover:bg-purple-50 hover:text-purple-700 dark:border-purple-700 dark:text-purple-400 dark:hover:bg-purple-950"
369
+ onclick={() => {
370
+ const parentPath = tab.path.replace(/[^/]+$/, '');
371
+ openZarrTab(parentPath, {
372
+ source: tab.source as 'remote' | 'url',
373
+ connectionId: tab.connectionId
374
+ });
375
+ }}
376
+ >
377
+ {t('fileBrowser.openAsZarr')}
378
+ </Button>
356
379
  {/if}
357
380
 
358
381
  {#if isMarimo}