feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

+87 -74
View File
@@ -23,22 +23,25 @@ import {
Clock,
Folder,
Pencil,
Play,
Clipboard,
} from "lucide-react";
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
import { listSnippets } from "@/lib/snippet-provider";
import type { Host, TabType, Tab, Snippet } from "@/types/ui-types";
import type { Host, TabType, Tab } from "@/types/ui-types";
import { canEditHost } from "@/sidebar/host-permissions";
import { RAIL_UTILITY_ITEMS, useRailItems } from "@/sidebar/rail-items";
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
import {
defaultConnectAction,
hostActionsFor,
runHostAction,
useHostActions,
} from "@/sidebar/host-contributions";
import { paletteEntriesFor, usePaletteEntries } from "./palette-registry";
import {
filterPaletteItems,
loadPaletteGroup,
paletteEntriesFor,
usePaletteEntries,
usePaletteGroups,
type PaletteItemDef,
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { shell } from "@/plugin-host/shell-bridge";
@@ -71,9 +74,11 @@ export function CommandPalette({
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
[],
);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const paletteGroups = usePaletteGroups();
const [groupItems, setGroupItems] = useState<
Record<string, PaletteItemDef[]>
>({});
const [selectedValue, setSelectedValue] = useState("");
const { runSnippet, dialog: runSnippetDialog } = useSnippetRunner();
useEffect(() => {
if (isOpen) {
@@ -82,12 +87,24 @@ export function CommandPalette({
getRecentActivity(5)
.then(setRecentActivity)
.catch(() => {});
listSnippets()
.then((data) => setSnippets(data as unknown as Snippet[]))
.catch(() => {});
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
for (const group of paletteGroups) {
void loadPaletteGroup(group).then((items) => {
if (!cancelled) {
setGroupItems((prev) => ({ ...prev, [group.id]: items }));
}
});
}
return () => {
cancelled = true;
};
}, [isOpen, paletteGroups]);
useEffect(() => {
if (!isOpen) return;
@@ -130,16 +147,6 @@ export function CommandPalette({
groupedHosts.push({ folder, hosts: fhosts });
}
const filteredSnippets = search.trim()
? snippets.filter((s) => {
const query = search.toLowerCase();
return (
s.name.toLowerCase().includes(query) ||
s.content.toLowerCase().includes(query)
);
})
: [];
useEffect(() => {
if (!isOpen) return;
const firstHost = filteredHosts[0];
@@ -159,10 +166,7 @@ export function CommandPalette({
};
const showHostResultsFirst = search.trim().length > 0;
// Closing the palette unmounts this component (AppShell only renders it
// while commandPaletteOpen is true), so keep rendering just the variables
// dialog after close if a snippet run is still pending its inputs.
if (!isOpen) return runSnippetDialog;
if (!isOpen) return null;
return (
<div
@@ -327,53 +331,64 @@ export function CommandPalette({
</>
)}
{filteredSnippets.length > 0 && (
<>
<CommandSeparator className="my-2" />
<CommandGroup
heading={t("commandPalette.snippets")}
className="px-2"
>
{filteredSnippets.map((snippet) => (
<CommandItem
key={snippet.id}
value={`snippet-${snippet.id}`}
onSelect={() => {
if (!activeTargetTab) return;
handleAction(() =>
runSnippet(snippet, [activeTargetTab]),
);
}}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer",
!activeTargetTab && "pointer-events-none opacity-50",
)}
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors shrink-0">
{snippet.isNote ? (
<Clipboard className="size-4 text-accent-brand" />
) : (
<Play className="size-4 text-accent-brand" />
)}
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-semibold truncate">
{snippet.name}
</span>
<span className="text-xs text-muted-foreground truncate font-mono">
{snippet.content}
</span>
</div>
<span className="text-[10px] text-muted-foreground/60 shrink-0">
{activeTargetTab
? t("commandPalette.runSnippetDesc")
: t("newUi.sidebar.snippets.noTerminalTabsOpen")}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{paletteGroups.map((group) => {
const items = filterPaletteItems(
groupItems[group.id] ?? [],
search,
group.showWhenEmpty,
);
if (items.length === 0) return null;
return (
<React.Fragment key={`group-${group.id}`}>
<CommandSeparator className="my-2" />
<CommandGroup heading={t(group.titleKey)} className="px-2">
{items.map((item) => {
const Icon = item.icon;
const blocked = !!item.needsTarget && !activeTargetTab;
return (
<CommandItem
key={`${group.id}-${item.id}`}
value={`group-${group.id}-${item.id}`}
onSelect={() => {
if (blocked) return;
handleAction(() =>
item.run({ targetTab: activeTargetTab, shell }),
);
}}
className={cn(
"group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer",
blocked && "pointer-events-none opacity-50",
)}
>
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors shrink-0">
{Icon && (
<Icon className="size-4 text-accent-brand" />
)}
</div>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-semibold truncate">
{item.title}
</span>
{item.description && (
<span className="text-xs text-muted-foreground truncate font-mono">
{item.description}
</span>
)}
</div>
{(blocked || item.hint) && (
<span className="text-[10px] text-muted-foreground/60 shrink-0">
{blocked
? t("commandPalette.noTargetTab")
: item.hint}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</React.Fragment>
);
})}
{paletteEntriesFor(paletteEntries, "global").length > 0 && (
<>
@@ -661,8 +676,6 @@ export function CommandPalette({
</div>
</Command>
</div>
{runSnippetDialog}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import type { ComponentType } from "react";
import type { KeybindingAction, KeyCombo } from "@/types/keybindings";
import { byOrderThenId, createRegistry } from "@/lib/registry";
export interface KeybindingEditorProps {
action: KeybindingAction;
onChange: (action: KeybindingAction) => void;
}
export interface KeybindingRunContext {
sessionId?: string;
host?: {
ip?: string;
username?: string;
port?: number | string;
name?: string;
} | null;
send?: (data: string) => void;
}
/**
* An action a key can be bound to. The shell registers its own; plugins add
* theirs with app.registerKeybindingAction. `id` is what a saved binding
* stores as action.type.
*/
export interface KeybindingActionDef {
id: string;
pluginId?: string;
/** A full translation key. */
labelKey: string;
scope: "session" | "global";
editor?: ComponentType<KeybindingEditorProps>;
summary?: ComponentType<KeybindingEditorProps>;
/** A full translation key when the action cannot be saved yet. */
validate?: (action: KeybindingAction) => string | null;
run?: (action: KeybindingAction, context: KeybindingRunContext) => void;
order?: number;
}
/** A built-in key the user can rebind. */
export interface KeybindingDefaultDef {
id: string;
pluginId?: string;
combo: KeyCombo;
/** A full translation key. */
descriptionKey: string;
order?: number;
}
/** Fired for the shell's own actions; AppShell runs them. */
export const GLOBAL_KEYBINDING_EVENT = "termix:global-keybinding";
function shellAction(id: string, labelKey: string, order: number) {
return {
id,
labelKey,
scope: "global" as const,
order,
run: (action: KeybindingAction) => {
window.dispatchEvent(
new CustomEvent(GLOBAL_KEYBINDING_EVENT, {
detail: { type: action.type },
}),
);
},
};
}
/** The actions the shell runs itself. */
const SHELL_ACTIONS: KeybindingActionDef[] = [
shellAction("nextTab", "newUi.sidebar.keybindings.actionNextTab", 1000),
shellAction(
"previousTab",
"newUi.sidebar.keybindings.actionPreviousTab",
1001,
),
shellAction(
"openCommandPalette",
"newUi.sidebar.keybindings.actionOpenCommandPalette",
1002,
),
shellAction(
"reconnectSession",
"newUi.sidebar.keybindings.actionReconnectSession",
1003,
),
];
export const SHELL_KEYBINDING_ACTION_IDS = SHELL_ACTIONS.map(
(action) => action.id,
);
const actions = createRegistry<KeybindingActionDef>(byOrderThenId);
const defaults = createRegistry<KeybindingDefaultDef>(byOrderThenId);
function install(): void {
for (const action of SHELL_ACTIONS) {
if (!actions.get(action.id)) actions.register(action);
}
}
install();
/** Adds a plugin's action. A shell action's id cannot be taken over. */
export function registerKeybindingAction(
action: KeybindingActionDef,
): () => void {
if (SHELL_KEYBINDING_ACTION_IDS.includes(action.id)) {
throw new Error(`Keybinding action "${action.id}" belongs to the shell`);
}
const existing = actions.get(action.id);
if (existing && existing.pluginId !== action.pluginId) {
throw new Error(
`Keybinding action "${action.id}" is already registered by ${existing.pluginId}`,
);
}
return actions.register(action);
}
export const registerKeybindingDefault = defaults.register;
export const listKeybindingActions = actions.list;
export const useKeybindingActions = actions.useList;
export const getKeybindingAction = actions.get;
export const listKeybindingDefaults = defaults.list;
export const useKeybindingDefaults = defaults.useList;
/** Back to the shell's own actions and no defaults, for tests. */
export function resetKeybindingRegistry(): void {
actions.reset();
defaults.reset();
install();
}
/**
* Runs a bound action through whoever registered it. False when nothing
* running handles its type (its plugin is off), so the caller can let the
* key through.
*/
export function runKeybindingAction(
action: KeybindingAction,
context: KeybindingRunContext = {},
): boolean {
const def = actions.get(action.type);
if (!def?.run) return false;
try {
def.run(action, context);
} catch (error) {
console.error(`[keybindings] ${action.type} failed`, error);
}
return true;
}
+57 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from "react";
import type { Host } from "@/types/ui-types";
import type { Host, Tab } from "@/types/ui-types";
import { byOrderThenId, createRegistry } from "@/lib/registry";
import type { TabShellCallbacks } from "./tab-registry";
@@ -38,3 +38,59 @@ export function paletteEntriesFor(
}
});
}
/** One row of a palette group. */
export interface PaletteItemDef {
id: string;
title: string;
description?: string;
icon?: ComponentType<{ className?: string }>;
keywords?: string[];
needsTarget?: boolean;
hint?: string;
run: (context: { targetTab?: Tab; shell: TabShellCallbacks }) => void;
}
/** A searchable group of items a plugin fills, e.g. its saved commands. */
export interface PaletteGroupDef {
id: string;
pluginId?: string;
titleKey: string;
order?: number;
load: () => PaletteItemDef[] | Promise<PaletteItemDef[]>;
showWhenEmpty?: boolean;
}
const groups = createRegistry<PaletteGroupDef>(byOrderThenId);
export const registerPaletteGroup = groups.register;
export const usePaletteGroups = groups.useList;
export const listPaletteGroups = groups.list;
export const resetPaletteGroups = groups.reset;
/** A group's items, or none when its loader throws. */
export async function loadPaletteGroup(
group: PaletteGroupDef,
): Promise<PaletteItemDef[]> {
try {
const items = await group.load();
return Array.isArray(items) ? items : [];
} catch {
return [];
}
}
/** The items the query matches, by title, description or keyword. */
export function filterPaletteItems(
items: PaletteItemDef[],
query: string,
showWhenEmpty = false,
): PaletteItemDef[] {
const needle = query.trim().toLowerCase();
if (!needle) return showWhenEmpty ? items : [];
return items.filter((item) =>
[item.title, item.description ?? "", ...(item.keywords ?? [])].some(
(text) => text.toLowerCase().includes(needle),
),
);
}
+1 -15
View File
@@ -18,16 +18,6 @@ import { listPanes, mapTabIds } from "./split/split-tree";
* snapshots, through app.tabs.getLayout and applyLayout.
*/
/**
* Core tab types worth saving in a layout. "dashboard" is left out on
* purpose: the shell always keeps one alive as the fallback tab, so it is
* not a meaningful part of an arrangement.
*/
const CORE_CAPTURABLE = new Set(["macros"]);
/** Core types reopened as singletons, with an optional preselected host. */
const CORE_SINGLETON = new Set(["macros"]);
/** Core tab types that are never part of a saved arrangement. */
const CORE_UNSAVED = new Set([
"dashboard",
@@ -43,20 +33,16 @@ const CORE_UNSAVED = new Set([
* it, and a layout keeps it so nothing is lost when the plugin comes back.
*/
function isUnregisteredPluginTabType(type: string): boolean {
return (
!CORE_UNSAVED.has(type) && !CORE_CAPTURABLE.has(type) && !getTabType(type)
);
return !CORE_UNSAVED.has(type) && !getTabType(type);
}
export function isCapturableTabType(type: string): boolean {
if (CORE_CAPTURABLE.has(type)) return true;
if (isUnregisteredPluginTabType(type)) return true;
const def = getTabType(type);
return !!def && def.inLayouts !== false;
}
function opensAsSingleton(type: string): boolean {
if (CORE_SINGLETON.has(type)) return true;
const def = getTabType(type);
return !!def && (!!def.singleton || !!def.hostless);
}
+1 -1
View File
@@ -73,7 +73,7 @@ export interface TabTypeDef {
panelFrame?: boolean;
/** False keeps this tab out of saved layouts and workspaces. */
inLayouts?: boolean;
/** History, macros and SSH tools act on the last one of these focused. */
/** Panels that type into a terminal act on the last one of these focused. */
commandTarget?: boolean;
/** Paints its own background; its frame stays transparent. */
ownBackground?: boolean;
-32
View File
@@ -1,6 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import {
Braces,
LayoutDashboard,
LayoutPanelLeft,
Server,
@@ -28,10 +27,6 @@ const DashboardTab = lazy(() =>
default: m.DashboardTab,
})),
);
// Rail panels promoted to full tabs.
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
/** Download a likely next tab without starting a connection or mounting UI. */
export function preloadTabSurface(type: TabType): void {
@@ -100,9 +95,6 @@ export function tabIcon(type: TabType) {
return <User className="size-3.5" />;
case "admin-settings":
return <Settings className="size-3.5" />;
case "macros":
return <Braces className="size-3.5" />;
case "split-screen":
return <LayoutPanelLeft className="size-3.5" />;
default: {
@@ -163,23 +155,11 @@ function RegisteredTab({
);
}
/**
* Everything the promoted rail panels need from AppShell. Passed as one bag
* rather than more positional params, which renderTabContent already has too
* many of.
*/
export type PromotedPanelProps = {
terminalTabs?: Tab[];
targetTerminalTabId?: string;
storageMode?: "local" | "cloud";
};
export interface TabRenderContext {
shell: TabShellCallbacks;
isVisible?: boolean;
isFocusedPane?: boolean;
inSplit?: boolean;
panelProps?: PromotedPanelProps;
}
export function renderTabContent(tab: Tab, context: TabRenderContext) {
@@ -188,7 +168,6 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
isVisible = true,
isFocusedPane = true,
inSplit = false,
panelProps,
} = context;
switch (tab.type) {
@@ -204,17 +183,6 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
case "split-screen":
return null;
case "macros":
return withTabSuspense(
<PanelTabFrame>
<MacrosPanel
terminalTabs={panelProps?.terminalTabs ?? []}
activeTabId={panelProps?.targetTerminalTabId ?? ""}
storageMode={panelProps?.storageMode ?? "local"}
/>
</PanelTabFrame>,
);
case "host-manager":
case "user-profile":
case "admin-settings":