@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.
- package/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- package/src/lib/theme.svelte.ts +63 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { type EncryptedStore, SecureStoreError } from './secure-store'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Storage envelope persisted per-key in the backing KV. Versioned so the
|
|
5
|
+
* format can evolve without silently breaking older payloads.
|
|
6
|
+
*/
|
|
7
|
+
interface Envelope {
|
|
8
|
+
v: 1
|
|
9
|
+
salt: string // base64, per-key
|
|
10
|
+
nonce: string // base64, per-write
|
|
11
|
+
ciphertext: string // base64
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WebStorageLike {
|
|
15
|
+
getItem(key: string): string | null
|
|
16
|
+
setItem(key: string, value: string): void
|
|
17
|
+
removeItem(key: string): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WebEncryptedStoreOptions {
|
|
21
|
+
/** Namespace prefix applied to every key before storage. */
|
|
22
|
+
namespace?: string
|
|
23
|
+
/** Storage backend. Defaults to globalThis.localStorage when present. */
|
|
24
|
+
storage?: WebStorageLike
|
|
25
|
+
/** PBKDF2 iterations. Default 200k. */
|
|
26
|
+
iterations?: number
|
|
27
|
+
/** Web Crypto SubtleCrypto override (for tests). */
|
|
28
|
+
subtle?: SubtleCrypto
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const DEFAULT_ITERATIONS = 200_000
|
|
32
|
+
const SALT_BYTES = 16
|
|
33
|
+
const NONCE_BYTES = 12
|
|
34
|
+
|
|
35
|
+
function toB64(bytes: Uint8Array): string {
|
|
36
|
+
let s = ''
|
|
37
|
+
for (const b of bytes) s += String.fromCharCode(b)
|
|
38
|
+
return btoa(s)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function fromB64(s: string): Uint8Array {
|
|
42
|
+
const bin = atob(s)
|
|
43
|
+
const out = new Uint8Array(bin.length)
|
|
44
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// TS's DOM lib widens `Uint8Array#buffer` to `ArrayBuffer | SharedArrayBuffer`,
|
|
49
|
+
// which the Web Crypto signatures reject. Copy the view into a fresh
|
|
50
|
+
// ArrayBuffer so the cast is sound at runtime.
|
|
51
|
+
function bufOf(view: Uint8Array): ArrayBuffer {
|
|
52
|
+
const out = new ArrayBuffer(view.byteLength)
|
|
53
|
+
new Uint8Array(out).set(view)
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Browser-grade encrypted KV. A single instance is bound to one master
|
|
59
|
+
* password for its lifetime — to rotate, instantiate a fresh store.
|
|
60
|
+
*
|
|
61
|
+
* The password material itself is never persisted; only PBKDF2-derived
|
|
62
|
+
* AES-GCM keys are cached in memory per-salt for the lifetime of the
|
|
63
|
+
* instance.
|
|
64
|
+
*/
|
|
65
|
+
export class WebEncryptedStore implements EncryptedStore {
|
|
66
|
+
private readonly storage: WebStorageLike
|
|
67
|
+
private readonly namespace: string
|
|
68
|
+
private readonly iterations: number
|
|
69
|
+
private readonly subtle: SubtleCrypto
|
|
70
|
+
private readonly password: string
|
|
71
|
+
private readonly keyCache = new Map<string, CryptoKey>()
|
|
72
|
+
|
|
73
|
+
constructor(password: string, opts: WebEncryptedStoreOptions = {}) {
|
|
74
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
75
|
+
throw new SecureStoreError('LOCKED', 'master password required')
|
|
76
|
+
}
|
|
77
|
+
const storage = opts.storage ?? (typeof globalThis !== 'undefined' ? (globalThis as { localStorage?: WebStorageLike }).localStorage : undefined)
|
|
78
|
+
if (!storage) {
|
|
79
|
+
throw new SecureStoreError('BACKEND_FAILURE', 'no Storage backend available')
|
|
80
|
+
}
|
|
81
|
+
const subtle = opts.subtle ?? (typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto?.subtle : undefined)
|
|
82
|
+
if (!subtle) {
|
|
83
|
+
throw new SecureStoreError('BACKEND_FAILURE', 'Web Crypto SubtleCrypto unavailable')
|
|
84
|
+
}
|
|
85
|
+
this.password = password
|
|
86
|
+
this.storage = storage
|
|
87
|
+
this.namespace = opts.namespace ?? 'red-ui:secure'
|
|
88
|
+
this.iterations = opts.iterations ?? DEFAULT_ITERATIONS
|
|
89
|
+
this.subtle = subtle
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private storageKey(key: string): string {
|
|
93
|
+
return `${this.namespace}:${key}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
private async deriveKey(salt: Uint8Array): Promise<CryptoKey> {
|
|
97
|
+
const saltKey = toB64(salt)
|
|
98
|
+
const cached = this.keyCache.get(saltKey)
|
|
99
|
+
if (cached) return cached
|
|
100
|
+
const enc = new TextEncoder().encode(this.password)
|
|
101
|
+
const material = await this.subtle.importKey(
|
|
102
|
+
'raw',
|
|
103
|
+
bufOf(enc),
|
|
104
|
+
'PBKDF2',
|
|
105
|
+
false,
|
|
106
|
+
['deriveKey'],
|
|
107
|
+
)
|
|
108
|
+
const key = await this.subtle.deriveKey(
|
|
109
|
+
{
|
|
110
|
+
name: 'PBKDF2',
|
|
111
|
+
salt: bufOf(salt),
|
|
112
|
+
iterations: this.iterations,
|
|
113
|
+
hash: 'SHA-256',
|
|
114
|
+
},
|
|
115
|
+
material,
|
|
116
|
+
{ name: 'AES-GCM', length: 256 },
|
|
117
|
+
false,
|
|
118
|
+
['encrypt', 'decrypt'],
|
|
119
|
+
)
|
|
120
|
+
this.keyCache.set(saltKey, key)
|
|
121
|
+
return key
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private randomBytes(n: number): Uint8Array {
|
|
125
|
+
const out = new Uint8Array(n)
|
|
126
|
+
const c = (globalThis as { crypto?: Crypto }).crypto
|
|
127
|
+
if (!c) throw new SecureStoreError('BACKEND_FAILURE', 'crypto.getRandomValues unavailable')
|
|
128
|
+
c.getRandomValues(out)
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async put(key: string, value: string): Promise<void> {
|
|
133
|
+
const salt = this.randomBytes(SALT_BYTES)
|
|
134
|
+
const nonce = this.randomBytes(NONCE_BYTES)
|
|
135
|
+
const cryptoKey = await this.deriveKey(salt)
|
|
136
|
+
const plaintext = new TextEncoder().encode(value)
|
|
137
|
+
let ciphertext: ArrayBuffer
|
|
138
|
+
try {
|
|
139
|
+
ciphertext = await this.subtle.encrypt(
|
|
140
|
+
{ name: 'AES-GCM', iv: bufOf(nonce) },
|
|
141
|
+
cryptoKey,
|
|
142
|
+
bufOf(plaintext),
|
|
143
|
+
)
|
|
144
|
+
} catch (e) {
|
|
145
|
+
throw new SecureStoreError('BACKEND_FAILURE', `encrypt failed: ${(e as Error).message}`)
|
|
146
|
+
}
|
|
147
|
+
const env: Envelope = {
|
|
148
|
+
v: 1,
|
|
149
|
+
salt: toB64(salt),
|
|
150
|
+
nonce: toB64(nonce),
|
|
151
|
+
ciphertext: toB64(new Uint8Array(ciphertext)),
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
this.storage.setItem(this.storageKey(key), JSON.stringify(env))
|
|
155
|
+
} catch (e) {
|
|
156
|
+
throw new SecureStoreError('BACKEND_FAILURE', `storage write failed: ${(e as Error).message}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async get(key: string): Promise<string | null> {
|
|
161
|
+
const raw = this.storage.getItem(this.storageKey(key))
|
|
162
|
+
if (!raw) return null
|
|
163
|
+
let env: Envelope
|
|
164
|
+
try {
|
|
165
|
+
env = JSON.parse(raw) as Envelope
|
|
166
|
+
} catch {
|
|
167
|
+
throw new SecureStoreError('CORRUPT_ENVELOPE', `key ${key}: not valid JSON`)
|
|
168
|
+
}
|
|
169
|
+
if (!env || env.v !== 1 || typeof env.salt !== 'string' || typeof env.nonce !== 'string' || typeof env.ciphertext !== 'string') {
|
|
170
|
+
throw new SecureStoreError('CORRUPT_ENVELOPE', `key ${key}: missing envelope fields`)
|
|
171
|
+
}
|
|
172
|
+
let salt: Uint8Array, nonce: Uint8Array, ct: Uint8Array
|
|
173
|
+
try {
|
|
174
|
+
salt = fromB64(env.salt)
|
|
175
|
+
nonce = fromB64(env.nonce)
|
|
176
|
+
ct = fromB64(env.ciphertext)
|
|
177
|
+
} catch {
|
|
178
|
+
throw new SecureStoreError('CORRUPT_ENVELOPE', `key ${key}: base64 decode failed`)
|
|
179
|
+
}
|
|
180
|
+
const cryptoKey = await this.deriveKey(salt)
|
|
181
|
+
let pt: ArrayBuffer
|
|
182
|
+
try {
|
|
183
|
+
pt = await this.subtle.decrypt(
|
|
184
|
+
{ name: 'AES-GCM', iv: bufOf(nonce) },
|
|
185
|
+
cryptoKey,
|
|
186
|
+
bufOf(ct),
|
|
187
|
+
)
|
|
188
|
+
} catch {
|
|
189
|
+
throw new SecureStoreError('DECRYPT_FAILED', `key ${key}: decryption failed (wrong password or tampered payload)`)
|
|
190
|
+
}
|
|
191
|
+
return new TextDecoder().decode(pt)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async delete(key: string): Promise<void> {
|
|
195
|
+
try {
|
|
196
|
+
this.storage.removeItem(this.storageKey(key))
|
|
197
|
+
} catch (e) {
|
|
198
|
+
throw new SecureStoreError('BACKEND_FAILURE', `storage delete failed: ${(e as Error).message}`)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { QueryResult } from '#reddb'
|
|
3
|
+
import { Braces, GitCompareArrows, Search, X } from 'lucide-svelte'
|
|
4
|
+
import { diffEntryFields, extractDiffEntries, formatDiffValue, summarizeDiff, type DiffEntry } from './diff-render'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
result: QueryResult
|
|
8
|
+
collection?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { result, collection }: Props = $props()
|
|
12
|
+
|
|
13
|
+
let mode = $state<'summary' | 'raw'>('summary')
|
|
14
|
+
let filter = $state('')
|
|
15
|
+
let selected = $state<DiffEntry | null>(null)
|
|
16
|
+
let showUnchanged = $state(false)
|
|
17
|
+
|
|
18
|
+
const entries = $derived(extractDiffEntries(result))
|
|
19
|
+
const summary = $derived(summarizeDiff(entries))
|
|
20
|
+
const filtered = $derived.by(() => {
|
|
21
|
+
const q = filter.trim().toLowerCase()
|
|
22
|
+
if (!q) return entries
|
|
23
|
+
return entries.filter((entry) =>
|
|
24
|
+
entry.id.toLowerCase().includes(q) ||
|
|
25
|
+
entry.change.toLowerCase().includes(q) ||
|
|
26
|
+
(entry.collection ?? '').toLowerCase().includes(q) ||
|
|
27
|
+
(entry.entityId ?? '').toLowerCase().includes(q) ||
|
|
28
|
+
formatDiffValue(entry.before).toLowerCase().includes(q) ||
|
|
29
|
+
formatDiffValue(entry.after).toLowerCase().includes(q),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
const active = $derived(selected ?? filtered[0] ?? null)
|
|
33
|
+
const activeFields = $derived(active ? diffEntryFields(active) : [])
|
|
34
|
+
const visibleFields = $derived(showUnchanged ? activeFields : activeFields.filter((field) => field.status !== 'unchanged'))
|
|
35
|
+
|
|
36
|
+
function statusClass(status: string): string {
|
|
37
|
+
if (status === 'added') return 'text-ok'
|
|
38
|
+
if (status === 'removed') return 'text-danger'
|
|
39
|
+
if (status === 'modified') return 'text-accent'
|
|
40
|
+
return 'text-fg-3'
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<div class="flex h-full flex-col text-fg-1">
|
|
45
|
+
{#if entries.length === 0}
|
|
46
|
+
<div class="flex-1 grid place-items-center text-fg-3 text-[12px] font-mono p-6">
|
|
47
|
+
No diff shape in this result.
|
|
48
|
+
</div>
|
|
49
|
+
{:else}
|
|
50
|
+
<div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-1/40">
|
|
51
|
+
<div class="inline-flex border border-line-2 rounded overflow-hidden">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors', mode === 'summary' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
|
|
55
|
+
onclick={() => (mode = 'summary')}
|
|
56
|
+
aria-pressed={mode === 'summary'}
|
|
57
|
+
>
|
|
58
|
+
<GitCompareArrows class="size-3" />
|
|
59
|
+
diff
|
|
60
|
+
</button>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'raw' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
|
|
64
|
+
onclick={() => (mode = 'raw')}
|
|
65
|
+
aria-pressed={mode === 'raw'}
|
|
66
|
+
>
|
|
67
|
+
<Braces class="size-3" />
|
|
68
|
+
raw
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
|
|
73
|
+
<Search class="size-3 text-fg-3" />
|
|
74
|
+
<input type="text" bind:value={filter} placeholder="change, id, value…" class="bg-transparent text-fg-1 outline-none w-[190px] placeholder:text-fg-3" />
|
|
75
|
+
{#if filter}
|
|
76
|
+
<button type="button" onclick={() => (filter = '')} class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear diff filter">
|
|
77
|
+
<X class="size-3" />
|
|
78
|
+
</button>
|
|
79
|
+
{/if}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div class="ml-auto flex items-center gap-3 text-fg-3">
|
|
83
|
+
{#each summary as [change, count] (change)}
|
|
84
|
+
<span><span class="text-fg-2">{change}</span> {count}</span>
|
|
85
|
+
{/each}
|
|
86
|
+
{#if collection}<span class="text-fg-2">{collection}</span>{/if}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{#if mode === 'raw'}
|
|
91
|
+
<div class="flex-1 overflow-auto p-3 text-[12px] font-mono">
|
|
92
|
+
<pre class="whitespace-pre-wrap text-fg-1">{JSON.stringify(filtered.map((entry) => entry.values), null, 2)}</pre>
|
|
93
|
+
</div>
|
|
94
|
+
{:else}
|
|
95
|
+
<div class="flex-1 min-h-0 grid grid-cols-[minmax(320px,0.9fr)_1.1fr]">
|
|
96
|
+
<div class="overflow-auto border-r border-line-1 text-[12px] font-mono">
|
|
97
|
+
{#each filtered as entry (entry.id)}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onclick={() => (selected = entry)}
|
|
101
|
+
class={['w-full text-left px-3 py-2 border-b border-line-1/60 hover:bg-bg-1/50 cursor-pointer', active === entry ? 'bg-bg-1 text-fg-0' : 'text-fg-2'].join(' ')}
|
|
102
|
+
>
|
|
103
|
+
<div class="flex items-center gap-2">
|
|
104
|
+
<span class="text-accent">{entry.change}</span>
|
|
105
|
+
<span class="text-fg-3 truncate">{entry.entityId ?? entry.id}</span>
|
|
106
|
+
{#if entry.collection}<span class="ml-auto text-fg-3">{entry.collection}</span>{/if}
|
|
107
|
+
</div>
|
|
108
|
+
{#if entry.note}
|
|
109
|
+
<div class="mt-1 truncate text-fg-1">{entry.note}</div>
|
|
110
|
+
{/if}
|
|
111
|
+
</button>
|
|
112
|
+
{/each}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div class="overflow-auto p-3 text-[12px] font-mono">
|
|
116
|
+
{#if active}
|
|
117
|
+
<div class="mb-3 grid grid-cols-[100px_1fr] gap-x-3 gap-y-1">
|
|
118
|
+
<span class="text-fg-3">change</span><span class="text-accent">{active.change}</span>
|
|
119
|
+
<span class="text-fg-3">entity</span><span class="text-fg-0">{active.entityId ?? active.id}</span>
|
|
120
|
+
<span class="text-fg-3">collection</span><span class="text-fg-0">{active.collection ?? '-'}</span>
|
|
121
|
+
{#if active.note}
|
|
122
|
+
<span class="text-fg-3">note</span><span class="text-fg-1">{active.note}</span>
|
|
123
|
+
{/if}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{#if activeFields.length > 0}
|
|
127
|
+
<div class="mb-3 border border-line-1">
|
|
128
|
+
<div class="flex items-center gap-2 border-b border-line-1 bg-bg-1 px-2 py-1.5">
|
|
129
|
+
<span class="text-fg-0">field diff</span>
|
|
130
|
+
<span class="text-fg-3">{visibleFields.length}/{activeFields.length} fields</span>
|
|
131
|
+
<label class="ml-auto inline-flex items-center gap-1 text-fg-3">
|
|
132
|
+
<input type="checkbox" bind:checked={showUnchanged} class="size-3 accent-current" />
|
|
133
|
+
unchanged
|
|
134
|
+
</label>
|
|
135
|
+
</div>
|
|
136
|
+
{#if visibleFields.length === 0}
|
|
137
|
+
<div class="px-2 py-2 text-fg-3">No changed fields in this row.</div>
|
|
138
|
+
{:else}
|
|
139
|
+
<table class="w-full border-collapse">
|
|
140
|
+
<thead class="bg-bg-0 text-fg-3">
|
|
141
|
+
<tr class="border-b border-line-1">
|
|
142
|
+
<th class="w-[110px] px-2 py-1 text-left font-normal">status</th>
|
|
143
|
+
<th class="w-[150px] px-2 py-1 text-left font-normal">field</th>
|
|
144
|
+
<th class="px-2 py-1 text-left font-normal">before</th>
|
|
145
|
+
<th class="px-2 py-1 text-left font-normal">after</th>
|
|
146
|
+
</tr>
|
|
147
|
+
</thead>
|
|
148
|
+
<tbody>
|
|
149
|
+
{#each visibleFields as field (field.field)}
|
|
150
|
+
<tr class="border-b border-line-1/60 last:border-b-0">
|
|
151
|
+
<td class={['px-2 py-1 align-top', statusClass(field.status)].join(' ')}>{field.status}</td>
|
|
152
|
+
<td class="px-2 py-1 align-top text-fg-0">{field.field}</td>
|
|
153
|
+
<td class="max-w-[260px] px-2 py-1 align-top text-fg-2">
|
|
154
|
+
<pre class="whitespace-pre-wrap break-words">{formatDiffValue(field.before)}</pre>
|
|
155
|
+
</td>
|
|
156
|
+
<td class="max-w-[260px] px-2 py-1 align-top text-fg-2">
|
|
157
|
+
<pre class="whitespace-pre-wrap break-words">{formatDiffValue(field.after)}</pre>
|
|
158
|
+
</td>
|
|
159
|
+
</tr>
|
|
160
|
+
{/each}
|
|
161
|
+
</tbody>
|
|
162
|
+
</table>
|
|
163
|
+
{/if}
|
|
164
|
+
</div>
|
|
165
|
+
{/if}
|
|
166
|
+
|
|
167
|
+
<div class="grid grid-cols-2 gap-3">
|
|
168
|
+
<section class="min-w-0">
|
|
169
|
+
<div class="type-label mb-1">full before</div>
|
|
170
|
+
<pre class="min-h-[160px] whitespace-pre-wrap break-words rounded border border-line-1 bg-bg-1 p-2 text-fg-1">{formatDiffValue(active.before)}</pre>
|
|
171
|
+
</section>
|
|
172
|
+
<section class="min-w-0">
|
|
173
|
+
<div class="type-label mb-1">full after</div>
|
|
174
|
+
<pre class="min-h-[160px] whitespace-pre-wrap break-words rounded border border-line-1 bg-bg-1 p-2 text-fg-1">{formatDiffValue(active.after)}</pre>
|
|
175
|
+
</section>
|
|
176
|
+
</div>
|
|
177
|
+
{:else}
|
|
178
|
+
<div class="text-fg-3">No diff entry selected.</div>
|
|
179
|
+
{/if}
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
{/if}
|
|
183
|
+
{/if}
|
|
184
|
+
</div>
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { QueryResult } from '#reddb'
|
|
3
|
+
import { Braces, FileText, Search, X } from 'lucide-svelte'
|
|
4
|
+
import { extractDocuments, type DocumentItem } from './document-render'
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
result: QueryResult
|
|
8
|
+
collection?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { result, collection }: Props = $props()
|
|
12
|
+
|
|
13
|
+
let mode = $state<'body' | 'fields' | 'raw'>('body')
|
|
14
|
+
let filter = $state('')
|
|
15
|
+
let selected = $state<DocumentItem | null>(null)
|
|
16
|
+
|
|
17
|
+
const docs = $derived(extractDocuments(result))
|
|
18
|
+
const filtered = $derived.by(() => {
|
|
19
|
+
const q = filter.trim().toLowerCase()
|
|
20
|
+
if (!q) return docs
|
|
21
|
+
return docs.filter((doc) =>
|
|
22
|
+
doc.title.toLowerCase().includes(q) ||
|
|
23
|
+
doc.subtitle.toLowerCase().includes(q) ||
|
|
24
|
+
JSON.stringify(doc.body).toLowerCase().includes(q),
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
const active = $derived(selected && filtered.includes(selected) ? selected : filtered[0] ?? null)
|
|
28
|
+
|
|
29
|
+
function pretty(v: unknown): string {
|
|
30
|
+
return JSON.stringify(v ?? null, null, 2)
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="flex h-full flex-col text-fg-1">
|
|
35
|
+
{#if docs.length === 0}
|
|
36
|
+
<div class="flex-1 grid place-items-center text-fg-3 text-[12px] font-mono p-6">
|
|
37
|
+
No document shape in this result.
|
|
38
|
+
</div>
|
|
39
|
+
{:else}
|
|
40
|
+
<div class="border-b border-line-1 px-3 py-1.5 flex items-center gap-2 text-[11px] font-mono bg-bg-1/40">
|
|
41
|
+
<div class="inline-flex border border-line-2 rounded overflow-hidden">
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors', mode === 'body' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
|
|
45
|
+
onclick={() => (mode = 'body')}
|
|
46
|
+
aria-pressed={mode === 'body'}
|
|
47
|
+
>
|
|
48
|
+
<Braces class="size-3" />
|
|
49
|
+
body
|
|
50
|
+
</button>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'fields' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
|
|
54
|
+
onclick={() => (mode = 'fields')}
|
|
55
|
+
aria-pressed={mode === 'fields'}
|
|
56
|
+
>
|
|
57
|
+
<FileText class="size-3" />
|
|
58
|
+
fields
|
|
59
|
+
</button>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
class={['inline-flex items-center gap-1 px-2 py-1 cursor-pointer transition-colors border-l border-line-2', mode === 'raw' ? 'bg-accent text-white' : 'bg-bg-1 text-fg-2 hover:text-fg-0'].join(' ')}
|
|
63
|
+
onclick={() => (mode = 'raw')}
|
|
64
|
+
aria-pressed={mode === 'raw'}
|
|
65
|
+
>
|
|
66
|
+
<Braces class="size-3" />
|
|
67
|
+
raw
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="inline-flex items-center gap-1.5 border border-line-1 rounded px-2 py-1 bg-bg-0">
|
|
72
|
+
<Search class="size-3 text-fg-3" />
|
|
73
|
+
<input type="text" bind:value={filter} placeholder="document search…" class="bg-transparent text-fg-1 outline-none w-[200px] placeholder:text-fg-3" />
|
|
74
|
+
{#if filter}
|
|
75
|
+
<button type="button" onclick={() => (filter = '')} class="text-fg-3 hover:text-fg-1 cursor-pointer" aria-label="Clear document filter">
|
|
76
|
+
<X class="size-3" />
|
|
77
|
+
</button>
|
|
78
|
+
{/if}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="ml-auto text-fg-3">
|
|
82
|
+
{filtered.length.toLocaleString()}{#if filter} of {docs.length.toLocaleString()}{/if} documents
|
|
83
|
+
{#if collection}· <span class="text-fg-2">{collection}</span>{/if}
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="flex-1 min-h-0 grid grid-cols-[minmax(280px,0.75fr)_1.25fr]">
|
|
88
|
+
<div class="overflow-auto border-r border-line-1 text-[12px] font-mono">
|
|
89
|
+
{#each filtered as doc (doc.rid)}
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onclick={() => (selected = doc)}
|
|
93
|
+
class={['w-full text-left px-3 py-2 border-b border-line-1/60 hover:bg-bg-1/50 cursor-pointer', active === doc ? 'bg-bg-1 text-fg-0' : 'text-fg-2'].join(' ')}
|
|
94
|
+
>
|
|
95
|
+
<div class="flex items-center gap-2">
|
|
96
|
+
<span class="text-accent truncate">{doc.title}</span>
|
|
97
|
+
<span class="ml-auto text-fg-3">#{doc.rid}</span>
|
|
98
|
+
</div>
|
|
99
|
+
{#if doc.subtitle}
|
|
100
|
+
<div class="mt-1 truncate text-fg-3">{doc.subtitle}</div>
|
|
101
|
+
{/if}
|
|
102
|
+
</button>
|
|
103
|
+
{/each}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div class="min-h-0 overflow-auto p-3 text-[12px] font-mono">
|
|
107
|
+
{#if active}
|
|
108
|
+
{#if mode === 'body'}
|
|
109
|
+
<pre class="m-0 whitespace-pre-wrap text-fg-1">{pretty(active.body)}</pre>
|
|
110
|
+
{:else if mode === 'fields'}
|
|
111
|
+
<table class="w-full border-collapse">
|
|
112
|
+
<tbody>
|
|
113
|
+
{#each Object.entries(active.fields) as [key, value] (key)}
|
|
114
|
+
<tr class="border-b border-line-1/60">
|
|
115
|
+
<td class="w-[180px] px-2 py-1 text-fg-3 align-top">{key}</td>
|
|
116
|
+
<td class="px-2 py-1 text-fg-1 align-top"><pre class="m-0 whitespace-pre-wrap">{pretty(value)}</pre></td>
|
|
117
|
+
</tr>
|
|
118
|
+
{/each}
|
|
119
|
+
</tbody>
|
|
120
|
+
</table>
|
|
121
|
+
{:else}
|
|
122
|
+
<pre class="m-0 whitespace-pre-wrap text-fg-1">{pretty(active.values)}</pre>
|
|
123
|
+
{/if}
|
|
124
|
+
{:else}
|
|
125
|
+
<div class="text-fg-3">No documents match the current filter.</div>
|
|
126
|
+
{/if}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div class="border-t border-line-1 px-3 py-1.5 text-[10px] font-mono text-fg-3">
|
|
131
|
+
{docs.length} documents{collection ? ' · ' : ''}{collection ?? ''}
|
|
132
|
+
</div>
|
|
133
|
+
{/if}
|
|
134
|
+
</div>
|