源码
packages/desktop/src/renderer/CommandSettings.tsx
import * as Dialog from "@radix-ui/react-dialog";
import * as Menu from "@radix-ui/react-dropdown-menu";
import { ActionHint } from "@zpi-dev/ui";
import {
Check,
ChevronDown,
Folder,
LoaderCircle,
Monitor,
MoreHorizontal,
Plus,
RefreshCcw,
Search,
SquareArrowRightEnter,
Terminal,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { CustomCommand, CustomCommandInput } from "../shared/custom-commands.ts";
import { CommandImportDialog } from "./CommandImportDialog.tsx";
import { SettingsBreadcrumb } from "./SettingsBreadcrumb.tsx";
import { SettingsHelp } from "./SettingsHelp.tsx";
import { useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";
import "./command-settings.css";
export function CommandSettings() {
const projects = useStore((state) => state.projects);
const [projectId, setProjectId] = useState<string>();
const [linkedSave, setLinkedSave] = useState<CustomCommandInput>();
const [commands, setCommands] = useState<CustomCommand[]>([]);
const [query, setQuery] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [editor, setEditor] = useState<CustomCommand | "new">();
const [confirmDelete, setConfirmDelete] = useState(false);
const [importing, setImporting] = useState(false);
const request = useRef(0);
const mounted = useRef(true);
const operationPending = useRef(false);
const moreButton = useRef<HTMLButtonElement>(null);
const confirmationFocus = useRef<HTMLElement | null>(null);
const rememberConfirmationFocus = () => {
confirmationFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
};
const restoreConfirmationFocus = (event: Event) => {
event.preventDefault();
const previous = confirmationFocus.current;
(previous?.isConnected ? previous : moreButton.current)?.focus();
};
const refresh = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
const token = ++request.current;
try {
const result = unwrap(await window.zpi.getCustomCommands(projectId));
if (!mounted.current || request.current !== token) return;
setCommands(result.commands);
setError(result.diagnostics.map((item) => userErrorMessage(item.message)).join("\n"));
} catch (failure) {
if (mounted.current && request.current === token) setError(userErrorMessage(failure));
} finally {
if (mounted.current && request.current === token) setLoading(false);
}
},
[projectId],
);
useEffect(() => {
mounted.current = true;
setCommands([]);
setError("");
void refresh();
const focus = () => void refresh(false);
window.addEventListener("focus", focus);
return () => {
mounted.current = false;
request.current++;
window.removeEventListener("focus", focus);
};
}, [refresh]);
useEffect(() => {
if (projectId && !projects.some((project) => project.id === projectId)) {
setProjectId(undefined);
setEditor(undefined);
setLinkedSave(undefined);
setImporting(false);
}
}, [projectId, projects]);
const act = async (operation: () => Promise<unknown>, close = false) => {
if (operationPending.current) return;
operationPending.current = true;
setBusy(true);
setError("");
try {
await operation();
if (!mounted.current) return;
if (close) {
setEditor(undefined);
setConfirmDelete(false);
}
await refresh();
window.dispatchEvent(new Event("zpi:commands-changed"));
} catch (failure) {
if (mounted.current) setError(userErrorMessage(failure));
} finally {
operationPending.current = false;
if (mounted.current) setBusy(false);
}
};
const save = (config: CustomCommandInput, confirmed = false) => {
if (editor && editor !== "new" && editor.importedSourcePath && !confirmed) {
rememberConfirmationFocus();
setLinkedSave(config);
return Promise.resolve();
}
return act(async () => {
if (!editor) return;
unwrap(
editor === "new"
? await window.zpi.createCustomCommand(config, projectId)
: await window.zpi.updateCustomCommand(
{
...config,
id: editor.id,
expectedRevision: editor.revision,
...(editor.importedSourcePath
? { confirmLinkedSource: true, expectedSourcePath: editor.importedSourcePath }
: {}),
},
projectId,
),
);
setLinkedSave(undefined);
}, true);
};
const scopeControl = (
<CommandScope
projectId={projectId}
align={editor ? "end" : "start"}
disabled={busy || Boolean(editor && editor !== "new")}
onChange={setProjectId}
/>
);
const filtered = commands.filter((command) =>
`${command.name} ${command.description ?? ""} ${command.prompt}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
return (
<section
className="command-settings"
aria-label="命令设置"
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Escape" && editor && !event.defaultPrevented) {
event.stopPropagation();
if (!busy && !confirmDelete && !linkedSave) {
setEditor(undefined);
setError("");
}
}
}}
>
{editor ? (
<>
<SettingsBreadcrumb
section="命令"
current={editor === "new" ? "新建命令" : editor.name}
disabled={busy}
onBack={() => {
setEditor(undefined);
setError("");
}}
/>
<h3 className="settings-label">
{editor === "new" ? "新建命令" : "编辑命令"}{" "}
<SettingsHelp label="命令编辑" text="名称和提示词必填,描述及参数提示可选。保存后返回列表。" />
</h3>
{error && (
<p className="run-error" role="alert">
{error}
</p>
)}
<CommandEditor
key={editor === "new" ? "new" : editor.id}
initial={editor === "new" ? undefined : editor}
busy={busy}
scopeControl={scopeControl}
onCancel={() => {
setEditor(undefined);
setError("");
}}
onDelete={() => {
setError("");
rememberConfirmationFocus();
setConfirmDelete(true);
}}
onSave={save}
/>
</>
) : (
<>
<div className="command-settings-toolbar">
<div className="command-scope-count">
{scopeControl}
<i />
<strong>
命令 <small>{filtered.length}</small>
</strong>
</div>
<label className="command-search">
<Search size={16} />
<input
aria-label="搜索命令"
placeholder="搜索命令..."
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
</div>
{error && (
<p className="run-error" role="alert">
{error}
</p>
)}
<div className="command-installed-header">
<h3>
已安装 <small>{filtered.length}</small>
</h3>
<div className="command-header-actions">
<Menu.Root>
<Menu.Trigger
ref={moreButton}
className="resource-icon-button"
aria-label="更多操作"
disabled={busy}
>
<MoreHorizontal size={16} />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
className="selection-menu command-menu"
align="end"
sideOffset={2}
onEscapeKeyDown={(event) => event.stopPropagation()}
>
<Menu.Item className="menu-item" onSelect={() => setImporting(true)}>
<SquareArrowRightEnter size={16} />
导入
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu.Root>
<ActionHint appearance="control" label="刷新">
<button
type="button"
className="resource-icon-button"
aria-label="刷新命令"
aria-busy={loading}
disabled={busy || loading}
onClick={() => void refresh()}
>
{loading ? (
<LoaderCircle size={16} className="command-loading-spinner" />
) : (
<RefreshCcw size={16} />
)}
</button>
</ActionHint>
<button
type="button"
className="command-new"
disabled={busy}
onClick={() => {
setEditor("new");
setError("");
}}
>
<Plus size={14} />
新建
</button>
</div>
</div>
{loading ? (
<div className="command-loading" role="status">
<LoaderCircle size={16} className="command-loading-spinner" />
加载中...
</div>
) : filtered.length ? (
<div className="command-list">
{filtered.map((command) => (
<div className="command-row" key={command.id}>
<button
type="button"
className="command-edit"
disabled={busy}
onClick={() => {
setEditor(command);
setError("");
}}
aria-label={`编辑命令 ${command.name}`}
>
<span className="command-avatar">
<Terminal size={16} />
</span>
<span className="command-summary">
<span className="command-name" title={command.name}>
{command.name}
{command.argumentHint && <span>{command.argumentHint}</span>}
</span>
<small>{command.description || "暂无描述"}</small>
</span>
</button>
<label className="settings-switch">
<input
type="checkbox"
aria-label={`启用命令 ${command.name}`}
checked={command.enabled}
disabled={busy || command.unreadable}
onChange={(event) => {
const enabled = event.target.checked;
void act(async () =>
unwrap(
await window.zpi.setCustomCommandEnabled({ id: command.id, enabled }, projectId),
),
);
}}
/>
<span aria-hidden="true" />
</label>
</div>
))}
</div>
) : (
<div className="command-empty">
{query.trim() ? (
"没有匹配的命令"
) : (
<>
<h3>尚未安装命令</h3>
<button type="button" className="command-new" onClick={() => setEditor("new")}>
<Plus size={16} />
新建
</button>
</>
)}
</div>
)}
</>
)}
{importing && (
<CommandImportDialog
projectId={projectId}
onClose={() => {
setImporting(false);
moreButton.current?.focus();
}}
onImported={async () => {
await refresh();
window.dispatchEvent(new Event("zpi:commands-changed"));
}}
/>
)}
<Dialog.Root
open={confirmDelete}
onOpenChange={(open) => {
if (!busy) setConfirmDelete(open);
}}
>
<Dialog.Portal>
<Dialog.Overlay className="modal-backdrop command-confirm-backdrop" />
<Dialog.Content
className="command-delete-dialog"
onCloseAutoFocus={restoreConfirmationFocus}
onPointerDownOutside={(event) => {
if (busy) event.preventDefault();
}}
onEscapeKeyDown={(event) => {
event.stopPropagation();
if (busy) event.preventDefault();
}}
>
<Dialog.Title>删除命令</Dialog.Title>
<Dialog.Description>
确定要删除命令「{editor && editor !== "new" ? editor.name : ""}」吗?此操作无法撤销。
{editor && editor !== "new" && editor.importedSourcePath
? "仅删除此链接,不会删除源命令文件。"
: ""}
</Dialog.Description>
{error && (
<p className="run-error" role="alert">
{error}
</p>
)}
<div className="command-form-actions">
<Dialog.Close disabled={busy}>取消</Dialog.Close>
<button
type="button"
className="command-confirm-delete"
disabled={busy}
onClick={() => {
if (editor && editor !== "new")
void act(
async () =>
unwrap(
await window.zpi.deleteCustomCommand(
{ id: editor.id, expectedRevision: editor.revision },
projectId,
),
),
true,
);
}}
>
删除
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root
open={Boolean(linkedSave)}
onOpenChange={(open) => {
if (!open && !busy) setLinkedSave(undefined);
}}
>
<Dialog.Portal>
<Dialog.Overlay className="modal-backdrop command-confirm-backdrop" />
<Dialog.Content
className="command-delete-dialog"
onCloseAutoFocus={restoreConfirmationFocus}
onPointerDownOutside={(event) => {
if (busy) event.preventDefault();
}}
onEscapeKeyDown={(event) => {
event.stopPropagation();
if (busy) event.preventDefault();
}}
>
<Dialog.Title>修改源命令文件</Dialog.Title>
<Dialog.Description>
保存将修改链接指向的源命令文件:
<br />
{editor && editor !== "new" ? editor.importedSourcePath : ""}
</Dialog.Description>
{error && (
<p className="run-error" role="alert">
{error}
</p>
)}
<div className="command-form-actions">
<Dialog.Close disabled={busy}>取消</Dialog.Close>
<button
type="button"
className="command-new"
disabled={busy}
onClick={() => {
if (linkedSave) void save(linkedSave, true);
}}
>
确认保存
</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</section>
);
}
export function CommandEditor({
initial,
busy,
scopeControl,
onSave,
onCancel,
onDelete,
}: {
scopeControl: import("react").ReactNode;
initial?: CustomCommand;
busy: boolean;
onSave: (config: CustomCommandInput) => Promise<void>;
onCancel: () => void;
onDelete: () => void;
}) {
const [name, setName] = useState(initial?.name.replace(/^\//, "") ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [argumentHint, setArgumentHint] = useState(initial?.argumentHint ?? "");
const [prompt, setPrompt] = useState(initial?.prompt ?? "");
const composing = useRef(false);
const valid = Boolean(
!initial?.unreadable && prompt.trim() && (initial || /^[A-Za-z0-9_-]{1,50}$/.test(name.trim())),
);
return (
<form
className="command-form"
onCompositionStart={() => {
composing.current = true;
}}
onCompositionEnd={() => {
composing.current = false;
}}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
if (valid && !busy) event.currentTarget.requestSubmit();
}
}}
onSubmit={(event) => {
event.preventDefault();
if (valid && !busy && !composing.current)
void onSave({
name: name.trim(),
description: description.trim() || undefined,
argumentHint: argumentHint.trim() || undefined,
prompt: prompt.trim(),
});
}}
>
{initial?.unreadable && (
<p className="run-error" role="alert">
源命令无法读取。可删除此链接,或修复源文件后刷新。
</p>
)}
<div className="command-form-top">
<label>
名称
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="my-command"
disabled={Boolean(initial) || busy}
maxLength={50}
/>
</label>
<span>作用域 {scopeControl}</span>
</div>
<label>
描述(可选)
<input
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="在命令选择器中显示的简短描述"
disabled={busy}
/>
</label>
<label>
参数提示(可选)
<input
value={argumentHint}
onChange={(event) => setArgumentHint(event.target.value)}
placeholder="例如 <file-path>"
disabled={busy}
/>
</label>
<label>
提示词
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
rows={5}
placeholder="填写调用该命令时发送的提示词..."
disabled={busy}
/>
</label>
<div className="command-form-actions">
{initial && (
<button type="button" className="command-delete" disabled={busy} onClick={onDelete}>
<Trash2 size={14} />
删除
</button>
)}
<button className="command-new" type="submit" disabled={!valid || busy}>
{busy ? "保存中..." : "保存"}
</button>
<button className="secondary" type="button" disabled={busy} onClick={onCancel}>
取消
</button>
</div>
</form>
);
}
function CommandScope({
align,
projectId,
disabled,
onChange,
}: {
align: "start" | "end";
projectId?: string;
disabled?: boolean;
onChange: (id?: string) => void;
}) {
const projects = useStore((state) => state.projects);
const project = projects.find((item) => item.id === projectId);
return (
<Menu.Root>
<Menu.Trigger className="command-scope" disabled={disabled} aria-label="命令范围">
{project ? <Folder size={16} /> : <Monitor size={16} />}
<span title={project?.name ?? "用户"}>{project?.name ?? "用户"}</span>
<ChevronDown size={16} />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content
className="selection-menu command-menu command-scope-menu"
align={align}
sideOffset={2}
onEscapeKeyDown={(event) => event.stopPropagation()}
>
<Menu.RadioGroup value={projectId ?? ""} onValueChange={(value) => onChange(value || undefined)}>
<Menu.RadioItem className="menu-item" value="">
<Monitor size={16} />
<span>用户</span>
<Menu.ItemIndicator>
<Check size={14} />
</Menu.ItemIndicator>
</Menu.RadioItem>
{projects.some((item) => !item.hidden) && (
<>
<Menu.Separator className="command-menu-separator" />
<Menu.Label className="menu-label">项目</Menu.Label>
</>
)}
{projects
.filter((item) => !item.hidden)
.map((item) => (
<Menu.RadioItem className="menu-item" key={item.id} value={item.id}>
<Folder size={16} />
<span title={item.name}>{item.name}</span>
<Menu.ItemIndicator>
<Check size={14} />
</Menu.ItemIndicator>
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</Menu.Content>
</Menu.Portal>
</Menu.Root>
);
}