@reddb-io/ui 0.3.0 → 0.3.2

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 (121) hide show
  1. package/build/_app/immutable/assets/0.BWZTMKZW.css +1 -0
  2. package/build/_app/immutable/assets/AnalyticsView.BLAJezw4.css +1 -0
  3. package/build/_app/immutable/assets/{Workspace.CvP5De7i.css → Workspace.DM2E1WiF.css} +1 -1
  4. package/build/_app/immutable/chunks/{C9rXzYLu.js → B-yl15ta.js} +1 -1
  5. package/build/_app/immutable/chunks/B5euRbKa.js +1 -0
  6. package/build/_app/immutable/chunks/BRIMmWzq.js +1 -0
  7. package/build/_app/immutable/chunks/BgOTHNR5.js +1 -0
  8. package/build/_app/immutable/chunks/Bgb6fHGb.js +18 -0
  9. package/build/_app/immutable/chunks/Btd6PFkC.js +1 -0
  10. package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
  11. package/build/_app/immutable/chunks/C3SGj6Qd.js +2 -0
  12. package/build/_app/immutable/chunks/{C278awGv.js → C7kf23SQ.js} +1 -1
  13. package/build/_app/immutable/chunks/CGFL56WJ.js +1 -0
  14. package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
  15. package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
  16. package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
  17. package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
  18. package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
  19. package/build/_app/immutable/chunks/DCJ0QyYH.js +1 -0
  20. package/build/_app/immutable/chunks/DN7v3Tar.js +2 -0
  21. package/build/_app/immutable/chunks/DRlh90Cv.js +1 -0
  22. package/build/_app/immutable/entry/app.BeX4Tmu5.js +2 -0
  23. package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
  24. package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
  25. package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
  26. package/build/_app/immutable/nodes/10.DeFqKRR2.js +96 -0
  27. package/build/_app/immutable/nodes/12.CzMvqoYS.js +3 -0
  28. package/build/_app/immutable/nodes/14.CAG5_cGP.js +1 -0
  29. package/build/_app/immutable/nodes/15.CRDBqbiH.js +60 -0
  30. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  31. package/build/_app/version.json +1 -1
  32. package/build/index.html +18 -15
  33. package/dist/embed/AnalyticsView-SKyGxeAx.js +23337 -0
  34. package/dist/embed/AnalyticsView-SKyGxeAx.js.map +1 -0
  35. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js +31960 -0
  36. package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
  37. package/dist/embed/event-BvRbkLjD.js +46 -0
  38. package/dist/embed/event-BvRbkLjD.js.map +1 -0
  39. package/dist/embed/index-0hA141D2.js +85 -0
  40. package/dist/embed/index-0hA141D2.js.map +1 -0
  41. package/dist/embed/index-BVxMkfHe.js +8 -0
  42. package/dist/embed/index-BVxMkfHe.js.map +1 -0
  43. package/dist/embed/index-BY6fc1oQ.js +856 -0
  44. package/dist/embed/index-BY6fc1oQ.js.map +1 -0
  45. package/dist/embed/index-client-BHUUkI8H.js +3304 -0
  46. package/dist/embed/index-client-BHUUkI8H.js.map +1 -0
  47. package/dist/embed/index.js +1 -1
  48. package/dist/embed/{sigma.esm-D5yoExsH.js → sigma.esm-BawyGZ3s.js} +2 -2
  49. package/dist/embed/{sigma.esm-D5yoExsH.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
  50. package/dist/embed/window-ZR8_mM8o.js +1889 -0
  51. package/dist/embed/window-ZR8_mM8o.js.map +1 -0
  52. package/package.json +7 -3
  53. package/src/app.css +86 -20
  54. package/src/lib/AnalyticsView.svelte +209 -0
  55. package/src/lib/AskView.svelte +255 -0
  56. package/src/lib/ClusterView.svelte +415 -95
  57. package/src/lib/CommandPalette.svelte +2 -0
  58. package/src/lib/ConnectDropdown.svelte +60 -3
  59. package/src/lib/DevConsole.svelte +155 -0
  60. package/src/lib/ErrorBoundary.fixture.svelte +11 -0
  61. package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
  62. package/src/lib/ErrorBoundary.svelte +78 -0
  63. package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
  64. package/src/lib/GlobalProgressBar.svelte +9 -0
  65. package/src/lib/LoadingOverlay.svelte +109 -0
  66. package/src/lib/MetricChart.svelte +85 -0
  67. package/src/lib/QueryEditor.svelte +48 -7
  68. package/src/lib/ResultsPane.svelte +76 -8
  69. package/src/lib/SchemaTree.svelte +25 -1
  70. package/src/lib/SecurityView.svelte +176 -65
  71. package/src/lib/SettingsView.svelte +1111 -254
  72. package/src/lib/StatusBar.svelte +19 -1
  73. package/src/lib/Topbar.svelte +29 -1
  74. package/src/lib/Workspace.svelte +136 -5
  75. package/src/lib/activity.svelte.ts +44 -35
  76. package/src/lib/collection-preview.ts +14 -0
  77. package/src/lib/connections.svelte.ts +167 -4
  78. package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
  79. package/src/lib/embed/index.ts +34 -1
  80. package/src/lib/index.ts +10 -0
  81. package/src/lib/loading.svelte.ts +142 -0
  82. package/src/lib/permission-gate.ts +120 -0
  83. package/src/lib/policies.ts +136 -0
  84. package/src/lib/query-tabs.svelte.ts +132 -53
  85. package/src/lib/reddb/analytics.ts +208 -0
  86. package/src/lib/reddb/ask.ts +123 -0
  87. package/src/lib/reddb/client.ts +292 -73
  88. package/src/lib/reddb/cluster-status.ts +306 -0
  89. package/src/lib/reddb/desktop-error.ts +89 -0
  90. package/src/lib/reddb/dev-console.ts +186 -0
  91. package/src/lib/reddb/index.ts +5 -0
  92. package/src/lib/reddb/local-url-provider.ts +28 -1
  93. package/src/lib/reddb/redwire-ws-spike.ts +590 -0
  94. package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
  95. package/src/lib/router.svelte.ts +12 -0
  96. package/src/lib/settings-authoring-client.ts +336 -0
  97. package/src/lib/settings-sections.ts +292 -196
  98. package/src/lib/tabs.svelte.ts +5 -0
  99. package/src/lib/workspace-session.ts +81 -0
  100. package/build/_app/immutable/assets/0.DQDLkSdR.css +0 -1
  101. package/build/_app/immutable/chunks/65XjEwaF.js +0 -367
  102. package/build/_app/immutable/chunks/BGcIe2ep.js +0 -2
  103. package/build/_app/immutable/chunks/BjFADNkA.js +0 -992
  104. package/build/_app/immutable/chunks/BrvvOVaJ.js +0 -1
  105. package/build/_app/immutable/chunks/D9CTkMjK.js +0 -1
  106. package/build/_app/immutable/chunks/oiO4RLqp.js +0 -1
  107. package/build/_app/immutable/chunks/pDJnDxXM.js +0 -1
  108. package/build/_app/immutable/entry/app.DdaMXHAv.js +0 -2
  109. package/build/_app/immutable/entry/start.-MLBtQXB.js +0 -1
  110. package/build/_app/immutable/nodes/0.Ysoz3Y3h.js +0 -1
  111. package/build/_app/immutable/nodes/1.C2jbGTOJ.js +0 -1
  112. package/build/_app/immutable/nodes/12.DQG5zpEx.js +0 -60
  113. package/build/_app/immutable/nodes/8.CrHI4F9p.js +0 -134
  114. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js +0 -28624
  115. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +0 -1
  116. package/dist/embed/index-DGhgtnaK.js +0 -577
  117. package/dist/embed/index-DGhgtnaK.js.map +0 -1
  118. package/dist/embed/index-client-ItCEU64C.js +0 -3087
  119. package/dist/embed/index-client-ItCEU64C.js.map +0 -1
  120. /package/build/_app/immutable/assets/{12.Dblyk0Kr.css → 15.Dblyk0Kr.css} +0 -0
  121. /package/build/_app/immutable/nodes/{10.CAG5_cGP.js → 13.CAG5_cGP.js} +0 -0
@@ -2,7 +2,7 @@
2
2
  import { useRouter } from './router.svelte'
3
3
  import { connection } from './connections.svelte'
4
4
  import { pendingChanges } from './pending-changes.svelte'
5
- import { ArrowRight, FilePenLine, Lock } from 'lucide-svelte'
5
+ import { ArrowRight, FilePenLine, Lock, Terminal } from 'lucide-svelte'
6
6
  import { readBuildInfo } from './build-info'
7
7
 
8
8
  const build = readBuildInfo()
@@ -48,6 +48,12 @@
48
48
  window.dispatchEvent(new CustomEvent('red:open-connect'))
49
49
  }
50
50
 
51
+ // Toggle the instrumented developer console (#128). Neutral chrome — the
52
+ // console never claims the accent, matching one-accent-per-screen.
53
+ function toggleConsole() {
54
+ window.dispatchEvent(new CustomEvent('red:toggle-dev-console'))
55
+ }
56
+
51
57
  function openCluster(e: MouseEvent) {
52
58
  const target = e.target as HTMLElement | null
53
59
  if (target?.closest('button, a, [data-no-nav]')) return
@@ -154,6 +160,18 @@
154
160
  </button>
155
161
  {/if}
156
162
 
163
+ <button
164
+ type="button"
165
+ onclick={toggleConsole}
166
+ data-no-nav
167
+ aria-label="Toggle developer console"
168
+ title="Developer console — every query and HTTP call, with timing and row counts"
169
+ class="inline-flex items-center gap-1 px-3 text-fg-3 hover:text-fg-1 hover:bg-bg-2/60 transition-colors cursor-pointer bg-transparent border-0 border-l border-line-1"
170
+ >
171
+ <Terminal class="size-3" />
172
+ <span>Console</span>
173
+ </button>
174
+
157
175
  <span
158
176
  class="inline-flex items-center px-3 shrink-0 font-mono text-fg-3 border-l border-line-1 select-none"
159
177
  title={`red-ui ${build.version} · ${build.gitSha} · built ${build.buildTime}`}
@@ -2,7 +2,7 @@
2
2
  import { Kbd } from '@reddb-io/ui-kit'
3
3
  import ConnectDropdown from './ConnectDropdown.svelte'
4
4
  import ActivityIndicator from './ActivityIndicator.svelte'
5
- import { Search, Sun, Moon, Network, Database, FileCode2, Shield, Settings2 } from 'lucide-svelte'
5
+ import { Search, Sun, Moon, Network, Database, FileCode2, Shield, Settings2, LineChart, Sparkles } from 'lucide-svelte'
6
6
  import { theme } from './theme.svelte'
7
7
  import { useRouter } from './router.svelte'
8
8
 
@@ -14,10 +14,12 @@
14
14
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true, ctrlKey: true, bubbles: true }))
15
15
  }
16
16
 
17
+ const onAsk = $derived(router.view === 'ask')
17
18
  const onQuery = $derived(router.view === 'query')
18
19
  const onCollections = $derived(router.view === 'collections')
19
20
  const onCluster = $derived(router.view === 'cluster')
20
21
  const onSecurity = $derived(router.view === 'security')
22
+ const onAnalytics = $derived(router.view === 'analytics')
21
23
  const onSettings = $derived(router.view === 'settings')
22
24
  </script>
23
25
 
@@ -38,6 +40,19 @@
38
40
  <div class="w-px h-[18px] bg-line-2"></div>
39
41
  {/if}
40
42
  <nav class="flex items-center gap-0.5">
43
+ <a
44
+ href={router.href({ view: 'ask' })}
45
+ onclick={(e) => router.go({ view: 'ask' }, e)}
46
+ title="Ask — grounded natural-language query"
47
+ aria-current={onAsk ? 'page' : undefined}
48
+ class={[
49
+ 'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
50
+ onAsk ? 'bg-bg-2 text-accent' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
51
+ ].join(' ')}
52
+ >
53
+ <Sparkles class="size-3.5" />
54
+ <span>Ask</span>
55
+ </a>
41
56
  <a
42
57
  href={router.href({ view: 'query' })}
43
58
  onclick={(e) => router.go({ view: 'query' }, e)}
@@ -90,6 +105,19 @@
90
105
  <Shield class="size-3.5" />
91
106
  <span>Security</span>
92
107
  </a>
108
+ <a
109
+ href={router.href({ view: 'analytics' })}
110
+ onclick={(e) => router.go({ view: 'analytics' }, e)}
111
+ title="Analytics"
112
+ aria-current={onAnalytics ? 'page' : undefined}
113
+ class={[
114
+ 'inline-flex items-center gap-1.5 h-7 px-2 rounded-md text-xs no-underline transition-colors',
115
+ onAnalytics ? 'bg-bg-2 text-fg-0' : 'text-fg-2 hover:text-fg-0 hover:bg-bg-2',
116
+ ].join(' ')}
117
+ >
118
+ <LineChart class="size-3.5" />
119
+ <span>Analytics</span>
120
+ </a>
93
121
  <a
94
122
  href={router.href({ view: 'settings' })}
95
123
  onclick={(e) => router.go({ view: 'settings' }, e)}
@@ -6,6 +6,9 @@
6
6
  // The SvelteKit Surface wraps this same component with a Kit-backed router.
7
7
  import { onMount, untrack } from 'svelte'
8
8
  import Splash from './Splash.svelte'
9
+ import LoadingOverlay from './LoadingOverlay.svelte'
10
+ import DevConsole from './DevConsole.svelte'
11
+ import GlobalProgressBar from './GlobalProgressBar.svelte'
9
12
  import Topbar from './Topbar.svelte'
10
13
  import StatusBar from './StatusBar.svelte'
11
14
  import CommandPalette from './CommandPalette.svelte'
@@ -14,10 +17,11 @@
14
17
  import PendingChangesPanel from './PendingChangesPanel.svelte'
15
18
  import CollectionsView from './CollectionsView.svelte'
16
19
  import ClusterView from './ClusterView.svelte'
20
+ import AskView from './AskView.svelte'
17
21
  import SecurityView from './SecurityView.svelte'
18
22
  import SettingsView from './SettingsView.svelte'
19
23
  import Appearance from './Appearance.svelte'
20
- import { connection, setConnectionProvider } from './connections.svelte'
24
+ import { connection, makeCustomConnection, setConnectionProvider } from './connections.svelte'
21
25
  import { runQueryPreferStream, type ConnectionProvider } from '#reddb'
22
26
  import { theme, type Theme } from './theme.svelte'
23
27
  import { skins } from './skins/skins.svelte'
@@ -28,6 +32,7 @@
28
32
  import { detectCapability } from './capability'
29
33
  import { defaultSubpage } from './collection-pages'
30
34
  import { createStateRouter, pathToLocation, setRouter, type Router, type View } from './router.svelte'
35
+ import { workspaceSession, type WorkspaceSnapshot } from './workspace-session'
31
36
 
32
37
  let {
33
38
  router: injectedRouter,
@@ -79,6 +84,27 @@
79
84
  }
80
85
  }
81
86
 
87
+ function buildSnapshot(): WorkspaceSnapshot {
88
+ return {
89
+ view: router.view,
90
+ collection: router.collection,
91
+ subpage: router.subpage,
92
+ tabs: tabs.tabs,
93
+ activeTabId: tabs.activeId,
94
+ }
95
+ }
96
+
97
+ function applySnapshot(snap: WorkspaceSnapshot): void {
98
+ if (snap.tabs.length > 0) tabs.hydrate(snap.tabs, snap.activeTabId)
99
+ router.go(
100
+ snap.collection
101
+ ? { view: 'collection', collection: snap.collection, subpage: snap.subpage ?? 'table' }
102
+ : { view: snap.view },
103
+ undefined,
104
+ true,
105
+ )
106
+ }
107
+
82
108
  onMount(() => {
83
109
  if (connectionProvider) {
84
110
  // Host-injected provider (#33): adopt its authenticated connection.
@@ -89,7 +115,13 @@
89
115
  // Connection Bootstrap: one boot resolver handles Tauri IPC, host globals,
90
116
  // the Open Contract URL/hash, and finally persisted standalone state.
91
117
  void connection.bootstrap().then((route) => {
92
- if (route) navigateToBootRoute(route)
118
+ if (route) {
119
+ navigateToBootRoute(route)
120
+ } else {
121
+ // No explicit boot route — restore the last workspace session.
122
+ const snap = workspaceSession.restore()
123
+ if (snap) applySnapshot(snap)
124
+ }
93
125
  })
94
126
  }
95
127
  })
@@ -103,6 +135,42 @@
103
135
  return () => window.removeEventListener('red:open-pending-changes', handler as EventListener)
104
136
  })
105
137
 
138
+ // Runtime deep-link routing: subscribe to Tauri "deep-link" events so that
139
+ // clicking a red:// URL while the app is already running focuses the window
140
+ // (handled Rust-side via single-instance) and routes to the target connection.
141
+ // Boot-time deep links go through connection.bootstrap(); this path handles
142
+ // everything that arrives after the app is already mounted.
143
+ onMount(() => {
144
+ if (typeof window === 'undefined') return
145
+ if (!('__TAURI_INTERNALS__' in window || '__TAURI__' in window)) return
146
+
147
+ let unlisten: (() => void) | undefined
148
+
149
+ void import('@tauri-apps/api/event')
150
+ .then(({ listen }) =>
151
+ listen<string[]>('deep-link', async (event) => {
152
+ const raw = event.payload?.[0]
153
+ if (!raw) return
154
+
155
+ // Extract ?to= route before normalizing the connection target.
156
+ let target = raw
157
+ let route: string | null = null
158
+ try {
159
+ const equiv = raw.replace(/^reds?(\+[a-z]+)?:\/\//, 'http://')
160
+ const u = new URL(equiv)
161
+ route = u.searchParams.get('to')
162
+ if (route) target = raw.split('?')[0]
163
+ } catch {}
164
+
165
+ await connection.tryConnect(makeCustomConnection(target))
166
+ if (route) navigateToBootRoute(route)
167
+ })
168
+ )
169
+ .then((fn) => { unlisten = fn })
170
+
171
+ return () => { unlisten?.() }
172
+ })
173
+
106
174
  // Wire the commit executor to the active client. Re-binds whenever the
107
175
  // underlying client changes (connect / disconnect / switch).
108
176
  $effect(() => {
@@ -112,7 +180,9 @@
112
180
  queryTabs.setExecutor(null)
113
181
  return
114
182
  }
115
- queryTabs.setExecutor((sql) => runQueryPreferStream(client, sql))
183
+ queryTabs.setExecutor((sql, options) =>
184
+ runQueryPreferStream(client, sql, options)
185
+ )
116
186
  pendingChanges.setExecutor(async (changes) =>
117
187
  Promise.all(
118
188
  changes.map(async (c): Promise<CommitOutcome> => {
@@ -161,6 +231,47 @@
161
231
  if (secureStore.store) connection.hydrateUrls()
162
232
  })
163
233
 
234
+ // Autosave: debounce-schedule a snapshot on every router/tab state change.
235
+ // Guard on `booted` so we don't snapshot the blank pre-splash state.
236
+ $effect(() => {
237
+ if (!booted) return
238
+ const snap = buildSnapshot()
239
+ workspaceSession.schedule(snap)
240
+ })
241
+
242
+ // Flush immediately on page hide / window blur so an edit made
243
+ // right before closing is never lost to the debounce window.
244
+ onMount(() => {
245
+ const flush = () => { if (booted) workspaceSession.flush(buildSnapshot()) }
246
+ document.addEventListener('visibilitychange', flush)
247
+ window.addEventListener('blur', flush)
248
+ return () => {
249
+ document.removeEventListener('visibilitychange', flush)
250
+ window.removeEventListener('blur', flush)
251
+ }
252
+ })
253
+
254
+ // Tauri close-request: flush, then destroy the window. The Rust watchdog
255
+ // races this — if the webview hangs, it force-destroys after its bound.
256
+ onMount(() => {
257
+ if (typeof window === 'undefined') return
258
+ if (!('__TAURI_INTERNALS__' in window || '__TAURI__' in window)) return
259
+
260
+ let unlisten: (() => void) | undefined
261
+
262
+ void import('@tauri-apps/api/event')
263
+ .then(({ listen }) =>
264
+ listen<void>('close-request', async () => {
265
+ workspaceSession.flush(buildSnapshot())
266
+ const { getCurrentWindow } = await import('@tauri-apps/api/window')
267
+ await getCurrentWindow().destroy()
268
+ }),
269
+ )
270
+ .then((fn) => { unlisten = fn })
271
+
272
+ return () => { unlisten?.() }
273
+ })
274
+
164
275
  function openNewQuery() {
165
276
  const label = queryTabs.nextLabel()
166
277
  const tab = tabs.open({ kind: 'query', label, key: label }, true)
@@ -236,14 +347,25 @@
236
347
  {#if !booted}
237
348
  <Splash onDone={() => (booted = true)} />
238
349
  {:else}
239
- <div class="app grid grid-rows-[44px_32px_1fr] h-screen">
350
+ <div class="app relative grid grid-rows-[44px_32px_1fr] h-screen">
351
+ <GlobalProgressBar />
240
352
  <Topbar showConnect={showConnect} />
241
353
  <StatusBar />
242
354
  <main class="relative overflow-hidden z-[1]">
243
- {#if router.view === 'cluster'}
355
+ {#if router.view === 'ask'}
356
+ <AskView />
357
+ {:else if router.view === 'cluster'}
244
358
  <ClusterView />
245
359
  {:else if router.view === 'security'}
246
360
  <SecurityView />
361
+ {:else if router.view === 'analytics'}
362
+ <!-- Lazy: LayerChart + d3 are heavy, so the charting bundle only
363
+ loads when the user actually opens Analytics. -->
364
+ {#await import('./AnalyticsView.svelte')}
365
+ <div class="flex h-full items-center justify-center font-mono text-[12px] text-fg-3">loading analytics…</div>
366
+ {:then { default: AnalyticsView }}
367
+ <AnalyticsView />
368
+ {/await}
247
369
  {:else if router.view === 'settings'}
248
370
  <SettingsView />
249
371
  {:else if router.view === 'appearance'}
@@ -258,3 +380,12 @@
258
380
  <PendingChangesPanel open={pendingOpen} onClose={() => (pendingOpen = false)} />
259
381
  </div>
260
382
  {/if}
383
+
384
+ <!-- Step-logged loading overlay (#127): sits above the shell during a slow
385
+ connect/boot; self-hides once the open resolves and never flashes on fast
386
+ opens. -->
387
+ <LoadingOverlay />
388
+
389
+ <!-- Instrumented developer console (#128): a collapsible, accent-free log of
390
+ every query/HTTP call the client made, toggled from the StatusBar. -->
391
+ <DevConsole />
@@ -16,61 +16,65 @@
16
16
  // without untrack, the effect would track those as dependencies and our
17
17
  // own writes would re-fire it — infinite loop (`effect_update_depth_exceeded`).
18
18
 
19
- import { untrack } from 'svelte'
19
+ import { untrack } from "svelte";
20
20
 
21
21
  export interface ActivityEntry {
22
- id: number
23
- label: string
24
- startedAt: number
25
- durationMs: number | null
26
- status: 'pending' | 'ok' | 'error'
27
- error?: string
22
+ id: number;
23
+ label: string;
24
+ startedAt: number;
25
+ durationMs: number | null;
26
+ status: "pending" | "ok" | "error";
27
+ error?: string;
28
28
  }
29
29
 
30
- const MAX_LOG = 20
30
+ const MAX_LOG = 20;
31
31
 
32
32
  class ActivityStore {
33
- inflight = $state(0)
34
- log = $state<ActivityEntry[]>([])
35
- private nextId = 1
33
+ inflight = $state(0);
34
+ active = $derived(this.inflight > 0);
35
+ log = $state<ActivityEntry[]>([]);
36
+ private nextId = 1;
36
37
 
37
38
  async track<T>(label: string, op: () => Promise<T>): Promise<T> {
38
- const id = this.nextId++
39
+ const id = this.nextId++;
39
40
  const entry: ActivityEntry = {
40
41
  id,
41
42
  label,
42
43
  startedAt: Date.now(),
43
44
  durationMs: null,
44
- status: 'pending',
45
- }
45
+ status: "pending",
46
+ };
46
47
  untrack(() => {
47
- this.log = [entry, ...this.log].slice(0, MAX_LOG)
48
- this.inflight += 1
49
- })
48
+ this.log = [entry, ...this.log].slice(0, MAX_LOG);
49
+ this.inflight += 1;
50
+ });
50
51
  try {
51
- const result = await op()
52
+ const result = await op();
52
53
  untrack(() =>
53
- this.update(id, { durationMs: Date.now() - entry.startedAt, status: 'ok' }),
54
- )
55
- return result
54
+ this.update(id, {
55
+ durationMs: Date.now() - entry.startedAt,
56
+ status: "ok",
57
+ })
58
+ );
59
+ return result;
56
60
  } catch (err) {
57
61
  untrack(() =>
58
62
  this.update(id, {
59
63
  durationMs: Date.now() - entry.startedAt,
60
- status: 'error',
64
+ status: "error",
61
65
  error: (err as Error).message,
62
- }),
63
- )
64
- throw err
66
+ })
67
+ );
68
+ throw err;
65
69
  } finally {
66
70
  untrack(() => {
67
- this.inflight = Math.max(0, this.inflight - 1)
68
- })
71
+ this.inflight = Math.max(0, this.inflight - 1);
72
+ });
69
73
  }
70
74
  }
71
75
 
72
76
  private update(id: number, patch: Partial<ActivityEntry>) {
73
- this.log = this.log.map((e) => (e.id === id ? { ...e, ...patch } : e))
77
+ this.log = this.log.map((e) => (e.id === id ? { ...e, ...patch } : e));
74
78
  }
75
79
 
76
80
  /** Panic button for when the indicator gets stuck out of sync with
@@ -79,13 +83,18 @@ class ActivityStore {
79
83
  reset() {
80
84
  untrack(() => {
81
85
  this.log = this.log.map((e) =>
82
- e.status === 'pending'
83
- ? { ...e, status: 'error', error: 'reset by user', durationMs: Date.now() - e.startedAt }
84
- : e,
85
- )
86
- this.inflight = 0
87
- })
86
+ e.status === "pending"
87
+ ? {
88
+ ...e,
89
+ status: "error",
90
+ error: "reset by user",
91
+ durationMs: Date.now() - e.startedAt,
92
+ }
93
+ : e
94
+ );
95
+ this.inflight = 0;
96
+ });
88
97
  }
89
98
  }
90
99
 
91
- export const activity = new ActivityStore()
100
+ export const activity = new ActivityStore();
@@ -0,0 +1,14 @@
1
+ import type { Capability } from "./renderers/types";
2
+
3
+ export function safeCollectionName(collection: string): string {
4
+ return collection.replace(/[^A-Za-z0-9_./-]/g, "");
5
+ }
6
+
7
+ export function collectionPreviewQuery(
8
+ collection: string,
9
+ capability: Capability | undefined
10
+ ): string {
11
+ const safe = safeCollectionName(collection);
12
+ if (capability === "kv") return `LIST KV ${safe} LIMIT 200`;
13
+ return `SELECT * FROM ${safe} LIMIT 200`;
14
+ }
@@ -11,6 +11,7 @@
11
11
  import {
12
12
  BROWSER_TRANSPORTS,
13
13
  DESKTOP_TRANSPORTS,
14
+ DesktopError,
14
15
  EMPTY_SERVER_CAPABILITIES,
15
16
  LocalUrlProvider,
16
17
  RedClient,
@@ -18,6 +19,7 @@ import {
18
19
  isUrlReachable,
19
20
  parseBootParams,
20
21
  resolveConnectionBootstrap,
22
+ transportForUrl,
21
23
  type Connection,
22
24
  type ConnectionBootstrap,
23
25
  type ConnectionProvider,
@@ -34,6 +36,7 @@ import {
34
36
  } from "./connection-history";
35
37
  import { secureStore } from "./secureStore.svelte";
36
38
  import { activity } from "./activity.svelte";
39
+ import { loading } from "./loading.svelte";
37
40
 
38
41
  export type { HistoryEntry } from "./connection-history";
39
42
 
@@ -173,13 +176,82 @@ export function makeCustomConnection(input: string): ConnectionPreset {
173
176
  // Transport reachability is a Surface property (#34, ADR-0003): a Tauri shell
174
177
  // can open native unix/embedded connections the browser can't. Detect the
175
178
  // Surface here so the default provider declares the right reachable set.
176
- function surfaceTransports(): Transport[] {
177
- const tauri =
179
+ function isTauriSurface(): boolean {
180
+ return (
178
181
  typeof window !== "undefined" &&
179
- ("__TAURI_INTERNALS__" in window || "__TAURI__" in window);
180
- return [...(tauri ? DESKTOP_TRANSPORTS : BROWSER_TRANSPORTS)];
182
+ ("__TAURI_INTERNALS__" in window || "__TAURI__" in window)
183
+ );
184
+ }
185
+
186
+ function surfaceTransports(): Transport[] {
187
+ return [...(isTauriSurface() ? DESKTOP_TRANSPORTS : BROWSER_TRANSPORTS)];
181
188
  }
182
189
 
190
+ /**
191
+ * Open a `file://` connection by asking the Tauri shell to spawn a local
192
+ * file-backed reddb sidecar; returns the `http://127.0.0.1:<port>` URL the
193
+ * HTTP client then talks to.
194
+ *
195
+ * Tauri-ness is checked HERE, at call time — not when the provider is built.
196
+ * In `tauri dev` this module can evaluate before Tauri injects its globals, so
197
+ * gating the resolver at construction left it unwired and `file://` fell
198
+ * through to a raw fetch ("Load failed"). At connect time the IPC bridge is
199
+ * always present. The browser has no filesystem/process access, so we fail
200
+ * with a clear message there instead.
201
+ */
202
+ async function tauriOpenEmbedded(fileUrl: string): Promise<string> {
203
+ if (!isTauriSurface()) {
204
+ throw new Error("file:// databases can only be opened in the desktop app");
205
+ }
206
+ const path = fileUrl.replace(/^file:\/\//i, "");
207
+ const { invoke } = await import("@tauri-apps/api/core");
208
+ try {
209
+ return await invoke<string>("open_embedded", { path });
210
+ } catch (e) {
211
+ // The standalone-Surface adapter seam: classify the command's structured
212
+ // error (or a legacy string/plugin error) in exactly one place, then throw
213
+ // a plain Error the provider can surface without any further coercion.
214
+ throw DesktopError.from(e);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Desktop HTTP for loopback targets goes through Tauri's rust-side fetch.
220
+ *
221
+ * An embedded file-backed reddb (and the bundled `red` generally) emits no CORS
222
+ * headers, so a cross-origin webview fetch from the app/dev origin to
223
+ * `127.0.0.1:<port>` is blocked ("Load failed"). Tauri's HTTP plugin issues the
224
+ * request from Rust, bypassing the webview's same-origin policy. Only loopback
225
+ * targets are rerouted; remote servers keep the plain webview fetch. In the
226
+ * browser this is always the plain fetch.
227
+ */
228
+ const desktopFetch: typeof fetch = async (input, init) => {
229
+ const url =
230
+ typeof input === "string"
231
+ ? input
232
+ : input instanceof URL
233
+ ? input.href
234
+ : input.url;
235
+ const isLoopback =
236
+ /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?(\/|$)/i.test(url);
237
+ if (isTauriSurface() && isLoopback) {
238
+ try {
239
+ const { fetch: tauriFetch } = await import("@tauri-apps/plugin-http");
240
+ return await tauriFetch(
241
+ input as URL | Request | string,
242
+ init as RequestInit
243
+ );
244
+ } catch (e) {
245
+ // Surface the real reason — the http plugin can reject with a bare
246
+ // string/object that would otherwise read as "undefined" upstream.
247
+ const reason =
248
+ e instanceof Error ? e.message : typeof e === "string" ? e : String(e);
249
+ throw new Error(`rust-side fetch failed for ${url}: ${reason}`);
250
+ }
251
+ }
252
+ return fetch(input, init);
253
+ };
254
+
183
255
  function isHandoffTokenConsumer(
184
256
  candidate: ConnectionProvider
185
257
  ): candidate is ConnectionProvider & HandoffTokenConsumer {
@@ -198,6 +270,13 @@ export const provider: ConnectionProvider & {
198
270
  presets: PRESETS,
199
271
  history: historyStore,
200
272
  transports: surfaceTransports(),
273
+ // Always wired: the resolver itself decides (at call time) whether it's on a
274
+ // Tauri Surface, so a not-yet-injected global at module load can't disable it.
275
+ embeddedResolver: tauriOpenEmbedded,
276
+ // Route loopback HTTP through Tauri's rust-side fetch (CORS bypass for the
277
+ // embedded/local reddb); plain webview fetch everywhere else.
278
+ clientFactory: (url, opts) =>
279
+ new RedClient(url, { ...opts, fetch: desktopFetch }),
201
280
  // Boot-params pre-configuration (#36, ADR-0005): a Surface (the MCP App)
202
281
  // seeds the endpoint + initial route in the app URL; the Core reads it
203
282
  // through the provider. Query tokens are ignored and hash tokens are not
@@ -370,7 +449,13 @@ class ConnectionStore {
370
449
  // An explicit connect attempt means a target has been chosen — unblocks
371
450
  // automatic refresh from here on (#21).
372
451
  this.#targetResolved = true;
452
+ // Step-logged loading (#127): the connect sequence appends real,
453
+ // timestamped steps so a slow open (sidecar spawn, readiness, handshake,
454
+ // initial stats/schema fetch) shows honest feedback instead of a frozen
455
+ // screen. Fast opens finish before the overlay's reveal threshold.
456
+ loading.begin(`Opening ${preset.label}`);
373
457
  try {
458
+ loading.step("Reaching server", preset.url);
374
459
  const active = await activity.track(`connect · ${preset.label}`, () =>
375
460
  getConnectionProvider().connect(preset.id)
376
461
  );
@@ -381,6 +466,7 @@ class ConnectionStore {
381
466
  description: preset.description,
382
467
  };
383
468
  persist(this.active);
469
+ loading.step("Fetching stats & capabilities");
384
470
  const [stats, replication, capabilities] = await Promise.all([
385
471
  activity
386
472
  .track(`connect · ${preset.label} stats`, () => active.client.stats())
@@ -405,9 +491,11 @@ class ConnectionStore {
405
491
  };
406
492
  this.#capabilities = capabilities;
407
493
  this.connected = true;
494
+ loading.succeed();
408
495
  return true;
409
496
  } catch (e) {
410
497
  this.probe = { reachable: false, error: (e as Error).message };
498
+ loading.fail((e as Error).message);
411
499
  return false;
412
500
  }
413
501
  }
@@ -491,3 +579,78 @@ class ConnectionStore {
491
579
  }
492
580
 
493
581
  export const connection = new ConnectionStore();
582
+
583
+ /**
584
+ * Honest, actionable copy for a `file://` target that can't be opened on the
585
+ * current Surface (#123). The web build has no filesystem/process access, so a
586
+ * local database file needs the desktop app — say that plainly instead of
587
+ * failing with a generic transport-unsupported error (live-or-empty honesty
588
+ * applied to the Connect flow).
589
+ */
590
+ export const LOCAL_FILE_DESKTOP_ONLY =
591
+ "Local database files (file://) can only be opened in the desktop app — the web version can't reach files on your machine.";
592
+
593
+ /**
594
+ * Explanation for an unreachable connection target, or null when the target IS
595
+ * reachable / has no file-specific guidance. The Connect UI shows this instead
596
+ * of the generic "unsupported transport" line when the user types a `file://`
597
+ * URL on the web (#123). Kept in the store (not the component) so the
598
+ * connect-flow seam is unit-testable without rendering.
599
+ */
600
+ export function unreachableConnectHint(url: string): string | null {
601
+ if (connection.canReach(url)) return null;
602
+ return transportForUrl(url) === "unix" ? LOCAL_FILE_DESKTOP_ONLY : null;
603
+ }
604
+
605
+ /**
606
+ * Native file picker — the subset of `@tauri-apps/plugin-dialog`'s `open` we
607
+ * use. Injected by tests; in production it's the lazily-imported dialog plugin.
608
+ */
609
+ export type FilePicker = (options: {
610
+ multiple: false;
611
+ directory: false;
612
+ title: string;
613
+ filters: { name: string; extensions: string[] }[];
614
+ }) => Promise<string | null>;
615
+
616
+ export interface OpenDatabaseFileResult {
617
+ ok: boolean;
618
+ /** True when the user dismissed the picker without choosing a file. */
619
+ cancelled?: boolean;
620
+ /** Human-readable reason when the flow failed (not a cancellation). */
621
+ reason?: string;
622
+ }
623
+
624
+ /**
625
+ * Desktop "Open database file…" flow (#123): open the OS picker for a `.rdb`
626
+ * file and connect to it through the existing `file://` embedded path (Tauri
627
+ * spawns a local file-backed reddb sidecar; the UI talks HTTP to it). Only the
628
+ * standalone (Tauri) Surface has a native picker — on the web there is none, so
629
+ * this returns the honest desktop-only message the caller surfaces.
630
+ *
631
+ * `pick` is injected by tests; in production it's the Tauri dialog plugin's
632
+ * `open`, imported lazily so the browser bundle never pulls it in.
633
+ */
634
+ export async function openDatabaseFile(
635
+ pick?: FilePicker
636
+ ): Promise<OpenDatabaseFileResult> {
637
+ if (!pick && !isTauriSurface()) {
638
+ return { ok: false, reason: LOCAL_FILE_DESKTOP_ONLY };
639
+ }
640
+ const open =
641
+ pick ??
642
+ ((await import("@tauri-apps/plugin-dialog")).open as unknown as FilePicker);
643
+ const selected = await open({
644
+ multiple: false,
645
+ directory: false,
646
+ title: "Open database file",
647
+ filters: [{ name: "reddb database", extensions: ["rdb"] }],
648
+ });
649
+ if (!selected) return { ok: false, cancelled: true };
650
+ const ok = await connection.tryConnect(
651
+ makeCustomConnection(`file://${selected}`)
652
+ );
653
+ return ok
654
+ ? { ok: true }
655
+ : { ok: false, reason: connection.probe.error ?? "unreachable" };
656
+ }