mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
feat(studio): the extension project scope moves into settings as an Extension scope row on the Permissions page; the extension page only says so when it is managing another project, as the new Set-scope board draws it
This commit is contained in:
1 parent
4c6ba31a1c
commit
5e00091026
10 files changed
+162
-45
No files matched your search
@@ -449,3 +449,13 @@
|
||||
.ext-foot { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--r-md); color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.ext-foot > .studio-icon { flex: none; width: 14px; height: 14px; }
|
||||
.ext-foot > span { flex: 1; min-width: 0; }
|
||||
.scoperow .scopeinfo { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; margin: 8px 0 12px; padding: 8px 12px; border-radius: var(--r-md); background: var(--page); color: var(--muted); font: var(--w-reg) var(--fs-meta)/1.6 var(--ui); }
|
||||
.scoperow .scopeinfo .pt { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); }
|
||||
.scoperow .scopeinfo .br { padding: 1px 7px; border-radius: var(--r-pill); background: var(--overlay); font: var(--w-reg) var(--fs-micro) var(--mono); }
|
||||
.scoperow .scopeanchor { position: relative; }
|
||||
.scoperow .scopeanchor .scope-pick { top: calc(100% + 4px); right: 0; left: auto; }
|
||||
.scoperow .who .cv { margin-left: 6px; font-style: normal; font-size: var(--fs-micro); color: var(--faint); }
|
||||
.scopeaway { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; padding: 8px 10px 8px 12px; border-radius: var(--r-md); background: color-mix(in srgb, var(--warn) 12%, transparent); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.scopeaway > span { flex: 1; min-width: 0; }
|
||||
.scopeaway .act { height: 24px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--r-pill); background: transparent; color: var(--text); font: var(--w-reg) var(--fs-meta)/1 var(--ui); cursor: pointer; }
|
||||
.scopeaway .act:hover { background: var(--overlay); }
|
||||
@@ -35,6 +35,7 @@ export const EXT: Messages = {
|
||||
"ext.awaiting_approval_declared_by_the": { zh: "待授权 · 来自项目声明", en: "Awaiting approval · declared by the project" },
|
||||
"ext.back_to_list": { zh: "返回列表", en: "Back to list" },
|
||||
"ext.back_to_my_packages": { zh: "返回我的发布", en: "Back to my packages" },
|
||||
"ext.back_to_the_current_project": { zh: "回到当前项目", en: "Back to the current project" },
|
||||
"ext.before_a_tool_runs": { zh: "工具执行前", en: "Before a tool runs" },
|
||||
"ext.before_context_is_compacted": { zh: "压缩上下文前", en: "Before context is compacted" },
|
||||
"ext.blank_starts_a_new_package": { zh: "留空时新包为 0.1.0;更新只自动递增纯数字三段版本的补丁号,其他版本请明确填写。", en: "Blank starts a new package at 0.1.0. Updates auto-increment only a numeric three-part version; enter other versions explicitly." },
|
||||
@@ -98,6 +99,7 @@ export const EXT: Messages = {
|
||||
"ext.export": { zh: "导出", en: "Export" },
|
||||
"ext.exported": { zh: "导出完成。", en: "Exported." },
|
||||
"ext.extension": { zh: "扩展", en: "Extension" },
|
||||
"ext.extension_scope": { zh: "扩展范围", en: "Extension scope" },
|
||||
"ext.external_services": { zh: "外部服务", en: "External services" },
|
||||
"ext.feature_needs_it_so_it": { zh: "{feature}需要它,不能关闭。", en: "{feature} needs it, so it cannot be turned off." },
|
||||
"ext.fix": { zh: "去修复", en: "Fix" },
|
||||
@@ -140,6 +142,8 @@ export const EXT: Messages = {
|
||||
"ext.loading_deferred_mcp_tools": { zh: "加载延后的 MCP 工具", en: "Loading deferred MCP tools" },
|
||||
"ext.long_running_process": { zh: "常驻进程", en: "Long-running process" },
|
||||
"ext.lowercase_letters_digits_dots_underscores": { zh: "小写字母、数字、点、下划线、连字符,最多 64 个字符。", en: "Lowercase letters, digits, dots, underscores and hyphens, up to 64 characters." },
|
||||
"ext.manage_which_project": { zh: "管理哪个项目的扩展", en: "Manage extensions for which project" },
|
||||
"ext.managing_other_project": { zh: "正在管理 {name} 的扩展,不是当前会话所在的项目。", en: "Managing the extensions of {name}, not the project this session is in." },
|
||||
"ext.mine": { zh: "我的", en: "Mine" },
|
||||
"ext.models_choice_only": { zh: "只能模型自选", en: "Model's choice only" },
|
||||
"ext.modifies_data": { zh: "会修改数据", en: "Modifies data" },
|
||||
@@ -276,6 +280,7 @@ export const EXT: Messages = {
|
||||
"ext.saved_as_handle_visible_only": { zh: "以 @{handle} 的名义保存,仅自己可见,不提交审核。只收来源地址,不上传文件。", en: "Saved as @{handle}, visible only to you and not submitted for review. Only the source address is sent; no files are uploaded." },
|
||||
"ext.saved_slug_version_visible_only": { zh: "已保存 {slug} {version},仅自己可见", en: "Saved {slug} {version}, visible only to you" },
|
||||
"ext.saved_to_path": { zh: "已保存至 {path}。", en: "Saved to {path}." },
|
||||
"ext.scope_hint": { zh: "技能和外部服务可以只对某个项目开关;这里选在为谁设置", en: "Skills and external services can be switched for one project only; choose who you are setting them for" },
|
||||
"ext.search_installed_packages": { zh: "搜索已安装包", en: "Search installed packages" },
|
||||
"ext.search_package_names_skills_commands": { zh: "搜索包名、技能、命令或服务", en: "Search package names, skills, commands or servers" },
|
||||
"ext.search_projects": { zh: "搜索项目…", en: "Search projects…" },
|
||||
|
||||
@@ -14,44 +14,65 @@ export function Exception({ onClear, busy }: { onClear: () => void; busy: boolea
|
||||
);
|
||||
}
|
||||
|
||||
// Which folder this page is answering for. The shell holds several projects at
|
||||
// once and the pane follows whichever one is in front, so the heading has to
|
||||
// name it — otherwise switching tabs silently changes the list under an
|
||||
// unchanged title. Worktrees of one repository share one answer, and saying how
|
||||
// many is what stops "my settings vanished on a new branch". The name is also
|
||||
// the way out: a project can be managed without the session moving to it.
|
||||
export function ScopeBar({
|
||||
// Which folder the extension lists answer for. The shell holds several projects
|
||||
// at once and the pane follows whichever one is in front, so this is chosen on
|
||||
// purpose here rather than inferred from the tab in front. Worktrees of one
|
||||
// repository share one answer, and saying how many is what stops "my settings
|
||||
// vanished on a new branch". Choosing a project manages it without the session
|
||||
// moving to it.
|
||||
export function ScopeRow({
|
||||
scope, scopes, onPick,
|
||||
}: {
|
||||
scope: CapabilityScope; scopes: CapabilityScope[]; onPick: (root: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const shared = scope.repo && (scope.trees ?? 1) > 1;
|
||||
const elsewhere = !scope.current;
|
||||
return (
|
||||
<div className="scopebar" data-away={elsewhere ? "" : undefined}>
|
||||
<button className="who" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
{scope.label || scope.name}
|
||||
<i className="cv" aria-hidden>▾</i>
|
||||
</button>
|
||||
<span className="pt" title={scope.root}>{scope.root}</span>
|
||||
{scope.branch && <span className="br">{scope.branch}</span>}
|
||||
{shared && <span className="sh">{t("ext.n_worktrees_share_this_setting", { n: scope.trees ?? 1 })}</span>}
|
||||
{!scope.repo && <span className="sh">{t("ext.not_a_git_repository_kept")}</span>}
|
||||
<span className="ov">
|
||||
{scope.overrides ? t("ext.n_settings_apply_only_here", { n: scope.overrides }) : t("ext.no_exceptions_for_this_project")}
|
||||
</span>
|
||||
{elsewhere && <span className="away">{t("ext.not_the_project_this_session")}</span>}
|
||||
{open && (
|
||||
<ScopePicker
|
||||
scopes={scopes}
|
||||
at={scope.root}
|
||||
onPick={(root) => {
|
||||
setOpen(false);
|
||||
onPick(root);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="scoperow">
|
||||
<div className="srow" data-setting="ext-scope">
|
||||
<div className="srow-meta">
|
||||
<span className="srow-name">{t("ext.manage_which_project")}</span>
|
||||
<span className="srow-desc">{t("ext.scope_hint")}</span>
|
||||
</div>
|
||||
<div className="srow-ctl scopeanchor">
|
||||
<button type="button" className="spill who" data-action="ext.scope" aria-expanded={open} onClick={() => setOpen(!open)}>
|
||||
{scope.label || scope.name}
|
||||
<i className="cv" aria-hidden>▾</i>
|
||||
</button>
|
||||
{open && (
|
||||
<ScopePicker
|
||||
scopes={scopes}
|
||||
at={scope.root}
|
||||
onPick={(root) => {
|
||||
setOpen(false);
|
||||
onPick(root);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scopeinfo">
|
||||
<span className="pt" title={scope.root}>{scope.root}</span>
|
||||
{scope.branch && <span className="br">{scope.branch}</span>}
|
||||
<span className="ds">
|
||||
{[
|
||||
shared ? t("ext.n_worktrees_share_this_setting", { n: scope.trees ?? 1 }) : null,
|
||||
!scope.repo ? t("ext.not_a_git_repository_kept") : null,
|
||||
scope.overrides ? t("ext.n_settings_apply_only_here", { n: scope.overrides }) : t("ext.no_exceptions_for_this_project"),
|
||||
].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The extension page names whose list it is only when that is not the running
|
||||
// project, so the same heading never quietly changes owner.
|
||||
export function ScopeAway({ scope, onBack }: { scope: CapabilityScope; onBack: () => void }) {
|
||||
return (
|
||||
<div className="scopeaway" role="note">
|
||||
<span>{t("ext.managing_other_project", { name: scope.label || scope.name })}</span>
|
||||
<button type="button" className="act" data-action="ext.scope-back" onClick={onBack}>{t("ext.back_to_the_current_project")}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,8 +29,10 @@ it("keeps the selected project's skills when the previous read finishes late", a
|
||||
accountUnread="" reloadAccount={() => {}}
|
||||
/>);
|
||||
|
||||
await userEvent.click(await screen.findByRole("tab", { name: /权限与安全/ }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: /Project A/ }));
|
||||
await userEvent.click(screen.getByRole("option", { name: /Project B/ }));
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
|
||||
await screen.findByText("only-b");
|
||||
await act(async () => finishA({ implicit: true, skills: [{ name: "only-a", enabled: true }] }));
|
||||
expect(screen.getByText("only-b")).toBeTruthy();
|
||||
|
||||
@@ -47,7 +47,8 @@ import { planProtocolSwitch } from "./protocolswitch";
|
||||
import { Boundary } from "./Boundary";
|
||||
import { DEFAULT_DAYS } from "./Usage";
|
||||
import { Appearance, SCHEMES } from "./Appearance";
|
||||
import { ScopeBar } from "./CapabilityScope";
|
||||
import { ScopeAway, ScopeRow } from "./CapabilityScope";
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { savedNotApplied, reason } from "../i18n/kernel";
|
||||
import type { SettingsProps as Props } from "./SettingsProps";
|
||||
import { approvalName } from "./approvals";
|
||||
@@ -457,8 +458,15 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
)}
|
||||
|
||||
{at === "tools" && (
|
||||
<SafetySettings port={port} mode={status?.toolApprovalMode} busy={busy} onChanged={onChanged}
|
||||
onMode={(id) => run(id, () => port.setApprovalMode(id))} />
|
||||
<>
|
||||
<SafetySettings port={port} mode={status?.toolApprovalMode} busy={busy} onChanged={onChanged}
|
||||
onMode={(id) => run(id, () => port.setApprovalMode(id))} />
|
||||
{scope && (
|
||||
<SectionCard title={t("ext.extension_scope")}>
|
||||
<ScopeRow scope={scope} scopes={scopes} onPick={setScopeAt} />
|
||||
</SectionCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{at === "display" && <ChatDisplaySettings port={port} />}
|
||||
@@ -480,7 +488,7 @@ export function Settings({ hub, onError, port, networkPort, localWorkspacePort,
|
||||
updating={updatingPkg.name} onChanged={afterExtChange} onReloadError={reload.report} onReloaded={reload.applied}
|
||||
onUpdate={(name) => setUpdatingPkg((p) => ({ ...p, name, applying: false }))} onFailed={setFailed} />
|
||||
}>
|
||||
{scope && <ScopeBar scope={scope} scopes={scopes} onPick={setScopeAt} />}
|
||||
{scope && !scope.current && <ScopeAway scope={scope} onBack={() => setScopeAt("")} />}
|
||||
<ModHost port={port} />
|
||||
<Group id="plugins"
|
||||
title={t("settings.plugin_packages")}
|
||||
|
||||
@@ -92,6 +92,16 @@ it.each(["first-exception", "issue-exception", "all-issues", "all-ready"])("noti
|
||||
} else expect(screen.getByText(failure)).toBeTruthy();
|
||||
});
|
||||
|
||||
// The project is chosen on the Permissions page, then the extension page is
|
||||
// opened again: it names whose list it shows only when that is not the running project.
|
||||
async function pickProject(from: string, to: string) {
|
||||
await userEvent.click(screen.getByRole("tab", { name: /权限与安全/ }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: new RegExp(from) }));
|
||||
await userEvent.click(screen.getByRole("option", { name: new RegExp(to) }));
|
||||
await screen.findByText(`/workspace/${to}`);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
|
||||
}
|
||||
|
||||
it("refreshes the current managed project after an unmounted batch partially finishes", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
let fail!: (error: Error) => void;
|
||||
@@ -106,9 +116,7 @@ it("refreshes the current managed project after an unmounted batch partially fin
|
||||
await userEvent.click(await screen.findByRole("button", { name: "添加…" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /接入外部服务/ }));
|
||||
await preview();
|
||||
await userEvent.click(within(document.querySelector<HTMLElement>(".scopebar")!).getByRole("button", { name: "main" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: /other/ }));
|
||||
await screen.findByText("/workspace/other");
|
||||
await pickProject("main", "other");
|
||||
expect(document.querySelector(".addsrv")).toBeNull();
|
||||
const reads = skills.mock.calls.length;
|
||||
await act(async () => fail(new Error(failure)));
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import "./testkit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ScopeAway, ScopeRow } from "./CapabilityScope";
|
||||
import type { CapabilityScope } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const scope = (over: Partial<CapabilityScope> = {}): CapabilityScope => ({
|
||||
key: "a", root: "/work/app", name: "app", label: "", repo: true, trees: 1, current: true, overrides: 0, branch: "main", ...over,
|
||||
}) as CapabilityScope;
|
||||
const scopes = [scope(), scope({ key: "b", root: "/work/site", name: "site", current: false, overrides: 2 })];
|
||||
|
||||
describe("the extension scope row in settings", () => {
|
||||
it("names the project in a pill, with where it is and what it carries below", () => {
|
||||
render(<ScopeRow scope={scope({ trees: 15 })} scopes={scopes} onPick={() => {}} />);
|
||||
expect(screen.getByRole("button", { name: /app/ })).toBeTruthy();
|
||||
expect(screen.getByText("管理哪个项目的扩展")).toBeTruthy();
|
||||
const info = document.querySelector(".scopeinfo")!;
|
||||
expect(info.textContent).toContain("/work/app");
|
||||
expect(info.textContent).toContain("main");
|
||||
expect(info.textContent).toContain("15 个工作树共用这份设置");
|
||||
expect(info.textContent).toContain("没有本项目的例外");
|
||||
});
|
||||
|
||||
it("counts the exceptions a project carries", () => {
|
||||
render(<ScopeRow scope={scope({ overrides: 3 })} scopes={scopes} onPick={() => {}} />);
|
||||
expect(document.querySelector(".scopeinfo")!.textContent).toContain("3 项只在这里生效");
|
||||
});
|
||||
|
||||
it("opens the project list from the pill and hands back the chosen root", async () => {
|
||||
const onPick = vi.fn();
|
||||
render(<ScopeRow scope={scope()} scopes={scopes} onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button", { name: /app/ }));
|
||||
await userEvent.click(screen.getByRole("option", { name: /site/ }));
|
||||
expect(onPick).toHaveBeenCalledWith("/work/site");
|
||||
expect(screen.queryByRole("option")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the extension page naming another project", () => {
|
||||
it("says whose list it is and offers the way back", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<ScopeAway scope={scope({ name: "site", current: false })} onBack={onBack} />);
|
||||
expect(screen.getByRole("note").textContent).toContain("正在管理 site 的扩展,不是当前会话所在的项目。");
|
||||
await userEvent.click(screen.getByRole("button", { name: "回到当前项目" }));
|
||||
expect(onBack).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, within } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { ServerRow } from "./ServerRow";
|
||||
@@ -137,6 +137,16 @@ it.each(["port", "root"].flatMap((change) => [false, true].map((tools) => [chang
|
||||
},
|
||||
);
|
||||
|
||||
// The project is chosen on the Permissions page, then the extension page is
|
||||
// opened again: it names whose list it shows only when that is not the running project.
|
||||
async function pickProject(from: string, to: string) {
|
||||
await userEvent.click(screen.getByRole("tab", { name: /权限与安全/ }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: new RegExp(from) }));
|
||||
await userEvent.click(screen.getByRole("option", { name: new RegExp(to) }));
|
||||
await screen.findByText(`/workspace/${to}`);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
|
||||
}
|
||||
|
||||
it("dismisses removal confirmation when choosing another managed project", async () => {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
const scopes: CapabilityScope[] = [
|
||||
@@ -156,9 +166,7 @@ it("dismisses removal confirmation when choosing another managed project", async
|
||||
/>);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "移除 docs" }));
|
||||
expect(screen.getByRole("button", { name: "取消" })).toBeTruthy();
|
||||
await userEvent.click(within(document.querySelector<HTMLElement>(".scopebar")!).getByRole("button", { name: "main" }));
|
||||
await userEvent.click(screen.getByRole("option", { name: /other/ }));
|
||||
await screen.findByText("/workspace/other");
|
||||
await pickProject("main", "other");
|
||||
expect(screen.queryByRole("button", { name: "取消" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "移除" })).toBeNull();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("the permissions page", () => {
|
||||
await waitFor(() => expect(shown("approval")).toBe(true));
|
||||
expect(shown("sandbox")).toBe(true);
|
||||
expect(shown("browser-tools")).toBe(true);
|
||||
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["批准", "沙箱与执行", "联网"]);
|
||||
expect([...document.querySelectorAll(".prefs-card > header h3")].map((h) => h.textContent)).toEqual(["批准", "沙箱与执行", "联网", "扩展范围"]);
|
||||
expect(document.querySelectorAll(".prefs-page")).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -36,10 +36,14 @@ function settings(port: AgentPort, onChanged = vi.fn()) {
|
||||
/>;
|
||||
}
|
||||
|
||||
// The project is chosen on the Permissions page, then the extension page is
|
||||
// opened again: it names whose list it shows only when that is not the running project.
|
||||
async function chooseProject(from: string, to: string) {
|
||||
await userEvent.click(within(document.querySelector<HTMLElement>(".scopebar")!).getByRole("button", { name: from }));
|
||||
await userEvent.click(screen.getByRole("tab", { name: /权限与安全/ }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: new RegExp(from) }));
|
||||
await userEvent.click(screen.getByRole("option", { name: new RegExp(to) }));
|
||||
await screen.findByText(`/workspace/${to}`);
|
||||
await userEvent.click(screen.getByRole("tab", { name: /^扩展/ }));
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
@@ -142,7 +146,7 @@ it.each(["success", "failure"])("isolates a skill %s after choosing another proj
|
||||
expect(control.disabled).toBe(false);
|
||||
expect(onChanged).toHaveBeenCalledTimes(2);
|
||||
expect(skills).toHaveBeenLastCalledWith("/workspace/other");
|
||||
expect(within(document.querySelector<HTMLElement>(".scopebar")!).getByText("other")).toBeTruthy();
|
||||
expect(within(document.querySelector<HTMLElement>(".scopeaway")!).getByText(/other/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refreshes a completed skill change after returning to its project", async () => {
|
||||
|
||||
Reference in new issue
Block a user