@hienlh/ppm 0.13.2 → 0.13.3
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 +9 -0
- package/assets/skills/ppm/SKILL.md +1 -1
- package/assets/skills/ppm/references/http-api.md +1 -1
- package/dist/web/assets/{audio-preview--hRMnXRZ.js → audio-preview-R7cq1uhJ.js} +1 -1
- package/dist/web/assets/{chat-tab-4kL3DNxf.js → chat-tab-umei1UkV.js} +4 -4
- package/dist/web/assets/{code-editor-Caq5_BaF.js → code-editor-BTosKXkr.js} +2 -2
- package/dist/web/assets/{conflict-editor-Dlo25nmt.js → conflict-editor-dzofjxab.js} +1 -1
- package/dist/web/assets/{database-viewer-DcBl6OkV.js → database-viewer-5Uf8Rrls.js} +1 -1
- package/dist/web/assets/{diff-viewer-CCzPq1o-.js → diff-viewer-DKLeIBkK.js} +1 -1
- package/dist/web/assets/{extension-webview-D7bGVSEd.js → extension-webview-HILvTnnn.js} +1 -1
- package/dist/web/assets/{image-preview-CfkqnhXJ.js → image-preview-0cJMnFZK.js} +1 -1
- package/dist/web/assets/index-DDBvHVVr.js +27 -0
- package/dist/web/assets/{markdown-renderer-DyAm7zuA.js → markdown-renderer-D0MrsVJB.js} +1 -1
- package/dist/web/assets/{pdf-preview-CZPcuy5c.js → pdf-preview-BBVDS-z5.js} +1 -1
- package/dist/web/assets/{port-forwarding-tab-3RNozlZ5.js → port-forwarding-tab-ByKzBs-R.js} +1 -1
- package/dist/web/assets/{postgres-viewer-CXJv4TXc.js → postgres-viewer-BnCbdR7g.js} +1 -1
- package/dist/web/assets/{settings-tab-Cnav4g2u.js → settings-tab-BPdzUw3v.js} +1 -1
- package/dist/web/assets/{sqlite-viewer-C8WUEFhA.js → sqlite-viewer-D6mSIIx2.js} +1 -1
- package/dist/web/assets/{terminal-tab-CaEsMxp8.js → terminal-tab-BLIA53mt.js} +1 -1
- package/dist/web/assets/{video-preview-Dfz71RGb.js → video-preview-CKaht6nI.js} +1 -1
- package/dist/web/index.html +1 -1
- package/dist/web/sw.js +1 -1
- package/package.json +1 -1
- package/src/providers/claude-agent-sdk.ts +5 -2
- package/src/server/routes/chat.ts +10 -1
- package/src/server/ws/chat.ts +29 -2
- package/src/types/chat.ts +1 -1
- package/src/web/components/chat/message-list.tsx +6 -5
- package/src/web/components/layout/command-palette.tsx +35 -12
- package/src/web/components/layout/draggable-tab.tsx +5 -5
- package/src/web/hooks/use-chat.ts +6 -0
- package/src/web/lib/score-file-search.ts +41 -21
- package/dist/web/assets/index-BGFG66Gh.js +0 -27
package/src/server/ws/chat.ts
CHANGED
|
@@ -50,6 +50,8 @@ interface SessionEntry {
|
|
|
50
50
|
teamNames: Set<string>;
|
|
51
51
|
/** toolUseId of a pending TeamCreate call */
|
|
52
52
|
pendingTeamCreate?: string;
|
|
53
|
+
/** Compact indicator state — sticky until turn ends or boundary received, synced on reconnect */
|
|
54
|
+
compactStatus?: "compacting" | null;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** Tracks active sessions — persists even when FE disconnects */
|
|
@@ -262,8 +264,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
262
264
|
if (evType === "system") {
|
|
263
265
|
const sub = (ev as any).subtype;
|
|
264
266
|
if (sub === "compacting") {
|
|
267
|
+
entry.compactStatus = "compacting";
|
|
268
|
+
console.log(`[chat] session=${sessionId} compact_status=compacting (persisted on entry)`);
|
|
265
269
|
broadcast(sessionId, { type: "compact_status", status: "compacting" });
|
|
266
270
|
} else if (sub === "compact_done") {
|
|
271
|
+
entry.compactStatus = null;
|
|
272
|
+
console.log(`[chat] session=${sessionId} compact_status=done (via compact_boundary)`);
|
|
267
273
|
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
268
274
|
}
|
|
269
275
|
if (!firstEventReceived) {
|
|
@@ -415,6 +421,14 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
415
421
|
if (evType === "done") {
|
|
416
422
|
entry.turnEvents = [];
|
|
417
423
|
entry.pendingApprovalEvent = undefined;
|
|
424
|
+
// Clear stale compact status if turn ended without compact_boundary.
|
|
425
|
+
// SDK may emit `status: compacting` without a matching boundary (deferred,
|
|
426
|
+
// resolved, or errored); without this clear, UI shows stuck "Compacting…".
|
|
427
|
+
if (entry.compactStatus === "compacting") {
|
|
428
|
+
entry.compactStatus = null;
|
|
429
|
+
console.log(`[chat] session=${sessionId} compact_status=done (cleared on turn done without boundary)`);
|
|
430
|
+
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
431
|
+
}
|
|
418
432
|
setPhase(sessionId, "idle");
|
|
419
433
|
// Reset heartbeat tracking for next turn
|
|
420
434
|
firstEventReceived = false;
|
|
@@ -432,6 +446,12 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
|
|
|
432
446
|
if (heartbeat) clearInterval(heartbeat);
|
|
433
447
|
entry.isStreamingActive = false;
|
|
434
448
|
entry.turnEvents = [];
|
|
449
|
+
// Force-clear compact status on stream teardown (error, close, etc.)
|
|
450
|
+
if (entry.compactStatus === "compacting") {
|
|
451
|
+
entry.compactStatus = null;
|
|
452
|
+
console.log(`[chat] session=${sessionId} compact_status=done (cleared on stream teardown)`);
|
|
453
|
+
broadcast(sessionId, { type: "compact_status", status: "done" });
|
|
454
|
+
}
|
|
435
455
|
setPhase(sessionId, "idle");
|
|
436
456
|
entry.pendingApprovalEvent = undefined;
|
|
437
457
|
// Cleanup bash output spies
|
|
@@ -488,6 +508,7 @@ export const chatWebSocket = {
|
|
|
488
508
|
phase: existing.phase,
|
|
489
509
|
pendingApproval: existing.pendingApprovalEvent ?? null,
|
|
490
510
|
sessionTitle: session?.title || null,
|
|
511
|
+
compactStatus: existing.compactStatus ?? null,
|
|
491
512
|
}));
|
|
492
513
|
|
|
493
514
|
// If actively streaming, send buffered turn events for reconnect sync
|
|
@@ -528,6 +549,7 @@ export const chatWebSocket = {
|
|
|
528
549
|
isStreamingActive: false,
|
|
529
550
|
teamWatchers: new Map(),
|
|
530
551
|
teamNames: new Set(),
|
|
552
|
+
compactStatus: null,
|
|
531
553
|
};
|
|
532
554
|
activeSessions.set(sessionId, newEntry);
|
|
533
555
|
setupClientPing(newEntry, ws);
|
|
@@ -539,6 +561,7 @@ export const chatWebSocket = {
|
|
|
539
561
|
phase: "idle",
|
|
540
562
|
pendingApproval: null,
|
|
541
563
|
sessionTitle: session?.title || null,
|
|
564
|
+
compactStatus: null,
|
|
542
565
|
}));
|
|
543
566
|
|
|
544
567
|
// Async: resolve title from SDK if in-memory title is generic (DB title takes priority)
|
|
@@ -580,7 +603,7 @@ export const chatWebSocket = {
|
|
|
580
603
|
const newEntry: SessionEntry = {
|
|
581
604
|
providerId: pid, clients: new Set([ws]), projectPath: pp, projectName: pn,
|
|
582
605
|
pingIntervals: new Map(), phase: "idle", turnEvents: [], isStreamingActive: false,
|
|
583
|
-
teamWatchers: new Map(), teamNames: new Set(),
|
|
606
|
+
teamWatchers: new Map(), teamNames: new Set(), compactStatus: null,
|
|
584
607
|
};
|
|
585
608
|
activeSessions.set(sessionId, newEntry);
|
|
586
609
|
setupClientPing(newEntry, ws);
|
|
@@ -605,6 +628,7 @@ export const chatWebSocket = {
|
|
|
605
628
|
phase: entry.phase,
|
|
606
629
|
pendingApproval: entry.pendingApprovalEvent ?? null,
|
|
607
630
|
sessionTitle: chatService.getSession(sessionId)?.title || null,
|
|
631
|
+
compactStatus: entry.compactStatus ?? null,
|
|
608
632
|
}));
|
|
609
633
|
if (entry.phase !== "idle") {
|
|
610
634
|
sendTurnEvents(sessionId, ws);
|
|
@@ -705,7 +729,10 @@ export const chatWebSocket = {
|
|
|
705
729
|
} else if (parsed.type === "cancel") {
|
|
706
730
|
// Fully teardown streaming session — user must resume to continue
|
|
707
731
|
const provider = providerRegistry.get(providerId);
|
|
708
|
-
|
|
732
|
+
const phase = entry?.phase ?? "unknown";
|
|
733
|
+
console.log(`[chat] session=${sessionId} WS cancel received from FE (phase=${phase})`);
|
|
734
|
+
logSessionEvent(sessionId, "CANCEL", `WS cancel from FE (phase=${phase})`);
|
|
735
|
+
provider?.abortQuery?.(sessionId, "ws_cancel");
|
|
709
736
|
} else if (parsed.type === "approval_response") {
|
|
710
737
|
const provider = providerRegistry.get(providerId);
|
|
711
738
|
if (provider && typeof provider.resolveApproval === "function") {
|
package/src/types/chat.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface AIProvider {
|
|
|
24
24
|
// Optional capabilities — providers implement what they support
|
|
25
25
|
resolveApproval?(requestId: string, approved: boolean, data?: unknown): void;
|
|
26
26
|
onToolApproval?: (callback: ToolApprovalHandler) => void;
|
|
27
|
-
abortQuery?(sessionId: string): void;
|
|
27
|
+
abortQuery?(sessionId: string, source?: string): void;
|
|
28
28
|
getMessages?(sessionId: string): Promise<ChatMessage[]>;
|
|
29
29
|
listSessionsByDir?(dir: string, opts?: { limit?: number; offset?: number }): Promise<SessionInfo[]>;
|
|
30
30
|
ensureProjectPath?(sessionId: string, path: string): void;
|
|
@@ -348,7 +348,11 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
|
|
|
348
348
|
const parsed = parseUserAttachments(content);
|
|
349
349
|
const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
|
|
350
350
|
const { command, cleanText } = parseCommandTags(noSysTags);
|
|
351
|
-
|
|
351
|
+
// Merge command args into body text so line-clamp + Show more applies uniformly
|
|
352
|
+
const bodyText = command?.args
|
|
353
|
+
? (cleanText ? `${command.args}\n\n${cleanText}` : command.args)
|
|
354
|
+
: cleanText;
|
|
355
|
+
return { files: parsed.files, text: bodyText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
|
|
352
356
|
}, [content]);
|
|
353
357
|
|
|
354
358
|
// Pre-compact expansion state — local per button instance
|
|
@@ -394,16 +398,13 @@ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact,
|
|
|
394
398
|
{/* System tags as badges */}
|
|
395
399
|
{tags.length > 0 && <SystemTagBadges tags={tags} />}
|
|
396
400
|
|
|
397
|
-
{/* Slash command chip */}
|
|
401
|
+
{/* Slash command chip — args rendered in body for expand/collapse support */}
|
|
398
402
|
{command && (
|
|
399
403
|
<div className="flex items-center gap-1.5 mb-0.5">
|
|
400
404
|
<span className="inline-flex items-center gap-1 rounded-md bg-primary/15 border border-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
|
401
405
|
<Slash className="size-3 shrink-0" />
|
|
402
406
|
{command.name}
|
|
403
407
|
</span>
|
|
404
|
-
{command.args && (
|
|
405
|
-
<span className="text-xs text-text-secondary truncate max-w-80">{command.args}</span>
|
|
406
|
-
)}
|
|
407
408
|
</div>
|
|
408
409
|
)}
|
|
409
410
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useRef, useMemo, useCallback, useDeferredValue } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Terminal,
|
|
4
4
|
MessageSquare,
|
|
@@ -25,7 +25,10 @@ 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 {
|
|
28
|
+
import { scoreFileSearchFast, compareScores, getFilename, type FileSearchScore } from "@/lib/score-file-search";
|
|
29
|
+
|
|
30
|
+
/** Max results to display — prevents rendering thousands of matches */
|
|
31
|
+
const MAX_RESULTS = 100;
|
|
29
32
|
|
|
30
33
|
interface CommandItem {
|
|
31
34
|
id: string;
|
|
@@ -108,6 +111,7 @@ const fsCache = new Map<string, string[]>();
|
|
|
108
111
|
|
|
109
112
|
export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boolean; onClose: () => void; initialQuery?: string }) {
|
|
110
113
|
const [query, setQuery] = useState("");
|
|
114
|
+
const deferredQuery = useDeferredValue(query);
|
|
111
115
|
const [selectedIdx, setSelectedIdx] = useState(0);
|
|
112
116
|
const [fsFiles, setFsFiles] = useState<string[]>([]);
|
|
113
117
|
const [fsLoading, setFsLoading] = useState(false);
|
|
@@ -305,11 +309,29 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
305
309
|
[actionCommands, fileCommands],
|
|
306
310
|
);
|
|
307
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Precomputed lowercase search index — avoids re-allocating thousands of
|
|
314
|
+
* lowercased strings per keystroke. Recomputed only when allCommands changes.
|
|
315
|
+
*/
|
|
316
|
+
const searchIndex = useMemo(() => {
|
|
317
|
+
return allCommands.map((cmd) => {
|
|
318
|
+
const path = cmd.keywords ?? cmd.label;
|
|
319
|
+
const pathLower = path.toLowerCase();
|
|
320
|
+
return {
|
|
321
|
+
cmd,
|
|
322
|
+
filenameLower: getFilename(pathLower),
|
|
323
|
+
pathLower,
|
|
324
|
+
labelLen: cmd.label.length,
|
|
325
|
+
depth: path.split("/").length,
|
|
326
|
+
};
|
|
327
|
+
});
|
|
328
|
+
}, [allCommands]);
|
|
329
|
+
|
|
308
330
|
const filtered = useMemo(() => {
|
|
309
331
|
// Path mode — search filesystem results using filename portion only
|
|
310
|
-
if (isPathQuery(
|
|
311
|
-
const lastSlash =
|
|
312
|
-
const fileFilter = lastSlash >= 0 ?
|
|
332
|
+
if (isPathQuery(deferredQuery)) {
|
|
333
|
+
const lastSlash = deferredQuery.lastIndexOf("/");
|
|
334
|
+
const fileFilter = lastSlash >= 0 ? deferredQuery.slice(lastSlash + 1).toLowerCase() : "";
|
|
313
335
|
if (!fileFilter) return fsCommands.slice(0, 50);
|
|
314
336
|
return fsCommands.filter((c) => {
|
|
315
337
|
const name = c.label.toLowerCase();
|
|
@@ -319,17 +341,18 @@ export function CommandPalette({ open, onClose, initialQuery = "" }: { open: boo
|
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
// Normal mode
|
|
322
|
-
if (!
|
|
344
|
+
if (!deferredQuery.trim()) return actionCommands;
|
|
345
|
+
const qLower = deferredQuery.toLowerCase();
|
|
323
346
|
const scored: Array<{ cmd: CommandItem; score: FileSearchScore }> = [];
|
|
324
|
-
for (const
|
|
325
|
-
const s =
|
|
326
|
-
if (s) scored.push({ cmd:
|
|
347
|
+
for (const entry of searchIndex) {
|
|
348
|
+
const s = scoreFileSearchFast(qLower, entry.filenameLower, entry.pathLower, entry.labelLen, entry.depth);
|
|
349
|
+
if (s) scored.push({ cmd: entry.cmd, score: s });
|
|
327
350
|
}
|
|
328
351
|
scored.sort((a, b) => compareScores(a.score, b.score));
|
|
329
|
-
const matched = scored.map((s) => s.cmd);
|
|
352
|
+
const matched = scored.slice(0, MAX_RESULTS).map((s) => s.cmd);
|
|
330
353
|
// Prepend DB results (already filtered server-side) when query is 2+ chars
|
|
331
|
-
return
|
|
332
|
-
}, [
|
|
354
|
+
return deferredQuery.trim().length >= 2 ? [...dbCommands, ...matched] : matched;
|
|
355
|
+
}, [searchIndex, actionCommands, fsCommands, dbCommands, deferredQuery]);
|
|
333
356
|
|
|
334
357
|
// Reset state when opening
|
|
335
358
|
useEffect(() => {
|
|
@@ -101,14 +101,14 @@ export function DraggableTab({
|
|
|
101
101
|
)}
|
|
102
102
|
>
|
|
103
103
|
<span
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
// Streaming: force amber (matches favicon streaming bg) so typing state is unmistakable
|
|
105
|
+
// regardless of tab active state. Otherwise inherits parent button's color (primary/text-secondary).
|
|
106
|
+
// Tag identity is now shown as a separate left-edge bar (see wrapper div below), not icon color.
|
|
107
|
+
className={cn("relative", isStreaming && "text-amber-500")}
|
|
108
108
|
>
|
|
109
109
|
<Icon className="size-4" />
|
|
110
110
|
{isStreaming ? (
|
|
111
|
-
// Messenger-style typing dots inside chat bubble — inherits current icon color
|
|
111
|
+
// Messenger-style typing dots inside chat bubble — inherits current icon color (amber while streaming)
|
|
112
112
|
<span aria-hidden className="absolute inset-0 flex items-center justify-center gap-[1.5px]">
|
|
113
113
|
<span className="tab-typing-dot size-[2px] rounded-full bg-current" />
|
|
114
114
|
<span className="tab-typing-dot size-[2px] rounded-full bg-current" style={{ animationDelay: "0.15s" }} />
|
|
@@ -505,6 +505,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
505
505
|
setPhase(p);
|
|
506
506
|
phaseRef.current = p;
|
|
507
507
|
setConnectingElapsed(p === "connecting" ? ((data as any).elapsed ?? 0) : 0);
|
|
508
|
+
// Safety: idle phase means no turn running — ensure compact indicator does not linger.
|
|
509
|
+
// BE should broadcast compact_status=done too, but this is a belt-and-braces clear.
|
|
510
|
+
if (p === "idle") setCompactStatus(null);
|
|
508
511
|
return;
|
|
509
512
|
}
|
|
510
513
|
|
|
@@ -523,6 +526,9 @@ export function useChat(sessionId: string | null, providerId = "claude", project
|
|
|
523
526
|
input: state.pendingApproval.input,
|
|
524
527
|
});
|
|
525
528
|
}
|
|
529
|
+
// Sync compact indicator from authoritative server state (covers reconnect).
|
|
530
|
+
// state.compactStatus is "compacting" | null — treat undefined as null for back-compat.
|
|
531
|
+
setCompactStatus(state.compactStatus === "compacting" ? "compacting" : null);
|
|
526
532
|
// If idle, refetch history (completed turns) and hide overlay
|
|
527
533
|
if (p === "idle") {
|
|
528
534
|
refetchRef.current?.();
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* > path contains(3) > fuzzy filename(4) > fuzzy path(5)
|
|
7
7
|
*
|
|
8
8
|
* Tie-breakers: shorter filename, fewer path segments.
|
|
9
|
+
*
|
|
10
|
+
* Hot-path note: callers pass PRE-LOWERCASED strings to avoid repeated
|
|
11
|
+
* allocations per keystroke. Use `scoreFileSearch` (convenience wrapper)
|
|
12
|
+
* for ad-hoc calls; use `scoreFileSearchFast` for the inner loop.
|
|
9
13
|
*/
|
|
10
14
|
|
|
11
15
|
export interface FileSearchScore {
|
|
@@ -20,7 +24,7 @@ export interface FileSearchScore {
|
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
/** Extract filename from a path */
|
|
23
|
-
function getFilename(path: string): string {
|
|
27
|
+
export function getFilename(path: string): string {
|
|
24
28
|
const i = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
25
29
|
return i >= 0 ? path.slice(i + 1) : path;
|
|
26
30
|
}
|
|
@@ -43,42 +47,58 @@ function fuzzyGap(query: string, text: string): number {
|
|
|
43
47
|
return gap;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Fast scoring — requires pre-lowercased inputs. Use for tight loops.
|
|
52
|
+
* All string params MUST already be lowercase.
|
|
53
|
+
*/
|
|
54
|
+
export function scoreFileSearchFast(
|
|
55
|
+
qLower: string,
|
|
56
|
+
filenameLower: string,
|
|
57
|
+
pathLower: string,
|
|
58
|
+
labelLen: number,
|
|
59
|
+
depth: number,
|
|
50
60
|
): 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
61
|
// Tier 0: exact filename match
|
|
58
|
-
if (
|
|
62
|
+
if (filenameLower === qLower) return { tier: 0, offset: 0, nameLen: labelLen, depth };
|
|
59
63
|
|
|
60
64
|
// Tier 1: filename starts with query
|
|
61
|
-
if (
|
|
65
|
+
if (filenameLower.startsWith(qLower)) return { tier: 1, offset: 0, nameLen: labelLen, depth };
|
|
62
66
|
|
|
63
67
|
// Tier 2: filename contains query as substring
|
|
64
|
-
const fnIdx =
|
|
65
|
-
if (fnIdx >= 0) return { tier: 2, offset: fnIdx, nameLen:
|
|
68
|
+
const fnIdx = filenameLower.indexOf(qLower);
|
|
69
|
+
if (fnIdx >= 0) return { tier: 2, offset: fnIdx, nameLen: labelLen, depth };
|
|
66
70
|
|
|
67
71
|
// Tier 3: full path contains query as substring
|
|
68
|
-
const pathIdx = pathLower.indexOf(
|
|
69
|
-
if (pathIdx >= 0) return { tier: 3, offset: pathIdx, nameLen:
|
|
72
|
+
const pathIdx = pathLower.indexOf(qLower);
|
|
73
|
+
if (pathIdx >= 0) return { tier: 3, offset: pathIdx, nameLen: labelLen, depth };
|
|
70
74
|
|
|
71
75
|
// Tier 4: fuzzy match on filename
|
|
72
|
-
const fnGap = fuzzyGap(
|
|
73
|
-
if (fnGap >= 0) return { tier: 4, offset: fnGap, nameLen:
|
|
76
|
+
const fnGap = fuzzyGap(qLower, filenameLower);
|
|
77
|
+
if (fnGap >= 0) return { tier: 4, offset: fnGap, nameLen: labelLen, depth };
|
|
74
78
|
|
|
75
79
|
// Tier 5: fuzzy match on full path
|
|
76
|
-
const pathGap = fuzzyGap(
|
|
77
|
-
if (pathGap >= 0) return { tier: 5, offset: pathGap, nameLen:
|
|
80
|
+
const pathGap = fuzzyGap(qLower, pathLower);
|
|
81
|
+
if (pathGap >= 0) return { tier: 5, offset: pathGap, nameLen: labelLen, depth };
|
|
78
82
|
|
|
79
83
|
return null;
|
|
80
84
|
}
|
|
81
85
|
|
|
86
|
+
/** Convenience wrapper — lowers inputs on the fly. Use for ad-hoc calls. */
|
|
87
|
+
export function scoreFileSearch(
|
|
88
|
+
query: string,
|
|
89
|
+
label: string,
|
|
90
|
+
path: string,
|
|
91
|
+
): FileSearchScore | null {
|
|
92
|
+
const pathLower = path.toLowerCase();
|
|
93
|
+
return scoreFileSearchFast(
|
|
94
|
+
query.toLowerCase(),
|
|
95
|
+
getFilename(pathLower),
|
|
96
|
+
pathLower,
|
|
97
|
+
label.length,
|
|
98
|
+
path.split("/").length,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
82
102
|
/** Compare two scores — for Array.sort (ascending = best first) */
|
|
83
103
|
export function compareScores(a: FileSearchScore, b: FileSearchScore): number {
|
|
84
104
|
return (
|