From 6163b93ef56e5339d750b72c45c45c28151a45e8 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:24:43 +0200 Subject: [PATCH] perf(launcher): window command list rendering --- package.json | 1 + .../measure-launcher-command-list-render.mjs | 284 ++++++++++++ .../test-launcher-command-list-windowing.mjs | 48 ++ .../src/components/LauncherCommandList.tsx | 413 +++++++++++++++--- .../src/components/LauncherCommandRow.tsx | 65 ++- 5 files changed, 727 insertions(+), 84 deletions(-) create mode 100644 scripts/measure-launcher-command-list-render.mjs create mode 100644 scripts/test-launcher-command-list-windowing.mjs diff --git a/package.json b/package.json index f262a8c1..32eb0939 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build": "npm run build:main && npm run build:renderer && npm run build:native", "build:main": "tsc -p tsconfig.main.json && cp src/main/emoji-data.json dist/main/emoji-data.json", "build:renderer": "vite build", + "measure:launcher-command-list": "node scripts/measure-launcher-command-list-render.mjs", "check:i18n": "node scripts/check-i18n.mjs", "test": "node --test 'scripts/test-*.mjs'", "build:native": "node scripts/build-native.mjs", diff --git a/scripts/measure-launcher-command-list-render.mjs b/scripts/measure-launcher-command-list-render.mjs new file mode 100644 index 00000000..5f90920a --- /dev/null +++ b/scripts/measure-launcher-command-list-render.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import * as esbuild from 'esbuild'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const rowPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandRow.tsx'); +const listPath = path.join(repoRoot, 'src/renderer/src/components/LauncherCommandList.tsx'); + +const rowCount = readNumberArg('--rows', 5000); +const iterations = readNumberArg('--iterations', 5); +const warmups = readNumberArg('--warmups', 1); +const jsonOnly = process.argv.includes('--json'); + +function readNumberArg(name, fallback) { + const raw = process.argv.find((arg) => arg.startsWith(`${name}=`)); + if (!raw) return fallback; + const value = Number(raw.slice(name.length + 1)); + return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback; +} + +const tmpDir = await fs.mkdtemp(path.join(repoRoot, '.launcher-command-list-measure-')); +const entryPath = path.join(tmpDir, 'entry.tsx'); +const bundlePath = path.join(tmpDir, 'bundle.mjs'); + +await fs.writeFile(entryPath, ` +import React from 'react'; +import { renderToString } from 'react-dom/server'; +import LauncherCommandList from ${JSON.stringify(listPath)}; + +type CommandInfo = { + id: string; + title: string; + subtitle?: string; + keywords?: string[]; + iconDataUrl?: string; + iconEmoji?: string; + iconName?: string; + category: 'app' | 'settings' | 'system' | 'extension' | 'script'; + path?: string; + browserResultKind?: 'open-tab' | 'bookmark' | 'history' | 'search'; + browserFaviconUrl?: string; +}; + +type LauncherCommandSection = { + title: string; + items: CommandInfo[]; +}; + +type Metrics = { + rowRenderCount: number; +}; + +declare global { + // eslint-disable-next-line no-var + var __launcherCommandListMetrics: Metrics | undefined; +} + +const rowCount = ${rowCount}; +const iterations = ${iterations}; +const warmups = ${warmups}; +const jsonOnly = ${JSON.stringify(jsonOnly)}; + +const TRANSLATIONS: Record = { + 'launcher.badges.application': 'Application', + 'launcher.badges.bookmark': 'Bookmark', + 'launcher.badges.extension': 'Extension', + 'launcher.badges.history': 'History', + 'launcher.badges.openTab': 'Open Tab', + 'launcher.badges.quickLink': 'Quick Link', + 'launcher.badges.script': 'Script', + 'launcher.badges.settings': 'System Settings', + 'launcher.categories.browser': 'Browser', + 'launcher.categories.files': 'Files', + 'launcher.categories.recent': 'Recent', + 'launcher.categories.search': 'Search', + 'launcher.sections.pinned': 'Pinned', + 'launcher.sections.results': 'Results', + 'launcher.sections.selectedText': 'Selected Text', + 'launcher.status.discoveringApps': 'Discovering apps...', + 'launcher.status.noMatchingResults': 'No matching results', + 'common.system': 'System', + 'read.title': 'Read', + 'settings.title': 'Settings', + 'whisper.title': 'Whisper', +}; + +function t(key: string): string { + return TRANSLATIONS[key] || key; +} + +function median(values: number[]): number { + const sorted = values.slice().sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)] || 0; +} + +function makeCommand(index: number, titlePrefix = 'Command'): CommandInfo { + const kind = index % 9; + const base = { + id: \`measure-command-\${index}\`, + title: \`\${titlePrefix} \${String(index).padStart(5, '0')}\`, + subtitle: index % 4 === 0 ? \`Workspace action \${index}\` : undefined, + keywords: ['measure', 'launcher', String(index)], + }; + if (kind === 0) { + return { ...base, category: 'app', path: \`/Applications/Measure \${index}.app\`, iconDataUrl: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=' }; + } + if (kind === 1) { + return { ...base, category: 'system', id: \`system-measure-\${index}\` }; + } + if (kind === 2) { + return { ...base, category: 'extension', path: \`measure-extension/command-\${index}\` }; + } + if (kind === 3) { + return { ...base, category: 'script', iconEmoji: '>' }; + } + if (kind === 4) { + return { ...base, category: 'system', browserResultKind: 'history', browserFaviconUrl: 'https://example.com/favicon.ico' }; + } + if (kind === 5) { + return { ...base, category: 'system', browserResultKind: 'open-tab' }; + } + if (kind === 6) { + return { ...base, category: 'system', browserResultKind: 'bookmark' }; + } + if (kind === 7) { + return { ...base, category: 'system', id: \`quicklink-measure-\${index}\`, iconName: 'Link' }; + } + return { ...base, category: 'settings' }; +} + +function makeSections(commands: CommandInfo[], titles: string[]): LauncherCommandSection[] { + const perSection = Math.max(1, Math.ceil(commands.length / titles.length)); + return titles.map((title, sectionIndex) => ({ + title, + items: commands.slice(sectionIndex * perSection, (sectionIndex + 1) * perSection), + })).filter((section) => section.items.length > 0); +} + +function flatten(sections: LauncherCommandSection[]): CommandInfo[] { + return sections.flatMap((section) => section.items); +} + +const rootCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index)); +const queryCommands = Array.from({ length: rowCount }, (_, index) => makeCommand(index, 'Query Match')).reverse(); +const narrowedCommands = Array.from({ length: Math.max(1, Math.floor(rowCount * 0.6)) }, (_, index) => makeCommand(index * 2, 'Filtered Match')); + +const rootSections = makeSections(rootCommands, ['', 'Pinned', 'Recent', 'Results', 'Files']); +const querySections = makeSections(queryCommands, ['Results', 'Browser', 'Files', 'Search']); +const narrowedSections = makeSections(narrowedCommands, ['Results', 'Browser', 'Files']); + +const scenarios = [ + { name: 'root results initial', sections: rootSections, selectedIndex: 0 }, + { name: 'selection moves to middle', sections: rootSections, selectedIndex: Math.floor(rowCount / 2) }, + { name: 'selection moves to end', sections: rootSections, selectedIndex: rowCount - 1 }, + { name: 'query update same-size reshuffle', sections: querySections, selectedIndex: 0 }, + { name: 'query update narrowed-large', sections: narrowedSections, selectedIndex: Math.min(20, narrowedCommands.length - 1) }, +]; + +const noop = () => {}; + +function renderScenario(scenario: typeof scenarios[number]) { + const displayCommands = flatten(scenario.sections); + const listRef = { current: null }; + const itemRefs = { current: [] }; + globalThis.__launcherCommandListMetrics = { rowRenderCount: 0 }; + const started = performance.now(); + const html = renderToString( + } + itemRefs={itemRefs as React.MutableRefObject<(HTMLDivElement | null)[]>} + isLoading={false} + isHidden={false} + displayCommands={displayCommands as any} + sections={scenario.sections as any} + calcResult={null} + calcOffset={0} + selectedIndex={scenario.selectedIndex} + commandAliases={{}} + commandHotkeys={{ + 'measure-command-2': 'Command+Shift+P', + 'measure-command-7': 'Control+Option+L', + 'quicklink-measure-16': 'Hyper+K', + }} + onCalculatorCopy={noop} + onCommandClick={noop} + onCommandContextMenu={noop} + t={t} + /> + ); + const durationMs = performance.now() - started; + return { + name: scenario.name, + durationMs, + rowRenderCount: globalThis.__launcherCommandListMetrics?.rowRenderCount || 0, + commandCount: displayCommands.length, + htmlLength: html.length, + }; +} + +for (let i = 0; i < warmups; i += 1) { + for (const scenario of scenarios) { + renderScenario(scenario); + } +} + +const results = scenarios.map((scenario) => { + const samples = Array.from({ length: iterations }, () => renderScenario(scenario)); + return { + name: scenario.name, + commandCount: samples[0]?.commandCount || 0, + rowRenderCount: samples[0]?.rowRenderCount || 0, + medianDurationMs: median(samples.map((sample) => sample.durationMs)), + minDurationMs: Math.min(...samples.map((sample) => sample.durationMs)), + maxDurationMs: Math.max(...samples.map((sample) => sample.durationMs)), + htmlLength: samples[0]?.htmlLength || 0, + }; +}); + +const totalRows = results.reduce((sum, result) => sum + result.rowRenderCount, 0); +const totalMedianMs = results.reduce((sum, result) => sum + result.medianDurationMs, 0); +const summary = { rowCount, iterations, results, totalRows, totalMedianMs }; + +if (!jsonOnly) { + console.log(\`LauncherCommandList render measurement (rows=\${rowCount}, iterations=\${iterations})\`); + for (const result of results) { + console.log([ + \`- \${result.name}\`, + \`commands=\${result.commandCount}\`, + \`rowRenders=\${result.rowRenderCount}\`, + \`median=\${result.medianDurationMs.toFixed(2)}ms\`, + \`min=\${result.minDurationMs.toFixed(2)}ms\`, + \`max=\${result.maxDurationMs.toFixed(2)}ms\`, + ].join(' | ')); + } + console.log(\`Total row renders per scenario sequence: \${totalRows}\`); + console.log(\`Total median render time per scenario sequence: \${totalMedianMs.toFixed(2)}ms\`); +} +console.log(JSON.stringify(summary, null, 2)); +`); + +const instrumentLauncherRowPlugin = { + name: 'instrument-launcher-command-row', + setup(build) { + build.onLoad({ filter: /LauncherCommandRow\.tsx$/ }, async (args) => { + let source = await fs.readFile(args.path, 'utf8'); + if (path.resolve(args.path) === rowPath) { + const marker = '}) => {\n'; + if (!source.includes(marker)) { + throw new Error('Unable to instrument LauncherCommandRow render counter.'); + } + source = source.replace(marker, `${marker} const __launcherMetrics = (globalThis.__launcherCommandListMetrics ||= { rowRenderCount: 0 });\n __launcherMetrics.rowRenderCount += 1;\n`); + } + return { contents: source, loader: 'tsx' }; + }); + }, +}; + +try { + await esbuild.build({ + entryPoints: [entryPath], + outfile: bundlePath, + absWorkingDir: repoRoot, + bundle: true, + format: 'esm', + platform: 'node', + jsx: 'automatic', + packages: 'external', + nodePaths: [path.join(repoRoot, 'node_modules')], + loader: { + '.svg': 'dataurl', + '.png': 'dataurl', + }, + plugins: [instrumentLauncherRowPlugin], + logLevel: 'silent', + }); + + await import(pathToFileURL(bundlePath).href); +} finally { + await fs.rm(tmpDir, { recursive: true, force: true }); +} diff --git a/scripts/test-launcher-command-list-windowing.mjs b/scripts/test-launcher-command-list-windowing.mjs new file mode 100644 index 00000000..b34dc9d0 --- /dev/null +++ b/scripts/test-launcher-command-list-windowing.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function runMeasurement() { + const output = execFileSync( + process.execPath, + [ + 'scripts/measure-launcher-command-list-render.mjs', + '--rows=5000', + '--iterations=1', + '--warmups=0', + '--json', + ], + { + cwd: repoRoot, + encoding: 'utf8', + maxBuffer: 1024 * 1024, + } + ); + const jsonStart = output.lastIndexOf('{\n "rowCount"'); + assert.notEqual(jsonStart, -1, `Measurement output did not include JSON summary:\n${output}`); + return JSON.parse(output.slice(jsonStart)); +} + +test('launcher command list windows large result sets instead of rendering every row', () => { + const metrics = runMeasurement(); + const fullSizeScenarios = metrics.results.filter((result) => result.commandCount === 5000); + assert.equal(fullSizeScenarios.length, 4); + + for (const result of fullSizeScenarios) { + assert.ok( + result.rowRenderCount < 100, + `${result.name} rendered ${result.rowRenderCount} rows for ${result.commandCount} commands` + ); + } + + assert.ok( + metrics.totalRows < 300, + `Expected the full scenario sequence to render fewer than 300 rows, got ${metrics.totalRows}` + ); +}); diff --git a/src/renderer/src/components/LauncherCommandList.tsx b/src/renderer/src/components/LauncherCommandList.tsx index d6873475..9a1e1921 100644 --- a/src/renderer/src/components/LauncherCommandList.tsx +++ b/src/renderer/src/components/LauncherCommandList.tsx @@ -9,6 +9,176 @@ export type LauncherCommandSection = { items: CommandInfo[]; }; +const COMMAND_ROW_HEIGHT = 38; +const SECTION_HEADER_HEIGHT = 26; +const CALCULATOR_CARD_HEIGHT = 124; +const DEFAULT_VIEWPORT_HEIGHT = 420; +const VIRTUALIZATION_THRESHOLD = 120; +const VIRTUALIZATION_OVERSCAN_PX = COMMAND_ROW_HEIGHT * 6; + +type LauncherVirtualEntry = + | { + kind: 'calculator'; + key: string; + top: number; + height: number; + absoluteIndex: number; + } + | { + kind: 'section'; + key: string; + title: string; + top: number; + height: number; + } + | { + kind: 'command'; + key: string; + command: CommandInfo; + flatIndex: number; + absoluteIndex: number; + top: number; + height: number; + }; + +type LauncherVirtualList = { + entries: LauncherVirtualEntry[]; + totalHeight: number; +}; + +function buildVirtualEntries( + sections: LauncherCommandSection[], + calcResult: CalcResult | null, + calcOffset: number +): LauncherVirtualList { + const entries: LauncherVirtualEntry[] = []; + let top = 0; + let flatIndexCursor = 0; + + if (calcResult) { + entries.push({ + kind: 'calculator', + key: 'calculator', + top, + height: CALCULATOR_CARD_HEIGHT, + absoluteIndex: 0, + }); + top += CALCULATOR_CARD_HEIGHT; + } + + sections.forEach((section, sectionIndex) => { + const sectionStartIndex = flatIndexCursor; + if (section.title) { + entries.push({ + kind: 'section', + key: `section-${sectionIndex}-${sectionStartIndex}-${section.title}`, + title: section.title, + top, + height: SECTION_HEADER_HEIGHT, + }); + top += SECTION_HEADER_HEIGHT; + } + + section.items.forEach((command, itemIndex) => { + const flatIndex = sectionStartIndex + itemIndex; + entries.push({ + kind: 'command', + key: command.id, + command, + flatIndex, + absoluteIndex: flatIndex + calcOffset, + top, + height: COMMAND_ROW_HEIGHT, + }); + top += COMMAND_ROW_HEIGHT; + }); + flatIndexCursor += section.items.length; + }); + + return { entries, totalHeight: top }; +} + +function findEntryIndexAtOffset(entries: LauncherVirtualEntry[], offset: number): number { + let low = 0; + let high = entries.length - 1; + let match = entries.length; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const entry = entries[mid]; + if (entry.top + entry.height >= offset) { + match = mid; + high = mid - 1; + } else { + low = mid + 1; + } + } + + return Math.max(0, Math.min(match, entries.length)); +} + +function getEntryRangeForOffsets( + entries: LauncherVirtualEntry[], + startOffset: number, + endOffset: number +): { start: number; end: number } { + if (entries.length === 0) return { start: 0, end: 0 }; + const start = findEntryIndexAtOffset(entries, startOffset); + let end = start; + while (end < entries.length && entries[end].top <= endOffset) { + end += 1; + } + return { start, end }; +} + +function mergeEntryRanges(ranges: Array<{ start: number; end: number }>): Array<{ start: number; end: number }> { + const normalized = ranges + .filter((range) => range.end > range.start) + .sort((a, b) => a.start - b.start); + const merged: Array<{ start: number; end: number }> = []; + + normalized.forEach((range) => { + const last = merged[merged.length - 1]; + if (last && range.start <= last.end) { + last.end = Math.max(last.end, range.end); + return; + } + merged.push({ ...range }); + }); + + return merged; +} + +function getVirtualizedEntries( + entries: LauncherVirtualEntry[], + scrollTop: number, + viewportHeight: number, + selectedIndex: number +): LauncherVirtualEntry[] { + const viewportStart = Math.max(0, scrollTop - VIRTUALIZATION_OVERSCAN_PX); + const viewportEnd = scrollTop + viewportHeight + VIRTUALIZATION_OVERSCAN_PX; + const ranges = [getEntryRangeForOffsets(entries, viewportStart, viewportEnd)]; + const selectedEntry = entries.find((entry) => entry.kind !== 'section' && entry.absoluteIndex === selectedIndex); + + if (selectedEntry && (selectedEntry.top < viewportStart || selectedEntry.top + selectedEntry.height > viewportEnd)) { + ranges.push( + getEntryRangeForOffsets( + entries, + Math.max(0, selectedEntry.top - VIRTUALIZATION_OVERSCAN_PX), + selectedEntry.top + selectedEntry.height + VIRTUALIZATION_OVERSCAN_PX + ) + ); + } + + return mergeEntryRanges(ranges).flatMap((range) => entries.slice(range.start, range.end)); +} + +function useLatestValue(value: T): React.MutableRefObject { + const ref = React.useRef(value); + ref.current = value; + return ref; +} + type LauncherCommandListProps = { listRef: React.RefObject; itemRefs: React.MutableRefObject<(HTMLDivElement | null)[]>; @@ -47,75 +217,180 @@ const LauncherCommandList: React.FC = ({ onCommandClick, onCommandContextMenu, t, -}) => ( -
- {isLoading ? ( -
-

{t('launcher.status.discoveringApps')}

-
- ) : displayCommands.length === 0 && !calcResult ? ( -
-

{t('launcher.status.noMatchingResults')}

-
- ) : ( -
- {calcResult && ( - (itemRefs.current[0] = el)} - onCopy={onCalculatorCopy} - t={t} - /> - )} - - {sections.reduce( - (acc, section) => { - const startIndex = acc.index; - if (section.title) { - acc.nodes.push( -
- {section.title} -
- ); - } - section.items.forEach((command, i) => { - const flatIndex = startIndex + i; - const absoluteIndex = flatIndex + calcOffset; - const commandAlias = String(commandAliases[command.id] || '').trim(); - const commandHotkey = String(commandHotkeys[command.id] || '').trim(); - acc.nodes.push( - (itemRefs.current[absoluteIndex] = el)} - commandAlias={commandAlias} - commandHotkey={commandHotkey} - onClick={(event) => { - void onCommandClick(command, absoluteIndex, event); - }} - onContextMenu={(event) => onCommandContextMenu(event, command, absoluteIndex)} - t={t} - /> - ); - }); - acc.index += section.items.length; - return acc; - }, - { nodes: [] as React.ReactNode[], index: 0 } - ).nodes} +}) => { + const [scrollTop, setScrollTop] = React.useState(0); + const [viewportHeight, setViewportHeight] = React.useState(DEFAULT_VIEWPORT_HEIGHT); + const commandClickRef = useLatestValue(onCommandClick); + const commandContextMenuRef = useLatestValue(onCommandContextMenu); + + const virtualList = React.useMemo( + () => buildVirtualEntries(sections, calcResult, calcOffset), + [calcOffset, calcResult, sections] + ); + const shouldVirtualize = virtualList.entries.length > VIRTUALIZATION_THRESHOLD; + const visibleEntries = React.useMemo( + () => + shouldVirtualize + ? getVirtualizedEntries(virtualList.entries, scrollTop, viewportHeight, selectedIndex) + : virtualList.entries, + [scrollTop, selectedIndex, shouldVirtualize, viewportHeight, virtualList.entries] + ); + const selectedEntry = React.useMemo( + () => virtualList.entries.find((entry) => entry.kind !== 'section' && entry.absoluteIndex === selectedIndex), + [selectedIndex, virtualList.entries] + ); + + const registerItemRef = React.useCallback( + (absoluteIndex: number, el: HTMLDivElement | null) => { + itemRefs.current[absoluteIndex] = el; + }, + [itemRefs] + ); + const registerCalculatorRef = React.useCallback( + (el: HTMLDivElement | null) => { + itemRefs.current[0] = el; + }, + [itemRefs] + ); + const handleCommandClick = React.useCallback( + (command: CommandInfo, absoluteIndex: number, event?: React.MouseEvent) => { + void commandClickRef.current(command, absoluteIndex, event); + }, + [commandClickRef] + ); + const handleCommandContextMenu = React.useCallback( + (event: React.MouseEvent, command: CommandInfo, absoluteIndex: number) => { + commandContextMenuRef.current(event, command, absoluteIndex); + }, + [commandContextMenuRef] + ); + const handleScroll = React.useCallback((event: React.UIEvent) => { + setScrollTop(event.currentTarget.scrollTop); + }, []); + + React.useEffect(() => { + const element = listRef.current; + if (!element || !shouldVirtualize) return; + + const updateViewportHeight = () => { + setViewportHeight(element.clientHeight || DEFAULT_VIEWPORT_HEIGHT); + setScrollTop(element.scrollTop); + }; + updateViewportHeight(); + + const resizeObserver = + typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(updateViewportHeight); + resizeObserver?.observe(element); + window.addEventListener('resize', updateViewportHeight); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateViewportHeight); + }; + }, [listRef, shouldVirtualize]); + + React.useEffect(() => { + const element = listRef.current; + if (!element || !shouldVirtualize || !selectedEntry) return; + + const currentTop = element.scrollTop; + const currentBottom = currentTop + (element.clientHeight || viewportHeight); + let nextTop: number | null = null; + + if (selectedEntry.top < currentTop) { + nextTop = selectedEntry.top; + } else if (selectedEntry.top + selectedEntry.height > currentBottom) { + nextTop = selectedEntry.top + selectedEntry.height - (element.clientHeight || viewportHeight); + } + + if (nextTop !== null) { + element.scrollTo({ top: Math.max(0, nextTop), behavior: 'smooth' }); + } + }, [listRef, selectedEntry, shouldVirtualize, viewportHeight]); + + const renderEntry = (entry: LauncherVirtualEntry, virtualized: boolean): React.ReactNode => { + let node: React.ReactNode; + if (entry.kind === 'calculator') { + node = ( + + ); + } else if (entry.kind === 'section') { + node = ( +
+ {entry.title} +
+ ); + } else { + const commandAlias = String(commandAliases[entry.command.id] || '').trim(); + const commandHotkey = String(commandHotkeys[entry.command.id] || '').trim(); + node = ( + + ); + } + + if (!virtualized) { + return {node}; + } + + return ( +
+ {node}
- )} -
-); + ); + }; + + return ( +
+ {isLoading ? ( +
+

{t('launcher.status.discoveringApps')}

+
+ ) : displayCommands.length === 0 && !calcResult ? ( +
+

{t('launcher.status.noMatchingResults')}

+
+ ) : shouldVirtualize ? ( +
+ {visibleEntries.map((entry) => renderEntry(entry, true))} +
+ ) : ( +
+ {visibleEntries.map((entry) => renderEntry(entry, false))} +
+ )} +
+ ); +}; export default LauncherCommandList; diff --git a/src/renderer/src/components/LauncherCommandRow.tsx b/src/renderer/src/components/LauncherCommandRow.tsx index 877b8247..62ada67a 100644 --- a/src/renderer/src/components/LauncherCommandRow.tsx +++ b/src/renderer/src/components/LauncherCommandRow.tsx @@ -12,30 +12,63 @@ import { type LauncherCommandRowProps = { command: CommandInfo; flatIndex: number; + absoluteIndex: number; selected: boolean; - itemRef: (el: HTMLDivElement | null) => void; + registerItemRef: (absoluteIndex: number, el: HTMLDivElement | null) => void; commandAlias: string; commandHotkey: string; - onClick: (event: React.MouseEvent) => void; - onContextMenu: (event: React.MouseEvent) => void; + onCommandClick: ( + command: CommandInfo, + selectedIndex: number, + event?: React.MouseEvent + ) => void | Promise; + onCommandContextMenu: ( + event: React.MouseEvent, + command: CommandInfo, + selectedIndex: number + ) => void; t: (key: string, params?: Record) => string; }; -const LauncherCommandRow: React.FC = ({ +const LauncherCommandRowComponent: React.FC = ({ command, flatIndex, + absoluteIndex, selected, - itemRef, + registerItemRef, commandAlias, commandHotkey, - onClick, - onContextMenu, + onCommandClick, + onCommandContextMenu, t, }) => { - const accessoryLabel = getCommandAccessoryLabel(command); - const typeBadgeLabel = getCommandTypeBadgeLabel(command, t); - const fallbackCategory = getCategoryLabel(command.category, t); - const hotkeyParts = commandHotkey ? getShortcutDisplayParts(commandHotkey) : []; + const accessoryLabel = React.useMemo(() => getCommandAccessoryLabel(command), [command]); + const typeBadgeLabel = React.useMemo(() => getCommandTypeBadgeLabel(command, t), [command, t]); + const fallbackCategory = React.useMemo(() => getCategoryLabel(command.category, t), [command.category, t]); + const hotkeyParts = React.useMemo( + () => (commandHotkey ? getShortcutDisplayParts(commandHotkey) : []), + [commandHotkey] + ); + const displayTitle = React.useMemo(() => getCommandDisplayTitle(command, t), [command, t]); + const commandIcon = React.useMemo(() => renderCommandIcon(command), [command]); + const itemRef = React.useCallback( + (el: HTMLDivElement | null) => { + registerItemRef(absoluteIndex, el); + }, + [absoluteIndex, registerItemRef] + ); + const handleClick = React.useCallback( + (event: React.MouseEvent) => { + void onCommandClick(command, absoluteIndex, event); + }, + [absoluteIndex, command, onCommandClick] + ); + const handleContextMenu = React.useCallback( + (event: React.MouseEvent) => { + onCommandContextMenu(event, command, absoluteIndex); + }, + [absoluteIndex, command, onCommandContextMenu] + ); return (
= ({ className={`command-item px-3 py-2 rounded-lg cursor-pointer ${ selected ? 'selected' : '' }`} - onClick={onClick} - onContextMenu={onContextMenu} + onClick={handleClick} + onContextMenu={handleContextMenu} >
- {renderCommandIcon(command)} + {commandIcon}
- {getCommandDisplayTitle(command, t)} + {displayTitle}
{accessoryLabel ? (
@@ -95,4 +128,6 @@ const LauncherCommandRow: React.FC = ({ ); }; +const LauncherCommandRow = React.memo(LauncherCommandRowComponent); + export default LauncherCommandRow;