mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-08-31 11:17:27 +00:00
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:
parent
e3aa6aaa43
commit
95e7ae16bc
11 changed files with 351 additions and 12 deletions
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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
104
sidecar/src/api/toast.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue