Skip to content

Commit 4e08fe8

Browse files
BIMvoicelouistrue
andauthored
fix(viewer): split CommandPalette's search/ranking helpers into their own module (#3958)
* fix(viewer): split CommandPalette's search/ranking helpers into their own module CommandPalette.tsx grew to 852 lines on main against its 849-line module-size budget after #3942 and #3945 each landed a command entry the same day, so check-module-size.mjs exits 1 on a clean checkout and every open PR inherits the red. Extract the pure fuzzy-search/ranking and recent-usage-tracking helpers (Category/Command/FlatItem types, score/rankCommand, getRecentIds/ recordUsage) into commandPaletteSearch.ts — none of it touches React or the store, a clean seam. CommandPalette.tsx is now 752 lines (97 lines of headroom); every command id is unchanged and the new module is imported and used in place. Closes #3957 * test(viewer): cover extracted command palette search --------- Co-authored-by: Louis Trümpler <78563314+louistrue@users.noreply.github.com>
1 parent 071fb0d commit 4e08fe8

5 files changed

Lines changed: 166 additions & 110 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ifc-lite/viewer": patch
3+
---
4+
5+
Split `CommandPalette.tsx`'s fuzzy search/ranking and recent-usage helpers into a new `commandPaletteSearch.ts` module. This is a pure internal refactor to bring the file back under the repo's module-size budget (it had grown to 852 lines against an 849-line budget after two same-day PRs each added a command entry) — no command was renamed, removed, or behaviorally changed.

apps/viewer/src/components/viewer/CommandPalette.tsx

Lines changed: 9 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -107,115 +107,15 @@ import { getRecentFiles, formatFileSize, getCachedFile, getCachedFileNames } fro
107107
import type { RecentFileEntry } from '@/lib/recent-files';
108108
import { closeActiveAnalysisExtension } from '@/services/analysis-extensions';
109109
import { describeRunCommandError } from '@/services/extensions/runtime-errors';
110-
111-
// ── Types ──────────────────────────────────────────────────────────────
112-
113-
type Category =
114-
| 'Recent'
115-
| 'File'
116-
| 'View'
117-
| 'Tools'
118-
| 'Visibility'
119-
| 'Panels'
120-
| 'Export'
121-
| 'Automation'
122-
| 'Preferences'
123-
| 'Extensions'
124-
| 'Learn';
125-
126-
interface Command {
127-
id: string;
128-
label: string;
129-
keywords: string; // extra search tokens (no UI display)
130-
category: Exclude<Category, 'Recent'>;
131-
icon: React.ElementType;
132-
shortcut?: string;
133-
detail?: string; // subtle secondary text (e.g. file size)
134-
action: () => void;
135-
/**
136-
* Run the action synchronously in the click handler instead of deferring to the
137-
* next animation frame — needed for a file dialog: Chrome only honours
138-
* `input.click()` / `showOpenFilePicker()` while transient user activation is
139-
* live, which a `requestAnimationFrame` hop would discard.
140-
*/
141-
immediate?: boolean;
142-
}
143-
144-
interface FlatItem {
145-
cmd: Command;
146-
flatIdx: number;
147-
}
148-
149-
// ── Constants ──────────────────────────────────────────────────────────
150-
151-
const RECENT_KEY = 'ifc-lite:cmd-palette:recent';
152-
const MAX_RECENT = 5;
153-
const CATEGORY_ORDER: Category[] = [
154-
'Recent', 'File', 'View', 'Tools', 'Visibility', 'Panels', 'Export', 'Automation', 'Preferences',
155-
];
156-
157-
// ── Search scoring ─────────────────────────────────────────────────────
158-
159-
/**
160-
* Score how well `query` matches `text`.
161-
* 0 = no match
162-
* 100 = exact substring
163-
* 50 = word-start initials
164-
* 1-25 = tight fuzzy (avg gap ≤ 5)
165-
*/
166-
function score(query: string, text: string): number {
167-
const q = query.toLowerCase();
168-
const t = text.toLowerCase();
169-
170-
// Exact substring
171-
if (t.includes(q)) return 100;
172-
173-
// Word-start initials (e.g. "cs" → "Color Spaces")
174-
const words = t.split(/[\s\-_:\/,]+/);
175-
let wi = 0, qi = 0;
176-
while (wi < words.length && qi < q.length) {
177-
if (words[wi].length > 0 && words[wi][0] === q[qi]) qi++;
178-
wi++;
179-
}
180-
if (qi === q.length) return 50;
181-
182-
// Tight fuzzy — reject if chars are scattered
183-
let lastIdx = -1, totalGap = 0;
184-
qi = 0;
185-
for (let i = 0; i < t.length && qi < q.length; i++) {
186-
if (t[i] === q[qi]) {
187-
if (lastIdx >= 0) totalGap += i - lastIdx - 1;
188-
lastIdx = i;
189-
qi++;
190-
}
191-
}
192-
if (qi < q.length) return 0;
193-
const avgGap = q.length > 1 ? totalGap / (q.length - 1) : 0;
194-
if (avgGap > 5) return 0;
195-
return Math.max(1, 25 - Math.round(avgGap * 3));
196-
}
197-
198-
/** Rank a command against the search query. Label dominates. */
199-
function rankCommand(cmd: Command, query: string): number {
200-
const l = score(query, cmd.label);
201-
const k = score(query, cmd.keywords) * 0.9;
202-
const c = score(query, cmd.category) * 0.5;
203-
return Math.max(l, k, c);
204-
}
205-
206-
// ── Recent usage ───────────────────────────────────────────────────────
207-
208-
function getRecentIds(): string[] {
209-
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]'); }
210-
catch { return []; }
211-
}
212-
function recordUsage(id: string) {
213-
try {
214-
const r = getRecentIds().filter(x => x !== id);
215-
r.unshift(id);
216-
localStorage.setItem(RECENT_KEY, JSON.stringify(r.slice(0, 30)));
217-
} catch { /* noop */ }
218-
}
110+
import {
111+
type Command,
112+
type FlatItem,
113+
MAX_RECENT,
114+
CATEGORY_ORDER,
115+
rankCommand,
116+
getRecentIds,
117+
recordUsage,
118+
} from './commandPaletteSearch';
219119

220120
/** Toggle a sidebar workspace panel (#1208). The store's `toggleWorkspacePanel`
221121
* owns the single-tenant + re-dock + detach semantics; a second activation closes
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
import assert from 'node:assert/strict';
6+
import { describe, it } from 'node:test';
7+
import { rankCommand, score, type Command } from './commandPaletteSearch.js';
8+
9+
function command(overrides: Partial<Command> = {}): Command {
10+
return {
11+
id: 'export:json',
12+
label: 'Export JSON',
13+
keywords: 'download data',
14+
category: 'Export',
15+
icon: () => null,
16+
action: () => {},
17+
...overrides,
18+
};
19+
}
20+
21+
describe('command palette search extraction (#3957)', () => {
22+
it('preserves exact, initials, fuzzy, and rejected-match score tiers', () => {
23+
assert.equal(score('json', 'Export JSON'), 100);
24+
assert.equal(score('ej', 'Export JSON'), 50);
25+
assert.ok(score('ept', 'Export') > 0 && score('ept', 'Export') < 50);
26+
assert.equal(score('xyz', 'Export JSON'), 0);
27+
});
28+
29+
it('keeps label matches ahead of keyword and category matches', () => {
30+
const cmd = command();
31+
assert.equal(rankCommand(cmd, 'json'), 100);
32+
assert.equal(rankCommand(cmd, 'download'), 90);
33+
assert.equal(rankCommand(cmd, 'export'), 100);
34+
});
35+
});
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* CommandPalette search — fuzzy scoring, ranking, and "recent commands"
7+
* persistence. Split out of `CommandPalette.tsx`: these are pure functions
8+
* (no React, no store) that the palette component composes.
9+
*/
10+
11+
export type Category =
12+
| 'Recent'
13+
| 'File'
14+
| 'View'
15+
| 'Tools'
16+
| 'Visibility'
17+
| 'Panels'
18+
| 'Export'
19+
| 'Automation'
20+
| 'Preferences'
21+
| 'Extensions'
22+
| 'Learn';
23+
24+
export interface Command {
25+
id: string;
26+
label: string;
27+
keywords: string; // extra search tokens (no UI display)
28+
category: Exclude<Category, 'Recent'>;
29+
icon: React.ElementType;
30+
shortcut?: string;
31+
detail?: string; // subtle secondary text (e.g. file size)
32+
action: () => void;
33+
/**
34+
* Run the action synchronously in the click handler instead of deferring to the
35+
* next animation frame — needed for a file dialog: Chrome only honours
36+
* `input.click()` / `showOpenFilePicker()` while transient user activation is
37+
* live, which a `requestAnimationFrame` hop would discard.
38+
*/
39+
immediate?: boolean;
40+
}
41+
42+
export interface FlatItem {
43+
cmd: Command;
44+
flatIdx: number;
45+
}
46+
47+
// ── Constants ──────────────────────────────────────────────────────────
48+
49+
export const RECENT_KEY = 'ifc-lite:cmd-palette:recent';
50+
export const MAX_RECENT = 5;
51+
export const CATEGORY_ORDER: Category[] = [
52+
'Recent', 'File', 'View', 'Tools', 'Visibility', 'Panels', 'Export', 'Automation', 'Preferences',
53+
];
54+
55+
// ── Search scoring ─────────────────────────────────────────────────────
56+
57+
/**
58+
* Score how well `query` matches `text`.
59+
* 0 = no match
60+
* 100 = exact substring
61+
* 50 = word-start initials
62+
* 1-25 = tight fuzzy (avg gap ≤ 5)
63+
*/
64+
export function score(query: string, text: string): number {
65+
const q = query.toLowerCase();
66+
const t = text.toLowerCase();
67+
68+
// Exact substring
69+
if (t.includes(q)) return 100;
70+
71+
// Word-start initials (e.g. "cs" → "Color Spaces")
72+
const words = t.split(/[\s\-_:\/,]+/);
73+
let wi = 0, qi = 0;
74+
while (wi < words.length && qi < q.length) {
75+
if (words[wi].length > 0 && words[wi][0] === q[qi]) qi++;
76+
wi++;
77+
}
78+
if (qi === q.length) return 50;
79+
80+
// Tight fuzzy — reject if chars are scattered
81+
let lastIdx = -1, totalGap = 0;
82+
qi = 0;
83+
for (let i = 0; i < t.length && qi < q.length; i++) {
84+
if (t[i] === q[qi]) {
85+
if (lastIdx >= 0) totalGap += i - lastIdx - 1;
86+
lastIdx = i;
87+
qi++;
88+
}
89+
}
90+
if (qi < q.length) return 0;
91+
const avgGap = q.length > 1 ? totalGap / (q.length - 1) : 0;
92+
if (avgGap > 5) return 0;
93+
return Math.max(1, 25 - Math.round(avgGap * 3));
94+
}
95+
96+
/** Rank a command against the search query. Label dominates. */
97+
export function rankCommand(cmd: Command, query: string): number {
98+
const l = score(query, cmd.label);
99+
const k = score(query, cmd.keywords) * 0.9;
100+
const c = score(query, cmd.category) * 0.5;
101+
return Math.max(l, k, c);
102+
}
103+
104+
// ── Recent usage ───────────────────────────────────────────────────────
105+
106+
export function getRecentIds(): string[] {
107+
try { return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]'); }
108+
catch { return []; }
109+
}
110+
export function recordUsage(id: string) {
111+
try {
112+
const r = getRecentIds().filter(x => x !== id);
113+
r.unshift(id);
114+
localStorage.setItem(RECENT_KEY, JSON.stringify(r.slice(0, 30)));
115+
} catch { /* noop */ }
116+
}

scripts/module-size-allowlist.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
418 apps/viewer/src/components/viewer/chat/ExecutableCodeBlock.tsx
5555
1802 apps/viewer/src/components/viewer/ChatPanel.tsx
5656
1314 apps/viewer/src/components/viewer/ClashPanel.tsx
57-
849 apps/viewer/src/components/viewer/CommandPalette.tsx
57+
752 apps/viewer/src/components/viewer/CommandPalette.tsx
5858
410 apps/viewer/src/components/viewer/ComparePanel.tsx
5959
1059 apps/viewer/src/components/viewer/DataConnector.tsx
6060
1842 apps/viewer/src/components/viewer/Drawing2DCanvas.tsx

0 commit comments

Comments
 (0)