feat: implement toast notifications system

Added schemas for toast notifications, including show, update, and hide actions. Integrated toast handling into the sidecar's command processing and UI components, allowing for interactive user feedback. Introduced a new Toast API for creating and managing toast instances.
This commit is contained in:
ByteAtATime 2025-06-17 18:27:21 -07:00
parent e3aa6aaa43
commit 95e7ae16bc
No known key found for this signature in database
11 changed files with 351 additions and 12 deletions

View file

@ -45,6 +45,39 @@ const ClearContainerPayloadSchema = z.object({
containerId: z.string()
});
const KeyboardShortcutSchema = z.object({
modifiers: z.array(z.enum(['cmd', 'ctrl', 'opt', 'shift'])),
key: z.string()
});
const ToastActionOptionsSchema = z.object({
title: z.string(),
onAction: z.boolean(),
shortcut: KeyboardShortcutSchema.optional()
});
const ToastStyleSchema = z.enum(['SUCCESS', 'FAILURE', 'ANIMATED']);
const ShowToastPayloadSchema = z.object({
id: z.number(),
title: z.string(),
message: z.string().optional(),
style: ToastStyleSchema.optional(),
primaryAction: ToastActionOptionsSchema.optional(),
secondaryAction: ToastActionOptionsSchema.optional()
});
const UpdateToastPayloadSchema = z.object({
id: z.number(),
title: z.string().optional(),
message: z.string().optional(),
style: ToastStyleSchema.optional()
});
const HideToastPayloadSchema = z.object({
id: z.number()
});
export const CommandSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('CREATE_INSTANCE'), payload: CreateInstancePayloadSchema }),
z.object({ type: z.literal('CREATE_TEXT_INSTANCE'), payload: CreateTextInstancePayloadSchema }),
@ -54,7 +87,10 @@ export const CommandSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('UPDATE_PROPS'), payload: UpdatePropsPayloadSchema }),
z.object({ type: z.literal('UPDATE_TEXT'), payload: UpdateTextPayloadSchema }),
z.object({ type: z.literal('REPLACE_CHILDREN'), payload: ReplaceChildrenPayloadSchema }),
z.object({ type: z.literal('CLEAR_CONTAINER'), payload: ClearContainerPayloadSchema })
z.object({ type: z.literal('CLEAR_CONTAINER'), payload: ClearContainerPayloadSchema }),
z.object({ type: z.literal('SHOW_TOAST'), payload: ShowToastPayloadSchema }),
z.object({ type: z.literal('UPDATE_TOAST'), payload: UpdateToastPayloadSchema }),
z.object({ type: z.literal('HIDE_TOAST'), payload: HideToastPayloadSchema })
]);
export type Command = z.infer<typeof CommandSchema>;

View file

@ -12,6 +12,7 @@ import { Action, ActionPanel } from './components/actions';
import { Detail } from './components/detail';
import { environment, getSelectedFinderItems, getSelectedText, open } from './environment';
import { preferencesStore } from '../preferences';
import { showToast } from './toast';
let currentPluginName: string | null = null;
let currentPluginPreferences: Array<{
@ -42,7 +43,7 @@ export const getRaycastApi = () => {
LaunchType,
getSelectedFinderItems,
getSelectedText,
showToast: () => {},
showToast,
Toast,
environment,
getPreferenceValues: () => {

104
sidecar/src/api/toast.ts Normal file
View file

@ -0,0 +1,104 @@
import type * as api from '@raycast/api';
import { writeOutput } from '../io';
import { getNextInstanceId, toasts } from '../state';
import { Toast as ToastEnum } from './types';
class ToastImpl implements api.Toast {
#id: number;
#style: api.Toast.Style;
#title: string;
#message?: string;
primaryAction?: api.Toast.ActionOptions;
secondaryAction?: api.Toast.ActionOptions;
constructor(options: api.Toast.Options) {
this.#id = getNextInstanceId();
this.#style = options.style ?? ToastEnum.Style.Success;
this.#title = options.title;
this.#message = options.message;
this.primaryAction = options.primaryAction;
this.secondaryAction = options.secondaryAction;
}
get id() {
return this.#id;
}
get style() {
return this.#style;
}
set style(newStyle: api.Toast.Style) {
this.#style = newStyle;
this._update();
}
get title() {
return this.#title;
}
set title(newTitle: string) {
this.#title = newTitle;
this._update();
}
get message() {
return this.#message;
}
set message(newMessage: string | undefined) {
this.#message = newMessage;
this._update();
}
private _update() {
writeOutput({
type: 'UPDATE_TOAST',
payload: {
id: this.#id,
style: this.#style,
title: this.#title,
message: this.#message
}
});
}
async hide(): Promise<void> {
writeOutput({ type: 'HIDE_TOAST', payload: { id: this.#id } });
toasts.delete(this.#id);
}
async show(): Promise<void> {
this._sendShowCommand();
}
_sendShowCommand() {
toasts.set(this.id, this);
writeOutput({
type: 'SHOW_TOAST',
payload: {
id: this.#id,
style: this.style,
title: this.title,
message: this.message,
primaryAction: this.primaryAction
? {
title: this.primaryAction.title,
onAction: !!this.primaryAction.onAction,
shortcut: this.primaryAction.shortcut
}
: undefined,
secondaryAction: this.secondaryAction
? {
title: this.secondaryAction.title,
onAction: !!this.secondaryAction.onAction,
shortcut: this.secondaryAction.shortcut
}
: undefined
}
});
}
}
export async function showToast(options: api.Toast.Options): Promise<api.Toast> {
const toast = new ToastImpl(options);
toast._sendShowCommand();
return toast;
}

View file

@ -1,7 +1,7 @@
import { createInterface } from 'readline';
import { writeLog, writeOutput } from './io';
import { runPlugin, sendPluginList } from './plugin';
import { instances, navigationStack } from './state';
import { instances, navigationStack, toasts } from './state';
import { batchedUpdates, updateContainer } from './reconciler';
import { preferencesStore } from './preferences';
import type { RaycastInstance } from './types';
@ -97,6 +97,26 @@ rl.on('line', (line) => {
}
break;
}
case 'dispatch-toast-action': {
const { toastId, actionType } = command.payload as {
toastId: number;
actionType: 'primary' | 'secondary';
};
const toast = toasts.get(toastId);
if (toast) {
const action = actionType === 'primary' ? toast.primaryAction : toast.secondaryAction;
if (action?.onAction) {
action.onAction(toast);
}
}
break;
}
case 'trigger-toast-hide': {
const { toastId } = command.payload as { toastId: number };
const toast = toasts.get(toastId);
toast?.hide();
break;
}
case 'selected-text-response': {
const { requestId, text, error } = command.payload as {
requestId: string;

View file

@ -4,6 +4,7 @@ import type React from 'react';
export const instances = new Map<number, AnyInstance>();
export const root: Container = { id: 'root', children: [] };
export const toasts = new Map<number, any>();
let instanceCounter = 0;
export const getNextInstanceId = (): number => ++instanceCounter;

View file

@ -128,6 +128,8 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--shadow-glow: 0 0 6px rgba(255, 255, 255, 1), 0 0 16px rgba(255, 255, 255, 1);
}
@layer base {

View file

@ -38,10 +38,6 @@ export function resolveIcon(icon: ImageLike | undefined | null): ResolvedIcon |
}
if (typeof icon === 'object' && 'source' in icon) {
if (RaycastIconSchema.safeParse(icon.source).success) {
return { type: 'raycast', name: icon.source };
}
return {
type: 'image',
src: convertFileSrc(assetsBasePath + icon.source),

View file

@ -2,6 +2,11 @@
import type { UINode } from '$lib/types';
import NodeRenderer from '$lib/components/NodeRenderer.svelte';
import { Separator } from '$lib/components/ui/separator/index.js';
import type { Toast } from '$lib/ui.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { shortcutToText } from '$lib/renderKey';
import { keyEventMatches, type KeyboardShortcut } from '$lib/props';
import { Kbd } from '../ui/kbd';
type Props = {
uiTree: Map<number, UINode>;
@ -11,6 +16,9 @@
actionPanel?: UINode;
actions?: UINode[];
navigationTitle?: string;
toasts?: Map<number, Toast>;
onToastAction: (toastId: number, actionType: 'primary' | 'secondary') => void;
onHideToast: (toastId: number) => void;
};
let {
uiTree,
@ -19,17 +27,113 @@
secondaryAction,
actionPanel,
actions,
navigationTitle
navigationTitle,
toasts = new Map(),
onToastAction
}: Props = $props();
let dropdownOpen = $state(false);
const showActionPanelDropdown = $derived((actions?.length ?? 0) > 1);
const toastToShow = $derived(Array.from(toasts.entries()).sort((a, b) => b[0] - a[0])[0]?.[1]);
const toastActions = $derived.by(() => {
const availableActions: {
type: 'primary' | 'secondary';
title: string;
shortcut?: KeyboardShortcut;
}[] = [];
if (toastToShow?.primaryAction) {
availableActions.push({ type: 'primary', ...toastToShow.primaryAction });
}
if (toastToShow?.secondaryAction) {
availableActions.push({ type: 'secondary', ...toastToShow.secondaryAction });
}
return availableActions;
});
function handleKeydown(e: KeyboardEvent) {
if (e.key.toLowerCase() === 't' && (e.ctrlKey || e.metaKey)) {
if (toastActions.length > 0) {
e.preventDefault();
dropdownOpen = !dropdownOpen;
}
} else if (
toastToShow?.primaryAction?.shortcut &&
keyEventMatches(e, toastToShow.primaryAction.shortcut)
) {
handleActionSelect('primary');
} else if (
toastToShow?.secondaryAction?.shortcut &&
keyEventMatches(e, toastToShow.secondaryAction.shortcut)
) {
handleActionSelect('secondary');
}
}
function handleActionSelect(actionType: 'primary' | 'secondary') {
if (toastToShow) {
onToastAction(toastToShow.id, actionType);
}
dropdownOpen = false;
}
function handleDropdownKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
dropdownOpen = false;
e.stopPropagation();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<footer class="bg-card flex h-12 shrink-0 items-center border-t px-4">
{#if navigationTitle}
{#if toastToShow}
<DropdownMenu.Root bind:open={dropdownOpen}>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<div {...props} class="flex items-center gap-2">
<div class="flex size-4 items-center justify-center">
{#if toastToShow.style === 'ANIMATED'}
<div
class="size-4 animate-spin rounded-full border-2 border-white/30 border-t-white"
></div>
{:else if toastToShow.style === 'SUCCESS'}
<div class="shadow-glow size-2 rounded-full bg-green-500 shadow-green-500"></div>
{:else if toastToShow.style === 'FAILURE'}
<div class="shadow-glow size-2 rounded-full bg-red-500 shadow-red-500"></div>
{/if}
</div>
<div>
<span>{toastToShow.title}</span>
<span class="text-muted-foreground text-sm">{toastToShow.message}</span>
</div>
{#if toastToShow.primaryAction || toastToShow.secondaryAction}
<Kbd>Ctrl + T</Kbd>
{/if}
</div>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="top" align="start" onkeydown={handleDropdownKeydown} class="w-60">
<DropdownMenu.Label>Toast Actions</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each toastActions as action (action.type)}
<DropdownMenu.Item onselect={() => handleActionSelect(action.type)}>
{action.title}
{#if action.shortcut}
<DropdownMenu.Shortcut>{shortcutToText(action.shortcut)}</DropdownMenu.Shortcut>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else if navigationTitle}
<div class="text-muted-foreground text-sm">{navigationTitle}</div>
{/if}
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
{#if actionPanel}
<div class="group flex items-center">
{#if primaryAction}

View file

@ -10,6 +10,28 @@ export const KeyboardShortcutSchema = z.object({
});
export type KeyboardShortcut = z.infer<typeof KeyboardShortcutSchema>;
export const keyEventMatches = (event: KeyboardEvent, shortcut: KeyboardShortcut) => {
const modifierMap = {
cmd: 'metaKey',
ctrl: 'ctrlKey',
opt: 'altKey',
shift: 'shiftKey'
} as const;
const keyMatch = event.key.toLowerCase() === shortcut.key.toLowerCase();
if (!keyMatch) return false;
const modifierMatch = Object.entries(modifierMap).every(([modifier, key]) => {
const isModifierRequired = shortcut.modifiers.includes(
modifier as z.infer<typeof KeyModifierSchema>
);
const isModifierPressed = event[key];
return isModifierRequired === isModifierPressed;
});
return modifierMatch;
};
export const ActionPropsSchema = z.object({
title: z.string(),
icon: ImageLikeSchema.optional(),

View file

@ -1,6 +1,17 @@
import type { UINode } from '$lib/types';
import type { Command } from '@raycast-linux/protocol';
import type { PluginInfo } from '@raycast-linux/protocol';
import type { KeyboardShortcut } from '$lib/props/actions';
import { SvelteMap } from 'svelte/reactivity';
export type Toast = {
id: number;
title: string;
message?: string;
style?: 'SUCCESS' | 'FAILURE' | 'ANIMATED';
primaryAction?: { title: string; onAction: boolean; shortcut?: KeyboardShortcut };
secondaryAction?: { title: string; onAction: boolean; shortcut?: KeyboardShortcut };
};
function createUiStore() {
// we're not using SvelteMap here because we're making a lot of mutations to the tree
@ -11,6 +22,7 @@ function createUiStore() {
let selectedNodeId = $state<number | undefined>(undefined);
let pluginList = $state<PluginInfo[]>([]);
let currentPreferences = $state<Record<string, unknown>>({});
const toasts = new SvelteMap<number, Toast>();
const applyCommands = (commands: Command[]) => {
const tempTree = new Map(uiTree);
@ -134,6 +146,23 @@ function createUiStore() {
}
break;
}
case 'SHOW_TOAST': {
const toast = command.payload as unknown as Toast;
toasts.set(toast.id, toast);
break;
}
case 'UPDATE_TOAST': {
const { id, ...rest } = command.payload;
const existingToast = toasts.get(id);
if (existingToast) {
toasts.set(id, { ...existingToast, ...rest });
}
break;
}
case 'HIDE_TOAST': {
toasts.delete(command.payload.id);
break;
}
case 'CREATE_TEXT_INSTANCE':
break;
case 'UPDATE_TEXT':
@ -164,6 +193,9 @@ function createUiStore() {
get currentPreferences() {
return currentPreferences;
},
get toasts() {
return toasts;
},
applyCommands,
setPluginList,
setCurrentPreferences,

View file

@ -11,13 +11,16 @@
import type { PluginInfo } from '@raycast-linux/protocol';
import { invoke } from '@tauri-apps/api/core';
import CommandPalette from '$lib/components/CommandPalette.svelte';
import { shortcutToText } from '$lib/renderKey';
import type { KeyboardShortcut } from '$lib/props';
type ViewState = 'plugin-list' | 'plugin-running' | 'settings';
let viewState = $state<ViewState>('plugin-list');
let installedApps = $state<any[]>([]);
const { uiTree, rootNodeId, selectedNodeId, pluginList, currentPreferences } = $derived(uiStore);
const { uiTree, rootNodeId, selectedNodeId, pluginList, currentPreferences, toasts } =
$derived(uiStore);
$effect(() => {
untrack(() => {
@ -49,7 +52,10 @@
function findActions(nodeId: number) {
const node = uiTree.get(nodeId);
if (!node) return;
if (node.type.startsWith('Action.') && !node.type.includes('Panel')) {
if (
(node.type.startsWith('Action.') || node.type === 'Action') &&
!node.type.includes('Panel')
) {
foundActions.push(node);
} else if (node.type.includes('Panel')) {
for (const childId of node.children) findActions(childId);
@ -162,6 +168,18 @@
console.log(apps);
installedApps = apps as any[];
});
function handleHideToast(toastId: number) {
sidecarService.dispatchEvent('trigger-toast-hide', { toastId });
}
function handleToastAction(toastId: number, actionType: 'primary' | 'secondary') {
sidecarService.dispatchEvent('dispatch-toast-action', { toastId, actionType });
}
function formatShortcut(shortcut: KeyboardShortcut) {
return shortcutToText(shortcut);
}
</script>
<svelte:window onkeydown={handleKeydown} />
@ -209,6 +227,9 @@
actionPanel={actionInfo.panel}
actions={actionInfo.allActions}
{navigationTitle}
{toasts}
onToastAction={handleToastAction}
onHideToast={handleHideToast}
/>
{/snippet}
</MainLayout>