mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-09-02 12:17:24 +00:00
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:
parent
398ed86c40
commit
d3e615de7e
6 changed files with 551 additions and 448 deletions
|
@ -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[]>([]);
|
||||
|
|
243
src/lib/command-palette.svelte.ts
Normal file
243
src/lib/command-palette.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
|
@ -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>
|
88
src/lib/components/command-palette/ActionBar.svelte
Normal file
88
src/lib/components/command-palette/ActionBar.svelte
Normal 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}
|
218
src/lib/components/command-palette/CommandPalette.svelte
Normal file
218
src/lib/components/command-palette/CommandPalette.svelte
Normal 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>
|
|
@ -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';
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue