@reddb-io/ui 0.1.1

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 (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. package/src/lib/theme.svelte.ts +63 -0
@@ -0,0 +1,747 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+ import { useRouter } from '$lib/router.svelte'
4
+ import { connection } from '$lib/connections.svelte'
5
+ import { secureStore } from '$lib/secureStore.svelte'
6
+ import { activity } from '$lib/activity.svelte'
7
+ import { tabs, type Tab } from '$lib/tabs.svelte'
8
+ import { queryTabs } from '$lib/query-tabs.svelte'
9
+ import { defaultSubpage, subpageCapability } from '$lib/collection-pages'
10
+ import {
11
+ collectionCatalogBadges,
12
+ collectionCatalogFromRow,
13
+ collectionCatalogQuery,
14
+ formatBytes,
15
+ formatCount,
16
+ type CollectionCatalogMetadata,
17
+ } from '$lib/collection-catalog'
18
+ import { registry } from '$lib/renderers'
19
+ import type { Capability } from '$lib/renderers'
20
+ import CollectionHistory from '$lib/CollectionHistory.svelte'
21
+ import LiveChanges from '$lib/LiveChanges.svelte'
22
+ import QueryEditor from '$lib/QueryEditor.svelte'
23
+ import type { CollectionMetadata, QueryResult, RedClient } from '#reddb'
24
+ import { X, Activity, Database, Plus, FileCode2, EyeOff, Eye, Info, HardDrive, Rows3, Layers3, ScanLine, GitBranch } from 'lucide-svelte'
25
+
26
+ const router = useRouter()
27
+
28
+ const OVERRIDE_CHOICES: { value: '' | Capability; label: string }[] = [
29
+ { value: '', label: 'Auto' },
30
+ { value: 'table', label: 'Table' },
31
+ { value: 'graph', label: 'Graph' },
32
+ { value: 'hypertable', label: 'Chart' },
33
+ { value: 'kv', label: 'KV' },
34
+ { value: 'vector', label: 'Vector' },
35
+ { value: 'queue', label: 'Queue' },
36
+ { value: 'stats', label: 'Stats' },
37
+ { value: 'diff', label: 'Diff' },
38
+ { value: 'document', label: 'Document' },
39
+ { value: 'json', label: 'JSON' },
40
+ ]
41
+
42
+ let results = $state<Record<string, QueryResult | null>>({})
43
+ let errors = $state<Record<string, string>>({})
44
+ let resultKeys = $state<Record<string, string>>({})
45
+ let catalogMetadata = $state<Record<string, CollectionCatalogMetadata | null>>({})
46
+ let catalogErrors = $state<Record<string, string>>({})
47
+ let metadata = $state<Record<string, CollectionMetadata | null>>({})
48
+ let metadataErrors = $state<Record<string, string>>({})
49
+ let historyOpen = $state<Record<string, boolean>>({})
50
+ let previousActiveId = $state<string | null>(null)
51
+ let lastActiveId: string | null = null
52
+ let menuOpen = $state(false)
53
+ let metadataOpen = $state(false)
54
+
55
+ $effect(() => {
56
+ const current = tabs.activeId
57
+ if (current !== lastActiveId) {
58
+ previousActiveId = lastActiveId
59
+ lastActiveId = current
60
+ }
61
+ })
62
+
63
+ /**
64
+ * Fetch a meaningful sub-graph: pull a window of edges first, then the
65
+ * nodes those edges actually reference (plus a few unconnected ones for
66
+ * context). A flat `SELECT *` on a CREATE GRAPH collection returns
67
+ * nodes-then-edges in insertion order, so without this split the first
68
+ * N rows are all nodes and zero edges land in the renderer.
69
+ */
70
+ async function fetchGraphSubgraph(client: RedClient, collection: string) {
71
+ const EDGE_LIMIT = 50000
72
+ const CENTRALITY_LIMIT = 100
73
+ // reddb's WHERE rid IN (…) silently returns 0 once the list size
74
+ // exceeds a small threshold (somewhere around 8 in 1.3.0). Chunk to
75
+ // stay safely under that ceiling and union the results.
76
+ const IN_CHUNK = 5
77
+ // Run node-batch queries in parallel but bounded so we don't slam the
78
+ // server. 8 concurrent fits ~1k batches in roughly a second.
79
+ const CONCURRENCY = 8
80
+
81
+ const [edgesRes, centralityRes] = await Promise.all([
82
+ activity.track(
83
+ `${collection} · edges (LIMIT ${EDGE_LIMIT})`,
84
+ () => client.query(
85
+ `SELECT * FROM ${collection} WHERE from_rid IS NOT NULL LIMIT ${EDGE_LIMIT}`,
86
+ ),
87
+ ),
88
+ activity.track(
89
+ `${collection} · centrality (LIMIT ${CENTRALITY_LIMIT})`,
90
+ () => client.query(`GRAPH CENTRALITY LIMIT ${CENTRALITY_LIMIT}`),
91
+ ).catch(() => null),
92
+ ])
93
+
94
+ const centralityByRid = new Map<number, { score: number; rank: number; label?: string }>()
95
+ if (centralityRes?.ok) {
96
+ centralityRes.result.records.forEach((rec, index) => {
97
+ const rawId = rec.values.node_id ?? rec.values.rid ?? rec.values.id
98
+ const rawScore = rec.values.score ?? rec.values.centrality_score ?? rec.values.centrality
99
+ const id = typeof rawId === 'number' ? rawId : typeof rawId === 'string' ? Number(rawId) : NaN
100
+ const score = typeof rawScore === 'number' ? rawScore : typeof rawScore === 'string' ? Number(rawScore) : NaN
101
+ if (!Number.isFinite(id) || !Number.isFinite(score)) return
102
+ centralityByRid.set(id, {
103
+ score,
104
+ rank: index + 1,
105
+ label: typeof rec.values.label === 'string' ? rec.values.label : undefined,
106
+ })
107
+ })
108
+ }
109
+
110
+ const rids = new Set<number>()
111
+ for (const rec of edgesRes.result.records) {
112
+ const f = rec.values.from_rid
113
+ const t = rec.values.to_rid
114
+ if (typeof f === 'number') rids.add(f)
115
+ if (typeof t === 'number') rids.add(t)
116
+ }
117
+ for (const rid of centralityByRid.keys()) rids.add(rid)
118
+
119
+ const ridList = [...rids]
120
+ const totalChunks = Math.ceil(ridList.length / IN_CHUNK)
121
+ const chunks: { idx: number; ids: string }[] = []
122
+ for (let i = 0; i < ridList.length; i += IN_CHUNK) {
123
+ chunks.push({
124
+ idx: Math.floor(i / IN_CHUNK) + 1,
125
+ ids: ridList.slice(i, i + IN_CHUNK).join(','),
126
+ })
127
+ }
128
+
129
+ const nodeRecords: typeof edgesRes.result.records = []
130
+ let nodesRes = edgesRes // template for shape
131
+
132
+ // Bounded-concurrency worker pool. Each worker pulls the next chunk
133
+ // off the shared cursor; that pattern keeps CONCURRENCY requests in
134
+ // flight without spawning a Promise per chunk up-front.
135
+ let cursor = 0
136
+ async function worker() {
137
+ while (cursor < chunks.length) {
138
+ const my = chunks[cursor++]
139
+ const r = await activity.track(
140
+ `${collection} · nodes ${my.idx}/${totalChunks}`,
141
+ () => client.query(`SELECT * FROM ${collection} WHERE rid IN (${my.ids})`),
142
+ )
143
+ nodeRecords.push(...r.result.records.map((rec) => {
144
+ const rid = rec.values.rid
145
+ const centrality = typeof rid === 'number' ? centralityByRid.get(rid) : undefined
146
+ if (!centrality) return rec
147
+ return {
148
+ ...rec,
149
+ values: {
150
+ ...rec.values,
151
+ centrality_score: centrality.score,
152
+ centrality_rank: centrality.rank,
153
+ centrality_label: centrality.label,
154
+ },
155
+ }
156
+ }))
157
+ nodesRes = r
158
+ }
159
+ }
160
+ await Promise.all(Array.from({ length: CONCURRENCY }, worker))
161
+
162
+ return {
163
+ ...nodesRes,
164
+ record_count: nodeRecords.length + edgesRes.result.records.length,
165
+ result: {
166
+ ...nodesRes.result,
167
+ records: [...nodeRecords, ...edgesRes.result.records],
168
+ },
169
+ }
170
+ }
171
+
172
+ function safeCollectionName(collection: string): string {
173
+ return collection.replace(/[^A-Za-z0-9_./-]/g, '')
174
+ }
175
+
176
+ function sqlString(s: string): string {
177
+ return `'${s.replace(/'/g, "''")}'`
178
+ }
179
+
180
+ async function fetchCollectionCatalog(client: RedClient, collection: string): Promise<CollectionCatalogMetadata> {
181
+ const r = await client.query(collectionCatalogQuery(collection))
182
+ const meta = collectionCatalogFromRow(r.result.records[0])
183
+ if (!meta) throw new Error(`${collection} was not found in red.collections`)
184
+ return meta
185
+ }
186
+
187
+ async function fetchCollectionModel(client: RedClient, collection: string): Promise<string | null> {
188
+ try {
189
+ const r = await client.query(`SELECT model FROM red.collections WHERE name = ${sqlString(collection)} LIMIT 1`)
190
+ const model = r.result.records[0]?.values.model
191
+ return typeof model === 'string' ? model : null
192
+ } catch {
193
+ return null
194
+ }
195
+ }
196
+
197
+ async function fetchStatsCollection(client: RedClient, collection: string) {
198
+ const safe = safeCollectionName(collection)
199
+ const model = await fetchCollectionModel(client, collection)
200
+ if (model === 'hll') return client.query(`HLL INFO ${safe}`)
201
+ if (model === 'sketch') return client.query(`SKETCH INFO ${safe}`)
202
+ if (model === 'filter') return client.query(`FILTER INFO ${safe}`)
203
+ if (model === 'mixed') {
204
+ for (const query of [`HLL INFO ${safe}`, `SKETCH INFO ${safe}`, `FILTER INFO ${safe}`]) {
205
+ try {
206
+ return await client.query(query)
207
+ } catch {
208
+ // Try the next probabilistic read form before falling back to SELECT.
209
+ }
210
+ }
211
+ }
212
+ try {
213
+ return await client.query(`SELECT * FROM ${safe} LIMIT 200`)
214
+ } catch {
215
+ for (const query of [`HLL INFO ${safe}`, `SKETCH INFO ${safe}`, `FILTER INFO ${safe}`]) {
216
+ try {
217
+ return await client.query(query)
218
+ } catch {
219
+ // Try the next probabilistic read form.
220
+ }
221
+ }
222
+ throw new Error(`No stats read form worked for ${collection}`)
223
+ }
224
+ }
225
+
226
+ async function fetchVectorSeed(client: RedClient, collection: string) {
227
+ const safe = safeCollectionName(collection)
228
+ const r = await client.query(`SELECT * FROM ${safe} LIMIT 200`)
229
+ return { ...r, capability: r.capability ?? 'vector' }
230
+ }
231
+
232
+ $effect(() => {
233
+ if (secureStore.locked) return
234
+ const client = connection.client
235
+ if (!client) return
236
+ for (const tab of tabs.tabs) {
237
+ if (tab.kind !== 'collection') continue
238
+ const subpage = tab.subpage ?? defaultSubpage(tab.capability)
239
+ const shouldFetchGraph = subpage === 'graph' || subpage === 'svg'
240
+ const shouldFetchQueue = subpage === 'queue' || tab.capability === 'queue'
241
+ const shouldFetchVector = subpage === 'vector' || tab.capability === 'vector'
242
+ const shouldFetchStats = subpage === 'stats' || tab.capability === 'stats'
243
+ const fetchMode = shouldFetchGraph ? 'graph-subgraph' : shouldFetchQueue ? 'queue-peek' : shouldFetchVector ? 'vector-inspector' : shouldFetchStats ? 'stats-info' : tab.capability ?? 'unknown'
244
+ const cacheKey = `${tab.key}:${subpage}:${fetchMode}`
245
+ if (resultKeys[tab.id] && resultKeys[tab.id] !== cacheKey) {
246
+ delete results[tab.id]
247
+ delete errors[tab.id]
248
+ }
249
+ if (results[tab.id] !== undefined || errors[tab.id]) continue
250
+ results[tab.id] = null
251
+ resultKeys[tab.id] = cacheKey
252
+ const collection = tab.key
253
+ const job = shouldFetchGraph
254
+ ? fetchGraphSubgraph(client, collection)
255
+ : shouldFetchQueue
256
+ ? activity.track(
257
+ `${collection} · QUEUE PEEK (LIMIT 200)`,
258
+ () => client.query(`QUEUE PEEK ${collection} 200`),
259
+ )
260
+ : shouldFetchVector
261
+ ? activity.track(
262
+ `${collection} · vector inspector seed`,
263
+ () => fetchVectorSeed(client, collection),
264
+ )
265
+ : shouldFetchStats
266
+ ? activity.track(
267
+ `${collection} · stats preview`,
268
+ () => fetchStatsCollection(client, collection),
269
+ )
270
+ : activity.track(
271
+ `${collection} · SELECT * (LIMIT 200)`,
272
+ () => client.query(`SELECT * FROM ${collection} LIMIT 200`),
273
+ )
274
+ job
275
+ .then((r) => { results[tab.id] = r })
276
+ .catch((e) => { errors[tab.id] = (e as Error).message })
277
+ }
278
+ })
279
+
280
+ $effect(() => {
281
+ if (secureStore.locked) return
282
+ const client = connection.client
283
+ if (!client) return
284
+ for (const tab of tabs.tabs) {
285
+ if (tab.kind !== 'collection') continue
286
+ if (catalogMetadata[tab.id] !== undefined || catalogErrors[tab.id]) continue
287
+ catalogMetadata[tab.id] = null
288
+ activity.track(`catalog · ${tab.key}`, () => fetchCollectionCatalog(client, tab.key))
289
+ .then((m) => { catalogMetadata[tab.id] = m })
290
+ .catch((e) => { catalogErrors[tab.id] = (e as Error).message })
291
+ }
292
+ })
293
+
294
+ $effect(() => {
295
+ if (!metadataOpen) return
296
+ if (secureStore.locked) return
297
+ const client = connection.client
298
+ if (!client) return
299
+ for (const tab of tabs.tabs) {
300
+ if (tab.kind !== 'collection') continue
301
+ if (metadata[tab.id] !== undefined || metadataErrors[tab.id]) continue
302
+ metadata[tab.id] = null
303
+ activity.track(`metadata · ${tab.key}`, () => client.collection(tab.key))
304
+ .then((m) => { metadata[tab.id] = m })
305
+ .catch((e) => { metadataErrors[tab.id] = (e as Error).message })
306
+ }
307
+ })
308
+
309
+ function closeTab(e: MouseEvent, id: string) {
310
+ e.stopPropagation()
311
+ const tab = tabs.tabs.find((t) => t.id === id)
312
+ if (tab?.kind === 'query' && queryTabs.isDirty(id)) {
313
+ const ok = typeof window !== 'undefined'
314
+ ? window.confirm('Discard unsaved query in this tab?')
315
+ : true
316
+ if (!ok) return
317
+ }
318
+ // If we're closing the collection that owns the current URL, the
319
+ // /c/[collection] route would just re-open the tab on every render.
320
+ // Navigate away so the URL stays in sync with what's open.
321
+ const closingUrlOwner = tab?.kind === 'collection' && tab.key === router.collection
322
+ tabs.close(id, previousActiveId)
323
+ queryTabs.remove(id)
324
+ delete results[id]
325
+ delete errors[id]
326
+ delete resultKeys[id]
327
+ delete catalogMetadata[id]
328
+ delete catalogErrors[id]
329
+ delete metadata[id]
330
+ delete metadataErrors[id]
331
+ delete historyOpen[id]
332
+ if (closingUrlOwner) {
333
+ const next = tabs.tabs.find((t) => t.kind === 'collection')
334
+ router.go(
335
+ next
336
+ ? { view: 'collection', collection: next.key, subpage: next.subpage ?? defaultSubpage(next.capability) }
337
+ : { view: 'collections' },
338
+ undefined,
339
+ true,
340
+ )
341
+ }
342
+ }
343
+
344
+ function openNewQuery() {
345
+ const label = queryTabs.nextLabel()
346
+ const tab = tabs.open(
347
+ { kind: 'query', label, key: label },
348
+ true,
349
+ )
350
+ queryTabs.ensure(tab.id)
351
+ menuOpen = false
352
+ }
353
+
354
+ onMount(() => {
355
+ const onNewQuery = () => openNewQuery()
356
+ window.addEventListener('red:new-query', onNewQuery)
357
+ return () => window.removeEventListener('red:new-query', onNewQuery)
358
+ })
359
+
360
+ function pickRenderer(tab: Tab, result: QueryResult) {
361
+ const cap = subpageCapability(tab.subpage, tab.capability ?? (result.capability as Capability | undefined))
362
+ return registry.pick(cap, result, tab.overrideCapability)
363
+ }
364
+
365
+ function openLiveChanges() {
366
+ tabs.open({ kind: 'live-changes', label: 'live · global', key: 'global' })
367
+ menuOpen = false
368
+ }
369
+
370
+ function onOverrideChange(tab: Tab, e: Event) {
371
+ const value = (e.currentTarget as HTMLSelectElement).value as '' | Capability
372
+ tabs.setOverride(tab.id, value === '' ? undefined : value)
373
+ }
374
+
375
+ function defaultCapabilityLabel(tab: Tab, result: QueryResult | null | undefined): string {
376
+ if (tab.subpage) return tab.subpage
377
+ if (tab.capability) return tab.capability
378
+ if (result?.capability) return result.capability
379
+ return 'auto'
380
+ }
381
+
382
+ function actionAllowed(v: NonNullable<CollectionMetadata['actions']>[string]): { allowed: boolean; reason?: string } {
383
+ if (typeof v === 'boolean') return { allowed: v }
384
+ return { allowed: v.allowed, reason: v.reason }
385
+ }
386
+
387
+ function objectKeys(v: unknown): string[] {
388
+ return v && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v as Record<string, unknown>) : []
389
+ }
390
+ </script>
391
+
392
+ <div class="h-full flex flex-col">
393
+ <div class="border-b border-line-1 flex items-stretch min-h-[36px] bg-bg-0">
394
+ {#if tabs.tabs.length === 0}
395
+ <div class="px-3 py-2 flex items-center gap-2 text-fg-3">
396
+ <Activity class="size-3.5" />
397
+ <span class="type-label">Results</span>
398
+ </div>
399
+ {:else}
400
+ <div class="flex items-stretch overflow-x-auto">
401
+ {#each tabs.tabs as tab (tab.id)}
402
+ {@const isActive = tab.id === tabs.activeId}
403
+ {@const cls = [
404
+ 'group flex items-center gap-2 px-3 py-1.5 text-[12px] font-mono border-r border-line-1 whitespace-nowrap no-underline',
405
+ isActive
406
+ ? 'bg-bg-1 text-fg-1 border-b-2 border-b-accent -mb-px'
407
+ : 'text-fg-3 hover:text-fg-1 hover:bg-bg-1/40',
408
+ ].join(' ')}
409
+ {#if tab.kind === 'collection'}
410
+ {@const tabTarget = { view: 'collection' as const, collection: tab.key, subpage: tab.subpage ?? defaultSubpage(tab.capability) }}
411
+ <a href={router.href(tabTarget)} onclick={(e) => router.go(tabTarget, e)} class={cls} title={tab.label}>
412
+ <span class="truncate max-w-[180px]">{tab.label}</span>
413
+ <span
414
+ class="opacity-60 group-hover:opacity-100 hover:bg-bg-2 rounded p-0.5"
415
+ role="button"
416
+ tabindex="-1"
417
+ aria-label="Close tab"
418
+ onclick={(e) => closeTab(e as unknown as MouseEvent, tab.id)}
419
+ onkeydown={(e) => { if (e.key === 'Enter') closeTab(e as unknown as MouseEvent, tab.id) }}
420
+ >
421
+ <X class="size-3" />
422
+ </span>
423
+ </a>
424
+ {:else}
425
+ <button type="button" class={cls} onclick={() => tabs.focus(tab.id)} title={tab.label}>
426
+ <span class="truncate max-w-[180px]">{tab.label}</span>
427
+ <span
428
+ class="opacity-60 group-hover:opacity-100 hover:bg-bg-2 rounded p-0.5"
429
+ role="button"
430
+ tabindex="-1"
431
+ aria-label="Close tab"
432
+ onclick={(e) => closeTab(e as unknown as MouseEvent, tab.id)}
433
+ onkeydown={(e) => { if (e.key === 'Enter') closeTab(e as unknown as MouseEvent, tab.id) }}
434
+ >
435
+ <X class="size-3" />
436
+ </span>
437
+ </button>
438
+ {/if}
439
+ {/each}
440
+ </div>
441
+ {/if}
442
+
443
+ {#if tabs.active && (tabs.active.kind === 'collection' || tabs.active.kind === 'query')}
444
+ {@const activeTab = tabs.active}
445
+ {@const activeResult = results[activeTab.id]}
446
+ <div class="ml-auto flex items-center gap-2 px-2 text-[11px] font-mono text-fg-3">
447
+ {#if activeTab.kind === 'collection'}
448
+ <button
449
+ type="button"
450
+ onclick={() => (metadataOpen = !metadataOpen)}
451
+ title={metadataOpen ? 'Hide collection metadata' : 'Show collection metadata'}
452
+ aria-pressed={metadataOpen}
453
+ class={[
454
+ 'inline-flex items-center gap-1 h-6 px-1.5 rounded border transition-colors cursor-pointer',
455
+ metadataOpen
456
+ ? 'border-accent/40 bg-accent/10 text-accent'
457
+ : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
458
+ ].join(' ')}
459
+ >
460
+ <Info class="size-3" />
461
+ <span>meta</span>
462
+ </button>
463
+ {#if connection.capabilities.vcs}
464
+ <!-- VCS history is server-gated (#22): absent when the connected
465
+ server doesn't support version control, not shown-but-broken. -->
466
+ <button
467
+ type="button"
468
+ onclick={() => (historyOpen = { ...historyOpen, [activeTab.id]: !(historyOpen[activeTab.id] ?? false) })}
469
+ title={historyOpen[activeTab.id] ? 'Hide version history' : 'Show version history and diffs'}
470
+ aria-pressed={historyOpen[activeTab.id] ?? false}
471
+ class={[
472
+ 'inline-flex items-center gap-1 h-6 px-1.5 rounded border transition-colors cursor-pointer',
473
+ historyOpen[activeTab.id]
474
+ ? 'border-accent/40 bg-accent/10 text-accent'
475
+ : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
476
+ ].join(' ')}
477
+ >
478
+ <GitBranch class="size-3" />
479
+ <span>history</span>
480
+ </button>
481
+ {/if}
482
+ {/if}
483
+ <button
484
+ type="button"
485
+ onclick={() => tabs.setShowSystem(activeTab.id, !activeTab.showSystemColumns)}
486
+ title={activeTab.showSystemColumns ? 'Hide reddb system columns (rid, collection, kind, tenant, created_at, updated_at)' : 'Show reddb system columns'}
487
+ aria-pressed={activeTab.showSystemColumns ?? false}
488
+ class={[
489
+ 'inline-flex items-center gap-1 h-6 px-1.5 rounded border transition-colors cursor-pointer',
490
+ activeTab.showSystemColumns
491
+ ? 'border-accent/40 bg-accent/10 text-accent'
492
+ : 'border-line-1 text-fg-3 hover:text-fg-1 hover:border-line-2',
493
+ ].join(' ')}
494
+ >
495
+ {#if activeTab.showSystemColumns}
496
+ <Eye class="size-3" />
497
+ {:else}
498
+ <EyeOff class="size-3" />
499
+ {/if}
500
+ <span>sys</span>
501
+ </button>
502
+ <span>{defaultCapabilityLabel(activeTab, activeResult)}</span>
503
+ <span class="text-fg-3">· override:</span>
504
+ <select
505
+ class="bg-bg-1 text-fg-1 border border-line-1 rounded px-1 py-0.5"
506
+ aria-label="Renderer override"
507
+ value={activeTab.overrideCapability ?? ''}
508
+ onchange={(e) => onOverrideChange(activeTab, e)}
509
+ >
510
+ {#each OVERRIDE_CHOICES as choice (choice.value)}
511
+ <option value={choice.value}>{choice.label}</option>
512
+ {/each}
513
+ </select>
514
+ </div>
515
+ {/if}
516
+
517
+ <div class="relative {tabs.active && (tabs.active.kind === 'collection' || tabs.active.kind === 'query') ? '' : 'ml-auto'} flex items-stretch">
518
+ <button
519
+ type="button"
520
+ class="px-2 inline-flex items-center text-fg-3 hover:text-fg-1"
521
+ aria-label="Open tab menu"
522
+ onclick={() => (menuOpen = !menuOpen)}
523
+ >
524
+ <Plus class="size-3.5" />
525
+ </button>
526
+ {#if menuOpen}
527
+ <div
528
+ role="menu"
529
+ class="absolute right-0 top-full mt-1 z-10 min-w-[220px] rounded border border-line-1 bg-bg-1 shadow-lg p-1 text-[12px] font-mono"
530
+ >
531
+ <button
532
+ type="button"
533
+ class="w-full text-left px-2 py-1 rounded hover:bg-bg-2 text-fg-1 flex items-center gap-2"
534
+ onclick={openNewQuery}
535
+ >
536
+ <FileCode2 class="size-3.5 text-fg-3" />
537
+ <span class="flex-1">New query</span>
538
+ <span class="text-fg-3 text-[10px]">⌘T</span>
539
+ </button>
540
+ <button
541
+ type="button"
542
+ class="w-full text-left px-2 py-1 rounded hover:bg-bg-2 text-fg-1"
543
+ onclick={openLiveChanges}
544
+ >
545
+ Live changes (global)
546
+ </button>
547
+ </div>
548
+ {/if}
549
+ </div>
550
+ </div>
551
+
552
+ <div class="flex-1 overflow-hidden bg-bg-0">
553
+ {#if !tabs.active}
554
+ <div class="h-full grid place-items-center p-6">
555
+ {#if connection.connected}
556
+ <div class="text-center text-fg-3 text-[12px] font-mono">
557
+ Connected to <span class="text-fg-1">{connection.active.url}</span>.
558
+ <br />
559
+ Pick a collection from the left to open a tab.
560
+ </div>
561
+ {:else}
562
+ <div class="text-center max-w-md">
563
+ <Database class="size-8 text-fg-3 mx-auto mb-3" strokeWidth={1.4} />
564
+ <h2 class="type-h2 m-0 mb-2">Workspace</h2>
565
+ <p class="text-fg-2 text-[13px] m-0">
566
+ Connect to a reddb instance from the topbar to start browsing
567
+ collections and running queries.
568
+ </p>
569
+ </div>
570
+ {/if}
571
+ </div>
572
+ {:else}
573
+ {@const tab = tabs.active}
574
+ {#if tab.kind === 'live-changes'}
575
+ <LiveChanges collection={undefined} />
576
+ {:else if tab.kind === 'query'}
577
+ <QueryEditor tabId={tab.id} fallbackLabel={tab.key} />
578
+ {:else if tab.kind === 'welcome'}
579
+ <div class="h-full grid place-items-center text-fg-3 text-[12px] font-mono">
580
+ Workspace
581
+ </div>
582
+ {:else}
583
+ {@const result = results[tab.id]}
584
+ {@const err = errors[tab.id]}
585
+ {#if err}
586
+ <div class="p-4 text-[12px] font-mono text-warn">
587
+ <div class="font-semibold mb-1">Query failed</div>
588
+ <pre class="whitespace-pre-wrap">{err}</pre>
589
+ </div>
590
+ {:else if !result}
591
+ <div class="h-full grid place-items-center text-fg-3 text-[12px] font-mono">
592
+ Loading {tab.label}…
593
+ </div>
594
+ {:else}
595
+ {@const renderer = pickRenderer(tab, result)}
596
+ {@const Renderer = renderer.component}
597
+ {@const catalog = catalogMetadata[tab.id]}
598
+ {@const catalogErr = catalogErrors[tab.id]}
599
+ <div class="h-full flex flex-col">
600
+ <div class="border-b border-line-1 bg-bg-1/35 px-3 py-2">
601
+ {#if catalogErr}
602
+ <div class="flex items-center gap-2 text-[11px] font-mono text-fg-3">
603
+ <Info class="size-3.5 text-warn" />
604
+ <span>Catalog metadata unavailable.</span>
605
+ <span class="truncate">{catalogErr}</span>
606
+ </div>
607
+ {:else if !catalog}
608
+ <div class="flex items-center gap-2 text-[11px] font-mono text-fg-3">
609
+ <Database class="size-3.5" />
610
+ <span>Loading collection catalog…</span>
611
+ </div>
612
+ {:else}
613
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-2">
614
+ <div class="min-w-[180px] flex-1">
615
+ <div class="flex items-center gap-2">
616
+ <span class="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] text-accent">
617
+ {catalog.model ?? 'collection'}
618
+ </span>
619
+ <span class="truncate font-mono text-[12px] text-fg-0">{catalog.name}</span>
620
+ </div>
621
+ <div class="mt-1 flex flex-wrap gap-1">
622
+ {#each collectionCatalogBadges(catalog) as badge}
623
+ <span class="rounded border border-line-1 bg-bg-0/70 px-1.5 py-0.5 font-mono text-[10px] text-fg-2">{badge}</span>
624
+ {/each}
625
+ {#if collectionCatalogBadges(catalog).length === 0}
626
+ <span class="font-mono text-[10px] text-fg-3">No type-specific catalog fields.</span>
627
+ {/if}
628
+ </div>
629
+ </div>
630
+
631
+ <div class="flex flex-wrap items-center gap-2 font-mono text-[11px]">
632
+ <div class="inline-flex min-w-[94px] items-center gap-1.5 rounded border border-line-1 bg-bg-0/70 px-2 py-1">
633
+ <Rows3 class="size-3.5 text-fg-3" />
634
+ <span class="text-fg-0">{formatCount(catalog.entities)}</span>
635
+ <span class="text-fg-3">entities</span>
636
+ </div>
637
+ <div class="inline-flex min-w-[92px] items-center gap-1.5 rounded border border-line-1 bg-bg-0/70 px-2 py-1">
638
+ <HardDrive class="size-3.5 text-fg-3" />
639
+ <span class="text-fg-0">{formatBytes(catalog.onDiskBytes)}</span>
640
+ <span class="text-fg-3">disk</span>
641
+ </div>
642
+ <div class="inline-flex min-w-[92px] items-center gap-1.5 rounded border border-line-1 bg-bg-0/70 px-2 py-1">
643
+ <Activity class="size-3.5 text-fg-3" />
644
+ <span class="text-fg-0">{formatBytes(catalog.inMemoryBytes)}</span>
645
+ <span class="text-fg-3">mem</span>
646
+ </div>
647
+ <div class="inline-flex min-w-[78px] items-center gap-1.5 rounded border border-line-1 bg-bg-0/70 px-2 py-1">
648
+ <Layers3 class="size-3.5 text-fg-3" />
649
+ <span class="text-fg-0">{formatCount(catalog.segments)}</span>
650
+ <span class="text-fg-3">seg</span>
651
+ </div>
652
+ <div class="inline-flex min-w-[78px] items-center gap-1.5 rounded border border-line-1 bg-bg-0/70 px-2 py-1">
653
+ <ScanLine class="size-3.5 text-fg-3" />
654
+ <span class="text-fg-0">{formatCount(catalog.indices)}</span>
655
+ <span class="text-fg-3">idx</span>
656
+ </div>
657
+ </div>
658
+ </div>
659
+ {/if}
660
+ </div>
661
+
662
+ {#if metadataOpen}
663
+ {@const meta = metadata[tab.id]}
664
+ {@const metaErr = metadataErrors[tab.id]}
665
+ <div class="border-b border-line-1 bg-bg-1/30 px-3 py-2 text-[11px] font-mono">
666
+ {#if metaErr}
667
+ <div class="flex items-start gap-2 text-fg-3">
668
+ <Info class="mt-0.5 size-3.5 text-warn" />
669
+ <div>
670
+ <div class="text-warn">Collection metadata unavailable.</div>
671
+ <div class="mt-0.5 break-words">{metaErr}</div>
672
+ </div>
673
+ </div>
674
+ {:else if !meta}
675
+ <div class="text-fg-3">Loading metadata…</div>
676
+ {:else}
677
+ <div class="grid grid-cols-[minmax(160px,0.8fr)_1.2fr] gap-3">
678
+ <div class="grid grid-cols-[78px_1fr] gap-x-2 gap-y-1">
679
+ <span class="text-fg-3">name</span>
680
+ <span class="text-fg-0 truncate">{meta.name ?? tab.key}</span>
681
+ <span class="text-fg-3">kind</span>
682
+ <span class="text-accent">{meta.kind ?? meta.capability ?? tab.capability ?? 'unknown'}</span>
683
+ <span class="text-fg-3">tenant</span>
684
+ <span class="text-fg-1">{meta.tenant ?? 'not exposed'}</span>
685
+ <span class="text-fg-3">indexes</span>
686
+ <span class="text-fg-1">{meta.indexes?.length ?? 0}</span>
687
+ </div>
688
+
689
+ <div class="grid gap-1">
690
+ <div class="flex flex-wrap gap-1">
691
+ {#if meta.capability}
692
+ <span class="rounded border border-accent/30 bg-accent/10 px-1.5 py-0.5 text-accent">{meta.capability}</span>
693
+ {/if}
694
+ {#each meta.capabilities ?? [] as cap}
695
+ <span class="rounded border border-line-1 px-1.5 py-0.5 text-fg-2">{cap}</span>
696
+ {/each}
697
+ {#if objectKeys(meta.schema).length > 0}
698
+ <span class="rounded border border-line-1 px-1.5 py-0.5 text-fg-2">schema {objectKeys(meta.schema).length}</span>
699
+ {/if}
700
+ {#if objectKeys(meta.retention).length > 0}
701
+ <span class="rounded border border-line-1 px-1.5 py-0.5 text-fg-2">retention</span>
702
+ {/if}
703
+ </div>
704
+
705
+ {#if meta.actions && objectKeys(meta.actions).length > 0}
706
+ <div class="mt-1 flex flex-wrap gap-1">
707
+ {#each Object.entries(meta.actions) as [action, value]}
708
+ {@const state = actionAllowed(value)}
709
+ <span
710
+ class={[
711
+ 'rounded border px-1.5 py-0.5',
712
+ state.allowed ? 'border-ok/30 bg-ok/10 text-ok' : 'border-danger/30 bg-danger/10 text-danger',
713
+ ].join(' ')}
714
+ title={state.reason}
715
+ >
716
+ {action}: {state.allowed ? 'allow' : 'deny'}
717
+ </span>
718
+ {/each}
719
+ </div>
720
+ {:else}
721
+ <div class="mt-1 text-fg-3">Action grants not exposed.</div>
722
+ {/if}
723
+ </div>
724
+ </div>
725
+ {/if}
726
+ </div>
727
+ {/if}
728
+ {#if historyOpen[tab.id]}
729
+ <div class="h-[320px] min-h-[240px] border-b border-line-1">
730
+ <CollectionHistory client={connection.client ?? undefined} collection={tab.key} />
731
+ </div>
732
+ {/if}
733
+ <div class="flex-1 min-h-0">
734
+ <Renderer
735
+ {result}
736
+ collection={tab.kind === 'collection' ? tab.key : undefined}
737
+ client={connection.client ?? undefined}
738
+ subpage={tab.kind === 'collection' ? tab.subpage : undefined}
739
+ showSystem={tab.showSystemColumns ?? false}
740
+ />
741
+ </div>
742
+ </div>
743
+ {/if}
744
+ {/if}
745
+ {/if}
746
+ </div>
747
+ </div>