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