@hienlh/ppm 0.11.12 → 0.11.14
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/CHANGELOG.md +10 -0
- package/dist/web/assets/{audio-preview-D8nR9F8d.js → audio-preview-DNMaA2Iy.js} +1 -1
- package/dist/web/assets/chat-tab-C1bWBIuO.js +12 -0
- package/dist/web/assets/{code-editor-CKcPOXZV.js → code-editor-bumY3csF.js} +2 -2
- package/dist/web/assets/{conflict-editor-DtzPzxR5.js → conflict-editor-C4vVGggc.js} +1 -1
- package/dist/web/assets/{database-viewer-DeiJYxBj.js → database-viewer-C2R4BmYb.js} +1 -1
- package/dist/web/assets/{diff-viewer-Dve9ga_f.js → diff-viewer-BkLjsOlF.js} +1 -1
- package/dist/web/assets/{extension-webview-DrL4qHNy.js → extension-webview-CYdFTUiW.js} +1 -1
- package/dist/web/assets/{image-preview-D3IgZDRo.js → image-preview-D7GaiqMk.js} +1 -1
- package/dist/web/assets/{index-8Mwobh7l.js → index-DDyfOgAn.js} +2 -2
- package/dist/web/assets/{markdown-renderer-zaluanbN.js → markdown-renderer-BUP7_3I1.js} +1 -1
- package/dist/web/assets/{pdf-preview-Yty6yXJU.js → pdf-preview-7pWtxUJf.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-Ddlryv9D.js → port-forwarding-tab-CJgjwcA4.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CUFg5d8S.js → postgres-viewer-CCr2drUV.js} +1 -1
- package/dist/web/assets/{settings-tab-DIJMW_ZS.js → settings-tab-CJ9mBPMc.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-BA2uk_fo.js → sqlite-viewer-W17NY9K0.js} +1 -1
- package/dist/web/assets/{terminal-tab-Bm0P3LZ7.js → terminal-tab-CPZdKlsT.js} +1 -1
- package/dist/web/assets/use-blob-url-BSltfg79.js +1 -0
- package/dist/web/assets/{video-preview-B2VaDLhw.js → video-preview-DDqJpn2U.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/services/slash-discovery/fuzzy-search.ts +7 -75
- package/src/shared/fuzzy-search.ts +87 -0
- package/src/web/components/chat/chat-tab.tsx +1 -5
- package/src/web/components/chat/message-input.tsx +5 -48
- package/src/web/components/chat/slash-command-picker.tsx +10 -20
- package/src/web/components/editor/use-blob-url.ts +6 -2
- package/dist/web/assets/chat-tab-yxo8oBYc.js +0 -12
- package/dist/web/assets/use-blob-url-BK7zshV7.js +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Minimal interface — any object with name + description is searchable */
|
|
2
|
+
export interface FuzzySearchable {
|
|
3
|
+
name: string;
|
|
4
|
+
description: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Iterative Levenshtein distance (single-row DP) */
|
|
8
|
+
export function levenshtein(a: string, b: string): number {
|
|
9
|
+
if (a === b) return 0;
|
|
10
|
+
if (a.length === 0) return b.length;
|
|
11
|
+
if (b.length === 0) return a.length;
|
|
12
|
+
|
|
13
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
14
|
+
let curr = new Array<number>(b.length + 1);
|
|
15
|
+
|
|
16
|
+
for (let i = 1; i <= a.length; i++) {
|
|
17
|
+
curr[0] = i;
|
|
18
|
+
for (let j = 1; j <= b.length; j++) {
|
|
19
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
20
|
+
curr[j] = Math.min(
|
|
21
|
+
curr[j - 1]! + 1, // insertion
|
|
22
|
+
prev[j]! + 1, // deletion
|
|
23
|
+
prev[j - 1]! + cost, // substitution
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
[prev, curr] = [curr, prev];
|
|
27
|
+
}
|
|
28
|
+
return prev[b.length]!;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FuzzyScore { rank: number; distance: number }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Score a query against a candidate string.
|
|
35
|
+
* Returns null if no reasonable match. Rank: 0=prefix, 1=contains, 2=fuzzy.
|
|
36
|
+
*/
|
|
37
|
+
export function scoreFuzzy(query: string, candidate: string): FuzzyScore | null {
|
|
38
|
+
const lq = query.toLowerCase();
|
|
39
|
+
const lc = candidate.toLowerCase();
|
|
40
|
+
|
|
41
|
+
if (lc.startsWith(lq)) return { rank: 0, distance: 0 };
|
|
42
|
+
if (lc.includes(lq)) return { rank: 1, distance: lc.indexOf(lq) };
|
|
43
|
+
|
|
44
|
+
const maxDist = Math.max(Math.floor(lq.length * 0.4), 2);
|
|
45
|
+
const dist = levenshtein(lq, lc.slice(0, lq.length + maxDist));
|
|
46
|
+
if (dist <= maxDist) return { rank: 2, distance: dist };
|
|
47
|
+
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Search items by query with fuzzy matching.
|
|
53
|
+
* Recently used items get a rank boost (sorted earlier within same rank tier).
|
|
54
|
+
* Returns ranked results (best match first), truncated to limit.
|
|
55
|
+
*/
|
|
56
|
+
export function searchFuzzy<T extends FuzzySearchable>(
|
|
57
|
+
items: T[],
|
|
58
|
+
query: string,
|
|
59
|
+
limit = 20,
|
|
60
|
+
recentNames: string[] = [],
|
|
61
|
+
): T[] {
|
|
62
|
+
if (!query) return items;
|
|
63
|
+
// Cap query length to prevent quadratic blowup in Levenshtein
|
|
64
|
+
query = query.slice(0, 50);
|
|
65
|
+
|
|
66
|
+
const recentSet = new Set(recentNames);
|
|
67
|
+
const scored: Array<{ item: T; rank: number; distance: number; recent: boolean }> = [];
|
|
68
|
+
|
|
69
|
+
for (const item of items) {
|
|
70
|
+
const nameScore = scoreFuzzy(query, item.name);
|
|
71
|
+
const descScore = scoreFuzzy(query, item.description);
|
|
72
|
+
const best = [nameScore, descScore]
|
|
73
|
+
.filter((s): s is FuzzyScore => s !== null)
|
|
74
|
+
.sort((a, b) => a.rank - b.rank || a.distance - b.distance)[0];
|
|
75
|
+
|
|
76
|
+
if (best) scored.push({ item, rank: best.rank, distance: best.distance, recent: recentSet.has(item.name) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
scored.sort((a, b) =>
|
|
80
|
+
a.rank - b.rank
|
|
81
|
+
|| a.distance - b.distance
|
|
82
|
+
|| (a.recent === b.recent ? 0 : a.recent ? -1 : 1)
|
|
83
|
+
|| a.item.name.localeCompare(b.item.name),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return scored.slice(0, limit).map((s) => s.item);
|
|
87
|
+
}
|
|
@@ -36,7 +36,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
36
36
|
const [slashItems, setSlashItems] = useState<SlashItem[]>([]);
|
|
37
37
|
const [showSlashPicker, setShowSlashPicker] = useState(false);
|
|
38
38
|
const [slashFilter, setSlashFilter] = useState("");
|
|
39
|
-
const [slashRanked, setSlashRanked] = useState(false);
|
|
40
39
|
const [slashSelected, setSlashSelected] = useState<SlashItem | null>(null);
|
|
41
40
|
const [slashRecentNames, setSlashRecentNames] = useState<string[]>([]);
|
|
42
41
|
|
|
@@ -255,9 +254,8 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
255
254
|
|
|
256
255
|
/** Stable callback for slash items loaded — prevents MessageInput memo break */
|
|
257
256
|
const handleSlashItemsLoaded = useCallback(
|
|
258
|
-
(items: SlashItem[],
|
|
257
|
+
(items: SlashItem[], recentNames?: string[]) => {
|
|
259
258
|
setSlashItems(items);
|
|
260
|
-
if (ranked !== undefined) setSlashRanked(ranked);
|
|
261
259
|
if (recentNames) setSlashRecentNames(recentNames);
|
|
262
260
|
},
|
|
263
261
|
[],
|
|
@@ -267,7 +265,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
267
265
|
const handleSlashStateChange = useCallback((visible: boolean, filter: string) => {
|
|
268
266
|
setShowSlashPicker(visible);
|
|
269
267
|
setSlashFilter(filter);
|
|
270
|
-
if (!visible || !filter) setSlashRanked(false);
|
|
271
268
|
}, []);
|
|
272
269
|
|
|
273
270
|
const handleSlashSelect = useCallback((item: SlashItem) => {
|
|
@@ -432,7 +429,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
|
432
429
|
onSelect={handleSlashSelect}
|
|
433
430
|
onClose={handleSlashClose}
|
|
434
431
|
visible={showSlashPicker}
|
|
435
|
-
ranked={slashRanked}
|
|
436
432
|
recentNames={slashRecentNames}
|
|
437
433
|
projectName={projectName}
|
|
438
434
|
/>
|
|
@@ -32,7 +32,7 @@ interface MessageInputProps {
|
|
|
32
32
|
projectName?: string;
|
|
33
33
|
/** Slash picker state change */
|
|
34
34
|
onSlashStateChange?: (visible: boolean, filter: string) => void;
|
|
35
|
-
onSlashItemsLoaded?: (items: SlashItem[],
|
|
35
|
+
onSlashItemsLoaded?: (items: SlashItem[], recentNames?: string[]) => void;
|
|
36
36
|
slashSelected?: SlashItem | null;
|
|
37
37
|
/** File picker state change */
|
|
38
38
|
onFileStateChange?: (visible: boolean, filter: string) => void;
|
|
@@ -96,9 +96,6 @@ export const MessageInput = memo(function MessageInput({
|
|
|
96
96
|
const mobileTextareaRef = useRef<HTMLTextAreaElement>(null);
|
|
97
97
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
98
98
|
const slashItemsRef = useRef<SlashItem[]>([]);
|
|
99
|
-
const slashRankedRef = useRef(false);
|
|
100
|
-
const slashDebounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
101
|
-
const slashSearchIdRef = useRef(0);
|
|
102
99
|
const fileItemsRef = useRef<FileNode[]>([]);
|
|
103
100
|
const resizeRafRef = useRef(0);
|
|
104
101
|
// Track picker open state to avoid unnecessary parent callbacks per keystroke
|
|
@@ -182,19 +179,18 @@ export const MessageInput = memo(function MessageInput({
|
|
|
182
179
|
const fetchSlashItems = useCallback(() => {
|
|
183
180
|
if (!projectName) {
|
|
184
181
|
slashItemsRef.current = [];
|
|
185
|
-
onSlashItemsLoaded?.([],
|
|
182
|
+
onSlashItemsLoaded?.([], []);
|
|
186
183
|
return;
|
|
187
184
|
}
|
|
188
185
|
api
|
|
189
186
|
.get<{ items: SlashItem[]; recentNames: string[] }>(`${projectUrl(projectName)}/chat/slash-items`)
|
|
190
187
|
.then((data) => {
|
|
191
188
|
slashItemsRef.current = data.items;
|
|
192
|
-
|
|
193
|
-
onSlashItemsLoaded?.(data.items, false, data.recentNames);
|
|
189
|
+
onSlashItemsLoaded?.(data.items, data.recentNames);
|
|
194
190
|
})
|
|
195
191
|
.catch(() => {
|
|
196
192
|
slashItemsRef.current = [];
|
|
197
|
-
onSlashItemsLoaded?.([],
|
|
193
|
+
onSlashItemsLoaded?.([], []);
|
|
198
194
|
});
|
|
199
195
|
}, [projectName, onSlashItemsLoaded]);
|
|
200
196
|
|
|
@@ -208,13 +204,6 @@ export const MessageInput = memo(function MessageInput({
|
|
|
208
204
|
return () => window.removeEventListener("ppm:slash-items-refresh", handler);
|
|
209
205
|
}, [fetchSlashItems]);
|
|
210
206
|
|
|
211
|
-
// Cleanup debounce timer on unmount
|
|
212
|
-
useEffect(() => {
|
|
213
|
-
return () => {
|
|
214
|
-
if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
|
|
215
|
-
};
|
|
216
|
-
}, []);
|
|
217
|
-
|
|
218
207
|
// Fetch file tree when projectName changes
|
|
219
208
|
useEffect(() => {
|
|
220
209
|
if (!projectName) {
|
|
@@ -481,30 +470,6 @@ export const MessageInput = memo(function MessageInput({
|
|
|
481
470
|
[handleSend, permissionMode, onModeChange],
|
|
482
471
|
);
|
|
483
472
|
|
|
484
|
-
/** Debounced server-side fuzzy search for slash items */
|
|
485
|
-
const fetchSlashSearch = useCallback(
|
|
486
|
-
(query: string) => {
|
|
487
|
-
if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
|
|
488
|
-
if (!projectName || !query) return;
|
|
489
|
-
const requestId = ++slashSearchIdRef.current;
|
|
490
|
-
slashDebounceRef.current = setTimeout(() => {
|
|
491
|
-
api
|
|
492
|
-
.get<{ items: SlashItem[]; recentNames: string[] }>(`${projectUrl(projectName)}/chat/slash-items?q=${encodeURIComponent(query)}`)
|
|
493
|
-
.then((data) => {
|
|
494
|
-
if (requestId !== slashSearchIdRef.current) return; // stale response
|
|
495
|
-
slashItemsRef.current = data.items;
|
|
496
|
-
slashRankedRef.current = true;
|
|
497
|
-
onSlashItemsLoaded?.(data.items, true, data.recentNames);
|
|
498
|
-
})
|
|
499
|
-
.catch(() => {
|
|
500
|
-
if (requestId !== slashSearchIdRef.current) return;
|
|
501
|
-
slashRankedRef.current = false;
|
|
502
|
-
});
|
|
503
|
-
}, 150);
|
|
504
|
-
},
|
|
505
|
-
[projectName, onSlashItemsLoaded],
|
|
506
|
-
);
|
|
507
|
-
|
|
508
473
|
const updatePickerState = useCallback(
|
|
509
474
|
(text: string, cursorPos: number) => {
|
|
510
475
|
const textBefore = text.slice(0, cursorPos);
|
|
@@ -513,9 +478,6 @@ export const MessageInput = memo(function MessageInput({
|
|
|
513
478
|
const hasSlash = textBefore.includes("/");
|
|
514
479
|
const hasAt = textBefore.includes("@");
|
|
515
480
|
if (!hasSlash && !hasAt) {
|
|
516
|
-
// Cancel pending slash search if any
|
|
517
|
-
if (slashDebounceRef.current) { clearTimeout(slashDebounceRef.current); slashDebounceRef.current = undefined; }
|
|
518
|
-
if (slashRankedRef.current) slashRankedRef.current = false;
|
|
519
481
|
// Close pickers only if they were actually open (avoid unnecessary parent setState)
|
|
520
482
|
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
521
483
|
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
@@ -530,15 +492,10 @@ export const MessageInput = memo(function MessageInput({
|
|
|
530
492
|
onSlashStateChange?.(true, filter);
|
|
531
493
|
slashPickerOpenRef.current = true;
|
|
532
494
|
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
533
|
-
if (filter) fetchSlashSearch(filter);
|
|
534
495
|
return;
|
|
535
496
|
}
|
|
536
497
|
}
|
|
537
498
|
|
|
538
|
-
// Cancel pending search when slash picker closes
|
|
539
|
-
if (slashDebounceRef.current) clearTimeout(slashDebounceRef.current);
|
|
540
|
-
if (slashRankedRef.current) slashRankedRef.current = false;
|
|
541
|
-
|
|
542
499
|
// Check for @ anywhere in text (after whitespace or at start)
|
|
543
500
|
if (hasAt) {
|
|
544
501
|
const atMatch = textBefore.match(/@(\S*)$/);
|
|
@@ -554,7 +511,7 @@ export const MessageInput = memo(function MessageInput({
|
|
|
554
511
|
if (slashPickerOpenRef.current) { onSlashStateChange?.(false, ""); slashPickerOpenRef.current = false; }
|
|
555
512
|
if (filePickerOpenRef.current) { onFileStateChange?.(false, ""); filePickerOpenRef.current = false; }
|
|
556
513
|
},
|
|
557
|
-
[onSlashStateChange, onFileStateChange
|
|
514
|
+
[onSlashStateChange, onFileStateChange],
|
|
558
515
|
);
|
|
559
516
|
|
|
560
517
|
/** Unified onChange for both textareas — updates ref, syncs other textarea, triggers picker */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent } from "react";
|
|
2
2
|
import { Sparkles, Terminal, Zap, RefreshCw, Clock } from "lucide-react";
|
|
3
3
|
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { searchFuzzy } from "../../../shared/fuzzy-search";
|
|
4
5
|
|
|
5
6
|
export interface SlashItem {
|
|
6
7
|
type: "skill" | "command" | "builtin";
|
|
@@ -18,8 +19,6 @@ interface SlashCommandPickerProps {
|
|
|
18
19
|
onSelect: (item: SlashItem) => void;
|
|
19
20
|
onClose: () => void;
|
|
20
21
|
visible: boolean;
|
|
21
|
-
/** When true, items are pre-ranked by server — skip client-side filtering */
|
|
22
|
-
ranked?: boolean;
|
|
23
22
|
/** Recently used item names (most recent first) */
|
|
24
23
|
recentNames?: string[];
|
|
25
24
|
/** Project name for cache invalidation */
|
|
@@ -32,7 +31,6 @@ export function SlashCommandPicker({
|
|
|
32
31
|
onSelect,
|
|
33
32
|
onClose,
|
|
34
33
|
visible,
|
|
35
|
-
ranked,
|
|
36
34
|
recentNames = [],
|
|
37
35
|
projectName,
|
|
38
36
|
}: SlashCommandPickerProps) {
|
|
@@ -42,34 +40,26 @@ export function SlashCommandPicker({
|
|
|
42
40
|
|
|
43
41
|
const recentSet = useMemo(() => new Set(recentNames), [recentNames]);
|
|
44
42
|
|
|
45
|
-
// Build display list: when
|
|
43
|
+
// Build display list: fuzzy search when filter is set, recents-first when idle
|
|
46
44
|
const displayItems = useMemo(() => {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} else if (filter) {
|
|
51
|
-
const q = filter.toLowerCase();
|
|
52
|
-
base = items.filter(
|
|
53
|
-
(item) => item.name.toLowerCase().includes(q) || item.description.toLowerCase().includes(q),
|
|
54
|
-
);
|
|
55
|
-
} else {
|
|
56
|
-
base = items;
|
|
45
|
+
if (filter) {
|
|
46
|
+
// Client-side fuzzy search (Levenshtein) — replaces old server-side search
|
|
47
|
+
return { items: searchFuzzy(items, filter, 20, recentNames), recentCount: 0 };
|
|
57
48
|
}
|
|
58
49
|
|
|
59
|
-
//
|
|
60
|
-
if (
|
|
50
|
+
// No filter — show all items with recents first
|
|
51
|
+
if (recentNames.length > 0) {
|
|
61
52
|
const recents: SlashItem[] = [];
|
|
62
53
|
const rest: SlashItem[] = [];
|
|
63
|
-
for (const item of
|
|
54
|
+
for (const item of items) {
|
|
64
55
|
if (recentSet.has(item.name)) recents.push(item);
|
|
65
56
|
else rest.push(item);
|
|
66
57
|
}
|
|
67
|
-
// Sort recents by their order in recentNames (most recent first)
|
|
68
58
|
recents.sort((a, b) => recentNames.indexOf(a.name) - recentNames.indexOf(b.name));
|
|
69
59
|
return { items: [...recents, ...rest], recentCount: recents.length };
|
|
70
60
|
}
|
|
71
|
-
return { items
|
|
72
|
-
}, [items, filter,
|
|
61
|
+
return { items, recentCount: 0 };
|
|
62
|
+
}, [items, filter, recentNames, recentSet]);
|
|
73
63
|
|
|
74
64
|
const filtered = displayItems.items;
|
|
75
65
|
const recentCount = displayItems.recentCount;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { projectUrl, getAuthToken } from "@/lib/api-client";
|
|
3
3
|
|
|
4
|
-
/** Shared hook: fetch a project file as a blob URL via /files/raw endpoint.
|
|
4
|
+
/** Shared hook: fetch a project file as a blob URL via /files/raw endpoint.
|
|
5
|
+
* Detects absolute paths (external files) and uses /api/fs/raw instead. */
|
|
5
6
|
export function useBlobUrl(
|
|
6
7
|
filePath: string,
|
|
7
8
|
projectName: string,
|
|
@@ -12,7 +13,10 @@ export function useBlobUrl(
|
|
|
12
13
|
|
|
13
14
|
useEffect(() => {
|
|
14
15
|
let revoke: string | undefined;
|
|
15
|
-
const
|
|
16
|
+
const isExternal = /^(\/|[A-Za-z]:[/\\])/.test(filePath);
|
|
17
|
+
const url = isExternal
|
|
18
|
+
? `/api/fs/raw?path=${encodeURIComponent(filePath)}`
|
|
19
|
+
: `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
|
|
16
20
|
const token = getAuthToken();
|
|
17
21
|
fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
|
18
22
|
.then((r) => {
|