@walkthru-earth/objex 0.1.0 → 1.0.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 (73) hide show
  1. package/README.md +1 -1
  2. package/dist/components/browser/FileBrowser.svelte +37 -1
  3. package/dist/components/browser/FileRow.svelte +8 -3
  4. package/dist/components/browser/FileTreeSidebar.svelte +1 -3
  5. package/dist/components/layout/AboutSheet.svelte +126 -0
  6. package/dist/components/layout/AboutSheet.svelte.d.ts +6 -0
  7. package/dist/components/layout/ConnectionDialog.svelte +186 -138
  8. package/dist/components/layout/ConnectionDialog.svelte.d.ts +1 -0
  9. package/dist/components/layout/Sidebar.svelte +19 -3
  10. package/dist/components/layout/TabBar.svelte +4 -7
  11. package/dist/components/viewers/CodeViewer.svelte +17 -9
  12. package/dist/components/viewers/ImageViewer.svelte +6 -16
  13. package/dist/components/viewers/MarkdownViewer.svelte +8 -16
  14. package/dist/components/viewers/MediaViewer.svelte +6 -17
  15. package/dist/components/viewers/ModelViewer.svelte +4 -2
  16. package/dist/components/viewers/NotebookViewer.svelte +90 -40
  17. package/dist/components/viewers/PdfViewer.svelte +5 -3
  18. package/dist/components/viewers/RawViewer.svelte +4 -2
  19. package/dist/components/viewers/TableGrid.svelte +3 -2
  20. package/dist/components/viewers/ZarrMapViewer.svelte +334 -40
  21. package/dist/components/viewers/ZarrMapViewer.svelte.d.ts +3 -8
  22. package/dist/components/viewers/ZarrViewer.svelte +459 -178
  23. package/dist/components/viewers/map/AttributeTable.svelte +1 -6
  24. package/dist/components/viewers/pmtiles/PmtilesArchiveView.svelte +2 -6
  25. package/dist/components/viewers/pmtiles/PmtilesTileInspector.svelte +96 -22
  26. package/dist/constants.d.ts +28 -0
  27. package/dist/constants.js +34 -0
  28. package/dist/file-icons/index.js +6 -0
  29. package/dist/i18n/ar.js +34 -0
  30. package/dist/i18n/en.js +34 -0
  31. package/dist/index.d.ts +4 -1
  32. package/dist/index.js +7 -1
  33. package/dist/query/wasm.js +5 -4
  34. package/dist/storage/browser-cloud.d.ts +7 -0
  35. package/dist/storage/browser-cloud.js +74 -7
  36. package/dist/storage/providers.d.ts +53 -0
  37. package/dist/storage/providers.js +318 -0
  38. package/dist/stores/connections.svelte.js +5 -5
  39. package/dist/stores/query-history.svelte.js +4 -5
  40. package/dist/stores/settings.svelte.js +4 -4
  41. package/dist/types.d.ts +2 -2
  42. package/dist/utils/clipboard.d.ts +13 -0
  43. package/dist/utils/clipboard.js +38 -0
  44. package/dist/utils/error.d.ts +8 -0
  45. package/dist/utils/error.js +12 -0
  46. package/dist/utils/format.d.ts +10 -0
  47. package/dist/utils/format.js +22 -0
  48. package/dist/utils/host-detection.js +78 -18
  49. package/dist/utils/notebook.d.ts +59 -0
  50. package/dist/utils/notebook.js +211 -0
  51. package/dist/utils/parquet-metadata.js +1 -1
  52. package/dist/utils/pmtiles-tile.js +2 -1
  53. package/dist/utils/pmtiles.js +2 -1
  54. package/dist/utils/storage-url.d.ts +1 -1
  55. package/dist/utils/storage-url.js +82 -24
  56. package/dist/utils/url-state.js +2 -7
  57. package/dist/utils/url.d.ts +15 -1
  58. package/dist/utils/url.js +45 -19
  59. package/dist/utils/zarr.d.ts +60 -20
  60. package/dist/utils/zarr.js +450 -103
  61. package/package.json +64 -52
  62. package/dist/assets/favicon.svg +0 -17
  63. package/dist/components/CLAUDE.md +0 -44
  64. package/dist/components/viewers/CLAUDE.md +0 -60
  65. package/dist/file-icons/CLAUDE.md +0 -21
  66. package/dist/i18n/CLAUDE.md +0 -19
  67. package/dist/query/CLAUDE.md +0 -22
  68. package/dist/storage/CLAUDE.md +0 -23
  69. package/dist/stores/CLAUDE.md +0 -29
  70. package/dist/types/notebookjs.d.ts +0 -14
  71. package/dist/utils/CLAUDE.md +0 -54
  72. package/dist/utils/analytics.d.ts +0 -10
  73. package/dist/utils/analytics.js +0 -38
@@ -18,6 +18,13 @@ import {
18
18
  } from '../ui/sheet/index.js';
19
19
  import { Switch } from '../ui/switch/index.js';
20
20
  import { t } from '../../i18n/index.svelte.js';
21
+ import {
22
+ buildEndpointFromTemplate,
23
+ getProvider,
24
+ PROVIDER_IDS,
25
+ PROVIDERS,
26
+ type ProviderId
27
+ } from '../../storage/providers.js';
21
28
  import { connections } from '../../stores/connections.svelte.js';
22
29
  import type { Connection, ConnectionConfig } from '../../types.js';
23
30
  import { describeParseResult, looksLikeUrl, parseStorageUrl } from '../../utils/storage-url.js';
@@ -26,24 +33,24 @@ interface Props {
26
33
  open: boolean;
27
34
  editConnection?: Connection | null;
28
35
  onSaved?: () => void;
36
+ onClose?: () => void;
29
37
  }
30
38
 
31
- let { open = $bindable(false), editConnection = null, onSaved = () => {} }: Props = $props();
39
+ let {
40
+ open = $bindable(false),
41
+ editConnection = null,
42
+ onSaved = () => {},
43
+ onClose = () => {}
44
+ }: Props = $props();
32
45
 
33
- const providers: Array<{ value: Connection['provider']; label: string }> = [
34
- { value: 's3', label: 'S3' },
35
- { value: 'gcs', label: 'GCS' },
36
- { value: 'r2', label: 'R2' },
37
- { value: 'minio', label: 'MinIO' },
38
- { value: 'azure', label: 'Azure' },
39
- { value: 'storj', label: 'Storj' }
40
- ];
46
+ // ---------------------------------------------------------------------------
47
+ // Form state
48
+ // ---------------------------------------------------------------------------
41
49
 
42
- // Form state — initialized with defaults, then reset via $effect when editConnection changes
43
50
  let name = $state('');
44
- let provider = $state<Connection['provider']>('s3');
51
+ let provider = $state<ProviderId>('s3');
45
52
  let bucket = $state('');
46
- let region = $state('us-west-2');
53
+ let region = $state('us-east-1');
47
54
  let endpoint = $state('');
48
55
  let anonymous = $state(true);
49
56
  let accessKey = $state('');
@@ -53,11 +60,79 @@ let saving = $state(false);
53
60
  let testing = $state(false);
54
61
  let testResult = $state<'success' | 'error' | null>(null);
55
62
  let parsedHint = $state<string | null>(null);
63
+ let endpointAutoFilled = $state(false);
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Derived state from provider registry
67
+ // ---------------------------------------------------------------------------
56
68
 
69
+ let providerDef = $derived(getProvider(provider));
57
70
  let isAzure = $derived(provider === 'azure');
58
- let isStorj = $derived(provider === 'storj');
59
- let needsRegion = $derived(provider !== 'azure' && provider !== 'r2' && provider !== 'storj');
60
- let bucketLabel = $derived(isAzure ? t('connection.container') : t('connection.bucket'));
71
+ let hasRegions = $derived(providerDef.regions.length > 0);
72
+ let needsRegion = $derived(providerDef.needsRegion);
73
+ let bucketLabel = $derived(providerDef.bucketLabel ?? t('connection.bucket'));
74
+
75
+ let isEditMode = $derived(editConnection !== null && editConnection !== undefined);
76
+ let title = $derived(isEditMode ? t('connection.editTitle') : t('connection.newTitle'));
77
+ let canSave = $derived(
78
+ name.trim() !== '' &&
79
+ bucket.trim() !== '' &&
80
+ (!needsRegion || region.trim() !== '') &&
81
+ (!providerDef.needsEndpoint || endpoint.trim() !== '')
82
+ );
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Form helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function resetForm(conn: Connection | null | undefined) {
89
+ const def = conn ? getProvider(conn.provider) : PROVIDERS.s3;
90
+ name = conn?.name ?? '';
91
+ provider = (conn?.provider as ProviderId) ?? 's3';
92
+ bucket = conn?.bucket ?? '';
93
+ region = conn?.region ?? def.defaultRegion;
94
+ endpoint = conn?.endpoint ?? '';
95
+ anonymous = conn?.anonymous ?? true;
96
+ accessKey = '';
97
+ secretKey = '';
98
+ sasToken = '';
99
+ saving = false;
100
+ testing = false;
101
+ testResult = null;
102
+ parsedHint = null;
103
+ endpointAutoFilled = false;
104
+ }
105
+
106
+ function selectProvider(id: ProviderId) {
107
+ const prev = provider;
108
+ if (id === prev) return;
109
+
110
+ provider = id;
111
+ const def = getProvider(id);
112
+
113
+ // Clear auto-filled endpoint from previous provider
114
+ if (endpointAutoFilled) {
115
+ endpoint = '';
116
+ endpointAutoFilled = false;
117
+ }
118
+
119
+ // Set default region
120
+ region = def.defaultRegion;
121
+
122
+ // Auto-fill endpoint from template if available and not user-typed
123
+ if (!endpoint && def.endpointTemplate) {
124
+ endpoint = buildEndpointFromTemplate(id, def.defaultRegion);
125
+ endpointAutoFilled = true;
126
+ }
127
+ }
128
+
129
+ function selectRegion(regionCode: string) {
130
+ region = regionCode;
131
+ // Update endpoint if it was auto-filled from template
132
+ if (endpointAutoFilled && providerDef.endpointTemplate) {
133
+ endpoint = buildEndpointFromTemplate(provider, regionCode);
134
+ }
135
+ }
61
136
 
62
137
  function handleBucketInput(value: string) {
63
138
  bucket = value;
@@ -80,50 +155,13 @@ function applyParsedUrl() {
80
155
  bucket = parsed.bucket;
81
156
  if (parsed.endpoint) endpoint = parsed.endpoint;
82
157
  if (parsed.region) region = parsed.region;
83
- // Auto-detect provider from URL
84
- const providerMap: Record<string, Connection['provider']> = {
85
- s3: 's3',
86
- gcs: 'gcs',
87
- r2: 'r2',
88
- minio: 'minio',
89
- azure: 'azure',
90
- storj: 'storj'
91
- };
92
- if (parsed.provider in providerMap) {
93
- provider = providerMap[parsed.provider];
158
+ if (parsed.provider && parsed.provider !== 'unknown' && parsed.provider in PROVIDERS) {
159
+ provider = parsed.provider as ProviderId;
94
160
  }
95
161
  parsedHint = null;
96
162
  }
97
163
 
98
- let isEditMode = $derived(editConnection !== null && editConnection !== undefined);
99
- let title = $derived(isEditMode ? t('connection.editTitle') : t('connection.newTitle'));
100
- let canSave = $derived(
101
- name.trim() !== '' && bucket.trim() !== '' && (!needsRegion || region.trim() !== '')
102
- );
103
-
104
- // Reset form fields when editConnection changes
105
- $effect(() => {
106
- const conn = editConnection;
107
- name = conn?.name ?? '';
108
- provider = conn?.provider ?? 's3';
109
- bucket = conn?.bucket ?? '';
110
- region = conn?.region ?? 'us-west-2';
111
- endpoint = conn?.endpoint ?? '';
112
- anonymous = conn?.anonymous ?? true;
113
- accessKey = '';
114
- secretKey = '';
115
- sasToken = '';
116
- saving = false;
117
- testing = false;
118
- testResult = null;
119
- parsedHint = null;
120
- });
121
-
122
- async function handleSave() {
123
- if (!canSave) return;
124
- saving = true;
125
-
126
- // Auto-parse URL in bucket field before saving
164
+ function buildConfig(fallbackName?: string): ConnectionConfig {
127
165
  let finalBucket = bucket.trim();
128
166
  let finalRegion = region.trim();
129
167
  let finalEndpoint = endpoint.trim();
@@ -136,29 +174,56 @@ async function handleSave() {
136
174
  if (parsed.endpoint) finalEndpoint = parsed.endpoint;
137
175
  if (parsed.region) finalRegion = parsed.region;
138
176
  }
177
+ const def = getProvider(provider);
178
+ return {
179
+ name: name.trim() || fallbackName || '',
180
+ provider,
181
+ bucket: finalBucket,
182
+ region: finalRegion,
183
+ endpoint: finalEndpoint,
184
+ anonymous,
185
+ authMethod:
186
+ def.authMethod === 'sas-token' && !anonymous ? 'sas-token' : !anonymous ? 'sigv4' : undefined,
187
+ ...(anonymous
188
+ ? {}
189
+ : def.authMethod === 'sas-token'
190
+ ? { sas_token: sasToken }
191
+ : { access_key: accessKey, secret_key: secretKey })
192
+ };
193
+ }
139
194
 
140
- try {
141
- const config: ConnectionConfig = {
142
- name: name.trim(),
143
- provider,
144
- bucket: finalBucket,
145
- region: finalRegion,
146
- endpoint: finalEndpoint,
147
- anonymous,
148
- authMethod: isAzure && !anonymous ? 'sas-token' : !anonymous ? 'sigv4' : undefined,
149
- ...(anonymous
150
- ? {}
151
- : isAzure
152
- ? { sas_token: sasToken }
153
- : { access_key: accessKey, secret_key: secretKey })
154
- };
195
+ // ---------------------------------------------------------------------------
196
+ // Effects
197
+ // ---------------------------------------------------------------------------
155
198
 
199
+ // Reset form when dialog opens
200
+ $effect(() => {
201
+ if (open) {
202
+ resetForm(editConnection);
203
+ }
204
+ });
205
+
206
+ // Notify parent when dialog closes
207
+ $effect(() => {
208
+ if (!open) {
209
+ onClose();
210
+ }
211
+ });
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Actions
215
+ // ---------------------------------------------------------------------------
216
+
217
+ async function handleSave() {
218
+ if (!canSave) return;
219
+ saving = true;
220
+ try {
221
+ const config = buildConfig();
156
222
  if (isEditMode && editConnection) {
157
223
  await connections.update(editConnection.id, config);
158
224
  } else {
159
225
  await connections.save(config);
160
226
  }
161
-
162
227
  onSaved();
163
228
  open = false;
164
229
  } catch (err) {
@@ -171,37 +236,8 @@ async function handleSave() {
171
236
  async function handleTestConnection() {
172
237
  testing = true;
173
238
  testResult = null;
174
-
175
239
  try {
176
- // Auto-parse URL in bucket field before testing
177
- let finalBucket = bucket.trim();
178
- let finalRegion = region.trim();
179
- let finalEndpoint = endpoint.trim();
180
- if (looksLikeUrl(finalBucket)) {
181
- const parsed = parseStorageUrl(finalBucket, {
182
- region: finalRegion || undefined,
183
- endpoint: finalEndpoint || undefined
184
- });
185
- finalBucket = parsed.bucket;
186
- if (parsed.endpoint) finalEndpoint = parsed.endpoint;
187
- if (parsed.region) finalRegion = parsed.region;
188
- }
189
-
190
- const config: ConnectionConfig = {
191
- name: name.trim() || 'test',
192
- provider,
193
- bucket: finalBucket,
194
- region: finalRegion,
195
- endpoint: finalEndpoint,
196
- anonymous,
197
- authMethod: isAzure && !anonymous ? 'sas-token' : !anonymous ? 'sigv4' : undefined,
198
- ...(anonymous
199
- ? {}
200
- : isAzure
201
- ? { sas_token: sasToken }
202
- : { access_key: accessKey, secret_key: secretKey })
203
- };
204
-
240
+ const config = buildConfig('test');
205
241
  const ok = await connections.testWithConfig(config, editConnection?.id);
206
242
  testResult = ok ? 'success' : 'error';
207
243
  } catch {
@@ -210,10 +246,6 @@ async function handleTestConnection() {
210
246
  testing = false;
211
247
  }
212
248
  }
213
-
214
- function handleCancel() {
215
- open = false;
216
- }
217
249
  </script>
218
250
 
219
251
  <Sheet bind:open>
@@ -248,25 +280,20 @@ function handleCancel() {
248
280
  <!-- Provider -->
249
281
  <fieldset class="flex flex-col gap-1.5">
250
282
  <legend class="text-sm font-medium">{t('connection.provider')}</legend>
251
- <div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Cloud storage provider">
252
- {#each providers as p (p.value)}
283
+ <div class="flex flex-wrap gap-1.5" role="radiogroup" aria-label="Cloud storage provider">
284
+ {#each PROVIDER_IDS as id (id)}
253
285
  <Button
254
- variant={provider === p.value ? 'default' : 'outline'}
286
+ variant={provider === id ? 'default' : 'outline'}
255
287
  size="sm"
256
- class="h-8 px-3 text-xs"
257
- aria-pressed={provider === p.value}
258
- onclick={() => {
259
- provider = p.value;
260
- if (p.value === 'storj' && !endpoint) {
261
- endpoint = 'https://gateway.storjshare.io';
262
- region = 'us1';
263
- }
264
- }}
288
+ class="h-7 px-2.5 text-xs"
289
+ aria-pressed={provider === id}
290
+ onclick={() => selectProvider(id)}
265
291
  >
266
- {p.label}
292
+ {PROVIDERS[id].label}
267
293
  </Button>
268
294
  {/each}
269
295
  </div>
296
+ <p class="text-xs text-muted-foreground">{providerDef.description}</p>
270
297
  </fieldset>
271
298
 
272
299
  <!-- Bucket / Container -->
@@ -295,39 +322,60 @@ function handleCancel() {
295
322
  </p>
296
323
  </div>
297
324
 
298
- <!-- Region (hidden for Azure and R2) -->
325
+ <!-- Region -->
299
326
  {#if needsRegion}
300
327
  <div class="flex flex-col gap-1.5">
301
328
  <label for="conn-region" class="text-sm font-medium">
302
329
  {t('connection.region')} <span class="text-destructive">*</span>
303
330
  </label>
304
- <Input
305
- id="conn-region"
306
- placeholder="us-west-2"
307
- bind:value={region}
308
- />
331
+ {#if hasRegions}
332
+ <!-- Dropdown for providers with known regions -->
333
+ <select
334
+ id="conn-region"
335
+ class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
336
+ value={region}
337
+ onchange={(e) => selectRegion((e.target as HTMLSelectElement).value)}
338
+ >
339
+ {#each providerDef.regions as r (r.code)}
340
+ <option value={r.code}>{r.label} ({r.code})</option>
341
+ {/each}
342
+ </select>
343
+ {:else}
344
+ <Input
345
+ id="conn-region"
346
+ placeholder={providerDef.defaultRegion}
347
+ bind:value={region}
348
+ />
349
+ {/if}
309
350
  </div>
310
351
  {/if}
311
352
 
312
353
  <!-- Endpoint -->
313
354
  <div class="flex flex-col gap-1.5">
314
- <label for="conn-endpoint" class="text-sm font-medium">{t('connection.endpoint')}{isAzure ? ' *' : ''}</label>
355
+ <label for="conn-endpoint" class="text-sm font-medium">
356
+ {t('connection.endpoint')}{providerDef.needsEndpoint ? ' *' : ''}
357
+ </label>
315
358
  <Input
316
359
  id="conn-endpoint"
317
- placeholder={isAzure
318
- ? 'https://myaccount.blob.core.windows.net'
319
- : isStorj
320
- ? 'https://gateway.storjshare.io'
321
- : 'https://custom-endpoint.example.com'}
360
+ placeholder={providerDef.endpointPlaceholder}
322
361
  bind:value={endpoint}
362
+ oninput={() => {
363
+ endpointAutoFilled = false;
364
+ }}
323
365
  />
324
- <p class="text-xs text-muted-foreground">
325
- {isAzure
326
- ? t('connection.azureEndpointHelper')
327
- : isStorj
328
- ? t('connection.storjEndpointHelper')
329
- : t('connection.endpointHelper')}
330
- </p>
366
+ {#if providerDef.endpointTemplate && !providerDef.needsEndpoint}
367
+ <p class="text-xs text-muted-foreground">
368
+ {t('connection.endpointHelper')}
369
+ </p>
370
+ {:else if isAzure}
371
+ <p class="text-xs text-muted-foreground">
372
+ {t('connection.azureEndpointHelper')}
373
+ </p>
374
+ {:else}
375
+ <p class="text-xs text-muted-foreground">
376
+ {t('connection.endpointHelper')}
377
+ </p>
378
+ {/if}
331
379
  </div>
332
380
 
333
381
  <!-- Anonymous Access -->
@@ -417,7 +465,7 @@ function handleCancel() {
417
465
  </Button>
418
466
 
419
467
  <div class="flex min-w-0 flex-1 justify-end gap-2">
420
- <Button variant="ghost" size="sm" onclick={handleCancel} disabled={saving}>
468
+ <Button variant="ghost" size="sm" onclick={() => (open = false)} disabled={saving}>
421
469
  {t('connection.cancel')}
422
470
  </Button>
423
471
 
@@ -3,6 +3,7 @@ interface Props {
3
3
  open: boolean;
4
4
  editConnection?: Connection | null;
5
5
  onSaved?: () => void;
6
+ onClose?: () => void;
6
7
  }
7
8
  declare const ConnectionDialog: import("svelte").Component<Props, {}, "open">;
8
9
  type ConnectionDialog = ReturnType<typeof ConnectionDialog>;
@@ -28,10 +28,12 @@ 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';
30
30
  import { clearUrlState, syncUrlParam } from '../../utils/url-state.js';
31
+ import AboutSheet from './AboutSheet.svelte';
31
32
  import ConnectionDialog from './ConnectionDialog.svelte';
32
33
  import LocaleToggle from './LocaleToggle.svelte';
33
34
  import ThemeToggle from './ThemeToggle.svelte';
34
35
 
36
+ let aboutOpen = $state(false);
35
37
  let dialogOpen = $state(false);
36
38
  let editingConnection = $state<Connection | null>(null);
37
39
  let detectedHost = $state<DetectedHost | null>(null);
@@ -209,9 +211,17 @@ async function handleBrowseConnection(connection: Connection) {
209
211
  <TooltipProvider>
210
212
  <div class="flex h-full w-12 flex-col items-center bg-sidebar py-2">
211
213
  <!-- App icon -->
212
- <div class="mb-2 flex size-8 items-center justify-center">
213
- <DatabaseIcon class="size-5 text-sidebar-primary" />
214
- </div>
214
+ <Tooltip>
215
+ <TooltipTrigger>
216
+ <button
217
+ class="mb-2 flex size-8 items-center justify-center rounded-lg transition-colors hover:bg-accent/50"
218
+ onclick={() => { aboutOpen = true; }}
219
+ >
220
+ <DatabaseIcon class="size-5 text-sidebar-primary" />
221
+ </button>
222
+ </TooltipTrigger>
223
+ <TooltipContent side="right">{t('about.title')}</TooltipContent>
224
+ </Tooltip>
215
225
 
216
226
  <Separator class="mx-2 mb-2" />
217
227
 
@@ -296,6 +306,8 @@ async function handleBrowseConnection(connection: Connection) {
296
306
  </div>
297
307
  </TooltipProvider>
298
308
 
309
+ <AboutSheet bind:open={aboutOpen} />
310
+
299
311
  <ConnectionDialog
300
312
  bind:open={dialogOpen}
301
313
  editConnection={editingConnection}
@@ -309,6 +321,10 @@ async function handleBrowseConnection(connection: Connection) {
309
321
  syncUrlParam(conn);
310
322
  }
311
323
  }
324
+ editingConnection = null;
312
325
  handleAutoDetection();
313
326
  }}
327
+ onClose={() => {
328
+ editingConnection = null;
329
+ }}
314
330
  />
@@ -8,6 +8,7 @@ import * as ContextMenu from '../ui/context-menu/index.js';
8
8
  import { ScrollArea } from '../ui/scroll-area/index.js';
9
9
  import { t } from '../../i18n/index.svelte.js';
10
10
  import { tabs } from '../../stores/tabs.svelte.js';
11
+ import { copyToClipboard } from '../../utils/clipboard.js';
11
12
  import { buildHttpsUrl, buildStorageUrl } from '../../utils/url.js';
12
13
 
13
14
  let { leading }: { leading?: Snippet } = $props();
@@ -27,13 +28,9 @@ function handleClose(event: MouseEvent, id: string) {
27
28
  async function handleCopy(type: 'https' | 's3', tab: (typeof tabs.items)[0]) {
28
29
  const url = type === 'https' ? buildHttpsUrl(tab) : buildStorageUrl(tab);
29
30
  if (!url) return;
30
- try {
31
- await navigator.clipboard.writeText(url);
32
- copiedType = `${type}-${tab.id}`;
33
- setTimeout(() => (copiedType = null), 2000);
34
- } catch {
35
- // clipboard API may fail in some contexts
36
- }
31
+ await copyToClipboard(url, (copied) => {
32
+ copiedType = copied ? `${type}-${tab.id}` : null;
33
+ });
37
34
  }
38
35
  </script>
39
36
 
@@ -8,6 +8,8 @@ import { t } from '../../i18n/index.svelte.js';
8
8
  import { getAdapter } from '../../storage/index.js';
9
9
  import { tabResources } from '../../stores/tab-resources.svelte.js';
10
10
  import type { Tab } from '../../types';
11
+ import { copyToClipboard } from '../../utils/clipboard.js';
12
+ import { handleLoadError } from '../../utils/error.js';
11
13
  import { extensionToShikiLang, highlightCode } from '../../utils/shiki';
12
14
  import { buildHttpsUrl } from '../../utils/url.js';
13
15
  import { getUrlView, updateUrlView } from '../../utils/url-state.js';
@@ -153,12 +155,23 @@ const language = $derived(languageMap[ext] ?? 'Plain Text');
153
155
  /** File types that support native formatting */
154
156
  const canFormat = $derived(['.json', '.sql', '.css', '.html', '.xml'].includes(ext));
155
157
 
158
+ // Auto-switch to STAC Browser when STAC JSON is detected (unless URL explicitly set #code)
159
+ let stacAutoSwitched = false;
160
+ $effect(() => {
161
+ if (isStacJson && !stacAutoSwitched && viewMode === 'code' && urlView !== 'code') {
162
+ stacAutoSwitched = true;
163
+ viewMode = 'stac-browser';
164
+ updateUrlView('stac-browser');
165
+ }
166
+ });
167
+
156
168
  // Reset iframe view mode when tab changes (component reuse across code-type tabs)
157
169
  let prevTabId = '';
158
170
  $effect(() => {
159
171
  const id = tab.id;
160
172
  if (prevTabId && prevTabId !== id) {
161
173
  viewMode = isHtml ? 'render' : 'code';
174
+ stacAutoSwitched = false;
162
175
  updateUrlView('');
163
176
  }
164
177
  prevTabId = id;
@@ -195,8 +208,9 @@ async function loadCode() {
195
208
  rawCode = new TextDecoder().decode(data);
196
209
  html = await highlightCode(rawCode, lang);
197
210
  } catch (err) {
198
- if (err instanceof DOMException && err.name === 'AbortError') return;
199
- error = err instanceof Error ? err.message : String(err);
211
+ const msg = handleLoadError(err);
212
+ if (msg === null) return;
213
+ error = msg;
200
214
  } finally {
201
215
  loading = false;
202
216
  }
@@ -287,13 +301,7 @@ function setViewMode(mode: 'code' | 'render' | 'stac-browser' | 'kepler' | 'mapu
287
301
  }
288
302
 
289
303
  async function copyCode() {
290
- try {
291
- await navigator.clipboard.writeText(rawCode);
292
- copied = true;
293
- setTimeout(() => (copied = false), 2000);
294
- } catch {
295
- // clipboard not available
296
- }
304
+ await copyToClipboard(rawCode, (v) => (copied = v));
297
305
  }
298
306
  </script>
299
307
 
@@ -9,24 +9,14 @@ import { onDestroy } from 'svelte';
9
9
  import { Badge } from '../ui/badge/index.js';
10
10
  import { Button } from '../ui/button/index.js';
11
11
  import * as DropdownMenu from '../ui/dropdown-menu/index.js';
12
+ import { getMimeType } from '../../file-icons/index.js';
12
13
  import { t } from '../../i18n/index.svelte.js';
13
14
  import { getAdapter } from '../../storage/index.js';
14
15
  import { tabResources } from '../../stores/tab-resources.svelte.js';
15
16
  import type { Tab } from '../../types';
17
+ import { handleLoadError } from '../../utils/error.js';
16
18
  import { buildHttpsUrl, canStreamDirectly } from '../../utils/url.js';
17
19
 
18
- const mimeMap: Record<string, string> = {
19
- png: 'image/png',
20
- jpg: 'image/jpeg',
21
- jpeg: 'image/jpeg',
22
- gif: 'image/gif',
23
- webp: 'image/webp',
24
- avif: 'image/avif',
25
- svg: 'image/svg+xml',
26
- bmp: 'image/bmp',
27
- ico: 'image/x-icon'
28
- };
29
-
30
20
  let { tab }: { tab: Tab } = $props();
31
21
 
32
22
  let abortController: AbortController | null = null;
@@ -64,16 +54,16 @@ async function loadImage() {
64
54
  // Authenticated S3 — download via storage adapter
65
55
  const adapter = getAdapter(tab.source, tab.connectionId);
66
56
  const data = await adapter.read(tab.path, undefined, undefined, signal);
67
- const ext = tab.extension.toLowerCase();
68
57
  const blob = new Blob([data as unknown as BlobPart], {
69
- type: mimeMap[ext] || 'application/octet-stream'
58
+ type: getMimeType(tab.extension)
70
59
  });
71
60
  blobUrl = URL.createObjectURL(blob);
72
61
  imgSrc = blobUrl;
73
62
  }
74
63
  } catch (err) {
75
- if (err instanceof DOMException && err.name === 'AbortError') return;
76
- error = err instanceof Error ? err.message : String(err);
64
+ const msg = handleLoadError(err);
65
+ if (msg === null) return;
66
+ error = msg;
77
67
  } finally {
78
68
  loading = false;
79
69
  }
@@ -7,6 +7,8 @@ import { t } from '../../i18n/index.svelte.js';
7
7
  import { getAdapter } from '../../storage/index.js';
8
8
  import { tabResources } from '../../stores/tab-resources.svelte.js';
9
9
  import type { Tab } from '../../types';
10
+ import { wireCodeCopyButtons } from '../../utils/clipboard.js';
11
+ import { handleLoadError } from '../../utils/error.js';
10
12
  import { EvidenceContext } from '../../utils/evidence-context';
11
13
  import { detectRTL, processDirection, renderMarkdown } from '../../utils/markdown';
12
14
  import {
@@ -114,8 +116,9 @@ async function loadMarkdown() {
114
116
  html = processDirection(rendered, isRTL);
115
117
  }
116
118
  } catch (err) {
117
- if (err instanceof DOMException && err.name === 'AbortError') return;
118
- error = err instanceof Error ? err.message : String(err);
119
+ const msg = handleLoadError(err);
120
+ if (msg === null) return;
121
+ error = msg;
119
122
  } finally {
120
123
  loading = false;
121
124
  }
@@ -124,24 +127,13 @@ async function loadMarkdown() {
124
127
  if (!error) {
125
128
  await tick();
126
129
  await renderMermaidDiagrams();
127
- wireCodeCopyButtons();
130
+ wireCopyButtons();
128
131
  }
129
132
  }
130
133
 
131
- function wireCodeCopyButtons() {
134
+ function wireCopyButtons() {
132
135
  if (!contentEl) return;
133
- for (const btn of contentEl.querySelectorAll('.code-copy-btn')) {
134
- btn.addEventListener('click', async () => {
135
- const code = decodeURIComponent((btn as HTMLElement).dataset.code ?? '');
136
- try {
137
- await navigator.clipboard.writeText(code);
138
- btn.classList.add('copied');
139
- setTimeout(() => btn.classList.remove('copied'), 2000);
140
- } catch {
141
- // clipboard not available
142
- }
143
- });
144
- }
136
+ wireCodeCopyButtons(contentEl, '.code-copy-btn');
145
137
  }
146
138
 
147
139
  async function renderMermaidDiagrams() {