mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: move snippets, keybinding actions and macros out of core into plugins
This commit is contained in:
1 parent
d9bba10612
commit
e0c30a4e6f
262 files changed
+9091
-10744
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user