@reddb-io/ui 0.3.1 → 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.
- package/build/_app/immutable/assets/0.BWZTMKZW.css +1 -0
- package/build/_app/immutable/chunks/{8zg2gJwV.js → B-yl15ta.js} +1 -1
- package/build/_app/immutable/chunks/{CJFJ501n.js → Bgb6fHGb.js} +1 -1
- package/build/_app/immutable/chunks/C-kqMbRi.js +1 -0
- package/build/_app/immutable/chunks/{CLBx21Hz.js → C3SGj6Qd.js} +2 -2
- package/build/_app/immutable/chunks/{DV9PDoM3.js → C7kf23SQ.js} +1 -1
- package/build/_app/immutable/chunks/CHvQYbpl.js +1 -0
- package/build/_app/immutable/chunks/CO7K4bD2.js +1164 -0
- package/build/_app/immutable/chunks/CP65IkJc.js +1 -0
- package/build/_app/immutable/chunks/CxnvJShw.js +404 -0
- package/build/_app/immutable/chunks/D02PB2u7.js +1 -0
- package/build/_app/immutable/chunks/{Bq4DTWK2.js → DN7v3Tar.js} +1 -1
- package/build/_app/immutable/entry/{app.BDog2y0J.js → app.BeX4Tmu5.js} +2 -2
- package/build/_app/immutable/entry/start.D7y1MZex.js +1 -0
- package/build/_app/immutable/nodes/0.C0e5KQVb.js +2 -0
- package/build/_app/immutable/nodes/1.ceiVuZbB.js +1 -0
- package/build/_app/immutable/nodes/{10.D_FCLs0Z.js → 10.DeFqKRR2.js} +2 -2
- package/build/_app/immutable/nodes/{12.DPE6v6Zf.js → 12.CzMvqoYS.js} +2 -2
- package/build/_app/immutable/nodes/{15.CcJlZGxa.js → 15.CRDBqbiH.js} +1 -1
- package/build/_app/version.json +1 -1
- package/build/index.html +13 -13
- package/dist/embed/{AnalyticsView-D8z1Nsed.js → AnalyticsView-SKyGxeAx.js} +849 -849
- package/dist/embed/{AnalyticsView-D8z1Nsed.js.map → AnalyticsView-SKyGxeAx.js.map} +1 -1
- package/dist/embed/{RedUiEmbedRoot-CtqTd_FG.js → RedUiEmbedRoot-BzVzGAFK.js} +13945 -13287
- package/dist/embed/RedUiEmbedRoot-BzVzGAFK.js.map +1 -0
- package/dist/embed/event-BvRbkLjD.js +46 -0
- package/dist/embed/event-BvRbkLjD.js.map +1 -0
- package/dist/embed/index-BVxMkfHe.js +8 -0
- package/dist/embed/index-BVxMkfHe.js.map +1 -0
- package/dist/embed/index-BY6fc1oQ.js +856 -0
- package/dist/embed/index-BY6fc1oQ.js.map +1 -0
- package/dist/embed/{index-client-BeiP-va5.js → index-client-BHUUkI8H.js} +121 -120
- package/dist/embed/{index-client-BeiP-va5.js.map → index-client-BHUUkI8H.js.map} +1 -1
- package/dist/embed/index.js +1 -1
- package/dist/embed/{sigma.esm-qqKBpvin.js → sigma.esm-BawyGZ3s.js} +2 -2
- package/dist/embed/{sigma.esm-qqKBpvin.js.map → sigma.esm-BawyGZ3s.js.map} +1 -1
- package/dist/embed/window-ZR8_mM8o.js +1889 -0
- package/dist/embed/window-ZR8_mM8o.js.map +1 -0
- package/package.json +5 -3
- package/src/lib/ConnectDropdown.svelte +60 -3
- package/src/lib/DevConsole.svelte +155 -0
- package/src/lib/ErrorBoundary.fixture.svelte +11 -0
- package/src/lib/ErrorBoundary.fixtures.svelte.ts +5 -0
- package/src/lib/ErrorBoundary.svelte +78 -0
- package/src/lib/ErrorBoundaryCrashChild.fixture.svelte +9 -0
- package/src/lib/LoadingOverlay.svelte +109 -0
- package/src/lib/StatusBar.svelte +19 -1
- package/src/lib/Workspace.svelte +118 -2
- package/src/lib/connections.svelte.ts +95 -1
- package/src/lib/embed/RedUiEmbedRoot.svelte +13 -8
- package/src/lib/embed/index.ts +34 -1
- package/src/lib/index.ts +7 -0
- package/src/lib/loading.svelte.ts +142 -0
- package/src/lib/reddb/client.ts +133 -15
- package/src/lib/reddb/desktop-error.ts +89 -0
- package/src/lib/reddb/dev-console.ts +186 -0
- package/src/lib/reddb/index.ts +2 -0
- package/src/lib/reddb/local-url-provider.ts +4 -8
- package/src/lib/reddb/tauri-encrypted-store.ts +4 -9
- package/src/lib/tabs.svelte.ts +5 -0
- package/src/lib/workspace-session.ts +81 -0
- package/build/_app/immutable/assets/0.DgbuWrJy.css +0 -1
- package/build/_app/immutable/chunks/85-Fh5Zp.js +0 -404
- package/build/_app/immutable/chunks/CnD4hwxC.js +0 -1106
- package/build/_app/immutable/chunks/DQCYUNtS.js +0 -1
- package/build/_app/immutable/entry/start.D1J45dq5.js +0 -1
- package/build/_app/immutable/nodes/0.BTrC4th2.js +0 -1
- package/build/_app/immutable/nodes/1.Ds1Tbjsm.js +0 -1
- package/dist/embed/RedUiEmbedRoot-CtqTd_FG.js.map +0 -1
- package/dist/embed/index-w1Qo_uW0.js +0 -677
- 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}
|
package/src/lib/StatusBar.svelte
CHANGED
|
@@ -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}`}
|
package/src/lib/Workspace.svelte
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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>
|
package/src/lib/embed/index.ts
CHANGED
|
@@ -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
|
}
|