Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"dev": "node ./scripts/electron-dev.cjs",
"build": "electron-vite build",
"typecheck": "tsc -p tsconfig.check.json",
"test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-packaged-runtime-imports.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && node scripts/check-backend-api-major.cjs && pnpm -C backend exec node --import tsx --test ../src/model-contract.test.ts ../src/use-ai-chat.test.ts ../src/extension-view.test.tsx ../src/electron/calculator.test.ts ../src/electron/url-utils.test.ts ../src/electron/running-app-status.test.ts ../src/electron/app-icon-cache.test.ts ../src/electron/app-index-service.test.ts ../src/electron/shortcut-ownership.test.ts ../src/electron/extension-permissions.test.ts ../src/electron/extension-ui-api.test.ts ../src/electron/ipc-registration.test.ts ../src/electron/app-ipc-handlers.test.ts ../src/electron/extension-window-manager.test.ts ../src/electron/window-navigation-policy.test.ts ../src/view-patches.test.ts && pnpm typecheck && pnpm -C backend test",
"test": "pnpm build && node --check dist/main/main.js && node --check dist/preload/preload.cjs && node scripts/check-packaged-runtime-imports.cjs && node scripts/check-design-system.cjs && node scripts/check-internal-extensions.cjs && node scripts/check-clone-safe-actions.cjs && node scripts/check-packaged-resources.cjs && node scripts/check-backend-contract-fixtures.cjs && node scripts/check-backend-api-major.cjs && pnpm -C backend exec node --import tsx --test ../src/model-contract.test.ts ../src/use-ai-chat.test.ts ../src/extension-view.test.tsx ../src/electron/calculator.test.ts ../src/electron/url-utils.test.ts ../src/electron/running-app-status.test.ts ../src/electron/app-icon-cache.test.ts ../src/electron/app-index-service.test.ts ../src/electron/shortcut-ownership.test.ts ../src/electron/extension-permissions.test.ts ../src/electron/extension-ui-api.test.ts ../src/electron/ipc-registration.test.ts ../src/electron/app-ipc-handlers.test.ts ../src/electron/extension-window-manager.test.ts ../src/electron/window-navigation-policy.test.ts ../src/view-patches.test.ts ../src/search-ranking.test.ts && pnpm typecheck && pnpm -C backend test",
"palette:debug": "node .agents/skills/palette-debug/palette-debug.cjs",
"learnings:export": "node scripts/learnings-export.cjs",
"logs:tail": "node scripts/tail-log.cjs",
Expand Down
6 changes: 4 additions & 2 deletions scripts/check-clone-safe-actions.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ if (!/function\s+normalizeActionPanel[\s\S]*const\s+\{\s*lazyActions,\s*\.\.\.sa
fail('normalizeActionPanel must strip lazyActions after normalizing them')
}

if (!/async\s+function\s+searchActions[\s\S]*structuredClone\(sorted\)[\s\S]*return\s+sorted/.test(source)) {
fail('searchActions must structuredClone-check results before returning through IPC')
// Note: prepareRootActionForRenderer already strips handlers so sorted is clone-safe.
// The discarded structuredClone(sorted) was removed per #33. Verify the function returns sorted.
if (!/async\s+function\s+searchActions[\s\S]*return\s+sorted/.test(source)) {
fail('searchActions must return the sorted results for IPC')
}

if (!/async\s+function\s+executeActionForIpc[\s\S]*structuredClone\(result\)/.test(source)) {
Expand Down
3 changes: 1 addition & 2 deletions src/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1377,13 +1377,12 @@ async function searchActions(query, options: any = {}) {
}
})

const sorted = measureDebugPerformanceSync('search.sort-prepare-clone', { queryLength: q.length, resultCount: results.length }, () => results
const sorted = measureDebugPerformanceSync('search.sort-prepare', { queryLength: q.length, resultCount: results.length }, () => results
.sort((a, b) => {
return b.score - a.score || b.lastUsed - a.lastUsed || a.title.localeCompare(b.title)
})
.slice(0, 30)
.map(prepareRootActionForRenderer))
structuredClone(sorted)
markDebugPerformance('search.actions.result', { queryLength: q.length, contributedCount: contributedItems.length, rankedCount: results.length, resultCount: sorted.length })
return sorted
})
Expand Down
14 changes: 3 additions & 11 deletions src/electron/search-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import crypto from 'node:crypto'
import { calculate, calculateDetailed, calculateRateResult, parseRateExpression } from './calculator'
import { scoreNormalizedNonEmpty } from '../search-ranking'

export { calculate, calculateDetailed, calculateRateResult, parseRateExpression }

Expand All @@ -11,19 +12,10 @@ export function hashValue(value: unknown) {
return crypto.createHash('sha1').update(String(value)).digest('hex')
}

export function scoreNormalized(value: unknown, q: string) {
export function scoreNormalized(value: unknown, q: string): number {
if (!q) return 0
const v = normalize(value)
if (v === q) return 100
if (v.startsWith(q)) return 80
if (v.includes(q)) return 50
let pos = 0
for (const ch of q) {
pos = v.indexOf(ch, pos)
if (pos === -1) return 0
pos += 1
}
return 20
return scoreNormalizedNonEmpty(v, q)
}

export function score(value: unknown, query: unknown) {
Expand Down
16 changes: 4 additions & 12 deletions src/filtering.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import { actionsFromPanel, type CommandItem, type CommandView } from './model'
import { scoreNormalizedNonEmpty } from './search-ranking'

export function scoreText(value: string | undefined, filter: string) {
const text = value?.toLowerCase() || ''
export function scoreText(value: string | undefined, filter: string): number {
if (!filter) return 1
if (text === filter) return 100
if (text.startsWith(filter)) return 80
if (text.includes(filter)) return 50
let position = 0
for (const character of filter) {
position = text.indexOf(character, position)
if (position === -1) return 0
position += 1
}
return 20
const text = (value || '').toLowerCase()
return scoreNormalizedNonEmpty(text, filter)
}

export function valuesMatch(filterValue: string, ...values: Array<string | undefined>) {
Expand Down
107 changes: 107 additions & 0 deletions src/search-ranking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { scoreNormalizedNonEmpty } from './search-ranking'
import { scoreText } from './filtering'
import { normalize, scoreNormalized } from './electron/search-utils'

// ── Core bands ───────────────────────────────────────────────────────
test('scoreNormalizedNonEmpty exact match', () => {
assert.equal(scoreNormalizedNonEmpty('hello', 'hello'), 100)
assert.equal(scoreNormalizedNonEmpty('a', 'a'), 100)
})

test('scoreNormalizedNonEmpty startsWith', () => {
assert.equal(scoreNormalizedNonEmpty('hello world', 'hello'), 80)
assert.equal(scoreNormalizedNonEmpty('abc', 'a'), 80)
assert.equal(scoreNormalizedNonEmpty('nevermind', 'never'), 80)
})

test('scoreNormalizedNonEmpty includes', () => {
assert.equal(scoreNormalizedNonEmpty('hello world', 'world'), 50)
assert.equal(scoreNormalizedNonEmpty('open settings', 'settings'), 50)
assert.equal(scoreNormalizedNonEmpty('abc123', '123'), 50)
})

test('scoreNormalizedNonEmpty fuzzy match', () => {
assert.equal(scoreNormalizedNonEmpty('hello', 'hlo'), 20)
assert.equal(scoreNormalizedNonEmpty('typescript', 'ts'), 20)
assert.equal(scoreNormalizedNonEmpty('settings', 'stng'), 20)
})

test('scoreNormalizedNonEmpty no match', () => {
assert.equal(scoreNormalizedNonEmpty('hello', 'xyz'), 0)
assert.equal(scoreNormalizedNonEmpty('abc', 'abx'), 0)
})

test('scoreNormalizedNonEmpty case sensitivity is caller responsibility', () => {
// Core expects already-lowered input; passing mixed case may not match
assert.equal(scoreNormalizedNonEmpty('Hello', 'hello'), 0)
assert.equal(scoreNormalizedNonEmpty('HELLO', 'hello'), 0)
})

// ── scoreText wrapper ─────────────────────────────────────────────────
test('scoreText empty filter returns 1', () => {
assert.equal(scoreText('anything', ''), 1)
assert.equal(scoreText(undefined, ''), 1)
})

test('scoreText exact match', () => {
assert.equal(scoreText('hello', 'hello'), 100)
})

test('scoreText startsWith', () => {
assert.equal(scoreText('hello world', 'hello'), 80)
})

test('scoreText includes', () => {
assert.equal(scoreText('hello world', 'world'), 50)
})

test('scoreText fuzzy', () => {
assert.equal(scoreText('settings', 'stng'), 20)
})

test('scoreText no match', () => {
assert.equal(scoreText('hello', 'xyz'), 0)
})

test('scoreText undefined value', () => {
assert.equal(scoreText(undefined, 'query'), 0)
})

test('scoreText case insensitive via toLowerCase', () => {
assert.equal(scoreText('Hello', 'hello'), 100)
assert.equal(scoreText('HELLO', 'hello'), 100)
})

// ── scoreNormalized wrapper ───────────────────────────────────────────
test('scoreNormalized empty query returns 0', () => {
assert.equal(scoreNormalized('anything', ''), 0)
assert.equal(scoreNormalized('', ''), 0)
assert.equal(scoreNormalized(undefined, ''), 0)
})

test('scoreNormalized exact match', () => {
assert.equal(scoreNormalized('hello', normalize('hello')), 100)
})

test('scoreNormalized startsWith', () => {
assert.equal(scoreNormalized('hello world', normalize('hello')), 80)
})

test('scoreNormalized includes', () => {
assert.equal(scoreNormalized('hello world', normalize('world')), 50)
})

test('scoreNormalized fuzzy', () => {
assert.equal(scoreNormalized('settings', normalize('stng')), 20)
})

test('scoreNormalized no match', () => {
assert.equal(scoreNormalized('hello', normalize('xyz')), 0)
})

test('scoreNormalized case insensitive and trim via normalize', () => {
assert.equal(scoreNormalized(' Hello ', normalize('hello')), 100)
assert.equal(scoreNormalized(123, normalize('123')), 100)
})
18 changes: 18 additions & 0 deletions src/search-ranking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Shared fuzzy-match scoring core.
*
* Both arguments must already be lowercased and trimmed by the caller.
* `query` must be non‑empty; wrappers guard that contract.
*/
export function scoreNormalizedNonEmpty(text: string, query: string): number {
if (text === query) return 100
if (text.startsWith(query)) return 80
if (text.includes(query)) return 50
let pos = 0
for (const ch of query) {
pos = text.indexOf(ch, pos)
if (pos === -1) return 0
pos += 1
}
return 20
}
Loading