From d3e615de7e8ab2e78aece5481fffaf34777fb9cc Mon Sep 17 00:00:00 2001 From: ByteAtATime Date: Sat, 28 Jun 2025 14:58:53 -0700 Subject: [PATCH] refactor(command-palette): extract logic into state and action bar This commit refactors the main `CommandPalette.svelte` component by extracting its complex logic into hooks. Additionally, the footer UI has been moved into a new `ActionBar.svelte` component. --- src/lib/apps.svelte.ts | 2 +- src/lib/command-palette.svelte.ts | 243 ++++++++++ src/lib/components/CommandPalette.svelte | 446 ------------------ .../command-palette/ActionBar.svelte | 88 ++++ .../command-palette/CommandPalette.svelte | 218 +++++++++ src/routes/+page.svelte | 2 +- 6 files changed, 551 insertions(+), 448 deletions(-) create mode 100644 src/lib/command-palette.svelte.ts delete mode 100644 src/lib/components/CommandPalette.svelte create mode 100644 src/lib/components/command-palette/ActionBar.svelte create mode 100644 src/lib/components/command-palette/CommandPalette.svelte diff --git a/src/lib/apps.svelte.ts b/src/lib/apps.svelte.ts index b24c5d1..d47f628 100644 --- a/src/lib/apps.svelte.ts +++ b/src/lib/apps.svelte.ts @@ -1,7 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { frecencyStore } from './frecency.svelte'; -type App = { name: string; comment?: string; exec: string; icon_path?: string }; +export type App = { name: string; comment?: string; exec: string; icon_path?: string }; class AppsStore { rawApps = $state([]); diff --git a/src/lib/command-palette.svelte.ts b/src/lib/command-palette.svelte.ts new file mode 100644 index 0000000..623aac7 --- /dev/null +++ b/src/lib/command-palette.svelte.ts @@ -0,0 +1,243 @@ +import type { PluginInfo } from '@raycast-linux/protocol'; +import { invoke } from '@tauri-apps/api/core'; +import Fuse from 'fuse.js'; +import { create, all } from 'mathjs'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import type { Quicklink } from '$lib/quicklinks.svelte'; +import { frecencyStore } from '$lib/frecency.svelte'; +import { viewManager } from './viewManager.svelte'; +import type { App } from './apps.svelte'; + +export type UnifiedItem = { + type: 'calculator' | 'plugin' | 'app' | 'quicklink'; + id: string; + data: any; + score: number; +}; + +type UseCommandPaletteItemsArgs = { + searchText: () => string; + plugins: () => PluginInfo[]; + installedApps: () => App[]; + quicklinks: () => Quicklink[]; + frecencyData: () => { itemId: string; useCount: number; lastUsedAt: number }[]; + selectedQuicklinkForArgument: () => Quicklink | null; +}; + +const math = create(all); + +export function useCommandPaletteItems({ + searchText, + plugins, + installedApps, + quicklinks, + frecencyData, + selectedQuicklinkForArgument +}: UseCommandPaletteItemsArgs) { + const allSearchableItems = $derived.by(() => { + const items: { type: 'plugin' | 'app' | 'quicklink'; id: string; data: any }[] = []; + items.push(...plugins().map((p) => ({ type: 'plugin', id: p.pluginPath, data: p }) as const)); + items.push(...installedApps().map((a) => ({ type: 'app', id: a.exec, data: a }) as const)); + items.push( + ...quicklinks().map((q) => ({ type: 'quicklink', id: `quicklink-${q.id}`, data: q }) as const) + ); + return items; + }); + + const fuse = $derived( + new Fuse(allSearchableItems, { + keys: [ + 'data.title', + 'data.pluginTitle', + 'data.description', + 'data.name', + 'data.comment', + 'data.link' + ], + threshold: 0.4, + includeScore: true + }) + ); + + const calculatorResult = $derived.by(() => { + const term = searchText(); + if (!term.trim() || selectedQuicklinkForArgument()) { + return null; + } + + try { + const result = math.evaluate(term.trim()); + if (typeof result === 'function' || typeof result === 'undefined') return null; + const resultString = math.format(result, { precision: 14 }); + if (resultString === term.trim()) return null; + return { value: resultString, type: math.typeOf(result) }; + } catch { + return null; + } + }); + + const displayItems = $derived.by(() => { + let items: (UnifiedItem & { fuseScore?: number })[] = []; + const term = searchText(); + + if (term.trim()) { + items = fuse.search(term).map((result) => ({ + ...result.item, + score: 0, + fuseScore: result.score + })); + } else { + items = allSearchableItems.map((item) => ({ ...item, score: 0, fuseScore: 1 })); + } + + const frecencyMap = new Map(frecencyData().map((item) => [item.itemId, item])); + const now = Date.now() / 1000; + const gravity = 1.8; + + items.forEach((item) => { + const frecency = frecencyMap.get(item.id); + let frecencyScore = 0; + if (frecency) { + const ageInHours = Math.max(1, (now - frecency.lastUsedAt) / 3600); + frecencyScore = (frecency.useCount * 1000) / Math.pow(ageInHours + 2, gravity); + } + const textScore = item.fuseScore !== undefined ? 1 - item.fuseScore * 100 : 0; + item.score = frecencyScore + textScore; + }); + + items.sort((a, b) => b.score - a.score); + + const calcRes = calculatorResult; + if (calcRes) { + items.unshift({ + type: 'calculator', + id: 'calculator', + data: { + value: term, + result: calcRes.value, + resultType: calcRes.type + }, + score: 9999 + }); + } + + return items; + }); + + return { displayItems }; +} + +type UseCommandPaletteActionsArgs = { + selectedItem: () => UnifiedItem | undefined; + onRunPlugin: (plugin: PluginInfo) => void; + resetState: () => void; + focusArgumentInput: () => void; +}; + +export function useCommandPaletteActions({ + selectedItem, + onRunPlugin, + resetState, + focusArgumentInput +}: UseCommandPaletteActionsArgs) { + async function executeQuicklink(quicklink: Quicklink, argument?: string) { + const finalLink = argument + ? quicklink.link.replace(/\{argument\}/g, encodeURIComponent(argument)) + : quicklink.link.replace(/\{argument\}/g, ''); + await invoke('execute_quicklink', { + link: finalLink, + application: quicklink.application + }); + resetState(); + } + + async function handleEnter() { + const item = selectedItem(); + if (!item) return; + + await frecencyStore.recordUsage(item.id); + + switch (item.type) { + case 'calculator': { + writeText(item.data.result); + break; + } + case 'plugin': { + onRunPlugin(item.data as PluginInfo); + break; + } + case 'app': { + if (item.data.exec) { + invoke('launch_app', { exec: item.data.exec }).catch(console.error); + } + break; + } + case 'quicklink': { + const quicklink = item.data as Quicklink; + if (quicklink.link.includes('{argument}')) { + focusArgumentInput(); + } else { + executeQuicklink(quicklink); + } + break; + } + } + } + + async function handleResetRanking() { + const item = selectedItem(); + if (item) { + await frecencyStore.deleteEntry(item.id); + } + } + + function handleCopyDeeplink() { + const item = selectedItem(); + if (item?.type !== 'plugin') return; + const plugin = item.data as PluginInfo; + const authorOrOwner = + plugin.owner === 'raycast' + ? 'raycast' + : typeof plugin.author === 'string' + ? plugin.author + : (plugin.author?.name ?? 'unknown'); + + const deeplink = `raycast://extensions/${authorOrOwner}/${plugin.pluginName}/${plugin.commandName}`; + writeText(deeplink); + } + + function handleConfigureCommand() { + const item = selectedItem(); + if (item?.type !== 'plugin') return; + viewManager.showSettings(item.data.pluginName); + } + + function handleCopyAppName() { + const item = selectedItem(); + if (item?.type !== 'app') return; + writeText(item.data.name); + } + + function handleCopyAppPath() { + const item = selectedItem(); + if (item?.type !== 'app') return; + writeText(item.data.exec); + } + + async function handleHideApp() { + const item = selectedItem(); + if (item?.type !== 'app') return; + await frecencyStore.hideItem(item.id); + } + + return { + executeQuicklink, + handleEnter, + handleResetRanking, + handleCopyDeeplink, + handleConfigureCommand, + handleCopyAppName, + handleCopyAppPath, + handleHideApp + }; +} diff --git a/src/lib/components/CommandPalette.svelte b/src/lib/components/CommandPalette.svelte deleted file mode 100644 index 22ae767..0000000 --- a/src/lib/components/CommandPalette.svelte +++ /dev/null @@ -1,446 +0,0 @@ - - - - -
-
-
- - - {#if selectedQuicklinkForArgument} -
- {searchText || selectedQuicklinkForArgument.name} - -
-
- - - -
-
-
- {/if} -
-
- -
- ({ ...item, itemType: 'item' }))} - onenter={handleEnter} - bind:selectedIndex - bind:listElement - > - {#snippet itemSnippet({ item, isSelected, onclick })} - {#if item.type === 'calculator'} - - {:else if item.type === 'plugin'} - {@const assetsPath = path.dirname(item.data.pluginPath) + '/assets'} - - {#snippet accessories()} - Command - {/snippet} - - {:else if item.type === 'app'} - - {#snippet accessories()} - - Application - - {/snippet} - - {:else if item.type === 'quicklink'} - - {#snippet accessories()} - - Quicklink - - {/snippet} - - {/if} - {/snippet} - -
- - {#if selectedItem} - - {#snippet primaryAction({ props })} - {@const primaryActionText = - selectedItem.type === 'app' - ? 'Open Application' - : selectedItem.type === 'quicklink' - ? 'Open Quicklink' - : 'Open Command'} - - {/snippet} - {#snippet actions()} - - {#if selectedItem.type === 'plugin'} - Reset Ranking - - - Copy Deeplink - - {shortcutToText({ key: 'c', modifiers: ['ctrl', 'shift'] })} - - - - - Configure Command - - {shortcutToText({ key: ',', modifiers: ['ctrl', 'shift'] })} - - - {:else if selectedItem.type === 'app'} - Reset Ranking - - - Copy Name - - {shortcutToText({ key: '.', modifiers: ['ctrl'] })} - - - - Copy Path - - {shortcutToText({ key: '.', modifiers: ['ctrl', 'shift'] })} - - - - - Hide Application - - {shortcutToText({ key: 'h', modifiers: ['ctrl'] })} - - - {/if} - - {/snippet} - - {/if} -
diff --git a/src/lib/components/command-palette/ActionBar.svelte b/src/lib/components/command-palette/ActionBar.svelte new file mode 100644 index 0000000..aafc0e4 --- /dev/null +++ b/src/lib/components/command-palette/ActionBar.svelte @@ -0,0 +1,88 @@ + + +{#if selectedItem} + + {#snippet primaryAction({ props })} + {@const primaryActionText = + selectedItem.type === 'app' + ? 'Open Application' + : selectedItem.type === 'quicklink' + ? 'Open Quicklink' + : 'Open Command'} + + {/snippet} + {#snippet actions()} + + {#if selectedItem.type === 'plugin'} + Reset Ranking + + + Copy Deeplink + + {shortcutToText({ key: 'c', modifiers: ['ctrl', 'shift'] })} + + + + + Configure Command + + {shortcutToText({ key: ',', modifiers: ['ctrl', 'shift'] })} + + + {:else if selectedItem.type === 'app'} + Reset Ranking + + + Copy Name + + {shortcutToText({ key: '.', modifiers: ['ctrl'] })} + + + + Copy Path + + {shortcutToText({ key: '.', modifiers: ['ctrl', 'shift'] })} + + + + + Hide Application + + {shortcutToText({ key: 'h', modifiers: ['ctrl'] })} + + + {/if} + + {/snippet} + +{/if} diff --git a/src/lib/components/command-palette/CommandPalette.svelte b/src/lib/components/command-palette/CommandPalette.svelte new file mode 100644 index 0000000..2ed5802 --- /dev/null +++ b/src/lib/components/command-palette/CommandPalette.svelte @@ -0,0 +1,218 @@ + + + + +
+
+
+ + + {#if selectedQuicklinkForArgument} +
+ {searchText || selectedQuicklinkForArgument.name} + +
+
+ + + +
+
+
+ {/if} +
+
+ +
+ ({ ...item, itemType: 'item' }))} + onenter={actions.handleEnter} + bind:selectedIndex + bind:listElement + > + {#snippet itemSnippet({ item, isSelected, onclick })} + {#if item.type === 'calculator'} + + {:else if item.type === 'plugin'} + {@const assetsPath = path.dirname(item.data.pluginPath) + '/assets'} + + {#snippet accessories()} + Command + {/snippet} + + {:else if item.type === 'app'} + + {#snippet accessories()} + + Application + + {/snippet} + + {:else if item.type === 'quicklink'} + + {#snippet accessories()} + + Quicklink + + {/snippet} + + {/if} + {/snippet} + +
+ + +
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 92fa9dc..ba413f2 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,7 +5,7 @@ import type { PluginInfo } from '@raycast-linux/protocol'; import { listen } from '@tauri-apps/api/event'; import { onMount } from 'svelte'; - import CommandPalette from '$lib/components/CommandPalette.svelte'; + import CommandPalette from '$lib/components/command-palette/CommandPalette.svelte'; import PluginRunner from '$lib/components/PluginRunner.svelte'; import Extensions from '$lib/components/Extensions.svelte'; import OAuthView from '$lib/components/OAuthView.svelte';