@hienlh/ppm 0.11.11 → 0.11.13

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 (30) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/web/assets/{audio-preview-DBoohjr_.js → audio-preview-BvgcrGbQ.js} +1 -1
  3. package/dist/web/assets/chat-tab-BznTVMoa.js +12 -0
  4. package/dist/web/assets/{code-editor-CsgU4_v0.js → code-editor-DobBqV6s.js} +2 -2
  5. package/dist/web/assets/{conflict-editor-77mY0l5p.js → conflict-editor-sH0ngkMp.js} +1 -1
  6. package/dist/web/assets/{database-viewer-BHlWYrEW.js → database-viewer-CExYBaSw.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-BLBnf1k7.js → diff-viewer-B3hk-GCU.js} +1 -1
  8. package/dist/web/assets/{extension-webview-BBiDp-Dm.js → extension-webview-BofKCLlN.js} +1 -1
  9. package/dist/web/assets/{image-preview-o3dt-W4e.js → image-preview-DIRhNXRg.js} +1 -1
  10. package/dist/web/assets/index-CBJpHXBr.js +23 -0
  11. package/dist/web/assets/{markdown-renderer-DauR_bTH.js → markdown-renderer-DthjF1EM.js} +1 -1
  12. package/dist/web/assets/{pdf-preview-DWk-mv_p.js → pdf-preview-CeKNu2YO.js} +1 -1
  13. package/dist/web/assets/{port-forwarding-tab-CnNHiV1J.js → port-forwarding-tab-DeupnZEu.js} +1 -1
  14. package/dist/web/assets/{postgres-viewer-CYP0QhAp.js → postgres-viewer-o_X5rcfk.js} +1 -1
  15. package/dist/web/assets/{settings-tab-BIVMWGLW.js → settings-tab-i8KvbzER.js} +1 -1
  16. package/dist/web/assets/{sqlite-viewer-BfhYqGGK.js → sqlite-viewer-DqUB0Ay9.js} +1 -1
  17. package/dist/web/assets/{terminal-tab-CsUkjfGm.js → terminal-tab-COozElr2.js} +1 -1
  18. package/dist/web/assets/{video-preview-M0cH5kLT.js → video-preview-DRmHpcT4.js} +1 -1
  19. package/dist/web/index.html +1 -1
  20. package/dist/web/sw.js +1 -1
  21. package/package.json +1 -1
  22. package/src/services/slash-discovery/fuzzy-search.ts +7 -75
  23. package/src/shared/fuzzy-search.ts +87 -0
  24. package/src/web/components/chat/chat-tab.tsx +1 -5
  25. package/src/web/components/chat/message-input.tsx +5 -48
  26. package/src/web/components/chat/slash-command-picker.tsx +10 -20
  27. package/src/web/components/layout/command-palette.tsx +8 -13
  28. package/src/web/lib/score-file-search.ts +90 -0
  29. package/dist/web/assets/chat-tab-Bw-y-XyO.js +0 -12
  30. package/dist/web/assets/index-Jmzyq_sm.js +0 -23
@@ -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[], ranked?: boolean, recentNames?: string[]) => {
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[], ranked?: boolean, recentNames?: string[]) => void;
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?.([], false, []);
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
- slashRankedRef.current = false;
193
- onSlashItemsLoaded?.(data.items, false, data.recentNames);
189
+ onSlashItemsLoaded?.(data.items, data.recentNames);
194
190
  })
195
191
  .catch(() => {
196
192
  slashItemsRef.current = [];
197
- onSlashItemsLoaded?.([], false, []);
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, fetchSlashSearch],
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 no filter + not ranked, put recents first
43
+ // Build display list: fuzzy search when filter is set, recents-first when idle
46
44
  const displayItems = useMemo(() => {
47
- let base: SlashItem[];
48
- if (ranked) {
49
- base = items;
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
- // Reorder: recents first when no filter and not server-ranked
60
- if (!filter && !ranked && recentNames.length > 0) {
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 base) {
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: base, recentCount: 0 };
72
- }, [items, filter, ranked, recentNames, recentSet]);
61
+ return { items, recentCount: 0 };
62
+ }, [items, filter, recentNames, recentSet]);
73
63
 
74
64
  const filtered = displayItems.items;
75
65
  const recentCount = displayItems.recentCount;
@@ -25,6 +25,7 @@ import { useFileStore, type FileNode } from "@/stores/file-store";
25
25
  import { useExtensionStore } from "@/stores/extension-store";
26
26
  import { api } from "@/lib/api-client";
27
27
  import { basename } from "@/lib/utils";
28
+ import { scoreFileSearch, compareScores, type FileSearchScore } from "@/lib/score-file-search";
28
29
 
29
30
  interface CommandItem {
30
31
  id: string;
@@ -315,19 +316,13 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
315
316
 
316
317
  // Normal mode
317
318
  if (!query.trim()) return actionCommands;
318
- const q = query.toLowerCase();
319
- const matchesFuzzy = (text: string) => {
320
- let ti = 0;
321
- for (let qi = 0; qi < q.length; qi++) {
322
- ti = text.indexOf(q[qi]!, ti);
323
- if (ti === -1) return false;
324
- ti++;
325
- }
326
- return true;
327
- };
328
- const matched = allCommands.filter(
329
- (c) => matchesFuzzy(c.label.toLowerCase()) || (c.keywords && matchesFuzzy(c.keywords.toLowerCase())),
330
- );
319
+ const scored: Array<{ cmd: CommandItem; score: FileSearchScore }> = [];
320
+ for (const c of allCommands) {
321
+ const s = scoreFileSearch(query, c.label, c.keywords ?? c.label);
322
+ if (s) scored.push({ cmd: c, score: s });
323
+ }
324
+ scored.sort((a, b) => compareScores(a.score, b.score));
325
+ const matched = scored.map((s) => s.cmd);
331
326
  // Prepend DB results (already filtered server-side) when query is 2+ chars
332
327
  return query.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
333
328
  }, [allCommands, actionCommands, fsCommands, dbCommands, query]);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * File search scoring for command palette.
3
+ * Lower score = better match. Returns null if no match.
4
+ *
5
+ * Tiers: exact filename(0) > filename prefix(1) > filename contains(2)
6
+ * > path contains(3) > fuzzy filename(4) > fuzzy path(5)
7
+ *
8
+ * Tie-breakers: shorter filename, fewer path segments.
9
+ */
10
+
11
+ export interface FileSearchScore {
12
+ /** 0-5, lower = better tier */
13
+ tier: number;
14
+ /** Position of substring match, or fuzzy gap penalty */
15
+ offset: number;
16
+ /** Candidate filename length (shorter = better) */
17
+ nameLen: number;
18
+ /** Number of path segments (fewer = more prominent) */
19
+ depth: number;
20
+ }
21
+
22
+ /** Extract filename from a path */
23
+ function getFilename(path: string): string {
24
+ const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
25
+ return i >= 0 ? path.slice(i + 1) : path;
26
+ }
27
+
28
+ /**
29
+ * Subsequence fuzzy match — returns gap penalty (sum of distances between
30
+ * consecutive matched chars). Lower = more consecutive. Returns -1 if no match.
31
+ */
32
+ function fuzzyGap(query: string, text: string): number {
33
+ let ti = 0;
34
+ let gap = 0;
35
+ let lastMatch = -1;
36
+ for (let qi = 0; qi < query.length; qi++) {
37
+ ti = text.indexOf(query[qi]!, ti);
38
+ if (ti === -1) return -1;
39
+ if (lastMatch >= 0) gap += ti - lastMatch - 1;
40
+ lastMatch = ti;
41
+ ti++;
42
+ }
43
+ return gap;
44
+ }
45
+
46
+ export function scoreFileSearch(
47
+ query: string,
48
+ label: string,
49
+ path: string,
50
+ ): FileSearchScore | null {
51
+ const q = query.toLowerCase();
52
+ const nameLower = label.toLowerCase();
53
+ const pathLower = path.toLowerCase();
54
+ const filename = getFilename(pathLower);
55
+ const depth = path.split("/").length;
56
+
57
+ // Tier 0: exact filename match
58
+ if (filename === q) return { tier: 0, offset: 0, nameLen: label.length, depth };
59
+
60
+ // Tier 1: filename starts with query
61
+ if (filename.startsWith(q)) return { tier: 1, offset: 0, nameLen: label.length, depth };
62
+
63
+ // Tier 2: filename contains query as substring
64
+ const fnIdx = filename.indexOf(q);
65
+ if (fnIdx >= 0) return { tier: 2, offset: fnIdx, nameLen: label.length, depth };
66
+
67
+ // Tier 3: full path contains query as substring
68
+ const pathIdx = pathLower.indexOf(q);
69
+ if (pathIdx >= 0) return { tier: 3, offset: pathIdx, nameLen: label.length, depth };
70
+
71
+ // Tier 4: fuzzy match on filename
72
+ const fnGap = fuzzyGap(q, filename);
73
+ if (fnGap >= 0) return { tier: 4, offset: fnGap, nameLen: label.length, depth };
74
+
75
+ // Tier 5: fuzzy match on full path
76
+ const pathGap = fuzzyGap(q, pathLower);
77
+ if (pathGap >= 0) return { tier: 5, offset: pathGap, nameLen: label.length, depth };
78
+
79
+ return null;
80
+ }
81
+
82
+ /** Compare two scores — for Array.sort (ascending = best first) */
83
+ export function compareScores(a: FileSearchScore, b: FileSearchScore): number {
84
+ return (
85
+ a.tier - b.tier ||
86
+ a.offset - b.offset ||
87
+ a.nameLen - b.nameLen ||
88
+ a.depth - b.depth
89
+ );
90
+ }