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.
This commit is contained in:
ByteAtATime 2025-06-28 14:58:53 -07:00
parent 398ed86c40
commit d3e615de7e
No known key found for this signature in database
6 changed files with 551 additions and 448 deletions

View file

@ -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<App[]>([]);

View file

@ -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
};
}

View file

@ -1,446 +0,0 @@
<script lang="ts">
import type { PluginInfo } from '@raycast-linux/protocol';
import { Input } from '$lib/components/ui/input';
import Calculator from '$lib/components/Calculator.svelte';
import BaseList from '$lib/components/BaseList.svelte';
import { invoke } from '@tauri-apps/api/core';
import Fuse from 'fuse.js';
import ListItemBase from './nodes/shared/ListItemBase.svelte';
import path from 'path';
import { create, all } from 'mathjs';
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { tick } from 'svelte';
import { quicklinksStore, type Quicklink } from '$lib/quicklinks.svelte';
import { appsStore } from '$lib/apps.svelte';
import { frecencyStore } from '$lib/frecency.svelte';
import ActionBar from './nodes/shared/ActionBar.svelte';
import { Button } from './ui/button';
import { Kbd } from './ui/kbd';
import ActionMenu from './nodes/shared/ActionMenu.svelte';
import * as DropdownMenu from './ui/dropdown-menu';
import { Separator } from './ui/separator';
import { shortcutToText } from '$lib/renderKey';
import { viewManager } from '$lib/viewManager.svelte';
type Props = {
plugins: PluginInfo[];
onRunPlugin: (plugin: PluginInfo) => void;
};
type UnifiedItem = {
type: 'calculator' | 'plugin' | 'app' | 'quicklink';
id: string;
data: any;
score: number;
};
let { plugins, onRunPlugin }: Props = $props();
const { apps: installedApps } = $derived(appsStore);
const { quicklinks } = $derived(quicklinksStore);
const { data: frecencyData } = $derived(frecencyStore);
let searchText = $state('');
let quicklinkArgument = $state('');
let selectedIndex = $state(0);
let listElement: HTMLElement | null = $state(null);
let searchInputEl: HTMLInputElement | null = $state(null);
let argumentInputEl: HTMLInputElement | null = $state(null);
let selectedQuicklinkForArgument: Quicklink | null = $state(null);
const math = create(all);
const frecencyMap = $derived(new Map(frecencyData.map((item) => [item.itemId, item])));
const allSearchableItems = $derived.by(() => {
const items = [];
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(() => {
if (!searchText.trim() || selectedQuicklinkForArgument) {
return null;
}
try {
const result = math.evaluate(searchText.trim());
if (typeof result === 'function' || typeof result === 'undefined') return null;
let resultString = math.format(result, { precision: 14 });
if (resultString === searchText.trim()) return null;
return { value: resultString, type: math.typeOf(result) };
} catch {
return null;
}
});
const displayItems = $derived.by(() => {
let items: (UnifiedItem & { fuseScore?: number })[] = [];
if (searchText.trim()) {
items = fuse.search(searchText).map((result) => ({
...result.item,
score: 0,
fuseScore: result.score
}));
} else {
items = allSearchableItems.map((item) => ({ ...item, score: 0, fuseScore: 1 }));
}
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);
if (calculatorResult) {
items.unshift({
type: 'calculator',
id: 'calculator',
data: {
value: searchText,
result: calculatorResult.value,
resultType: calculatorResult.type
},
score: 9999
});
}
return items;
});
const selectedItem = $derived(displayItems[selectedIndex]);
$effect(() => {
const item = displayItems[selectedIndex];
if (item?.type === 'quicklink' && item.data.link.includes('{argument}')) {
selectedQuicklinkForArgument = item.data;
} else {
selectedQuicklinkForArgument = null;
}
});
function resetState() {
searchText = '';
quicklinkArgument = '';
selectedIndex = 0;
selectedQuicklinkForArgument = null;
tick().then(() => searchInputEl?.focus());
}
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() {
if (!selectedItem) return;
const item = selectedItem;
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}')) {
await tick();
argumentInputEl?.focus();
} else {
executeQuicklink(quicklink);
}
break;
}
}
async function handleArgumentKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedQuicklinkForArgument) {
await executeQuicklink(selectedQuicklinkForArgument, quicklinkArgument);
}
} else if (e.key === 'Escape' || (e.key === 'Backspace' && quicklinkArgument === '')) {
e.preventDefault();
quicklinkArgument = '';
await tick();
searchInputEl?.focus();
}
}
async function handleResetRanking() {
if (selectedItem) {
const itemToReset = selectedItem;
await frecencyStore.deleteEntry(itemToReset.id);
}
}
function handleCopyDeeplink() {
if (selectedItem?.type !== 'plugin') return;
const plugin = selectedItem.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() {
if (selectedItem?.type !== 'plugin') return;
const plugin = selectedItem.data as PluginInfo;
viewManager.showSettings(plugin.pluginName);
}
function handleCopyAppName() {
if (selectedItem?.type !== 'app') return;
writeText(selectedItem.data.name);
}
function handleCopyAppPath() {
if (selectedItem?.type !== 'app') return;
writeText(selectedItem.data.exec);
}
async function handleHideApp() {
if (selectedItem?.type !== 'app') return;
const itemToHide = selectedItem;
await frecencyStore.hideItem(itemToHide.id);
}
async function handleKeyDown(e: KeyboardEvent) {
if (!selectedItem) return;
const keyMap: Record<string, (() => void) | (() => Promise<void>) | undefined> = {
'C-S-c': selectedItem.type === 'plugin' ? handleCopyDeeplink : undefined,
'C-S-,': selectedItem.type === 'plugin' ? handleConfigureCommand : undefined,
'C-.': selectedItem.type === 'app' ? handleCopyAppName : undefined,
'C-S-.': selectedItem.type === 'app' ? handleCopyAppPath : undefined,
'C-h': selectedItem.type === 'app' ? handleHideApp : undefined
};
const shortcut = `${e.metaKey ? 'M-' : ''}${e.ctrlKey ? 'C-' : ''}${e.shiftKey ? 'S-' : ''}${e.key.toLowerCase()}`;
const action =
keyMap[shortcut.replace('meta', 'M').replace('control', 'C').replace('shift', 'S')];
if (action) {
e.preventDefault();
await action();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<main class="bg-background text-foreground flex h-screen flex-col">
<header class="flex h-12 shrink-0 items-center border-b px-2">
<div class="relative flex w-full items-center">
<Input
class="w-full rounded-none border-none !bg-transparent pr-0 text-base"
placeholder={selectedQuicklinkForArgument
? selectedQuicklinkForArgument.name
: 'Search for extensions and commands...'}
bind:value={searchText}
bind:ref={searchInputEl}
autofocus
/>
{#if selectedQuicklinkForArgument}
<div class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center">
<span class="whitespace-pre text-transparent"
>{searchText || selectedQuicklinkForArgument.name}</span
>
<span class="w-2"></span>
<div class="pointer-events-auto">
<div class="inline-grid items-center">
<span
class="invisible col-start-1 row-start-1 px-3 text-base whitespace-pre md:text-sm"
aria-hidden="true"
>
{quicklinkArgument || 'Query'}
</span>
<Input
class="col-start-1 row-start-1 h-7 w-full"
placeholder="Query"
bind:value={quicklinkArgument}
bind:ref={argumentInputEl}
onkeydown={handleArgumentKeydown}
/>
</div>
</div>
</div>
{/if}
</div>
</header>
<div class="grow overflow-y-auto">
<BaseList
items={displayItems.map((item) => ({ ...item, itemType: 'item' }))}
onenter={handleEnter}
bind:selectedIndex
bind:listElement
>
{#snippet itemSnippet({ item, isSelected, onclick })}
{#if item.type === 'calculator'}
<Calculator
searchText={item.data.value}
mathResult={item.data.result}
mathResultType={item.data.resultType}
{isSelected}
onSelect={onclick}
/>
{:else if item.type === 'plugin'}
{@const assetsPath = path.dirname(item.data.pluginPath) + '/assets'}
<ListItemBase
title={item.data.title}
subtitle={item.data.pluginTitle}
icon={item.data.icon || 'app-window-16'}
{assetsPath}
{isSelected}
{onclick}
>
{#snippet accessories()}
<span class="text-muted-foreground ml-auto text-xs whitespace-nowrap"> Command </span>
{/snippet}
</ListItemBase>
{:else if item.type === 'app'}
<ListItemBase
title={item.data.name}
subtitle={item.data.comment}
icon={item.data.icon_path ?? 'app-window-16'}
{isSelected}
{onclick}
>
{#snippet accessories()}
<span class="text-muted-foreground ml-auto text-xs whitespace-nowrap">
Application
</span>
{/snippet}
</ListItemBase>
{:else if item.type === 'quicklink'}
<ListItemBase
title={item.data.name}
subtitle={item.data.link.replace(/\{argument\}/g, '...')}
icon={item.data.icon ?? 'link-16'}
{isSelected}
{onclick}
>
{#snippet accessories()}
<span class="text-muted-foreground ml-auto text-xs whitespace-nowrap">
Quicklink
</span>
{/snippet}
</ListItemBase>
{/if}
{/snippet}
</BaseList>
</div>
{#if selectedItem}
<ActionBar>
{#snippet primaryAction({ props })}
{@const primaryActionText =
selectedItem.type === 'app'
? 'Open Application'
: selectedItem.type === 'quicklink'
? 'Open Quicklink'
: 'Open Command'}
<Button {...props} onclick={handleEnter}>
{primaryActionText}
<Kbd></Kbd>
</Button>
{/snippet}
{#snippet actions()}
<ActionMenu>
{#if selectedItem.type === 'plugin'}
<DropdownMenu.Item onclick={handleResetRanking}>Reset Ranking</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleCopyDeeplink}>
Copy Deeplink
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'c', modifiers: ['ctrl', 'shift'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleConfigureCommand}>
Configure Command
<DropdownMenu.Shortcut>
{shortcutToText({ key: ',', modifiers: ['ctrl', 'shift'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{:else if selectedItem.type === 'app'}
<DropdownMenu.Item onclick={handleResetRanking}>Reset Ranking</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleCopyAppName}>
Copy Name
<DropdownMenu.Shortcut>
{shortcutToText({ key: '.', modifiers: ['ctrl'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleCopyAppPath}>
Copy Path
<DropdownMenu.Shortcut>
{shortcutToText({ key: '.', modifiers: ['ctrl', 'shift'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleHideApp}>
Hide Application
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'h', modifiers: ['ctrl'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{/if}
</ActionMenu>
{/snippet}
</ActionBar>
{/if}
</main>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import type { UnifiedItem } from '$lib/command-palette.svelte';
import ActionBar from '$lib/components/nodes/shared/ActionBar.svelte';
import { Button } from '$lib/components/ui/button';
import { Kbd } from '$lib/components/ui/kbd';
import ActionMenu from '$lib/components/nodes/shared/ActionMenu.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { shortcutToText } from '$lib/renderKey';
type Props = {
selectedItem: UnifiedItem | undefined;
actions: {
handleEnter: () => Promise<void>;
handleResetRanking: () => Promise<void>;
handleCopyDeeplink: () => void;
handleConfigureCommand: () => void;
handleCopyAppName: () => void;
handleCopyAppPath: () => void;
handleHideApp: () => Promise<void>;
};
};
let { selectedItem, actions: barActions }: Props = $props();
</script>
{#if selectedItem}
<ActionBar>
{#snippet primaryAction({ props })}
{@const primaryActionText =
selectedItem.type === 'app'
? 'Open Application'
: selectedItem.type === 'quicklink'
? 'Open Quicklink'
: 'Open Command'}
<Button {...props} onclick={barActions?.handleEnter}>
{primaryActionText}
<Kbd></Kbd>
</Button>
{/snippet}
{#snippet actions()}
<ActionMenu>
{#if selectedItem.type === 'plugin'}
<DropdownMenu.Item onclick={barActions.handleResetRanking}
>Reset Ranking</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={barActions.handleCopyDeeplink}>
Copy Deeplink
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'c', modifiers: ['ctrl', 'shift'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={barActions.handleConfigureCommand}>
Configure Command
<DropdownMenu.Shortcut>
{shortcutToText({ key: ',', modifiers: ['ctrl', 'shift'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{:else if selectedItem.type === 'app'}
<DropdownMenu.Item onclick={barActions.handleResetRanking}
>Reset Ranking</DropdownMenu.Item
>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={barActions.handleCopyAppName}>
Copy Name
<DropdownMenu.Shortcut>
{shortcutToText({ key: '.', modifiers: ['ctrl'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={barActions.handleCopyAppPath}>
Copy Path
<DropdownMenu.Shortcut>
{shortcutToText({ key: '.', modifiers: ['ctrl', 'shift'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={barActions.handleHideApp}>
Hide Application
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'h', modifiers: ['ctrl'] })}
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{/if}
</ActionMenu>
{/snippet}
</ActionBar>
{/if}

View file

@ -0,0 +1,218 @@
<script lang="ts">
import type { PluginInfo } from '@raycast-linux/protocol';
import { Input } from '$lib/components/ui/input';
import Calculator from '$lib/components/Calculator.svelte';
import BaseList from '$lib/components/BaseList.svelte';
import ListItemBase from '../nodes/shared/ListItemBase.svelte';
import path from 'path';
import { tick } from 'svelte';
import type { Quicklink } from '$lib/quicklinks.svelte';
import { appsStore } from '$lib/apps.svelte';
import { frecencyStore } from '$lib/frecency.svelte';
import { quicklinksStore } from '$lib/quicklinks.svelte';
import { useCommandPaletteItems, useCommandPaletteActions } from '$lib/command-palette.svelte';
import CommandPaletteActionBar from './ActionBar.svelte';
type Props = {
plugins: PluginInfo[];
onRunPlugin: (plugin: PluginInfo) => void;
};
let { plugins, onRunPlugin }: Props = $props();
const { apps: installedApps } = $derived(appsStore);
const { quicklinks } = $derived(quicklinksStore);
const { data: frecencyData } = $derived(frecencyStore);
let searchText = $state('');
let quicklinkArgument = $state('');
let selectedIndex = $state(0);
let listElement: HTMLElement | null = $state(null);
let searchInputEl: HTMLInputElement | null = $state(null);
let argumentInputEl: HTMLInputElement | null = $state(null);
let selectedQuicklinkForArgument: Quicklink | null = $state(null);
const { displayItems } = useCommandPaletteItems({
searchText: () => searchText,
plugins: () => plugins,
installedApps: () => installedApps,
quicklinks: () => quicklinks,
frecencyData: () => frecencyData,
selectedQuicklinkForArgument: () => selectedQuicklinkForArgument
});
const selectedItem = $derived(displayItems[selectedIndex]);
function resetState() {
searchText = '';
quicklinkArgument = '';
selectedIndex = 0;
selectedQuicklinkForArgument = null;
tick().then(() => searchInputEl?.focus());
}
async function focusArgumentInput() {
await tick();
argumentInputEl?.focus();
}
const actions = useCommandPaletteActions({
selectedItem: () => selectedItem,
onRunPlugin,
resetState,
focusArgumentInput
});
$effect(() => {
const item = displayItems[selectedIndex];
if (item?.type === 'quicklink' && item.data.link.includes('{argument}')) {
selectedQuicklinkForArgument = item.data;
} else {
selectedQuicklinkForArgument = null;
}
});
async function handleArgumentKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedQuicklinkForArgument) {
await actions.executeQuicklink(selectedQuicklinkForArgument, quicklinkArgument);
}
} else if (e.key === 'Escape' || (e.key === 'Backspace' && quicklinkArgument === '')) {
e.preventDefault();
quicklinkArgument = '';
await tick();
searchInputEl?.focus();
}
}
async function handleKeyDown(e: KeyboardEvent) {
if (!selectedItem) return;
const keyMap = {
'C-S-c': selectedItem.type === 'plugin' ? actions.handleCopyDeeplink : undefined,
'C-S-,': selectedItem.type === 'plugin' ? actions.handleConfigureCommand : undefined,
'C-.': selectedItem.type === 'app' ? actions.handleCopyAppName : undefined,
'C-S-.': selectedItem.type === 'app' ? actions.handleCopyAppPath : undefined,
'C-h': selectedItem.type === 'app' ? actions.handleHideApp : undefined
};
const shortcut = `${e.metaKey ? 'M-' : ''}${e.ctrlKey ? 'C-' : ''}${e.shiftKey ? 'S-' : ''}${e.key.toLowerCase()}`;
const action = keyMap[shortcut as keyof typeof keyMap];
if (action) {
e.preventDefault();
await action();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<main class="bg-background text-foreground flex h-screen flex-col">
<header class="flex h-12 shrink-0 items-center border-b px-2">
<div class="relative flex w-full items-center">
<Input
class="w-full rounded-none border-none !bg-transparent pr-0 text-base"
placeholder={selectedQuicklinkForArgument
? selectedQuicklinkForArgument.name
: 'Search for extensions and commands...'}
bind:value={searchText}
bind:ref={searchInputEl}
autofocus
/>
{#if selectedQuicklinkForArgument}
<div class="pointer-events-none absolute top-0 left-0 flex h-full w-full items-center">
<span class="whitespace-pre text-transparent"
>{searchText || selectedQuicklinkForArgument.name}</span
>
<span class="w-2"></span>
<div class="pointer-events-auto">
<div class="inline-grid items-center">
<span
class="invisible col-start-1 row-start-1 px-3 text-base whitespace-pre md:text-sm"
aria-hidden="true"
>
{quicklinkArgument || 'Query'}
</span>
<Input
class="col-start-1 row-start-1 h-7 w-full"
placeholder="Query"
bind:value={quicklinkArgument}
bind:ref={argumentInputEl}
onkeydown={handleArgumentKeydown}
/>
</div>
</div>
</div>
{/if}
</div>
</header>
<div class="grow overflow-y-auto">
<BaseList
items={displayItems.map((item) => ({ ...item, itemType: 'item' }))}
onenter={actions.handleEnter}
bind:selectedIndex
bind:listElement
>
{#snippet itemSnippet({ item, isSelected, onclick })}
{#if item.type === 'calculator'}
<Calculator
searchText={item.data.value}
mathResult={item.data.result}
mathResultType={item.data.resultType}
{isSelected}
onSelect={onclick}
/>
{:else if item.type === 'plugin'}
{@const assetsPath = path.dirname(item.data.pluginPath) + '/assets'}
<ListItemBase
title={item.data.title}
subtitle={item.data.pluginTitle}
icon={item.data.icon || 'app-window-16'}
{assetsPath}
{isSelected}
{onclick}
>
{#snippet accessories()}
<span class="text-muted-foreground ml-auto text-xs whitespace-nowrap"> Command </span>
{/snippet}
</ListItemBase>
{:else if item.type === 'app'}
<ListItemBase
title={item.data.name}
subtitle={item.data.comment}
icon={item.data.icon_path ?? 'app-window-16'}
{isSelected}
{onclick}
>
{#snippet accessories()}
<span class="text-muted-foreground ml-auto text-xs whitespace-nowrap">
Application
</span>
{/snippet}
</ListItemBase>
{:else if item.type === 'quicklink'}
<ListItemBase
title={item.data.name}
subtitle={item.data.link.replace(/\{argument\}/g, '...')}
icon={item.data.icon ?? 'link-16'}
{isSelected}
{onclick}
>
{#snippet accessories()}
<span class="text-muted-foreground ml-auto text-xs whitespace-nowrap">
Quicklink
</span>
{/snippet}
</ListItemBase>
{/if}
{/snippet}
</BaseList>
</div>
<CommandPaletteActionBar {selectedItem} {actions} />
</main>

View file

@ -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';