@reddb-io/ui 0.3.1 → 0.3.3

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 (74) hide show
  1. package/build/_app/immutable/assets/0.CEQwbG0J.css +1 -0
  2. package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
  3. package/build/_app/immutable/chunks/{CLBx21Hz.js → C3SGj6Qd.js} +2 -2
  4. package/build/_app/immutable/chunks/{DV9PDoM3.js → C9pP73gT.js} +1 -1
  5. package/build/_app/immutable/chunks/{CJFJ501n.js → CEBul73-.js} +1 -1
  6. package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
  7. package/build/_app/immutable/chunks/{8zg2gJwV.js → CXuFZWQe.js} +1 -1
  8. package/build/_app/immutable/chunks/CgIoFKBK.js +1 -0
  9. package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
  10. package/build/_app/immutable/chunks/D6XJvoMn.js +404 -0
  11. package/build/_app/immutable/chunks/DLDgyIn5.js +1164 -0
  12. package/build/_app/immutable/chunks/{Bq4DTWK2.js → DN7v3Tar.js} +1 -1
  13. package/build/_app/immutable/entry/{app.BDog2y0J.js → app.BBk9O_OI.js} +2 -2
  14. package/build/_app/immutable/entry/start.C9qfj9ex.js +1 -0
  15. package/build/_app/immutable/nodes/0.BBf1ZyjL.js +2 -0
  16. package/build/_app/immutable/nodes/1.BmVeJAGw.js +1 -0
  17. package/build/_app/immutable/nodes/{10.D_FCLs0Z.js → 10.DZmZgLvj.js} +1 -1
  18. package/build/_app/immutable/nodes/{12.DPE6v6Zf.js → 12.CzMvqoYS.js} +2 -2
  19. package/build/_app/immutable/nodes/15.CRwZaU1B.js +60 -0
  20. package/build/_app/version.json +1 -1
  21. package/build/index.html +13 -13
  22. package/dist/embed/{AnalyticsView-D8z1Nsed.js → AnalyticsView-BWwvQS2I.js} +849 -849
  23. package/dist/embed/{AnalyticsView-D8z1Nsed.js.map → AnalyticsView-BWwvQS2I.js.map} +1 -1
  24. package/dist/embed/RedUiEmbedRoot-DAB-6DEr.js +35587 -0
  25. package/dist/embed/RedUiEmbedRoot-DAB-6DEr.js.map +1 -0
  26. package/dist/embed/event-BvRbkLjD.js +46 -0
  27. package/dist/embed/event-BvRbkLjD.js.map +1 -0
  28. package/dist/embed/index-BVxMkfHe.js +8 -0
  29. package/dist/embed/index-BVxMkfHe.js.map +1 -0
  30. package/dist/embed/index-Bgo4yw4C.js +856 -0
  31. package/dist/embed/index-Bgo4yw4C.js.map +1 -0
  32. package/dist/embed/{index-client-BeiP-va5.js → index-client-BHUUkI8H.js} +121 -120
  33. package/dist/embed/{index-client-BeiP-va5.js.map → index-client-BHUUkI8H.js.map} +1 -1
  34. package/dist/embed/index.js +1 -1
  35. package/dist/embed/{sigma.esm-qqKBpvin.js → sigma.esm-BOgYLguI.js} +2 -2
  36. package/dist/embed/{sigma.esm-qqKBpvin.js.map → sigma.esm-BOgYLguI.js.map} +1 -1
  37. package/dist/embed/window-ZR8_mM8o.js +1889 -0
  38. package/dist/embed/window-ZR8_mM8o.js.map +1 -0
  39. package/package.json +5 -3
  40. package/src/app.css +5 -0
  41. package/src/lib/ConnectDropdown.svelte +60 -3
  42. package/src/lib/DevConsole.svelte +155 -0
  43. package/src/lib/ErrorBoundary.fixture.svelte +11 -0
  44. package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
  45. package/src/lib/ErrorBoundary.svelte +78 -0
  46. package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
  47. package/src/lib/LoadingOverlay.svelte +109 -0
  48. package/src/lib/StatusBar.svelte +19 -1
  49. package/src/lib/Workspace.svelte +118 -2
  50. package/src/lib/connections.svelte.ts +95 -1
  51. package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
  52. package/src/lib/embed/index.ts +34 -1
  53. package/src/lib/index.ts +7 -0
  54. package/src/lib/loading.svelte.ts +142 -0
  55. package/src/lib/reddb/client.ts +133 -15
  56. package/src/lib/reddb/desktop-error.ts +89 -0
  57. package/src/lib/reddb/dev-console.ts +186 -0
  58. package/src/lib/reddb/index.ts +2 -0
  59. package/src/lib/reddb/local-url-provider.ts +4 -8
  60. package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
  61. package/src/lib/tabs.svelte.ts +5 -0
  62. package/src/lib/workspace-session.ts +81 -0
  63. package/build/_app/immutable/assets/0.DgbuWrJy.css +0 -1
  64. package/build/_app/immutable/chunks/85-Fh5Zp.js +0 -404
  65. package/build/_app/immutable/chunks/CnD4hwxC.js +0 -1106
  66. package/build/_app/immutable/chunks/DQCYUNtS.js +0 -1
  67. package/build/_app/immutable/entry/start.D1J45dq5.js +0 -1
  68. package/build/_app/immutable/nodes/0.BTrC4th2.js +0 -1
  69. package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +0 -1
  70. package/build/_app/immutable/nodes/15.CcJlZGxa.js +0 -60
  71. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js +0 -31302
  72. package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +0 -1
  73. package/dist/embed/index-w1Qo_uW0.js +0 -677
  74. package/dist/embed/index-w1Qo_uW0.js.map +0 -1
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ // Black-screen shield (#126). A render crash inside the Workspace used to
3
+ // tear down the whole webview and leave a black window with no way back.
4
+ // This wraps its children in a Svelte error boundary whose `failed` state
5
+ // shows the message, the stack, and two recovery actions:
6
+ // - Retry render: re-runs the boundary's children (`reset`). Recovers when
7
+ // the fault was transient — the offending state has since changed.
8
+ // - Reload window: hard-reloads the document. Always works, even when the
9
+ // fault reproduces on every render.
10
+ // Styling stays on the dark tokens and keeps a single accent (the primary
11
+ // Retry action), per the one-accent discipline.
12
+ import type { Snippet } from 'svelte'
13
+ import { RotateCw, RefreshCw } from 'lucide-svelte'
14
+
15
+ let { children }: { children: Snippet } = $props()
16
+
17
+ function reloadWindow() {
18
+ if (typeof window !== 'undefined') window.location.reload()
19
+ }
20
+
21
+ function messageOf(error: unknown): string {
22
+ if (error instanceof Error) return error.message || error.name
23
+ return String(error)
24
+ }
25
+
26
+ function stackOf(error: unknown): string | null {
27
+ return error instanceof Error && error.stack ? error.stack : null
28
+ }
29
+ </script>
30
+
31
+ <svelte:boundary>
32
+ {@render children()}
33
+
34
+ {#snippet failed(error, reset)}
35
+ <div
36
+ role="alert"
37
+ class="flex h-full w-full flex-col items-center justify-center gap-5 overflow-auto bg-bg-0 px-6 py-10 text-center"
38
+ >
39
+ <div class="flex flex-col items-center gap-2">
40
+ <RotateCw class="size-8 text-fg-3" strokeWidth={1.4} />
41
+ <h1 class="type-h2 m-0 text-fg-0">Something crashed while rendering</h1>
42
+ <p class="m-0 max-w-lg text-[13px] leading-relaxed text-fg-2">
43
+ The workspace hit an unexpected error. Your connection is untouched —
44
+ retry the render, or reload the window to start clean.
45
+ </p>
46
+ </div>
47
+
48
+ <p class="m-0 max-w-lg break-words font-mono text-[12px] text-accent">
49
+ {messageOf(error)}
50
+ </p>
51
+
52
+ {#if stackOf(error)}
53
+ <pre
54
+ class="m-0 max-h-52 w-full max-w-2xl overflow-auto rounded-lg border border-line-1 bg-bg-1 p-3 text-left font-mono text-[11px] leading-relaxed text-fg-3"
55
+ >{stackOf(error)}</pre>
56
+ {/if}
57
+
58
+ <div class="flex items-center gap-2">
59
+ <button
60
+ type="button"
61
+ onclick={reset}
62
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-accent px-3 text-[12px] font-medium text-white hover:bg-accent/90"
63
+ >
64
+ <RotateCw class="size-3.5" strokeWidth={2} />
65
+ Retry render
66
+ </button>
67
+ <button
68
+ type="button"
69
+ onclick={reloadWindow}
70
+ class="inline-flex h-9 items-center gap-1.5 rounded-lg border border-line-1 bg-bg-1 px-3 text-[12px] font-medium text-fg-1 hover:bg-bg-2"
71
+ >
72
+ <RefreshCw class="size-3.5" strokeWidth={2} />
73
+ Reload window
74
+ </button>
75
+ </div>
76
+ </div>
77
+ {/snippet}
78
+ </svelte:boundary>
@@ -0,0 +1,9 @@
1
+ <script lang="ts">
2
+ // Test-only child that throws during render when `crashCtrl.crash` is set,
3
+ // reproducing a component regression that would otherwise black out the
4
+ // webview. On a boundary reset it is recreated and re-reads the flag.
5
+ import { crashCtrl } from './ErrorBoundary.fixtures.svelte'
6
+ if (crashCtrl.crash) throw new Error(crashCtrl.message)
7
+ </script>
8
+
9
+ <div data-testid="child-alive">child rendered</div>
@@ -0,0 +1,109 @@
1
+ <script lang="ts">
2
+ // Step-logged loading overlay (#127). While a connection open is in flight it
3
+ // shows an elapsed badge and a scrolling log of the real steps the connect
4
+ // sequence appended (spawn, readiness, handshake, initial stats/schema). A
5
+ // stalled step reads as stalled — the last (active) step stays on screen and
6
+ // its elapsed keeps counting. Live-or-empty: it renders only real steps from
7
+ // the `loading` store, never fabricated progress.
8
+ //
9
+ // Flash guard: fast opens never paint the overlay. We only reveal after
10
+ // `revealAfterMs` of continuous loading; an open that finishes under the
11
+ // threshold clears the store before the reveal timer fires, so nothing shows.
12
+ import { loading as defaultStore, formatElapsed, type LoadingStep } from './loading.svelte'
13
+
14
+ let {
15
+ store = defaultStore,
16
+ revealAfterMs = 400,
17
+ }: {
18
+ store?: typeof defaultStore
19
+ revealAfterMs?: number
20
+ } = $props()
21
+
22
+ let revealed = $state(false)
23
+ let now = $state(0)
24
+
25
+ // Drive the reveal threshold and the ticking elapsed badge off the store's
26
+ // active flag. Both timers are torn down the instant the open finishes, so a
27
+ // sub-threshold open never flips `revealed` true.
28
+ $effect(() => {
29
+ if (!store.active) {
30
+ revealed = false
31
+ return
32
+ }
33
+ now = Date.now()
34
+ const reveal = setTimeout(() => (revealed = true), revealAfterMs)
35
+ const tick = setInterval(() => (now = Date.now()), 100)
36
+ return () => {
37
+ clearTimeout(reveal)
38
+ clearInterval(tick)
39
+ }
40
+ })
41
+
42
+ const elapsed = $derived(store.active ? store.elapsedMs(now) : 0)
43
+
44
+ function stepElapsed(step: LoadingStep): number {
45
+ if (step.durationMs !== null) return step.durationMs
46
+ return Math.max(0, now - step.startedAt)
47
+ }
48
+ </script>
49
+
50
+ {#if store.active && revealed}
51
+ <div
52
+ class="fixed inset-0 z-[9998] grid place-items-center bg-bg-0/85 backdrop-blur-sm"
53
+ role="status"
54
+ aria-live="polite"
55
+ aria-busy="true"
56
+ data-testid="loading-overlay"
57
+ >
58
+ <div class="w-[min(460px,90vw)] rounded-lg border border-line-2 bg-bg-1 p-5 shadow-2xl">
59
+ <div class="flex items-center justify-between gap-3">
60
+ <div class="flex items-center gap-2 min-w-0">
61
+ <span
62
+ class="h-2 w-2 shrink-0 animate-pulse rounded-full bg-accent shadow-[0_0_12px_var(--color-accent-glow)] motion-reduce:animate-none"
63
+ ></span>
64
+ <span class="truncate font-mono text-[13px] font-semibold text-fg-0">
65
+ {store.label ?? 'Opening connection'}
66
+ </span>
67
+ </div>
68
+ <span
69
+ class="shrink-0 rounded bg-bg-2 px-1.5 py-0.5 font-mono text-[12px] tabular-nums text-fg-2"
70
+ data-testid="loading-elapsed"
71
+ >
72
+ {formatElapsed(elapsed)}
73
+ </span>
74
+ </div>
75
+
76
+ <ol class="mt-4 max-h-[220px] space-y-1.5 overflow-y-auto" data-testid="loading-steps">
77
+ {#each store.steps as step (step.id)}
78
+ <li class="flex items-start gap-2 font-mono text-[12px]" data-testid="loading-step">
79
+ <span
80
+ class="mt-[3px] h-1.5 w-1.5 shrink-0 rounded-full"
81
+ class:bg-accent={step.status === 'active'}
82
+ class:animate-pulse={step.status === 'active'}
83
+ class:motion-reduce:animate-none={step.status === 'active'}
84
+ class:bg-ok={step.status === 'done'}
85
+ class:bg-danger={step.status === 'error'}
86
+ aria-hidden="true"
87
+ ></span>
88
+ <span class="min-w-0 flex-1">
89
+ <span
90
+ class="text-fg-1"
91
+ class:text-fg-0={step.status === 'active'}
92
+ class:text-danger={step.status === 'error'}
93
+ >{step.label}</span>
94
+ {#if step.detail}
95
+ <span class="ml-1 text-fg-3">{step.detail}</span>
96
+ {/if}
97
+ {#if step.error}
98
+ <span class="ml-1 text-danger">— {step.error}</span>
99
+ {/if}
100
+ </span>
101
+ <span class="shrink-0 tabular-nums text-fg-3" data-testid="loading-step-elapsed">
102
+ {formatElapsed(stepElapsed(step))}
103
+ </span>
104
+ </li>
105
+ {/each}
106
+ </ol>
107
+ </div>
108
+ </div>
109
+ {/if}
@@ -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}`}
@@ -6,6 +6,8 @@
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'
9
11
  import GlobalProgressBar from './GlobalProgressBar.svelte'
10
12
  import Topbar from './Topbar.svelte'
11
13
  import StatusBar from './StatusBar.svelte'
@@ -19,7 +21,7 @@
19
21
  import SecurityView from './SecurityView.svelte'
20
22
  import SettingsView from './SettingsView.svelte'
21
23
  import Appearance from './Appearance.svelte'
22
- import { connection, setConnectionProvider } from './connections.svelte'
24
+ import { connection, makeCustomConnection, setConnectionProvider } from './connections.svelte'
23
25
  import { runQueryPreferStream, type ConnectionProvider } from '#reddb'
24
26
  import { theme, type Theme } from './theme.svelte'
25
27
  import { skins } from './skins/skins.svelte'
@@ -30,6 +32,7 @@
30
32
  import { detectCapability } from './capability'
31
33
  import { defaultSubpage } from './collection-pages'
32
34
  import { createStateRouter, pathToLocation, setRouter, type Router, type View } from './router.svelte'
35
+ import { workspaceSession, type WorkspaceSnapshot } from './workspace-session'
33
36
 
34
37
  let {
35
38
  router: injectedRouter,
@@ -81,6 +84,27 @@
81
84
  }
82
85
  }
83
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
+
84
108
  onMount(() => {
85
109
  if (connectionProvider) {
86
110
  // Host-injected provider (#33): adopt its authenticated connection.
@@ -91,7 +115,13 @@
91
115
  // Connection Bootstrap: one boot resolver handles Tauri IPC, host globals,
92
116
  // the Open Contract URL/hash, and finally persisted standalone state.
93
117
  void connection.bootstrap().then((route) => {
94
- 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
+ }
95
125
  })
96
126
  }
97
127
  })
@@ -105,6 +135,42 @@
105
135
  return () => window.removeEventListener('red:open-pending-changes', handler as EventListener)
106
136
  })
107
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
+
108
174
  // Wire the commit executor to the active client. Re-binds whenever the
109
175
  // underlying client changes (connect / disconnect / switch).
110
176
  $effect(() => {
@@ -165,6 +231,47 @@
165
231
  if (secureStore.store) connection.hydrateUrls()
166
232
  })
167
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
+
168
275
  function openNewQuery() {
169
276
  const label = queryTabs.nextLabel()
170
277
  const tab = tabs.open({ kind: 'query', label, key: label }, true)
@@ -273,3 +380,12 @@
273
380
  <PendingChangesPanel open={pendingOpen} onClose={() => (pendingOpen = false)} />
274
381
  </div>
275
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 />
@@ -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
 
@@ -202,7 +205,14 @@ async function tauriOpenEmbedded(fileUrl: string): Promise<string> {
202
205
  }
203
206
  const path = fileUrl.replace(/^file:\/\//i, "");
204
207
  const { invoke } = await import("@tauri-apps/api/core");
205
- return invoke<string>("open_embedded", { path });
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
+ }
206
216
  }
207
217
 
208
218
  /**
@@ -439,7 +449,13 @@ class ConnectionStore {
439
449
  // An explicit connect attempt means a target has been chosen — unblocks
440
450
  // automatic refresh from here on (#21).
441
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}`);
442
457
  try {
458
+ loading.step("Reaching server", preset.url);
443
459
  const active = await activity.track(`connect · ${preset.label}`, () =>
444
460
  getConnectionProvider().connect(preset.id)
445
461
  );
@@ -450,6 +466,7 @@ class ConnectionStore {
450
466
  description: preset.description,
451
467
  };
452
468
  persist(this.active);
469
+ loading.step("Fetching stats & capabilities");
453
470
  const [stats, replication, capabilities] = await Promise.all([
454
471
  activity
455
472
  .track(`connect · ${preset.label} stats`, () => active.client.stats())
@@ -474,9 +491,11 @@ class ConnectionStore {
474
491
  };
475
492
  this.#capabilities = capabilities;
476
493
  this.connected = true;
494
+ loading.succeed();
477
495
  return true;
478
496
  } catch (e) {
479
497
  this.probe = { reachable: false, error: (e as Error).message };
498
+ loading.fail((e as Error).message);
480
499
  return false;
481
500
  }
482
501
  }
@@ -560,3 +579,78 @@ class ConnectionStore {
560
579
  }
561
580
 
562
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
+ }
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { BitsConfig } from 'bits-ui'
3
3
  import Workspace from '../Workspace.svelte'
4
+ import ErrorBoundary from '../ErrorBoundary.svelte'
4
5
  import type { ConnectionProvider } from '../reddb'
5
6
  import type { Theme } from '../theme.svelte'
6
7
 
@@ -20,12 +21,16 @@
20
21
  </script>
21
22
 
22
23
  <BitsConfig defaultPortalTo={portalTarget}>
23
- <Workspace
24
- {connectionProvider}
25
- {initialRoute}
26
- showConnect={false}
27
- themeTarget={shadowHost}
28
- persistTheme={false}
29
- {initialTheme}
30
- />
24
+ <!-- Black-screen shield (#126): keep an embed render crash inside the boundary
25
+ rather than blanking the host's mount point. -->
26
+ <ErrorBoundary>
27
+ <Workspace
28
+ {connectionProvider}
29
+ {initialRoute}
30
+ showConnect={false}
31
+ themeTarget={shadowHost}
32
+ persistTheme={false}
33
+ {initialTheme}
34
+ />
35
+ </ErrorBoundary>
31
36
  </BitsConfig>
@@ -1,6 +1,7 @@
1
1
  import appCss from "../../app.css?inline";
2
2
  import type { Component } from "svelte";
3
- import type { ConnectionProvider } from "../reddb";
3
+ import type { ConnectionProvider, ConsoleSink } from "../reddb";
4
+ import { devConsole } from "../reddb/dev-console";
4
5
  import type { Theme } from "../theme.svelte";
5
6
  import { RED_UI_VERSION, readBuildInfo } from "../build-info";
6
7
 
@@ -18,6 +19,9 @@ export {
18
19
  RedClient,
19
20
  type Connection,
20
21
  type ConnectionProvider,
22
+ type ConsoleEntry,
23
+ type ConsoleEntryKind,
24
+ type ConsoleSink,
21
25
  type InjectedClientOptions,
22
26
  type RedClientOptions,
23
27
  } from "../reddb";
@@ -27,6 +31,14 @@ export interface RedUiEmbedOptions {
27
31
  initialRoute?: string;
28
32
  theme?: Theme;
29
33
  shadowRoot?: ShadowRootInit;
34
+ /**
35
+ * Host tap on red-ui's developer console. When set, every query/HTTP
36
+ * round-trip the embedded Core records — verb, target, duration, row count,
37
+ * sanitized payload — is also delivered to the host, so a host app (e.g.
38
+ * red-request) can mirror red-ui's RQL/SQL traffic into its own developer
39
+ * bar. Entries are already secret-redacted before they reach the sink.
40
+ */
41
+ consoleSink?: ConsoleSink;
30
42
  }
31
43
 
32
44
  export interface RedUiEmbedHandle {
@@ -167,11 +179,16 @@ export async function mountRedUi(
167
179
  },
168
180
  });
169
181
 
182
+ const removeSink = opts.consoleSink
183
+ ? devConsole.addSink(opts.consoleSink)
184
+ : undefined;
185
+
170
186
  return {
171
187
  host,
172
188
  shadowRoot,
173
189
  portalTarget,
174
190
  destroy: () => {
191
+ removeSink?.();
175
192
  void unmount(instance);
176
193
  shadowRoot.textContent = "";
177
194
  },
@@ -180,6 +197,7 @@ export async function mountRedUi(
180
197
 
181
198
  export class RedUiElement extends HTMLElement {
182
199
  #connectionProvider: ConnectionProvider | null = null;
200
+ #consoleSink: ConsoleSink | null = null;
183
201
  initialRoute: string | undefined;
184
202
  theme: Theme | undefined;
185
203
  #handle: RedUiEmbedHandle | null = null;
@@ -201,6 +219,20 @@ export class RedUiElement extends HTMLElement {
201
219
  if (this.isConnected) this.#mounting = this.#mount();
202
220
  }
203
221
 
222
+ get consoleSink(): ConsoleSink | null {
223
+ return this.#consoleSink;
224
+ }
225
+
226
+ /**
227
+ * Host tap on the embedded Core's developer console (see
228
+ * `RedUiEmbedOptions.consoleSink`). Setting it after mount remounts, like
229
+ * `connectionProvider` — hosts normally assign both before attach.
230
+ */
231
+ set consoleSink(sink: ConsoleSink | null) {
232
+ this.#consoleSink = sink;
233
+ if (this.isConnected) this.#mounting = this.#mount();
234
+ }
235
+
204
236
  connectedCallback() {
205
237
  if (!this.hasAttribute("version"))
206
238
  this.setAttribute("version", RED_UI_VERSION);
@@ -220,6 +252,7 @@ export class RedUiElement extends HTMLElement {
220
252
  connectionProvider: this.#connectionProvider,
221
253
  initialRoute: this.initialRoute,
222
254
  theme: this.theme,
255
+ consoleSink: this.#consoleSink ?? undefined,
223
256
  });
224
257
  }
225
258
  }