@polderlabs/bizar 4.7.2 → 4.9.0
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/bizar-dash/dist/assets/index-DU61awG3.js +9 -0
- package/bizar-dash/dist/assets/index-DU61awG3.js.map +1 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js +366 -0
- package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DX_Jh8Wc.css → main-DfmIfOUS.css} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js → mobile-CL5uUQEC.js} +1 -1
- package/bizar-dash/dist/assets/{mobile-Chvf9u_B.js.map → mobile-CL5uUQEC.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +338 -0
- package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/memory-lightrag.mjs +109 -0
- package/bizar-dash/src/server/memory-store.mjs +121 -0
- package/bizar-dash/src/server/otel.mjs +133 -0
- package/bizar-dash/src/server/routes/chat.mjs +246 -170
- package/bizar-dash/src/server/routes/memory.mjs +46 -0
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +82 -48
- package/bizar-dash/src/server/server.mjs +40 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +204 -89
- package/bizar-dash/src/web/lib/search.ts +115 -0
- package/bizar-dash/src/web/mobile/views/MobileSettings.tsx +10 -35
- package/bizar-dash/src/web/mobile/views/QrCodePanel.tsx +69 -0
- package/bizar-dash/src/web/styles/memory.css +84 -1
- package/bizar-dash/src/web/styles/settings.css +80 -0
- package/bizar-dash/src/web/views/Memory.tsx +6 -1
- package/bizar-dash/src/web/views/Settings.tsx +96 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphLegend.tsx +29 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphPanel.tsx +192 -0
- package/bizar-dash/src/web/views/memory/MemoryGraphView.tsx +336 -0
- package/bizar-dash/tests/backup-restore.test.tsx +35 -17
- package/bizar-dash/tests/bundle-analysis.test.mjs +70 -0
- package/bizar-dash/tests/components/settings-search.test.tsx +180 -0
- package/bizar-dash/tests/docker-build.test.mjs +96 -0
- package/bizar-dash/tests/lib/search-fuzzy.test.ts +149 -0
- package/bizar-dash/tests/memory-graph-view.test.tsx +69 -0
- package/bizar-dash/tests/memory-graph.test.mjs +95 -0
- package/bizar-dash/tests/otel.test.mjs +188 -0
- package/cli/commands/dash.mjs +6 -0
- package/package.json +7 -1
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js +0 -361
- package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js +0 -351
- package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +0 -1
|
@@ -41,6 +41,7 @@ import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
|
|
|
41
41
|
import { createV2Router } from './routes-v2/index.mjs';
|
|
42
42
|
import { counter, gauge, render as renderMetrics } from './metrics.mjs';
|
|
43
43
|
import { warn } from './logger.mjs';
|
|
44
|
+
import { initOtel, shutdownOtel } from './otel.mjs';
|
|
44
45
|
|
|
45
46
|
// v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
|
|
46
47
|
// registry; render() emits the text exposition format consumed by
|
|
@@ -57,6 +58,7 @@ const wsClientsGauge = gauge(
|
|
|
57
58
|
);
|
|
58
59
|
|
|
59
60
|
let processHandlersInstalled = false;
|
|
61
|
+
let otelSigtermInstalled = false;
|
|
60
62
|
let v2Bus = null;
|
|
61
63
|
let v2Auth = null;
|
|
62
64
|
|
|
@@ -150,6 +152,44 @@ export async function createServer({
|
|
|
150
152
|
bizarRoot,
|
|
151
153
|
}) {
|
|
152
154
|
installProcessHandlers();
|
|
155
|
+
// v4.9.0 — Initialise OpenTelemetry first so every subsequent handler
|
|
156
|
+
// (auth, rate-limit, routes) executes inside the SDK's lifecycle.
|
|
157
|
+
// Off by default; opt in via BIZAR_OTEL=1 / OTEL_ENABLED=1. The SDK
|
|
158
|
+
// itself never throws — see otel.mjs for the graceful-degradation
|
|
159
|
+
// contract — so this call is always safe to make.
|
|
160
|
+
try {
|
|
161
|
+
if (
|
|
162
|
+
process.env.OTEL_ENABLED === '1' ||
|
|
163
|
+
process.env.BIZAR_OTEL === '1'
|
|
164
|
+
) {
|
|
165
|
+
initOtel();
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
/* never block startup on OTel */
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// v4.9.0 — SIGTERM handler that flushes pending spans before exit.
|
|
172
|
+
// Installed exactly once per process (mirrors `installProcessHandlers`).
|
|
173
|
+
if (!otelSigtermInstalled) {
|
|
174
|
+
otelSigtermInstalled = true;
|
|
175
|
+
const flushAndExit = async (signal) => {
|
|
176
|
+
try {
|
|
177
|
+
await shutdownOtel();
|
|
178
|
+
} finally {
|
|
179
|
+
// Re-raise the default action so kill -TERM / SIGINT still
|
|
180
|
+
// shut the rest of the server down. We do NOT process.exit(0)
|
|
181
|
+
// hard because Express needs to drain pending connections too,
|
|
182
|
+
// but a clean exit is the operator's job — we only guarantee
|
|
183
|
+
// spans are flushed.
|
|
184
|
+
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
|
185
|
+
// Allow Node's default SIGTERM behaviour to terminate the
|
|
186
|
+
// process; the small async overlap here is intentional.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
process.on('SIGTERM', () => { void flushAndExit('SIGTERM'); });
|
|
191
|
+
process.on('SIGINT', () => { void flushAndExit('SIGINT'); });
|
|
192
|
+
}
|
|
153
193
|
const app = express();
|
|
154
194
|
app.disable('x-powered-by');
|
|
155
195
|
// v3.5.4 (CORS) — Reflect the request Origin back as
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight
|
|
1
|
+
// src/web/components/SettingsSearch.tsx — fuzzy search across Settings sections with scroll-to highlight.
|
|
2
|
+
// v4.9: replaces Fuse.js with Levenshtein-based fuzzySearch; adds recent searches + match highlighting.
|
|
2
3
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
4
|
import { Search, X } from 'lucide-react';
|
|
4
|
-
import Fuse from 'fuse.js';
|
|
5
5
|
import { cn } from '../lib/utils';
|
|
6
|
+
import { fuzzySearch, type SearchableItem, type SearchResult } from '../lib/search';
|
|
6
7
|
|
|
7
8
|
export type SettingsSection = {
|
|
8
9
|
id: string;
|
|
@@ -23,85 +24,106 @@ type Props = {
|
|
|
23
24
|
onJump: (sectionId: string, fieldKey?: string) => void;
|
|
24
25
|
};
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
item: SettingsField | SettingsSection;
|
|
28
|
-
score: number;
|
|
29
|
-
type: 'section' | 'field';
|
|
30
|
-
sectionId: string;
|
|
31
|
-
fieldKey?: string;
|
|
32
|
-
};
|
|
27
|
+
/* ─── Recent searches (localStorage) ─── */
|
|
33
28
|
|
|
34
|
-
|
|
35
|
-
if (!query.trim()) return [];
|
|
29
|
+
const RECENT_KEY = 'bizar_settings_recent';
|
|
36
30
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
31
|
+
function getRecentSearches(): string[] {
|
|
32
|
+
try {
|
|
33
|
+
const stored = localStorage.getItem(RECENT_KEY);
|
|
34
|
+
return stored ? JSON.parse(stored) : [];
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
40
|
+
function saveRecentSearch(query: string) {
|
|
41
|
+
if (!query) return;
|
|
42
|
+
try {
|
|
43
|
+
const recent = getRecentSearches().filter((q) => q !== query);
|
|
44
|
+
recent.unshift(query);
|
|
45
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(recent.slice(0, 5)));
|
|
46
|
+
} catch {
|
|
47
|
+
// localStorage may be full or disabled — silently ignore
|
|
48
|
+
}
|
|
49
|
+
}
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
/* ─── Highlight matched terms ─── */
|
|
49
52
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
function highlightMatch(text: string, query: string): React.ReactNode {
|
|
54
|
+
if (!query) return text;
|
|
55
|
+
// Escape regex special characters in the user query
|
|
56
|
+
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
57
|
+
const regex = new RegExp(`(${escaped})`, 'gi');
|
|
58
|
+
const parts = text.split(regex);
|
|
59
|
+
return parts.map((part, i) =>
|
|
60
|
+
regex.test(part) ? <mark key={i}>{part}</mark> : <span key={i}>{part}</span>,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ─── Build SearchableItems from sections ─── */
|
|
58
65
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
+
function toSearchableItems(sections: SettingsSection[]): SearchableItem[] {
|
|
67
|
+
const items: SearchableItem[] = [];
|
|
68
|
+
for (const section of sections) {
|
|
69
|
+
// Section itself is searchable
|
|
70
|
+
items.push({
|
|
71
|
+
key: section.id,
|
|
72
|
+
label: section.label,
|
|
73
|
+
section: section.id,
|
|
74
|
+
description: `${section.fields.length} field${section.fields.length === 1 ? '' : 's'}`,
|
|
66
75
|
});
|
|
76
|
+
// Each field is searchable
|
|
77
|
+
for (const field of section.fields) {
|
|
78
|
+
items.push({
|
|
79
|
+
key: field.key,
|
|
80
|
+
label: field.label,
|
|
81
|
+
section: section.id,
|
|
82
|
+
value: field.value,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
67
85
|
}
|
|
68
|
-
|
|
69
|
-
// Deduplicate by sectionId+fieldKey, prefer lower score
|
|
70
|
-
const seen = new Set<string>();
|
|
71
|
-
return results
|
|
72
|
-
.filter((r) => {
|
|
73
|
-
const k = `${r.sectionId}::${r.fieldKey ?? ''}`;
|
|
74
|
-
if (seen.has(k)) return false;
|
|
75
|
-
seen.add(k);
|
|
76
|
-
return true;
|
|
77
|
-
})
|
|
78
|
-
.sort((a, b) => a.score - b.score)
|
|
79
|
-
.slice(0, 8);
|
|
86
|
+
return items;
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
/* ─── Component ─── */
|
|
90
|
+
|
|
82
91
|
export function SettingsSearch({ sections, onJump }: Props) {
|
|
83
92
|
const [q, setQ] = useState('');
|
|
84
|
-
const [matches, setMatches] = useState<
|
|
93
|
+
const [matches, setMatches] = useState<SearchResult[]>([]);
|
|
85
94
|
const [activeIdx, setActiveIdx] = useState(0);
|
|
86
95
|
const [highlighted, setHighlighted] = useState<string | null>(null);
|
|
96
|
+
const [focused, setFocused] = useState(false);
|
|
87
97
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
88
98
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
89
99
|
const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
90
100
|
|
|
101
|
+
const allItems = useRef<SearchableItem[]>([]);
|
|
102
|
+
// Rebuild items when sections change
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
allItems.current = toSearchableItems(sections);
|
|
105
|
+
}, [sections]);
|
|
106
|
+
|
|
91
107
|
const search = useCallback(
|
|
92
|
-
(query: string) => {
|
|
108
|
+
(query: string, immediate = false) => {
|
|
93
109
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
94
110
|
if (!query.trim()) {
|
|
95
111
|
setMatches([]);
|
|
96
112
|
setActiveIdx(0);
|
|
97
113
|
return;
|
|
98
114
|
}
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
const run = () => {
|
|
116
|
+
const results = fuzzySearch(query, allItems.current);
|
|
117
|
+
setMatches(results);
|
|
101
118
|
setActiveIdx(0);
|
|
102
|
-
}
|
|
119
|
+
};
|
|
120
|
+
if (immediate) {
|
|
121
|
+
run();
|
|
122
|
+
} else {
|
|
123
|
+
debounceRef.current = setTimeout(run, 150);
|
|
124
|
+
}
|
|
103
125
|
},
|
|
104
|
-
[
|
|
126
|
+
[], // stable — allItems is a ref
|
|
105
127
|
);
|
|
106
128
|
|
|
107
129
|
useEffect(() => {
|
|
@@ -116,6 +138,25 @@ export function SettingsSearch({ sections, onJump }: Props) {
|
|
|
116
138
|
};
|
|
117
139
|
}, []);
|
|
118
140
|
|
|
141
|
+
/* ─── Quick-jump with flash ─── */
|
|
142
|
+
|
|
143
|
+
const jumpToSection = (sectionId: string, key: string) => {
|
|
144
|
+
// Look for the field by data-setting-id within the section
|
|
145
|
+
const fieldEl = document.querySelector(
|
|
146
|
+
`[data-section="${sectionId}"] [data-setting-id="${key}"]`,
|
|
147
|
+
);
|
|
148
|
+
if (fieldEl) {
|
|
149
|
+
fieldEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
150
|
+
fieldEl.classList.add('setting-flash');
|
|
151
|
+
setTimeout(() => fieldEl.classList.remove('setting-flash'), 2000);
|
|
152
|
+
}
|
|
153
|
+
// Also scroll the section Card into view
|
|
154
|
+
const sectionEl = document.querySelector(`[data-section="${sectionId}"]`);
|
|
155
|
+
if (sectionEl) {
|
|
156
|
+
sectionEl.scrollIntoView({ behavior: 'smooth', block: fieldEl ? 'nearest' : 'start' });
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
119
160
|
const highlightSection = (sectionId: string) => {
|
|
120
161
|
setHighlighted(sectionId);
|
|
121
162
|
if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current);
|
|
@@ -124,6 +165,24 @@ export function SettingsSearch({ sections, onJump }: Props) {
|
|
|
124
165
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
125
166
|
};
|
|
126
167
|
|
|
168
|
+
const commitSelection = (result: SearchResult) => {
|
|
169
|
+
saveRecentSearch(q);
|
|
170
|
+
if (result.key === result.section) {
|
|
171
|
+
// It's a section match — jump to section
|
|
172
|
+
highlightSection(result.section);
|
|
173
|
+
onJump(result.section);
|
|
174
|
+
} else {
|
|
175
|
+
// It's a field match — jump to field with flash
|
|
176
|
+
highlightSection(result.section);
|
|
177
|
+
onJump(result.section, result.key);
|
|
178
|
+
jumpToSection(result.section, result.key);
|
|
179
|
+
}
|
|
180
|
+
setQ('');
|
|
181
|
+
inputRef.current?.blur();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/* ─── Keyboard navigation ─── */
|
|
185
|
+
|
|
127
186
|
const handleKey = (e: React.KeyboardEvent) => {
|
|
128
187
|
if (e.key === 'Escape') {
|
|
129
188
|
setQ('');
|
|
@@ -134,20 +193,45 @@ export function SettingsSearch({ sections, onJump }: Props) {
|
|
|
134
193
|
} else if (e.key === 'ArrowUp') {
|
|
135
194
|
e.preventDefault();
|
|
136
195
|
setActiveIdx((i) => Math.max(i - 1, 0));
|
|
137
|
-
} else if (e.key === 'Enter'
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
196
|
+
} else if (e.key === 'Enter') {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
// If debounce hasn't fired yet, search immediately
|
|
199
|
+
if (matches.length === 0 && q.trim()) {
|
|
200
|
+
search(q, true);
|
|
201
|
+
}
|
|
202
|
+
if (matches[activeIdx]) {
|
|
203
|
+
commitSelection(matches[activeIdx]);
|
|
204
|
+
}
|
|
142
205
|
}
|
|
143
206
|
};
|
|
144
207
|
|
|
145
|
-
const handleSelect = (m:
|
|
146
|
-
|
|
147
|
-
onJump(m.sectionId, m.fieldKey);
|
|
148
|
-
setQ('');
|
|
208
|
+
const handleSelect = (m: SearchResult) => {
|
|
209
|
+
commitSelection(m);
|
|
149
210
|
};
|
|
150
211
|
|
|
212
|
+
/* ─── Recent searches helpers ─── */
|
|
213
|
+
|
|
214
|
+
const recent = getRecentSearches();
|
|
215
|
+
|
|
216
|
+
const handleRecentClick = (term: string) => {
|
|
217
|
+
setQ(term);
|
|
218
|
+
inputRef.current?.focus();
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const clearRecent = () => {
|
|
222
|
+
try {
|
|
223
|
+
localStorage.removeItem(RECENT_KEY);
|
|
224
|
+
} catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
// Force re-render
|
|
228
|
+
setFocused((f) => f);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/* ─── Render ─── */
|
|
232
|
+
|
|
233
|
+
const showRecent = focused && !q.trim() && recent.length > 0;
|
|
234
|
+
|
|
151
235
|
return (
|
|
152
236
|
<div className="settings-search">
|
|
153
237
|
<div className="settings-search-input-wrap">
|
|
@@ -159,6 +243,8 @@ export function SettingsSearch({ sections, onJump }: Props) {
|
|
|
159
243
|
value={q}
|
|
160
244
|
onChange={(e) => setQ(e.target.value)}
|
|
161
245
|
onKeyDown={handleKey}
|
|
246
|
+
onFocus={() => setFocused(true)}
|
|
247
|
+
onBlur={() => setTimeout(() => setFocused(false), 150)}
|
|
162
248
|
aria-label="Search settings"
|
|
163
249
|
/>
|
|
164
250
|
{q && (
|
|
@@ -173,35 +259,64 @@ export function SettingsSearch({ sections, onJump }: Props) {
|
|
|
173
259
|
)}
|
|
174
260
|
</div>
|
|
175
261
|
|
|
262
|
+
{/* Recent searches dropdown */}
|
|
263
|
+
{showRecent && (
|
|
264
|
+
<div className="settings-search-recent">
|
|
265
|
+
<div className="settings-search-recent-header">
|
|
266
|
+
<span className="settings-search-recent-label">Recent</span>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
className="settings-search-recent-clear"
|
|
270
|
+
onClick={clearRecent}
|
|
271
|
+
tabIndex={-1}
|
|
272
|
+
>
|
|
273
|
+
Clear
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
{recent.map((term) => (
|
|
277
|
+
<button
|
|
278
|
+
key={term}
|
|
279
|
+
type="button"
|
|
280
|
+
className="settings-search-recent-item"
|
|
281
|
+
onMouseDown={(e) => {
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
handleRecentClick(term);
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<Search size={11} />
|
|
287
|
+
<span>{term}</span>
|
|
288
|
+
</button>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
|
|
293
|
+
{/* Search results */}
|
|
176
294
|
{q && matches.length > 0 && (
|
|
177
295
|
<div className="settings-search-results">
|
|
178
|
-
{matches.map((m, idx) =>
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
<
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
onClick={() => handleSelect(m)}
|
|
191
|
-
onMouseEnter={() => setActiveIdx(idx)}
|
|
192
|
-
>
|
|
193
|
-
<span className={cn('settings-search-result-type', m.type === 'section' && 'settings-search-result-type-section')}>
|
|
194
|
-
{m.type === 'section' ? 'section' : 'field'}
|
|
195
|
-
</span>
|
|
196
|
-
<span className="settings-search-result-label">
|
|
197
|
-
{label}
|
|
198
|
-
</span>
|
|
199
|
-
{m.type === 'field' && (
|
|
200
|
-
<span className="settings-search-result-key mono muted">{key}</span>
|
|
296
|
+
{matches.map((m, idx) => (
|
|
297
|
+
<button
|
|
298
|
+
key={`${m.section}::${m.key}`}
|
|
299
|
+
type="button"
|
|
300
|
+
className={cn('settings-search-result', idx === activeIdx && 'settings-search-result-active')}
|
|
301
|
+
onClick={() => handleSelect(m)}
|
|
302
|
+
onMouseEnter={() => setActiveIdx(idx)}
|
|
303
|
+
>
|
|
304
|
+
<span
|
|
305
|
+
className={cn(
|
|
306
|
+
'settings-search-result-type',
|
|
307
|
+
m.key === m.section && 'settings-search-result-type-section',
|
|
201
308
|
)}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
309
|
+
>
|
|
310
|
+
{m.key === m.section ? 'section' : 'field'}
|
|
311
|
+
</span>
|
|
312
|
+
<span className="settings-search-result-label">
|
|
313
|
+
{highlightMatch(m.label, q)}
|
|
314
|
+
</span>
|
|
315
|
+
{m.key !== m.section && (
|
|
316
|
+
<span className="settings-search-result-key mono muted">{m.key}</span>
|
|
317
|
+
)}
|
|
318
|
+
</button>
|
|
319
|
+
))}
|
|
205
320
|
</div>
|
|
206
321
|
)}
|
|
207
322
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/web/lib/search.ts — fuzzy search with typo tolerance (Levenshtein distance).
|
|
2
|
+
// Provides SearchableItem/SearchResult types and a fuzzySearch function for
|
|
3
|
+
// settings, commands, and any structured UI search.
|
|
4
|
+
|
|
5
|
+
export interface SearchableItem {
|
|
6
|
+
key: string;
|
|
7
|
+
label: string;
|
|
8
|
+
section: string;
|
|
9
|
+
value?: string | number | boolean;
|
|
10
|
+
description?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface SearchResult extends SearchableItem {
|
|
14
|
+
score: number;
|
|
15
|
+
matchedField: 'label' | 'value' | 'description';
|
|
16
|
+
matchedTerm: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Levenshtein distance for typo tolerance.
|
|
21
|
+
*/
|
|
22
|
+
export function levenshtein(a: string, b: string): number {
|
|
23
|
+
const alen = a.length;
|
|
24
|
+
const blen = b.length;
|
|
25
|
+
const matrix: number[][] = Array.from({ length: blen + 1 }, () => Array(alen + 1).fill(0));
|
|
26
|
+
for (let i = 0; i <= alen; i++) matrix[0][i] = i;
|
|
27
|
+
for (let j = 0; j <= blen; j++) matrix[j][0] = j;
|
|
28
|
+
for (let j = 1; j <= blen; j++) {
|
|
29
|
+
for (let i = 1; i <= alen; i++) {
|
|
30
|
+
const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
31
|
+
matrix[j][i] = Math.min(
|
|
32
|
+
matrix[j][i - 1] + 1,
|
|
33
|
+
matrix[j - 1][i] + 1,
|
|
34
|
+
matrix[j - 1][i - 1] + indicator,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return matrix[blen][alen];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Score a single field against the query.
|
|
43
|
+
* Returns a numeric score (higher = better match) or 0 if no match.
|
|
44
|
+
*
|
|
45
|
+
* Scoring tiers (mutually exclusive — first match wins):
|
|
46
|
+
* 100 — exact field match (query === field)
|
|
47
|
+
* 90 — field starts with query (prefix match)
|
|
48
|
+
* 80 — a word in the field starts with query (word boundary)
|
|
49
|
+
* 40 — typo-tolerant match (Levenshtein ≤ 2 against the first query.length chars)
|
|
50
|
+
* 30 — substring match anywhere in field (fallback)
|
|
51
|
+
*/
|
|
52
|
+
export function scoreField(query: string, field: string): number {
|
|
53
|
+
// Exact match
|
|
54
|
+
if (field === query) return 100;
|
|
55
|
+
|
|
56
|
+
// Field prefix match (field starts with query)
|
|
57
|
+
if (field.startsWith(query)) return 90;
|
|
58
|
+
|
|
59
|
+
// Word boundary match (any word starts with query)
|
|
60
|
+
const words = field.split(/\s+/);
|
|
61
|
+
for (const word of words) {
|
|
62
|
+
if (word.startsWith(query)) return 80;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Typo tolerance: compare query against first query.length chars of field
|
|
66
|
+
const compareLen = Math.min(query.length, field.length);
|
|
67
|
+
const dist = levenshtein(query, field.substring(0, compareLen));
|
|
68
|
+
if (dist <= 2) return 40;
|
|
69
|
+
|
|
70
|
+
// Substring match (anywhere)
|
|
71
|
+
if (field.includes(query)) return 30;
|
|
72
|
+
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fuzzy search over an array of SearchableItem.
|
|
78
|
+
* Returns results sorted by descending score. Empty query returns [].
|
|
79
|
+
*/
|
|
80
|
+
export function fuzzySearch(query: string, items: SearchableItem[]): SearchResult[] {
|
|
81
|
+
const q = query.toLowerCase().trim();
|
|
82
|
+
if (!q) return [];
|
|
83
|
+
|
|
84
|
+
return items
|
|
85
|
+
.map((item) => {
|
|
86
|
+
const labelScore = scoreField(q, item.label.toLowerCase());
|
|
87
|
+
const valueScore = item.value ? scoreField(q, String(item.value).toLowerCase()) : 0;
|
|
88
|
+
const descScore = item.description ? scoreField(q, item.description.toLowerCase()) : 0;
|
|
89
|
+
|
|
90
|
+
const max = Math.max(labelScore, valueScore, descScore);
|
|
91
|
+
if (max === 0) return null;
|
|
92
|
+
|
|
93
|
+
const matchedField: SearchResult['matchedField'] =
|
|
94
|
+
labelScore >= valueScore && labelScore >= descScore
|
|
95
|
+
? 'label'
|
|
96
|
+
: valueScore >= labelScore && valueScore >= descScore
|
|
97
|
+
? 'value'
|
|
98
|
+
: 'description';
|
|
99
|
+
const matchedTerm =
|
|
100
|
+
matchedField === 'label'
|
|
101
|
+
? item.label
|
|
102
|
+
: matchedField === 'value'
|
|
103
|
+
? String(item.value)
|
|
104
|
+
: item.description!;
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
...item,
|
|
108
|
+
score: max,
|
|
109
|
+
matchedField,
|
|
110
|
+
matchedTerm,
|
|
111
|
+
};
|
|
112
|
+
})
|
|
113
|
+
.filter((r): r is SearchResult => r !== null)
|
|
114
|
+
.sort((a, b) => b.score - a.score);
|
|
115
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// src/mobile/views/MobileSettings.tsx — mobile settings with full desktop parity.
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
|
-
import {
|
|
4
|
-
import { QRCodeSVG } from 'qrcode.react';
|
|
3
|
+
import { Smartphone, Sun, Moon, Monitor, Save } from 'lucide-react';
|
|
5
4
|
import { api } from '../../lib/api';
|
|
6
5
|
import { applyTheme, applyThemeTokens, type Settings, type Snapshot, type ThemeName } from '../../lib/types';
|
|
6
|
+
import { QrCodePanel } from './QrCodePanel';
|
|
7
7
|
|
|
8
8
|
type Props = {
|
|
9
9
|
settings: Settings;
|
|
@@ -30,21 +30,12 @@ const PRESET_ACCENTS = [
|
|
|
30
30
|
{ name: 'Mono', accent: '#6b7280' },
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
-
function formatCountdown(ms: number): string {
|
|
34
|
-
if (ms <= 0) return 'expired';
|
|
35
|
-
const s = Math.floor(ms / 1000);
|
|
36
|
-
const m = Math.floor(s / 60);
|
|
37
|
-
const r = s % 60;
|
|
38
|
-
return `${m}:${String(r).padStart(2, '0')}`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
33
|
export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props) {
|
|
42
34
|
const [settings, setSettings] = useState<Settings>(initial);
|
|
43
35
|
const [dirty, setDirty] = useState(false);
|
|
44
36
|
const [saving, setSaving] = useState(false);
|
|
45
37
|
const [pair, setPair] = useState<PairSession | null>(null);
|
|
46
38
|
const [pairing, setPairing] = useState(false);
|
|
47
|
-
const [now, setNow] = useState(Date.now());
|
|
48
39
|
|
|
49
40
|
useEffect(() => {
|
|
50
41
|
setSettings(initial);
|
|
@@ -52,12 +43,6 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
|
|
|
52
43
|
if (initial.theme) applyThemeTokens(initial.theme);
|
|
53
44
|
}, [initial]);
|
|
54
45
|
|
|
55
|
-
useEffect(() => {
|
|
56
|
-
if (!pair) return;
|
|
57
|
-
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
58
|
-
return () => clearInterval(t);
|
|
59
|
-
}, [pair]);
|
|
60
|
-
|
|
61
46
|
const patchTheme = (patch: Partial<Settings['theme']>) => {
|
|
62
47
|
setSettings((cur) => {
|
|
63
48
|
const next = { ...cur, theme: { ...cur.theme, ...patch } };
|
|
@@ -101,8 +86,8 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
|
|
|
101
86
|
}
|
|
102
87
|
};
|
|
103
88
|
|
|
104
|
-
|
|
105
|
-
|
|
89
|
+
// remaining/expired are now managed inside QrCodePanel to keep
|
|
90
|
+
// qrcode.react out of the main mobile bundle chunk.
|
|
106
91
|
|
|
107
92
|
return (
|
|
108
93
|
<div className="mobile-view">
|
|
@@ -332,24 +317,14 @@ export function MobileSettings({ settings: initial, snapshot, onRefresh }: Props
|
|
|
332
317
|
<div className="mobile-card">
|
|
333
318
|
{!pair && (
|
|
334
319
|
<button type="button" className="mobile-btn" onClick={startPair} disabled={pairing} style={{ width: '100%' }}>
|
|
335
|
-
<
|
|
320
|
+
<Smartphone size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
|
|
336
321
|
</button>
|
|
337
322
|
)}
|
|
338
|
-
{pair &&
|
|
339
|
-
<
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
<div style={{ marginTop: 8, fontSize: 12 }}>
|
|
344
|
-
Expires in <strong>{formatCountdown(remaining)}</strong>
|
|
345
|
-
</div>
|
|
346
|
-
<div className="mono" style={{ fontSize: 10, wordBreak: 'break-all', marginTop: 4 }}>{pair.publicUrl}</div>
|
|
347
|
-
</div>
|
|
348
|
-
)}
|
|
349
|
-
{pair && expired && (
|
|
350
|
-
<button type="button" className="mobile-btn" onClick={startPair} style={{ width: '100%' }}>
|
|
351
|
-
<RefreshCw size={14} /> Generate new QR
|
|
352
|
-
</button>
|
|
323
|
+
{pair && (
|
|
324
|
+
<QrCodePanel
|
|
325
|
+
pair={pair}
|
|
326
|
+
onStart={startPair}
|
|
327
|
+
/>
|
|
353
328
|
)}
|
|
354
329
|
</div>
|
|
355
330
|
</section>
|