@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
@@ -1,7 +1,22 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte'
3
- import { connection, makeCustomConnection } from './connections.svelte'
4
- import { Zap, Loader2, AlertCircle, CheckCircle2, Clock, X, Plug, ChevronDown } from 'lucide-svelte'
3
+ import {
4
+ connection,
5
+ makeCustomConnection,
6
+ openDatabaseFile,
7
+ unreachableConnectHint,
8
+ } from './connections.svelte'
9
+ import {
10
+ Zap,
11
+ Loader2,
12
+ AlertCircle,
13
+ CheckCircle2,
14
+ Clock,
15
+ X,
16
+ Plug,
17
+ ChevronDown,
18
+ FolderOpen,
19
+ } from 'lucide-svelte'
5
20
 
6
21
  const DEFAULT_URL = 'red://localhost'
7
22
 
@@ -29,7 +44,11 @@
29
44
  // opaque transport-unsupported error from the client.
30
45
  if (!connection.canReach(cleaned)) {
31
46
  status = 'error'
32
- error = `This app can't reach that transport here — supported: ${supportedHint}`
47
+ // A file:// target on the web gets an honest, actionable explanation
48
+ // (#123) instead of the generic transport-unsupported line.
49
+ error =
50
+ unreachableConnectHint(cleaned) ??
51
+ `This app can't reach that transport here — supported: ${supportedHint}`
33
52
  return
34
53
  }
35
54
  status = 'probing'
@@ -51,6 +70,27 @@
51
70
  attempt(url)
52
71
  }
53
72
 
73
+ // Desktop-only "Open database file…" flow (#123): the native OS picker feeds
74
+ // the chosen .rdb into the existing file:// connect path. Hidden on the web
75
+ // Surface, where there is no picker (file:// is unreachable there).
76
+ async function openFile() {
77
+ status = 'probing'
78
+ error = null
79
+ rtt = null
80
+ const res = await openDatabaseFile()
81
+ if (res.ok) {
82
+ status = 'ok'
83
+ rtt = connection.probe.rtt_ms ?? null
84
+ url = connection.active.url
85
+ setTimeout(() => (open = false), 350)
86
+ } else if (res.cancelled) {
87
+ status = 'idle'
88
+ } else {
89
+ status = 'error'
90
+ error = res.reason ?? 'unreachable'
91
+ }
92
+ }
93
+
54
94
  function fmtRel(ms: number) {
55
95
  const d = Date.now() - ms
56
96
  if (d < 60_000) return 'just now'
@@ -110,6 +150,10 @@
110
150
  const reachableHistory = $derived(
111
151
  connection.history.filter((h) => !h.url || connection.canReach(h.url)),
112
152
  )
153
+
154
+ // Native file open is only reachable on a Surface that can speak the unix /
155
+ // embedded-file transport (the Tauri desktop shell) (#123).
156
+ const canOpenFile = $derived(connection.supportedTransports.includes('unix'))
113
157
  </script>
114
158
 
115
159
  <div class="relative">
@@ -176,6 +220,19 @@
176
220
  </div>
177
221
  </form>
178
222
 
223
+ {#if canOpenFile}
224
+ <button
225
+ type="button"
226
+ onclick={openFile}
227
+ disabled={status === 'probing'}
228
+ title="Open a local .rdb database file"
229
+ class="mt-1 w-full h-9 bg-bg-2 border border-line-2 text-fg-1 font-medium rounded-md cursor-pointer transition-colors hover:border-line-3 hover:text-fg-0 disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2 text-[13px]"
230
+ >
231
+ <FolderOpen class="size-3.5 text-fg-3" />
232
+ Open database file…
233
+ </button>
234
+ {/if}
235
+
179
236
  {#if reachableHistory.length > 0}
180
237
  <div class="mt-3 pt-3 border-t border-line-1">
181
238
  <div class="flex items-center gap-1.5 mb-2">
@@ -0,0 +1,155 @@
1
+ <script lang="ts">
2
+ // Instrumented developer console panel (#128). A collapsible, bottom-docked
3
+ // log of every query and HTTP call the client made — verb, target, duration,
4
+ // row count — copyable and secret-free. It bridges the framework-agnostic
5
+ // `devConsole` store (deliberately rune-free so the data layer can log
6
+ // without importing Svelte) into reactivity by subscribing and mirroring the
7
+ // snapshot into `$state`.
8
+ //
9
+ // It never steals the accent: status is carried by ok/danger dots and the
10
+ // panel chrome uses the neutral surface ramp, so the one-accent-per-screen
11
+ // rule (design principle #6) holds even while the console is open.
12
+ import { devConsole as defaultStore, type ConsoleEntry } from '#reddb'
13
+ import { X, Copy, Trash2, Check } from 'lucide-svelte'
14
+
15
+ let {
16
+ store = defaultStore,
17
+ open: openProp = false,
18
+ }: {
19
+ store?: typeof defaultStore
20
+ open?: boolean
21
+ } = $props()
22
+
23
+ // Seed the panel's open state from the prop once; thereafter it's toggled
24
+ // locally by the shell event and the close button.
25
+ // svelte-ignore state_referenced_locally
26
+ let open = $state(openProp)
27
+ let entries = $state<readonly ConsoleEntry[]>([])
28
+ let copiedId = $state<number | null>(null)
29
+
30
+ // Bridge the rune-free store into reactivity: subscribe once, mirror every
31
+ // snapshot into local `$state`. The subscribe fires immediately, so the
32
+ // panel paints the current log on mount.
33
+ $effect(() => store.subscribe((snap) => (entries = snap)))
34
+
35
+ // Toggle from anywhere in the shell (the StatusBar button dispatches this).
36
+ $effect(() => {
37
+ const toggle = () => (open = !open)
38
+ window.addEventListener('red:toggle-dev-console', toggle)
39
+ return () => window.removeEventListener('red:toggle-dev-console', toggle)
40
+ })
41
+
42
+ // Newest first — an operator reads the most recent call at the top.
43
+ const ordered = $derived([...entries].reverse())
44
+
45
+ async function copy(text: string, id: number) {
46
+ try {
47
+ await navigator.clipboard?.writeText(text)
48
+ copiedId = id
49
+ setTimeout(() => {
50
+ if (copiedId === id) copiedId = null
51
+ }, 1200)
52
+ } catch {
53
+ // Clipboard denied (insecure context / permissions) — silently no-op.
54
+ }
55
+ }
56
+
57
+ function entryText(e: ConsoleEntry): string {
58
+ const head = `${e.verb} ${e.target} → ${e.ok ? (e.status ?? 'ok') : 'error'} · ${e.durationMs}ms${
59
+ e.rowCount === undefined ? '' : ` · ${e.rowCount} rows`
60
+ }`
61
+ const parts = [head]
62
+ if (e.payload) parts.push(e.payload)
63
+ if (e.error) parts.push(e.error)
64
+ return parts.join('\n')
65
+ }
66
+
67
+ function fmtDuration(ms: number): string {
68
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`
69
+ }
70
+ </script>
71
+
72
+ {#if open}
73
+ <div
74
+ class="fixed inset-x-0 bottom-0 z-[9990] flex max-h-[45vh] flex-col border-t border-line-2 bg-bg-1 shadow-2xl"
75
+ role="log"
76
+ aria-label="Developer console"
77
+ data-testid="dev-console"
78
+ >
79
+ <div class="flex items-center justify-between gap-3 border-b border-line-1 bg-bg-2/60 px-3 py-1.5">
80
+ <div class="flex items-center gap-2 font-mono text-[11px] text-fg-2">
81
+ <span class="uppercase tracking-wider text-fg-1">Console</span>
82
+ <span class="text-fg-3" data-testid="dev-console-count">{entries.length} calls</span>
83
+ </div>
84
+ <div class="flex items-center gap-1">
85
+ <button
86
+ type="button"
87
+ onclick={() => store.clear()}
88
+ title="Clear console"
89
+ aria-label="Clear console"
90
+ class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[11px] text-fg-3 hover:bg-bg-3/60 hover:text-fg-1 transition-colors"
91
+ >
92
+ <Trash2 class="size-3" />
93
+ Clear
94
+ </button>
95
+ <button
96
+ type="button"
97
+ onclick={() => (open = false)}
98
+ title="Close console"
99
+ aria-label="Close console"
100
+ class="inline-flex items-center rounded p-0.5 text-fg-3 hover:bg-bg-3/60 hover:text-fg-1 transition-colors"
101
+ >
102
+ <X class="size-3.5" />
103
+ </button>
104
+ </div>
105
+ </div>
106
+
107
+ <div class="min-h-0 flex-1 overflow-y-auto">
108
+ {#if ordered.length === 0}
109
+ <div class="grid h-24 place-items-center font-mono text-[12px] text-fg-3">
110
+ No calls yet — the client hasn't asked the database anything.
111
+ </div>
112
+ {:else}
113
+ <ul class="divide-y divide-line-1/60">
114
+ {#each ordered as entry (entry.id)}
115
+ <li class="group flex items-start gap-2 px-3 py-1.5 font-mono text-[11px] hover:bg-bg-2/40" data-testid="dev-console-entry">
116
+ <span
117
+ class="mt-[5px] h-1.5 w-1.5 shrink-0 rounded-full"
118
+ class:bg-ok={entry.ok}
119
+ class:bg-danger={!entry.ok}
120
+ aria-hidden="true"
121
+ ></span>
122
+ <span class="w-11 shrink-0 uppercase text-fg-2">{entry.verb}</span>
123
+ <span class="min-w-0 flex-1">
124
+ <span class="text-fg-1">{entry.target}</span>
125
+ {#if entry.payload}
126
+ <span class="ml-1 text-fg-3 break-all">{entry.payload.replace(/\s+/g, ' ').slice(0, 120)}</span>
127
+ {/if}
128
+ {#if entry.error}
129
+ <span class="ml-1 text-danger break-all">— {entry.error}</span>
130
+ {/if}
131
+ </span>
132
+ {#if entry.rowCount !== undefined}
133
+ <span class="shrink-0 tabular-nums text-fg-3" title="rows returned">{entry.rowCount}r</span>
134
+ {/if}
135
+ <span class="w-12 shrink-0 text-right tabular-nums text-fg-2" title="duration">{fmtDuration(entry.durationMs)}</span>
136
+ <button
137
+ type="button"
138
+ onclick={() => copy(entryText(entry), entry.id)}
139
+ title="Copy entry"
140
+ aria-label="Copy entry"
141
+ class="shrink-0 rounded p-0.5 text-fg-3 opacity-0 transition-opacity hover:bg-bg-3/60 hover:text-fg-1 group-hover:opacity-100 focus:opacity-100"
142
+ >
143
+ {#if copiedId === entry.id}
144
+ <Check class="size-3 text-ok" />
145
+ {:else}
146
+ <Copy class="size-3" />
147
+ {/if}
148
+ </button>
149
+ </li>
150
+ {/each}
151
+ </ul>
152
+ {/if}
153
+ </div>
154
+ </div>
155
+ {/if}
@@ -0,0 +1,11 @@
1
+ <script lang="ts">
2
+ // Test-only harness: wraps the crash child in the real ErrorBoundary exactly
3
+ // as the Workspace root does, so the regression test exercises the shipped
4
+ // boundary rather than a stand-in.
5
+ import ErrorBoundary from './ErrorBoundary.svelte'
6
+ import CrashChild from './ErrorBoundaryCrashChild.fixture.svelte'
7
+ </script>
8
+
9
+ <ErrorBoundary>
10
+ <CrashChild />
11
+ </ErrorBoundary>
@@ -0,0 +1,5 @@
1
+ // Test-only reactive control for the ErrorBoundary regression test. Kept in a
2
+ // `.svelte.ts` module so the `$state` rune is compiled. The crash child reads
3
+ // `crashCtrl.crash` at (re)creation time, so flipping it before a boundary
4
+ // reset models a transient fault that has since cleared.
5
+ export const crashCtrl = $state({ crash: true, message: 'render-crash-test' })
@@ -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,9 @@
1
+ <script lang="ts">
2
+ import { activity } from './activity.svelte'
3
+ </script>
4
+
5
+ {#if activity.active}
6
+ <div class="global-progress" role="progressbar" aria-label="reddb activity" aria-valuetext="Loading">
7
+ <div class="global-progress__bar"></div>
8
+ </div>
9
+ {/if}
@@ -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}
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ // Renders a metric the way its *nature* dictates (eixo 3): the caller passes
3
+ // the ChartKind derived from the descriptor's `kind`, and this component
4
+ // picks the visualisation — a rate line, a filled gauge level, or a
5
+ // P50/P95/P99 percentile curve. No chart-type picker is ever shown to the
6
+ // user; the metric's kind decides.
7
+ import { LineChart, AreaChart } from 'layerchart'
8
+ import type { ChartKind, NormalizedSeries } from '#reddb'
9
+
10
+ interface Props {
11
+ chart: ChartKind
12
+ series: NormalizedSeries[]
13
+ unit?: string | null
14
+ }
15
+ let { chart, series, unit = null }: Props = $props()
16
+
17
+ // Worse percentiles trend redder; single series leads with the accent.
18
+ const PALETTE = ['#ff2056', '#4ade80', '#fbbf24', '#38bdf8', '#a78bfa']
19
+ const PERCENTILE_COLOR: Record<string, string> = {
20
+ p50: '#4ade80',
21
+ p95: '#fbbf24',
22
+ p99: '#ff2056',
23
+ }
24
+
25
+ // LayerChart auto-detects a time scale from Date x-values.
26
+ const chartSeries = $derived(
27
+ series.map((s, i) => ({
28
+ key: s.label,
29
+ color: PERCENTILE_COLOR[s.label] ?? PALETTE[i % PALETTE.length],
30
+ data: s.points.map((p) => ({ date: new Date(p.t), value: p.v })),
31
+ })),
32
+ )
33
+
34
+ const hasData = $derived(series.some((s) => s.points.length > 0))
35
+
36
+ // Gauge / ratio: lead with the most recent level, big.
37
+ const latest = $derived.by(() => {
38
+ const pts = series[0]?.points
39
+ return pts && pts.length ? pts[pts.length - 1].v : null
40
+ })
41
+ const isRatio = $derived(chart === 'gauge')
42
+
43
+ function fmtValue(v: number): string {
44
+ if (isRatio && v >= 0 && v <= 1) return `${(v * 100).toFixed(1)}%`
45
+ if (Math.abs(v) >= 1000) return v.toLocaleString(undefined, { maximumFractionDigits: 1 })
46
+ return v.toFixed(Math.abs(v) < 1 ? 3 : 2)
47
+ }
48
+ </script>
49
+
50
+ {#if !hasData}
51
+ <div class="flex h-full items-center justify-center text-[12px] text-fg-3 font-mono">
52
+ no samples in window
53
+ </div>
54
+ {:else if chart === 'gauge'}
55
+ <!-- Point-in-time level. A ratio shows a 0–100% bar; other gauges show the
56
+ raw latest value. -->
57
+ <div class="flex h-full flex-col items-center justify-center gap-3">
58
+ <div class="font-mono text-[40px] font-semibold leading-none text-accent">
59
+ {latest === null ? '—' : fmtValue(latest)}{#if unit && !isRatio}<span class="ml-1 text-[16px] text-fg-3">{unit}</span>{/if}
60
+ </div>
61
+ {#if isRatio && latest !== null}
62
+ <div class="h-1.5 w-56 overflow-hidden rounded-full bg-bg-3">
63
+ <div class="h-full rounded-full bg-accent" style={`width: ${Math.max(0, Math.min(100, latest * 100))}%`}></div>
64
+ </div>
65
+ {/if}
66
+ </div>
67
+ {:else if chart === 'area'}
68
+ <AreaChart
69
+ data={chartSeries[0]?.data ?? []}
70
+ x="date"
71
+ y="value"
72
+ series={chartSeries}
73
+ props={{ area: { 'fill-opacity': 0.12 } }}
74
+ legend={chartSeries.length > 1}
75
+ />
76
+ {:else}
77
+ <!-- line + percentiles -->
78
+ <LineChart
79
+ data={chartSeries[0]?.data ?? []}
80
+ x="date"
81
+ y="value"
82
+ series={chartSeries}
83
+ legend={chartSeries.length > 1}
84
+ />
85
+ {/if}
@@ -1,7 +1,7 @@
1
1
  <script lang="ts">
2
- import { Play, Loader2 } from 'lucide-svelte'
2
+ import { Play, Loader2, X } from 'lucide-svelte'
3
3
  import { connection } from '$lib/connections.svelte'
4
- import { queryTabs, deriveQueryLabel } from '$lib/query-tabs.svelte'
4
+ import { queryTabs, deriveQueryLabel, formatElapsed } from '$lib/query-tabs.svelte'
5
5
  import { tabs } from '$lib/tabs.svelte'
6
6
  import { registry } from '$lib/renderers'
7
7
  import type { Capability } from '$lib/renderers'
@@ -23,6 +23,19 @@
23
23
  const tab = $derived(tabs.tabs.find((t) => t.id === tabId))
24
24
 
25
25
  let textareaEl: HTMLTextAreaElement | undefined = $state()
26
+ let now = $state(Date.now())
27
+ const elapsed = $derived(
28
+ qs?.runningStartedAt ? formatElapsed(Math.max(0, now - qs.runningStartedAt)) : '0ms'
29
+ )
30
+
31
+ $effect(() => {
32
+ if (!qs?.running) return
33
+ now = Date.now()
34
+ const id = setInterval(() => {
35
+ now = Date.now()
36
+ }, 100)
37
+ return () => clearInterval(id)
38
+ })
26
39
 
27
40
  function setSql(value: string) {
28
41
  queryTabs.setSql(tabId, value)
@@ -36,6 +49,10 @@
36
49
  await queryTabs.run(tabId)
37
50
  }
38
51
 
52
+ function cancel() {
53
+ queryTabs.cancel(tabId)
54
+ }
55
+
39
56
  function onKey(e: KeyboardEvent) {
40
57
  if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
41
58
  e.preventDefault()
@@ -93,15 +110,39 @@
93
110
  </div>
94
111
 
95
112
  <div class="flex-1 overflow-hidden bg-bg-0">
96
- {#if qs?.error}
113
+ {#if qs?.running}
114
+ <div class="h-full grid place-items-center p-6" aria-live="polite" aria-busy="true">
115
+ <div class="w-full max-w-md rounded border border-line-1 bg-bg-1 p-4 font-mono">
116
+ <div class="flex items-start justify-between gap-4">
117
+ <div class="min-w-0">
118
+ <div class="flex items-center gap-2 text-fg-1 text-[13px]">
119
+ <Loader2 class="size-4 animate-spin text-accent" />
120
+ <span>Running query</span>
121
+ </div>
122
+ <div class="mt-2 text-[11px] text-fg-3">
123
+ elapsed <span class="tabular-nums text-fg-2">{elapsed}</span>
124
+ </div>
125
+ </div>
126
+ <button
127
+ type="button"
128
+ class="inline-flex h-7 items-center gap-1 rounded border border-line-2 px-2 text-[11px] text-fg-2 hover:border-danger/50 hover:text-danger cursor-pointer"
129
+ onclick={cancel}
130
+ aria-label="Cancel running query"
131
+ >
132
+ <X class="size-3.5" />
133
+ <span>Cancel</span>
134
+ </button>
135
+ </div>
136
+ <div class="mt-3 max-h-24 overflow-hidden rounded bg-bg-0 px-2 py-1.5 text-[11px] text-fg-3">
137
+ <pre class="m-0 whitespace-pre-wrap break-words">{qs.sql.trim()}</pre>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ {:else if qs?.error}
97
142
  <div class="p-4 text-[12px] font-mono text-warn">
98
143
  <div class="font-semibold mb-1">Query failed</div>
99
144
  <pre class="whitespace-pre-wrap">{qs.error}</pre>
100
145
  </div>
101
- {:else if qs?.running && !qs.result}
102
- <div class="h-full grid place-items-center text-fg-3 text-[12px] font-mono">
103
- Running…
104
- </div>
105
146
  {:else if !qs?.result}
106
147
  <div class="h-full grid place-items-center text-fg-3 text-[12px] font-mono">
107
148
  Run a query to see results here.