дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(agents): configurable executable for every built-in agent (#682)
Settings › Tools › Agents is split into Built-in and ACP Registry tabs. The Built-in tab lists every built-in agent with its status (custom executable or not found on PATH) and, for the selected agent, its default launch line, an Executable field (empty = auto-detect on PATH) with Browse and Reset, and its sign-in hint. Edits are staged with the Settings dialog and written on Apply / OK; Cancel discards them. The override is written to <config>/agents/<id>.toml, keeping hand-written name, driver, args and env; resetting a command-only override removes the file. AgentProvider now exposes the program, args, built-in defaults, login hint and whether the command is editable: built-ins started through a package runner (npx) and registry installs are read-only, with the reason shown. Fixes #596 Co-authored-by: a-rookie-of-C-language <[email protected]>
Цей коміт міститься в:
1 джерело
e4b8b76452
коміт
9dbae1d189
22 files changed
+1086
-57
No files matched your search
@@ -1,11 +1,16 @@
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The compact variant may shrink in toolbars; here it keeps its height above the panes. */
|
||||
.tabs {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.notice,
|
||||
.warning,
|
||||
.error,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* Settings › Tools › Agents: agents of the ACP Registry this machine can run, with
|
||||
* Install / Update / Remove. Installs run in the background with progress; leaving the
|
||||
* page cancels one.
|
||||
* Settings › Tools › Agents: the launch command of built-in agents (Claude Code…), and
|
||||
* agents of the ACP Registry this machine can run, with Install / Update / Remove.
|
||||
* Installs run in the background with progress; leaving the page cancels one.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AgentRegistry, RegistryAgent, RequestId } from "@/bindings";
|
||||
import { isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T } from "@/ui";
|
||||
import { Badge, Button, Icon, ProgressBar, SearchField, Spinner, T, Tabs } from "@/ui";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { providerIcon } from "./model";
|
||||
import {
|
||||
@@ -18,9 +18,12 @@ import {
|
||||
matchesQuery,
|
||||
rowActions,
|
||||
sortAgents,
|
||||
tauriCommands,
|
||||
tauriRegistry,
|
||||
type CommandsBackend,
|
||||
type RegistryBackend,
|
||||
} from "./registry";
|
||||
import { BuiltinAgents } from "./BuiltinAgents";
|
||||
import styles from "./AgentRegistry.module.css";
|
||||
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
@@ -35,11 +38,37 @@ interface Job {
|
||||
export function AgentRegistryPage({
|
||||
onChanged,
|
||||
backend = tauriRegistry,
|
||||
commandBackend = tauriCommands,
|
||||
}: {
|
||||
/** An agent was installed, updated or removed (the agent list changed). */
|
||||
onChanged?: () => void;
|
||||
backend?: RegistryBackend;
|
||||
commandBackend?: CommandsBackend;
|
||||
}) {
|
||||
const [tab, setTab] = useState("builtin");
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<Tabs
|
||||
variant="compact"
|
||||
className={styles.tabs}
|
||||
items={[
|
||||
{ id: "builtin", label: "Built-in" },
|
||||
{ id: "registry", label: "ACP Registry" },
|
||||
]}
|
||||
activeId={tab}
|
||||
onSelect={setTab}
|
||||
aria-label={tx("Agents")}
|
||||
/>
|
||||
{tab === "builtin" ? (
|
||||
<BuiltinAgents backend={commandBackend} onChanged={onChanged} />
|
||||
) : (
|
||||
<RegistryList backend={backend} onChanged={onChanged} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegistryList({ backend, onChanged }: { backend: RegistryBackend; onChanged?: () => void }) {
|
||||
const [registry, setRegistry] = useState<AgentRegistry | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
.split {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-control);
|
||||
overflow: hidden;
|
||||
}
|
||||
.master {
|
||||
flex: 0 0 300px;
|
||||
padding: 6px;
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.agentRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.agentName {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--fg-strong);
|
||||
}
|
||||
.title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-input-large);
|
||||
}
|
||||
.launch {
|
||||
display: block;
|
||||
margin: 8px 0 0;
|
||||
color: var(--fg-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.status {
|
||||
margin: 6px 0 24px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.hint code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--fg);
|
||||
}
|
||||
.label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.controls > :first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.hint {
|
||||
margin: 8px 0 16px;
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/** Built-in launch settings; each executable is staged independently until Apply. */
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AgentProvider } from "@/bindings";
|
||||
import { tx, useTx } from "@/platform/i18n";
|
||||
import { Badge, Button, Icon, IconButton, Input, List, Spinner, T } from "@/ui";
|
||||
import { useSettingsForm } from "@/workbench/contributions";
|
||||
import { providerIcon } from "./model";
|
||||
import type { CommandsBackend } from "./registry";
|
||||
import styles from "./BuiltinAgents.module.css";
|
||||
|
||||
function Status({ agent }: { agent: AgentProvider }) {
|
||||
const translate = useTx();
|
||||
if (!agent.commandOverridden && agent.installed) return null;
|
||||
return (
|
||||
<Badge tone={agent.commandOverridden ? "accent" : "warning"}>
|
||||
{agent.commandOverridden ? translate("Custom") : translate("Not found")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function BuiltinAgents({ backend, onChanged }: { backend: CommandsBackend; onChanged?: () => void }) {
|
||||
const [agents, setAgents] = useState<AgentProvider[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState(0);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
backend.providers().then(
|
||||
(list) => live && setAgents(list.filter((p) => p.defaultProgram != null)),
|
||||
(e: unknown) => live && setError(e instanceof Error ? e.message : String(e)),
|
||||
);
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [backend]);
|
||||
const refresh = useCallback(async () => {
|
||||
const list = await backend.providers();
|
||||
setAgents(list.filter((p) => p.defaultProgram != null));
|
||||
}, [backend]);
|
||||
if (error)
|
||||
return (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
if (!agents) return <Spinner size={14} />;
|
||||
const agent = agents[selected] ?? agents[0];
|
||||
return (
|
||||
<div className={styles.split}>
|
||||
<List
|
||||
className={styles.master}
|
||||
items={agents}
|
||||
getKey={(p) => p.id}
|
||||
activeIndex={selected}
|
||||
onActiveChange={setSelected}
|
||||
rowHeight={32}
|
||||
aria-label={tx("Built-in agents")}
|
||||
renderItem={(p) => (
|
||||
<div className={styles.agentRow}>
|
||||
<Icon name={providerIcon(p.id)} size={16} />
|
||||
<span className={styles.agentName}>{p.name}</span> <Status agent={p} />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{agent && <Details key={agent.id} agent={agent} backend={backend} refresh={refresh} onChanged={onChanged} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Details({
|
||||
agent,
|
||||
backend,
|
||||
refresh,
|
||||
onChanged,
|
||||
}: {
|
||||
agent: AgentProvider;
|
||||
backend: CommandsBackend;
|
||||
refresh: () => Promise<void>;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const form = useSettingsForm();
|
||||
const base = agent.commandOverridden ? agent.program : "";
|
||||
const { value, current, set, saving } = form.useExternal(`agents.${agent.id}.command`, base, async (draft) => {
|
||||
const command = draft.trim() || null;
|
||||
await backend.set(agent.id, command);
|
||||
await refresh();
|
||||
onChanged?.();
|
||||
return command === agent.defaultProgram ? "" : (command ?? "");
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// An edit can have been staged by a previous page instance. Its Apply callback
|
||||
// refreshes that instance; the mounted one follows the newly applied value too.
|
||||
useEffect(() => {
|
||||
if (!saving && current !== base) {
|
||||
void refresh().catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
|
||||
}
|
||||
}, [base, current, saving, refresh]);
|
||||
const launch = [agent.defaultProgram, ...agent.defaultArgs].join(" ");
|
||||
return (
|
||||
<section className={styles.details} aria-label={tx`${agent.name} executable`}>
|
||||
<div className={styles.title}>
|
||||
<Icon name={providerIcon(agent.id)} size={20} />
|
||||
<h3>{agent.name}</h3>
|
||||
</div>
|
||||
<code className={styles.launch}>{launch}</code>
|
||||
<p className={styles.status}>
|
||||
{agent.commandOverridden ? (
|
||||
<T>Custom executable</T>
|
||||
) : agent.installed ? (
|
||||
<T>Found on PATH</T>
|
||||
) : (
|
||||
<T>Not found on PATH</T>
|
||||
)}
|
||||
</p>
|
||||
{agent.commandEditable ? (
|
||||
<>
|
||||
<label className={styles.label} htmlFor={`agent-executable-${agent.id}`}>
|
||||
<T>Executable</T>
|
||||
</label>
|
||||
<div className={styles.controls}>
|
||||
<Input
|
||||
id={`agent-executable-${agent.id}`}
|
||||
mono
|
||||
value={value}
|
||||
disabled={saving}
|
||||
placeholder={tx`Auto-detect (${agent.defaultProgram})`}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
size="small"
|
||||
title="Browse…"
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await backend.chooseExecutable(tx`Select ${agent.name}`, value);
|
||||
if (path) set(path);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="small" disabled={saving || !value} onClick={() => set("")}>
|
||||
<T>Reset</T>
|
||||
</Button>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
<T>Leave empty to find the executable on PATH. Changes apply to new sessions.</T>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className={styles.hint}>
|
||||
{agent.commandReadOnlyReason === "packageRunner" ? (
|
||||
<T>This agent runs through a package runner (npm or uv) and has no executable to choose.</T>
|
||||
) : agent.commandReadOnlyReason === "registry" ? (
|
||||
<T>This agent is installed from the ACP Registry. Manage its installation in the ACP Registry tab.</T>
|
||||
) : (
|
||||
<T>This agent is configured in its agent file.</T>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agent.loginHint && (
|
||||
<p className={styles.hint}>
|
||||
<LoginHint text={agent.loginHint} />
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className={styles.error} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Login hints are plain text with optional backtick-delimited command spans. */
|
||||
function LoginHint({ text }: { text: string }) {
|
||||
const translate = useTx();
|
||||
return translate(text)
|
||||
.split(/(`[^`]+`)/g)
|
||||
.map((part, i) => (i % 2 === 1 ? <code key={i}>{part.slice(1, -1)}</code> : part));
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { AgentProvider, AgentRegistry, InstallEvent, RegistryAgent, RequestId } from "@/bindings";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { setLocale, tx } from "@/platform/i18n";
|
||||
import { iconMarkup } from "@/ui/icons";
|
||||
import { MessagesHost } from "@/workbench/messages";
|
||||
import { AgentRegistryPage } from "./AgentRegistryPage";
|
||||
import { AgentRegistryPage as RegistryPage } from "./AgentRegistryPage";
|
||||
import { SettingsFormContext, registerSettingsPage } from "@/workbench/contributions";
|
||||
import { useSettingsFormFor } from "@/features/settings/components/context";
|
||||
import { closeSettings, openSettings, selectPage } from "@/features/settings/model/staging";
|
||||
import { SettingsDialog } from "@/features/settings/dialog/SettingsDialog";
|
||||
import { REGISTRY_PAGE } from "./model";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
function AgentRegistryPage(props: ComponentProps<typeof RegistryPage>) {
|
||||
const form = useSettingsFormFor(REGISTRY_PAGE, "global");
|
||||
return (
|
||||
<SettingsFormContext.Provider value={form}>
|
||||
<RegistryPage {...props} />
|
||||
</SettingsFormContext.Provider>
|
||||
);
|
||||
}
|
||||
import { ADD_AGENT, providerOptions } from "./providerOptions";
|
||||
import { afterRemove, matchesQuery, rowActions, sortAgents, type RegistryBackend } from "./registry";
|
||||
import {
|
||||
afterRemove,
|
||||
matchesQuery,
|
||||
rowActions,
|
||||
sortAgents,
|
||||
type CommandsBackend,
|
||||
type RegistryBackend,
|
||||
} from "./registry";
|
||||
|
||||
const agent = (partial: Partial<RegistryAgent> & Pick<RegistryAgent, "id" | "name">): RegistryAgent => ({
|
||||
version: "1.0.0",
|
||||
@@ -114,6 +136,14 @@ describe("agent picker", () => {
|
||||
const provider = (p: Partial<AgentProvider> & Pick<AgentProvider, "id" | "source">): AgentProvider => ({
|
||||
name: p.id,
|
||||
command: p.id,
|
||||
program: p.id,
|
||||
args: [],
|
||||
defaultProgram: p.id,
|
||||
defaultArgs: [],
|
||||
loginHint: null,
|
||||
commandOverridden: false,
|
||||
commandEditable: true,
|
||||
commandReadOnlyReason: null,
|
||||
installed: true,
|
||||
config: [],
|
||||
commands: [],
|
||||
@@ -143,10 +173,15 @@ describe("agent picker", () => {
|
||||
});
|
||||
|
||||
describe("Agents settings page", () => {
|
||||
const renderRegistry = (...args: Parameters<typeof render>) => {
|
||||
const result = render(...args);
|
||||
fireEvent.click(screen.getByRole("tab", { name: "ACP Registry" }));
|
||||
return result;
|
||||
};
|
||||
it("shows the built-in Copilot name and icon in its registry row", async () => {
|
||||
const backend = new FakeBackend();
|
||||
backend.registry = { agents: [agent({ id: "copilot", name: "GitHub Copilot" })], warning: null };
|
||||
render(<AgentRegistryPage backend={backend} />);
|
||||
renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
const copilot = await screen.findByRole("listitem", { name: "GitHub Copilot" });
|
||||
const expected = document.createElement("div");
|
||||
expected.innerHTML = iconMarkup("agent-copilot")!;
|
||||
@@ -156,7 +191,7 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("lists registry agents, says they are third-party, and searches them", async () => {
|
||||
const backend = new FakeBackend();
|
||||
render(<AgentRegistryPage backend={backend} />);
|
||||
renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
expect(screen.getByText(/third-party software/)).toBeInTheDocument();
|
||||
expect(within(row("Amp")).getByRole("button", { name: "Install" })).toBeEnabled();
|
||||
@@ -175,7 +210,7 @@ describe("Agents settings page", () => {
|
||||
it("installs with progress, then reports the change", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
render(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
await waitFor(() => expect(row("Amp")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("Amp")).getByRole("button", { name: "Install" }));
|
||||
expect(backend.installs).toEqual(["amp-acp"]);
|
||||
@@ -194,7 +229,7 @@ describe("Agents settings page", () => {
|
||||
it("asks before installing an agent whose download has no checksum", async () => {
|
||||
const backend = new FakeBackend();
|
||||
backend.registry = { agents: [{ ...AMP, noChecksum: true }], warning: null };
|
||||
render(
|
||||
renderRegistry(
|
||||
<>
|
||||
<AgentRegistryPage backend={backend} />
|
||||
<MessagesHost />
|
||||
@@ -218,7 +253,7 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("installs an agent with a checksum without asking", async () => {
|
||||
const backend = new FakeBackend();
|
||||
render(
|
||||
renderRegistry(
|
||||
<>
|
||||
<AgentRegistryPage backend={backend} />
|
||||
<MessagesHost />
|
||||
@@ -233,7 +268,7 @@ describe("Agents settings page", () => {
|
||||
|
||||
it("shows why an install failed, and cancels one when the page closes", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const { unmount } = render(<AgentRegistryPage backend={backend} />);
|
||||
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
await waitFor(() => expect(row("Cline")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("Cline")).getByRole("button", { name: "Update" }));
|
||||
await act(async () => backend.fail(new IpcError({ kind: "unavailable", message: "npm is required" })));
|
||||
@@ -251,7 +286,7 @@ describe("Agents settings page", () => {
|
||||
it("removes an installed agent", async () => {
|
||||
const backend = new FakeBackend();
|
||||
const onChanged = vi.fn();
|
||||
render(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
renderRegistry(<AgentRegistryPage onChanged={onChanged} backend={backend} />);
|
||||
await waitFor(() => expect(row("fast-agent")).toBeInTheDocument());
|
||||
fireEvent.click(within(row("fast-agent")).getByRole("button", { name: "Remove" }));
|
||||
await waitFor(() => expect(within(row("fast-agent")).getByRole("button", { name: "Install" })).toBeEnabled());
|
||||
@@ -262,12 +297,308 @@ describe("Agents settings page", () => {
|
||||
it("falls back to the saved list with a warning, and reports a registry it cannot load", async () => {
|
||||
const backend = new FakeBackend();
|
||||
backend.registry = { agents: AGENTS, warning: "Could not reach the ACP Registry" };
|
||||
const { unmount } = render(<AgentRegistryPage backend={backend} />);
|
||||
const { unmount } = renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Could not reach the ACP Registry"));
|
||||
unmount();
|
||||
|
||||
backend.list.mockRejectedValueOnce(new IpcError({ kind: "io", message: "offline" }));
|
||||
render(<AgentRegistryPage backend={backend} />);
|
||||
renderRegistry(<AgentRegistryPage backend={backend} />);
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("Cannot load the ACP Registry: offline"));
|
||||
});
|
||||
});
|
||||
|
||||
class FakeCommands implements CommandsBackend {
|
||||
list: AgentProvider[] = [];
|
||||
saved: [string, string | null][] = [];
|
||||
providers = vi.fn(async () => this.list);
|
||||
chooseExecutable = vi.fn(async () => "/chosen/agent");
|
||||
async set(id: string, command: string | null) {
|
||||
this.saved.push([id, command]);
|
||||
// What the agent list shows after the override takes effect.
|
||||
this.list = this.list.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
program: command ?? p.defaultProgram!,
|
||||
command: command ?? p.defaultProgram!,
|
||||
commandOverridden: command != null && command !== p.defaultProgram,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const claudeProvider = (command: string): AgentProvider => ({
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
command,
|
||||
program: command,
|
||||
args: [],
|
||||
defaultProgram: "claude",
|
||||
defaultArgs: [],
|
||||
loginHint: "Run `claude` in a terminal and sign in.",
|
||||
commandOverridden: command !== "claude",
|
||||
commandEditable: true,
|
||||
commandReadOnlyReason: null,
|
||||
installed: true,
|
||||
config: [],
|
||||
commands: [],
|
||||
source: { kind: "builtin" },
|
||||
});
|
||||
|
||||
describe("Built-in executable overrides", () => {
|
||||
let dispose = () => {};
|
||||
beforeEach(() => openSettings(REGISTRY_PAGE));
|
||||
afterEach(() => {
|
||||
closeSettings();
|
||||
dispose();
|
||||
});
|
||||
|
||||
const openCommands = async (commands: FakeCommands, onChanged = vi.fn()) => {
|
||||
const backend = new FakeBackend();
|
||||
dispose = registerSettingsPage({
|
||||
id: REGISTRY_PAGE,
|
||||
title: "Agents",
|
||||
group: "Tools",
|
||||
component: () => <RegistryPage backend={backend} commandBackend={commands} onChanged={onChanged} />,
|
||||
});
|
||||
render(<SettingsDialog />);
|
||||
return screen.findByRole("region", { name: "Claude Code executable" });
|
||||
};
|
||||
const apply = () => screen.getByRole("button", { name: "Apply" });
|
||||
const field = () => screen.getByRole("textbox", { name: "Executable" });
|
||||
|
||||
it("follows locale changes in tabs and renders login commands as inline code", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
claudeProvider("claude"),
|
||||
{
|
||||
...claudeProvider("codex"),
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
defaultProgram: "codex",
|
||||
commandOverridden: false,
|
||||
installed: false,
|
||||
},
|
||||
];
|
||||
const section = await openCommands(commands);
|
||||
expect(within(section).getByText("claude", { selector: "p code" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Found on PATH")).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: "Claude Code" })).toBeInTheDocument();
|
||||
try {
|
||||
await act(async () => {
|
||||
await setLocale("zh");
|
||||
});
|
||||
expect(screen.getByRole("option", { name: "Codex 未找到" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "内置" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "ACP 注册表" })).toBeInTheDocument();
|
||||
await act(async () => {
|
||||
await setLocale("en");
|
||||
});
|
||||
expect(screen.getByRole("option", { name: "Codex Not found" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Built-in" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "ACP Registry" })).toBeInTheDocument();
|
||||
} finally {
|
||||
await act(async () => {
|
||||
await setLocale("en");
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("reports when providers cannot be read (no trusted project)", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.providers.mockRejectedValue(new IpcError({ kind: "unavailable", message: "restricted" }));
|
||||
render(<AgentRegistryPage backend={new FakeBackend()} commandBackend={commands} />);
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent("restricted"));
|
||||
expect(screen.queryByRole("textbox", { name: "Executable" })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["Apply", "OK"])("stages a binary path and writes it only on %s", async (button) => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [claudeProvider("claude")];
|
||||
const onChanged = vi.fn();
|
||||
const section = await openCommands(commands, onChanged);
|
||||
expect(apply()).toBeDisabled();
|
||||
expect(field()).toHaveValue("");
|
||||
expect(within(section).queryByRole("button", { name: "Save" })).toBeNull();
|
||||
fireEvent.change(field(), { target: { value: "D:\\tools\\claude.exe" } });
|
||||
expect(commands.saved).toEqual([]);
|
||||
expect(onChanged).not.toHaveBeenCalled();
|
||||
expect(apply()).toBeEnabled();
|
||||
expect(screen.queryByRole("option", { name: /Claude Code Custom/ })).toBeNull();
|
||||
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: button })));
|
||||
expect(commands.saved).toEqual([["claude", "D:\\tools\\claude.exe"]]);
|
||||
expect(onChanged).toHaveBeenCalledOnce();
|
||||
if (button === "Apply") {
|
||||
expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument();
|
||||
expect(apply()).toBeDisabled();
|
||||
} else expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("Cancel discards command edits without writing", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
claudeProvider("/saved/claude"),
|
||||
{
|
||||
...claudeProvider("codex"),
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
defaultProgram: "codex",
|
||||
commandOverridden: false,
|
||||
},
|
||||
];
|
||||
await openCommands(commands);
|
||||
fireEvent.change(field(), { target: { value: "/unsaved/claude" } });
|
||||
fireEvent.mouseDown(screen.getByRole("option", { name: "Codex" }));
|
||||
fireEvent.change(field(), { target: { value: "/unsaved/codex" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(commands.saved).toEqual([]);
|
||||
act(() => openSettings(REGISTRY_PAGE));
|
||||
expect(await screen.findByRole("textbox", { name: "Executable" })).toHaveValue("/saved/claude");
|
||||
expect(apply()).toBeDisabled();
|
||||
});
|
||||
|
||||
it.each(["clear", "Reset"])("%s stages automatic detection until Apply", async (action) => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [claudeProvider("/saved/claude")];
|
||||
const section = await openCommands(commands);
|
||||
if (action === "clear") fireEvent.change(field(), { target: { value: "" } });
|
||||
else fireEvent.click(within(section).getByRole("button", { name: "Reset" }));
|
||||
expect(field()).toHaveValue("");
|
||||
expect(commands.saved).toEqual([]);
|
||||
expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument();
|
||||
expect(apply()).toBeEnabled();
|
||||
await act(async () => fireEvent.click(apply()));
|
||||
expect(commands.saved).toEqual([["claude", null]]);
|
||||
expect(field()).toHaveValue("");
|
||||
expect(screen.queryByRole("option", { name: /Claude Code Custom/ })).toBeNull();
|
||||
expect(apply()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("page Reset discards edits and returning to the saved value clears the modified state", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [claudeProvider("/saved/claude")];
|
||||
await openCommands(commands);
|
||||
fireEvent.change(field(), { target: { value: "/edited/claude" } });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "Reset" })[0]!);
|
||||
expect(field()).toHaveValue("/saved/claude");
|
||||
expect(apply()).toBeDisabled();
|
||||
fireEvent.change(field(), { target: { value: "/edited/claude" } });
|
||||
fireEvent.change(field(), { target: { value: "/saved/claude" } });
|
||||
expect(apply()).toBeDisabled();
|
||||
expect(commands.saved).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps edits across page navigation and applies with Agents mounted=%s", async (mounted) => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [claudeProvider("claude")];
|
||||
await openCommands(commands);
|
||||
const off = registerSettingsPage({ id: "other", title: "Other" });
|
||||
try {
|
||||
fireEvent.change(field(), { target: { value: "/custom/claude" } });
|
||||
act(() => selectPage("other"));
|
||||
expect(apply()).toBeEnabled();
|
||||
if (mounted) {
|
||||
act(() => selectPage(REGISTRY_PAGE));
|
||||
expect(await screen.findByRole("textbox", { name: "Executable" })).toHaveValue("/custom/claude");
|
||||
}
|
||||
await act(async () => fireEvent.click(apply()));
|
||||
act(() => selectPage(REGISTRY_PAGE));
|
||||
expect(await screen.findByRole("textbox", { name: "Executable" })).toHaveValue("/custom/claude");
|
||||
expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument();
|
||||
expect(commands.saved).toEqual([["claude", "/custom/claude"]]);
|
||||
expect(apply()).toBeDisabled();
|
||||
} finally {
|
||||
off();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a failed command for retry and shows only the applied badge", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [claudeProvider("claude")];
|
||||
const set = vi.spyOn(commands, "set").mockRejectedValueOnce(new Error("Read-only configuration"));
|
||||
await openCommands(commands);
|
||||
fireEvent.change(field(), { target: { value: " /custom/claude " } });
|
||||
await act(async () => fireEvent.click(apply()));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Read-only configuration");
|
||||
expect(field()).toHaveValue(" /custom/claude ");
|
||||
expect(screen.queryByRole("option", { name: /Claude Code Custom/ })).toBeNull();
|
||||
expect(apply()).toBeEnabled();
|
||||
await act(async () => fireEvent.click(apply()));
|
||||
expect(set).toHaveBeenCalledTimes(2);
|
||||
expect(commands.saved).toEqual([["claude", "/custom/claude"]]);
|
||||
expect(field()).toHaveValue("/custom/claude");
|
||||
expect(screen.getByRole("option", { name: /Claude Code Custom/ })).toBeInTheDocument();
|
||||
});
|
||||
it.each(["Apply", "OK"])("lists built-ins and writes two independent edits on %s", async (button) => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
claudeProvider("claude"),
|
||||
{
|
||||
...claudeProvider("codex"),
|
||||
id: "codex",
|
||||
name: "Codex",
|
||||
defaultProgram: "codex",
|
||||
commandOverridden: false,
|
||||
args: ["app-server"],
|
||||
defaultArgs: ["app-server"],
|
||||
installed: false,
|
||||
},
|
||||
];
|
||||
const changed = vi.fn();
|
||||
await openCommands(commands, changed);
|
||||
expect(screen.getByRole("option", { name: "Claude Code" })).toBeInTheDocument();
|
||||
const codex = screen.getByRole("option", { name: "Codex Not found" });
|
||||
fireEvent.change(field(), { target: { value: "/custom/claude" } });
|
||||
fireEvent.mouseDown(codex);
|
||||
expect(screen.getByText("codex app-server")).toBeInTheDocument();
|
||||
expect(field()).toHaveAttribute("placeholder", "Auto-detect (codex)");
|
||||
fireEvent.change(field(), { target: { value: "/custom/codex" } });
|
||||
expect(commands.saved).toEqual([]);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: button })));
|
||||
expect(commands.saved).toEqual([
|
||||
["claude", "/custom/claude"],
|
||||
["codex", "/custom/codex"],
|
||||
]);
|
||||
expect(changed).toHaveBeenCalledTimes(2);
|
||||
if (button === "Apply") expect(screen.getByRole("option", { name: "Codex Custom" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(["packageRunner", "registry"] as const)("keeps %s built-ins visible and read-only", async (reason) => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [
|
||||
{
|
||||
...claudeProvider("npx"),
|
||||
id: "claude-acp",
|
||||
name: "Claude Code (ACP)",
|
||||
defaultProgram: "npx",
|
||||
defaultArgs: ["-y", "adapter"],
|
||||
commandEditable: false,
|
||||
commandReadOnlyReason: reason,
|
||||
source: reason === "registry" ? { kind: "registry", version: "1", update: null } : { kind: "builtin" },
|
||||
},
|
||||
];
|
||||
render(<AgentRegistryPage backend={new FakeBackend()} commandBackend={commands} />);
|
||||
expect(await screen.findByText("npx -y adapter")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox", { name: "Executable" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Browse…" })).toBeNull();
|
||||
expect(
|
||||
screen.getByText(
|
||||
reason === "packageRunner" ? /runs through a package runner/ : /installed from the ACP Registry/,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(commands.saved).toEqual([]);
|
||||
});
|
||||
|
||||
it("stages a browsed executable", async () => {
|
||||
const commands = new FakeCommands();
|
||||
commands.list = [claudeProvider("claude")];
|
||||
await openCommands(commands);
|
||||
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Browse…" })));
|
||||
expect(field()).toHaveValue("/chosen/agent");
|
||||
expect(commands.saved).toEqual([]);
|
||||
await act(async () => fireEvent.click(apply()));
|
||||
expect(commands.saved).toEqual([["claude", "/chosen/agent"]]);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,14 @@ const claude: AgentProvider = {
|
||||
installed: true,
|
||||
config: [],
|
||||
source: { kind: "builtin" },
|
||||
program: "claude",
|
||||
args: [],
|
||||
defaultProgram: "claude",
|
||||
defaultArgs: [],
|
||||
loginHint: null,
|
||||
commandOverridden: false,
|
||||
commandEditable: true,
|
||||
commandReadOnlyReason: null,
|
||||
// Offered by the agent's last session in this project.
|
||||
commands: [command("compact", "Free up context")],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
/** Agents of the ACP Registry: listing, install / update / remove (`agent_registry_*`). */
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type { AgentRegistry, InstallEvent, RegistryAgent, RegistryDistribution, RequestId } from "@/bindings";
|
||||
import type {
|
||||
AgentProvider,
|
||||
AgentRegistry,
|
||||
InstallEvent,
|
||||
RegistryAgent,
|
||||
RegistryDistribution,
|
||||
RequestId,
|
||||
} from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
|
||||
export interface RegistryBackend {
|
||||
@@ -10,6 +18,13 @@ export interface RegistryBackend {
|
||||
cancel(request: RequestId): void;
|
||||
}
|
||||
|
||||
/** The built-in agents' launch commands: read, and override through an agent file. */
|
||||
export interface CommandsBackend {
|
||||
providers(): Promise<AgentProvider[]>;
|
||||
set(id: string, command: string | null): Promise<void>;
|
||||
chooseExecutable(title: string, current?: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export const tauriRegistry: RegistryBackend = {
|
||||
list: (request) => call(commands.agentRegistryList(request)),
|
||||
install: async (id, request, onEvent) => {
|
||||
@@ -23,6 +38,17 @@ export const tauriRegistry: RegistryBackend = {
|
||||
cancel: (request) => void commands.cancelRequest(request).catch(() => {}),
|
||||
};
|
||||
|
||||
export const tauriCommands: CommandsBackend = {
|
||||
providers: () => call(commands.agentProviders()),
|
||||
chooseExecutable: async (title, current) => {
|
||||
const picked = await open({ title, defaultPath: current || undefined, directory: false, multiple: false });
|
||||
return typeof picked === "string" ? picked : null;
|
||||
},
|
||||
set: async (id, command) => {
|
||||
await call(commands.agentSetCommand(id, command));
|
||||
},
|
||||
};
|
||||
|
||||
export const DISTRIBUTION_LABEL: Record<RegistryDistribution, string> = {
|
||||
binary: "Binary",
|
||||
npx: "npm",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Page context for the form components: which page / scope they edit and the search query. */
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
import type { SettingsForm } from "@/workbench/contributions";
|
||||
import { stage, stagedValue, useSettingsDialog, type Scope } from "../model/staging";
|
||||
import { externalKey, stage, stageExternal, stagedValue, useSettingsDialog, type Scope } from "../model/staging";
|
||||
import { labelMatches } from "../model/pages";
|
||||
|
||||
export interface PageContextValue {
|
||||
@@ -38,11 +38,26 @@ export function useSettingsFormFor(pageId: string, scope: Scope): SettingsForm {
|
||||
scope,
|
||||
useValue: (key: string) => useStagedValue(scope, key),
|
||||
set: (key: string, value: unknown) => stage(pageId, scope, key, value),
|
||||
useExternal: <T>(key: string, base: T, apply: (value: T) => Promise<T>) =>
|
||||
useExternalValue(pageId, key, base, apply),
|
||||
}),
|
||||
[pageId, scope],
|
||||
);
|
||||
}
|
||||
|
||||
function useExternalValue<T>(page: string, key: string, base: T, apply: (value: T) => Promise<T>) {
|
||||
const id = externalKey(page, key);
|
||||
const staged = useSettingsDialog((s) => s.stagedExternal[id]);
|
||||
const current = useSettingsDialog((s) => (id in s.externalApplied ? (s.externalApplied[id] as T) : base));
|
||||
const saving = useSettingsDialog((s) => s.saving);
|
||||
return {
|
||||
value: staged ? (staged.value as T) : current,
|
||||
current,
|
||||
set: (value: T) => stageExternal(page, key, value, current, apply),
|
||||
saving,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSearchQuery(): string {
|
||||
return useSettingsDialog((s) => s.query);
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ function SettingsDialogInner() {
|
||||
/** Page tree + current page; reads the shared settings staging store. */
|
||||
export function SettingsLayout() {
|
||||
const pages = useSettingsPages((s) => s.pages);
|
||||
const { pageId, query, staged, stagedSecrets, saving } = useSettingsDialog();
|
||||
const { pageId, query, staged, stagedSecrets, stagedExternal, saving } = useSettingsDialog();
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
|
||||
const tree = useMemo(() => buildPageTree(pages.values()), [pages]);
|
||||
@@ -235,8 +235,9 @@ export function SettingsLayout() {
|
||||
const dirtyPages = useMemo(() => {
|
||||
const pages = new Set(Object.values(staged).map((c) => c.page));
|
||||
if (Object.keys(stagedSecrets).length) pages.add(SSH_SETTINGS_PAGE);
|
||||
for (const c of Object.values(stagedExternal)) pages.add(c.page);
|
||||
return pages;
|
||||
}, [staged, stagedSecrets]);
|
||||
}, [staged, stagedSecrets, stagedExternal]);
|
||||
const pageDirty = !!current && dirtyPages.has(current.key);
|
||||
|
||||
// Back / forward through pages visited while the dialog is open.
|
||||
@@ -338,7 +339,7 @@ export function SettingsLayout() {
|
||||
|
||||
/** OK / Cancel / Apply, ordered per platform by the dialog primitive. */
|
||||
function useSettingsButtons(): DialogButton[] {
|
||||
const { staged, stagedSecrets, base, globalBase, saving } = useSettingsDialog();
|
||||
const { staged, stagedSecrets, stagedExternal, base, globalBase, saving } = useSettingsDialog();
|
||||
const pending = useMemo(() => pendingChanges({ base, globalBase, staged }).length > 0, [base, globalBase, staged]);
|
||||
const ok = async () => {
|
||||
if (await applySettings()) closeSettings();
|
||||
@@ -348,7 +349,7 @@ function useSettingsButtons(): DialogButton[] {
|
||||
{ label: "Cancel", role: "cancel", onClick: closeSettings },
|
||||
{
|
||||
label: "Apply",
|
||||
disabled: (!pending && !Object.keys(stagedSecrets).length) || saving,
|
||||
disabled: (!pending && !Object.keys(stagedSecrets).length && !Object.keys(stagedExternal).length) || saving,
|
||||
onClick: () => void applySettings(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface StagedChange {
|
||||
page: string;
|
||||
}
|
||||
|
||||
interface ExternalChange {
|
||||
page: string;
|
||||
value: unknown;
|
||||
apply(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface SettingsDialogState {
|
||||
open: boolean;
|
||||
pageId: string;
|
||||
@@ -33,6 +39,9 @@ export interface SettingsDialogState {
|
||||
staged: Record<string, StagedChange>;
|
||||
/** SSH credentials stay outside settings JSON until Apply / OK. */
|
||||
stagedSecrets: Record<string, string>;
|
||||
stagedExternal: Record<string, ExternalChange>;
|
||||
/** Values written outside settings JSON since opening the dialog. */
|
||||
externalApplied: Record<string, unknown>;
|
||||
/** Scope chosen per page (Global / This project). */
|
||||
scopes: Record<string, Scope>;
|
||||
saving: boolean;
|
||||
@@ -52,6 +61,8 @@ export const useSettingsDialog = create<SettingsDialogState>(() => ({
|
||||
newProjects: false,
|
||||
staged: {},
|
||||
stagedSecrets: {},
|
||||
stagedExternal: {},
|
||||
externalApplied: {},
|
||||
scopes: {},
|
||||
saving: false,
|
||||
error: null,
|
||||
@@ -79,6 +90,8 @@ export function openSettings(pageId?: string, opts: { newProjects?: boolean } =
|
||||
newProjects: opts.newProjects ?? false,
|
||||
staged: {},
|
||||
stagedSecrets: {},
|
||||
stagedExternal: {},
|
||||
externalApplied: {},
|
||||
saving: false,
|
||||
error: null,
|
||||
});
|
||||
@@ -108,7 +121,15 @@ type Bases = Pick<SettingsDialogState, "base" | "globalBase">;
|
||||
const baseFor = (s: Bases, scope: Scope) => (scope === "global" ? s.globalBase : s.base);
|
||||
|
||||
export function closeSettings() {
|
||||
useSettingsDialog.setState({ open: false, staged: {}, stagedSecrets: {}, error: null, saving: false });
|
||||
useSettingsDialog.setState({
|
||||
open: false,
|
||||
staged: {},
|
||||
stagedSecrets: {},
|
||||
stagedExternal: {},
|
||||
externalApplied: {},
|
||||
error: null,
|
||||
saving: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Shows `pageId` (`page#focus` also selects `focus` on it). */
|
||||
@@ -180,11 +201,25 @@ export function stageSshSecret(id: string, value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export const externalKey = (page: string, key: string) => `${page}|${key}`;
|
||||
|
||||
export function stageExternal<T>(page: string, key: string, value: T, base: T, apply: (value: T) => Promise<T>) {
|
||||
useSettingsDialog.setState((s) => {
|
||||
if (s.saving) return {};
|
||||
const stagedExternal = { ...s.stagedExternal };
|
||||
const id = externalKey(page, key);
|
||||
if (deepEqual(value, base)) delete stagedExternal[id];
|
||||
else stagedExternal[id] = { page, value, apply: () => apply(value) };
|
||||
return { stagedExternal, error: null };
|
||||
});
|
||||
}
|
||||
|
||||
/** Discards staged edits of one page (the dialog's Reset button). */
|
||||
export function resetPage(page: string) {
|
||||
useSettingsDialog.setState((s) => ({
|
||||
staged: Object.fromEntries(Object.entries(s.staged).filter(([, c]) => c.page !== page)),
|
||||
stagedSecrets: page === SSH_SETTINGS_PAGE ? {} : s.stagedSecrets,
|
||||
stagedExternal: Object.fromEntries(Object.entries(s.stagedExternal).filter(([, c]) => c.page !== page)),
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
@@ -254,7 +289,8 @@ export async function applySettings(deps: PersistDeps = defaultDeps()): Promise<
|
||||
if (initial.saving) return false;
|
||||
const changes = pendingChanges(initial);
|
||||
const secrets = Object.entries(initial.stagedSecrets);
|
||||
if (!changes.length && !secrets.length) return true;
|
||||
const external = Object.entries(initial.stagedExternal);
|
||||
if (!changes.length && !secrets.length && !external.length) return true;
|
||||
useSettingsDialog.setState({ saving: true, error: null });
|
||||
const result = await persistChanges(changes, deps);
|
||||
const { written } = result;
|
||||
@@ -293,6 +329,22 @@ export async function applySettings(deps: PersistDeps = defaultDeps()): Promise<
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!error) {
|
||||
for (const [id, change] of external) {
|
||||
if (useSettingsDialog.getState().stagedExternal[id] !== change) continue;
|
||||
try {
|
||||
const value = await change.apply();
|
||||
useSettingsDialog.setState((s) => {
|
||||
const stagedExternal = { ...s.stagedExternal };
|
||||
if (stagedExternal[id] === change) delete stagedExternal[id];
|
||||
return { stagedExternal, externalApplied: { ...s.externalApplied, [id]: value } };
|
||||
});
|
||||
} catch (e) {
|
||||
error = `${id}: ${e instanceof Error ? e.message : String(e)}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
useSettingsDialog.setState({ saving: false, error });
|
||||
return error === null;
|
||||
}
|
||||
@@ -44,6 +44,9 @@ export function TestSettingsForm({
|
||||
const form = useMemo<SettingsForm>(
|
||||
() => ({
|
||||
scope,
|
||||
useExternal: () => {
|
||||
throw new Error("External settings are not supported by this stand-in");
|
||||
},
|
||||
useValue: (key) => useTestForm((s) => getAt(s.values, key)),
|
||||
set: (key, value) =>
|
||||
useTestForm.setState((s) => ({ values: putAt(s.values, key, value), staged: [...s.staged, [key, value]] })),
|
||||
|
||||
Reference in new issue
Заблокувати користувача