feat: implement preferences management for plugins

Added functionality to manage plugin preferences, including getting and setting preferences through the sidecar. Introduced a PreferencesStore to handle preference persistence and retrieval. Updated relevant components to support preference interactions, enhancing user experience in plugin settings.
This commit is contained in:
ByteAtATime 2025-06-15 14:14:16 -07:00
parent 09486045c8
commit 3e0fb8fca3
No known key found for this signature in database
9 changed files with 417 additions and 11 deletions

View file

@ -72,13 +72,32 @@ const LogMessageSchema = z.object({
export const SidecarMessageSchema = z.union([BatchUpdateSchema, CommandSchema, LogMessageSchema]);
export type SidecarMessage = z.infer<typeof SidecarMessageSchema>;
export const PreferenceSchema = z.object({
name: z.string(),
title: z.string(),
description: z.string().optional(),
type: z.enum(['textfield', 'dropdown', 'checkbox', 'directory']),
required: z.boolean().optional(),
default: z.union([z.string(), z.boolean()]).optional(),
data: z
.array(
z.object({
title: z.string(),
value: z.string()
})
)
.optional()
});
export type Preference = z.infer<typeof PreferenceSchema>;
export const PluginInfoSchema = z.object({
title: z.string(),
description: z.string().optional(),
pluginName: z.string(),
commandName: z.string(),
pluginPath: z.string(),
icon: z.string().optional()
icon: z.string().optional(),
preferences: z.array(PreferenceSchema).optional()
});
export type PluginInfo = z.infer<typeof PluginInfoSchema>;
@ -88,6 +107,15 @@ export const PluginListSchema = z.object({
});
export type PluginList = z.infer<typeof PluginListSchema>;
export const PreferenceValuesSchema = z.object({
type: z.literal('preference-values'),
payload: z.object({
pluginName: z.string(),
values: z.record(z.string(), z.unknown())
})
});
export type PreferenceValues = z.infer<typeof PreferenceValuesSchema>;
export const GoBackToPluginListSchema = z.object({
type: z.literal('go-back-to-plugin-list'),
payload: z.object({})
@ -99,6 +127,7 @@ export const SidecarMessageWithPluginsSchema = z.union([
CommandSchema,
LogMessageSchema,
PluginListSchema,
PreferenceValuesSchema,
GoBackToPluginListSchema
]);
export type SidecarMessageWithPlugins = z.infer<typeof SidecarMessageWithPluginsSchema>;

View file

@ -11,6 +11,26 @@ import { Form } from './components/form';
import { Action, ActionPanel } from './components/actions';
import { Detail } from './components/detail';
import { environment, getSelectedFinderItems, getSelectedText } from './environment';
import { preferencesStore } from '../preferences';
let currentPluginName: string | null = null;
let currentPluginPreferences: Array<{
name: string;
title: string;
description?: string;
type: 'textfield' | 'dropdown' | 'checkbox' | 'directory';
required?: boolean;
default?: string | boolean;
data?: Array<{ title: string; value: string }>;
}> = [];
export const setCurrentPlugin = (
pluginName: string,
preferences?: typeof currentPluginPreferences
) => {
currentPluginName = pluginName;
currentPluginPreferences = preferences || [];
};
export const getRaycastApi = () => {
const LocalStorage = createLocalStorage();
@ -25,12 +45,17 @@ export const getRaycastApi = () => {
showToast: () => {},
Toast,
environment,
getPreferenceValues: () => ({
getPreferenceValues: () => {
if (currentPluginName) {
return preferencesStore.getPreferenceValues(currentPluginName, currentPluginPreferences);
}
return {
lang1: 'en',
lang2: 'zh-CN',
autoInput: true,
defaultAction: 'copy'
}),
};
},
usePersistentState: <T>(
key: string,
initialValue: T

View file

@ -3,6 +3,7 @@ import { writeLog, writeOutput } from './io';
import { runPlugin, sendPluginList } from './plugin';
import { instances, navigationStack } from './state';
import { batchedUpdates, updateContainer } from './reconciler';
import { preferencesStore } from './preferences';
import type { RaycastInstance } from './types';
process.on('unhandledRejection', (reason: unknown) => {
@ -30,6 +31,26 @@ rl.on('line', (line) => {
runPlugin(pluginPath);
break;
}
case 'get-preferences': {
const { pluginName } = command.payload as { pluginName: string };
const preferences = preferencesStore.getAllPreferences();
writeOutput({
type: 'preference-values',
payload: {
pluginName,
values: preferences[pluginName] || {}
}
});
break;
}
case 'set-preferences': {
const { pluginName, values } = command.payload as {
pluginName: string;
values: Record<string, unknown>;
};
preferencesStore.setPreferenceValues(pluginName, values);
break;
}
case 'pop-view': {
const previousElement = navigationStack.pop();
if (previousElement) {

View file

@ -1,7 +1,7 @@
import React from 'react';
import { updateContainer } from './reconciler';
import { writeLog, writeOutput } from './io';
import { getRaycastApi } from './api';
import { getRaycastApi, setCurrentPlugin } from './api';
import { inspect } from 'util';
import * as fs from 'fs';
import * as path from 'path';
@ -63,6 +63,15 @@ export const discoverPlugins = (): PluginInfo[] => {
icon?: string;
subtitle?: string;
}>;
preferences?: Array<{
name: string;
title: string;
description?: string;
type: 'textfield' | 'dropdown' | 'checkbox' | 'directory';
required?: boolean;
default?: string | boolean;
data?: Array<{ title: string; value: string }>;
}>;
} = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
const commands = packageJson.commands || [];
@ -78,7 +87,8 @@ export const discoverPlugins = (): PluginInfo[] => {
pluginName: packageJson.name || pluginDirName,
commandName: command.name,
pluginPath: commandFilePath,
icon: command.icon || packageJson.icon
icon: command.icon || packageJson.icon,
preferences: packageJson.preferences
});
} else {
writeLog(`Command file ${commandFilePath} not found for command ${command.name}`);
@ -111,9 +121,33 @@ export const loadPlugin = (pluginPath: string): string => {
export const runPlugin = (pluginPath?: string): void => {
let scriptText: string;
let pluginName = 'unknown';
let preferences: Array<{
name: string;
title: string;
description?: string;
type: 'textfield' | 'dropdown' | 'checkbox' | 'directory';
required?: boolean;
default?: string | boolean;
data?: Array<{ title: string; value: string }>;
}> = [];
if (pluginPath) {
scriptText = loadPlugin(pluginPath);
// Extract plugin info from path to set preferences context
const pluginDir = path.dirname(pluginPath);
const packageJsonPath = path.join(pluginDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
pluginName = packageJson.name || path.basename(pluginDir);
preferences = packageJson.preferences || [];
} catch (error) {
writeLog(`Error reading plugin package.json: ${error}`);
}
}
} else {
const fallbackPluginsDir = path.join(
process.env.HOME || '/tmp',
@ -123,16 +157,21 @@ export const runPlugin = (pluginPath?: string): void => {
if (fs.existsSync(fallbackPath)) {
scriptText = loadPlugin(fallbackPath);
pluginName = 'google-translate';
} else {
const oldFallbackPath = path.join(__dirname, '../dist/plugin/translate-form.txt');
if (fs.existsSync(oldFallbackPath)) {
scriptText = loadPlugin(oldFallbackPath);
pluginName = 'translate-fallback';
} else {
throw new Error('No plugin specified and no fallback plugin found');
}
}
}
// Set the current plugin context for preferences
setCurrentPlugin(pluginName, preferences);
const pluginModule = {
exports: {} as { default: React.ComponentType | null }
};

View file

@ -0,0 +1,72 @@
import * as fs from 'fs';
import * as path from 'path';
import { writeLog } from './io';
import type { Preference } from '@raycast-linux/protocol';
export class PreferencesStore {
private preferencesPath: string;
private preferences: Record<string, Record<string, unknown>> = {};
constructor() {
const preferencesDir = path.join(process.env.HOME || '/tmp', '.local/share/raycast-linux');
this.preferencesPath = path.join(preferencesDir, 'preferences.json');
this.loadPreferences();
}
private loadPreferences(): void {
try {
if (fs.existsSync(this.preferencesPath)) {
const data = fs.readFileSync(this.preferencesPath, 'utf-8');
this.preferences = JSON.parse(data);
}
} catch (error) {
writeLog(`Error loading preferences: ${error}`);
this.preferences = {};
}
}
private savePreferences(): void {
try {
const dir = path.dirname(this.preferencesPath);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(this.preferencesPath, JSON.stringify(this.preferences, null, 2));
} catch (error) {
writeLog(`Error saving preferences: ${error}`);
}
}
public getPreferenceValues(
pluginName: string,
preferenceDefinitions?: Preference[]
): Record<string, unknown> {
const pluginPrefs = this.preferences[pluginName] || {};
const result: Record<string, unknown> = {};
if (preferenceDefinitions) {
for (const pref of preferenceDefinitions) {
if (pluginPrefs[pref.name] !== undefined) {
result[pref.name] = pluginPrefs[pref.name];
} else if (pref.default !== undefined) {
result[pref.name] = pref.default;
}
}
}
return result;
}
public setPreferenceValues(pluginName: string, values: Record<string, unknown>): void {
if (!this.preferences[pluginName]) {
this.preferences[pluginName] = {};
}
Object.assign(this.preferences[pluginName], values);
this.savePreferences();
}
public getAllPreferences(): Record<string, Record<string, unknown>> {
return { ...this.preferences };
}
}
export const preferencesStore = new PreferencesStore();

View file

@ -0,0 +1,171 @@
<script lang="ts">
import type { PluginInfo, Preference } from '@raycast-linux/protocol';
import { Input } from '$lib/components/ui/input';
import Icon from '$lib/components/Icon.svelte';
type Props = {
plugins: PluginInfo[];
onBack: () => void;
onSavePreferences: (pluginName: string, values: Record<string, unknown>) => void;
onGetPreferences: (pluginName: string) => void;
currentPreferences: Record<string, unknown>;
};
let { plugins, onBack, onSavePreferences, onGetPreferences, currentPreferences }: Props =
$props();
let selectedPluginIndex = $state(0);
let preferenceValues = $state<Record<string, unknown>>({});
const selectedPlugin = $derived(plugins[selectedPluginIndex]);
const pluginsWithPreferences = $derived(
plugins.filter((p) => p.preferences && p.preferences.length > 0)
);
$effect(() => {
if (selectedPlugin) {
onGetPreferences(selectedPlugin.pluginName);
}
});
$effect(() => {
preferenceValues = { ...currentPreferences };
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onBack();
} else if (event.key === 'ArrowUp') {
event.preventDefault();
selectedPluginIndex = Math.max(0, selectedPluginIndex - 1);
} else if (event.key === 'ArrowDown') {
event.preventDefault();
selectedPluginIndex = Math.min(pluginsWithPreferences.length - 1, selectedPluginIndex + 1);
}
}
function handleSave() {
if (selectedPlugin) {
onSavePreferences(selectedPlugin.pluginName, preferenceValues);
}
}
function handlePreferenceChange(prefName: string, value: unknown) {
preferenceValues = { ...preferenceValues, [prefName]: value };
}
function getPreferenceValue(pref: Preference): unknown {
return preferenceValues[pref.name] ?? pref.default ?? '';
}
</script>
<svelte:window onkeydown={handleKeydown} />
<main class="bg-background text-foreground flex h-screen">
<div class="flex w-80 flex-col border-r">
<header class="flex h-12 shrink-0 items-center border-b px-4">
<button onclick={onBack} class="hover:bg-accent mr-3 rounded p-1">
<Icon icon="chevron-left-16" class="size-4" />
</button>
<h1 class="font-medium">Extension Settings</h1>
</header>
<div class="flex-1 overflow-y-auto">
{#each pluginsWithPreferences as plugin, index}
<button
type="button"
class="hover:bg-accent/50 flex w-full items-center gap-3 px-4 py-3 text-left"
class:bg-accent={selectedPluginIndex === index}
onclick={() => (selectedPluginIndex = index)}
>
<div class="flex size-8 shrink-0 items-center justify-center">
<Icon icon={plugin.icon || 'app-window-16'} class="size-5" />
</div>
<div class="flex flex-col">
<span class="text-sm font-medium">{plugin.title}</span>
<span class="text-muted-foreground text-xs">{plugin.pluginName}</span>
</div>
</button>
{/each}
</div>
</div>
<div class="flex flex-1 flex-col">
{#if selectedPlugin && selectedPlugin.preferences}
<header class="flex h-12 shrink-0 items-center justify-between border-b px-6">
<div>
<h2 class="font-medium">{selectedPlugin.title}</h2>
<p class="text-muted-foreground text-sm">{selectedPlugin.description}</p>
</div>
<button
onclick={handleSave}
class="bg-primary text-primary-foreground hover:bg-primary/90 rounded px-4 py-2 text-sm"
>
Save
</button>
</header>
<div class="flex-1 overflow-y-auto p-6">
<div class="max-w-md space-y-6">
{#each selectedPlugin.preferences as pref}
<div class="space-y-2">
<label class="text-sm font-medium">
{pref.title}
{#if pref.required}<span class="text-red-500">*</span>{/if}
</label>
{#if pref.description}
<p class="text-muted-foreground text-xs">{pref.description}</p>
{/if}
{#if pref.type === 'textfield'}
<Input
value={getPreferenceValue(pref) as string}
onchange={(e) =>
handlePreferenceChange(pref.name, (e.target as HTMLInputElement)?.value)}
placeholder={pref.default as string}
/>
{:else if pref.type === 'checkbox'}
<label class="flex items-center space-x-2">
<input
type="checkbox"
checked={getPreferenceValue(pref) as boolean}
onchange={(e) =>
handlePreferenceChange(pref.name, (e.target as HTMLInputElement)?.checked)}
class="rounded"
/>
<span class="text-sm">Enable</span>
</label>
{:else if pref.type === 'dropdown' && pref.data}
<select
value={getPreferenceValue(pref) as string}
onchange={(e) =>
handlePreferenceChange(pref.name, (e.target as HTMLSelectElement)?.value)}
class="bg-background border-border w-full rounded border px-3 py-2 text-sm"
>
{#each pref.data as option}
<option value={option.value}>{option.title}</option>
{/each}
</select>
{:else if pref.type === 'directory'}
<Input
value={getPreferenceValue(pref) as string}
onchange={(e) =>
handlePreferenceChange(pref.name, (e.target as HTMLInputElement)?.value)}
placeholder={pref.default as string}
/>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<div class="text-center">
<p class="text-muted-foreground">Select a plugin to configure its settings</p>
</div>
</div>
{/if}
</div>
</main>

View file

@ -67,6 +67,14 @@ class SidecarService {
this.dispatchEvent('request-plugin-list');
};
getPreferences = (pluginName: string) => {
this.dispatchEvent('get-preferences', { pluginName });
};
setPreferences = (pluginName: string, values: Record<string, unknown>) => {
this.dispatchEvent('set-preferences', { pluginName, values });
};
#handleStdout = (chunk: Uint8Array) => {
try {
this.#receiveBuffer = Buffer.concat([this.#receiveBuffer, Buffer.from(chunk)]);
@ -120,6 +128,11 @@ class SidecarService {
return;
}
if (typedMessage.type === 'preference-values') {
uiStore.setCurrentPreferences(typedMessage.payload.values);
return;
}
if (typedMessage.type === 'go-back-to-plugin-list') {
if (this.#onGoBackToPluginList) {
this.#onGoBackToPluginList();

View file

@ -10,6 +10,7 @@ function createUiStore() {
let rootNodeId = $state<number | null>(null);
let selectedNodeId = $state<number | undefined>(undefined);
let pluginList = $state<PluginInfo[]>([]);
let currentPreferences = $state<Record<string, unknown>>({});
const applyCommands = (commands: Command[]) => {
const tempTree = new Map(uiTree);
@ -48,6 +49,10 @@ function createUiStore() {
pluginList = plugins;
};
const setCurrentPreferences = (preferences: Record<string, unknown>) => {
currentPreferences = preferences;
};
const resetForNewPlugin = () => {
uiTree = new Map();
rootNodeId = null;
@ -156,8 +161,12 @@ function createUiStore() {
get pluginList() {
return pluginList;
},
get currentPreferences() {
return currentPreferences;
},
applyCommands,
setPluginList,
setCurrentPreferences,
resetForNewPlugin
};
}

View file

@ -8,13 +8,14 @@
import Content from '$lib/components/layout/Content.svelte';
import Footer from '$lib/components/layout/Footer.svelte';
import PluginList from '$lib/components/PluginList.svelte';
import SettingsView from '$lib/components/SettingsView.svelte';
import type { PluginInfo } from '@raycast-linux/protocol';
type ViewState = 'plugin-list' | 'plugin-running';
type ViewState = 'plugin-list' | 'plugin-running' | 'settings';
let viewState = $state<ViewState>('plugin-list');
const { uiTree, rootNodeId, selectedNodeId, pluginList } = $derived(uiStore);
const { uiTree, rootNodeId, selectedNodeId, pluginList, currentPreferences } = $derived(uiStore);
$effect(() => {
untrack(() => {
@ -85,6 +86,12 @@
}
function handleKeydown(event: KeyboardEvent) {
if (viewState === 'plugin-list' && event.key === ',' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
viewState = 'settings';
return;
}
if (viewState !== 'plugin-running') return;
if (event.key === 'Escape') {
@ -130,12 +137,32 @@
viewState = 'plugin-running';
searchText = '';
}
function handleBackToPluginList() {
viewState = 'plugin-list';
}
function handleSavePreferences(pluginName: string, values: Record<string, unknown>) {
sidecarService.setPreferences(pluginName, values);
}
function handleGetPreferences(pluginName: string) {
sidecarService.getPreferences(pluginName);
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if viewState === 'plugin-list'}
<PluginList plugins={pluginList} onRunPlugin={handleRunPlugin} />
{:else if viewState === 'settings'}
<SettingsView
plugins={pluginList}
onBack={handleBackToPluginList}
onSavePreferences={handleSavePreferences}
onGetPreferences={handleGetPreferences}
{currentPreferences}
/>
{:else if viewState === 'plugin-running' && rootNode}
<MainLayout>
{#snippet header()}