@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/web/assets/{audio-preview-D8nR9F8d.js → audio-preview-DNMaA2Iy.js} +1 -1
  3. package/dist/web/assets/chat-tab-C1bWBIuO.js +12 -0
  4. package/dist/web/assets/{code-editor-CKcPOXZV.js → code-editor-bumY3csF.js} +2 -2
  5. package/dist/web/assets/{conflict-editor-DtzPzxR5.js → conflict-editor-C4vVGggc.js} +1 -1
  6. package/dist/web/assets/{database-viewer-DeiJYxBj.js → database-viewer-C2R4BmYb.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-Dve9ga_f.js → diff-viewer-BkLjsOlF.js} +1 -1
  8. package/dist/web/assets/{extension-webview-DrL4qHNy.js → extension-webview-CYdFTUiW.js} +1 -1
  9. package/dist/web/assets/{image-preview-D3IgZDRo.js → image-preview-D7GaiqMk.js} +1 -1
  10. package/dist/web/assets/{index-8Mwobh7l.js → index-DDyfOgAn.js} +2 -2
  11. package/dist/web/assets/{markdown-renderer-zaluanbN.js → markdown-renderer-BUP7_3I1.js} +1 -1
  12. package/dist/web/assets/{pdf-preview-Yty6yXJU.js → pdf-preview-7pWtxUJf.js} +1 -1
  13. package/dist/web/assets/{port-forwarding-tab-Ddlryv9D.js → port-forwarding-tab-CJgjwcA4.js} +1 -1
  14. package/dist/web/assets/{postgres-viewer-CUFg5d8S.js → postgres-viewer-CCr2drUV.js} +1 -1
  15. package/dist/web/assets/{settings-tab-DIJMW_ZS.js → settings-tab-CJ9mBPMc.js} +1 -1
  16. package/dist/web/assets/{sqlite-viewer-BA2uk_fo.js → sqlite-viewer-W17NY9K0.js} +1 -1
  17. package/dist/web/assets/{terminal-tab-Bm0P3LZ7.js → terminal-tab-CPZdKlsT.js} +1 -1
  18. package/dist/web/assets/use-blob-url-BSltfg79.js +1 -0
  19. package/dist/web/assets/{video-preview-B2VaDLhw.js → video-preview-DDqJpn2U.js} +1 -1
  20. package/dist/web/index.html +1 -1
  21. package/dist/web/sw.js +1 -1
  22. package/package.json +1 -1
  23. package/src/services/slash-discovery/fuzzy-search.ts +7 -75
  24. package/src/shared/fuzzy-search.ts +87 -0
  25. package/src/web/components/chat/chat-tab.tsx +1 -5
  26. package/src/web/components/chat/message-input.tsx +5 -48
  27. package/src/web/components/chat/slash-command-picker.tsx +10 -20
  28. package/src/web/components/editor/use-blob-url.ts +6 -2
  29. package/dist/web/assets/chat-tab-yxo8oBYc.js +0 -12
  30. 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[], 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;
@@ -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 url = `${projectUrl(projectName)}/files/raw?path=${encodeURIComponent(filePath)}`;
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) => {