diff --git a/apps/desktop/src/components/settings/McpResourceScopePicker.vue b/apps/desktop/src/components/settings/McpResourceScopePicker.vue index 7a95723842..97a53e8b09 100644 --- a/apps/desktop/src/components/settings/McpResourceScopePicker.vue +++ b/apps/desktop/src/components/settings/McpResourceScopePicker.vue @@ -53,6 +53,7 @@ const { t } = useI18n(); const search = ref(""); const expandedGroupIds = ref(new Set()); const initializedExpansion = ref(false); +const lastCustomScope = ref<{ allowedGroupIds: string[]; allowedConnectionIds: string[] } | null>(null); const scopeMode = computed(() => (props.allowedConnectionIds === null ? "all" : "custom")); const connectionById = computed(() => new Map(props.connections.map((connection) => [connection.id, connection]))); @@ -205,10 +206,19 @@ const visibleRows = computed(() => { return rows; }); +function copyCustomScope(groupIds: readonly string[], connectionIds: readonly string[] | null): { allowedGroupIds: string[]; allowedConnectionIds: string[] } { + return { allowedGroupIds: [...groupIds], allowedConnectionIds: [...(connectionIds ?? [])] }; +} + function setScopeMode(mode: ScopeMode) { - if (props.disabled || mode === scopeMode.value) return; - if (mode === "all" && (props.allowedGroupIds.length > 0 || (props.allowedConnectionIds?.length ?? 0) > 0) && !window.confirm(t("settings.mcpResourceScopeAllConfirm"))) return; - emit("update:scope", mode === "all" ? { allowedGroupIds: [], allowedConnectionIds: null } : { allowedGroupIds: [], allowedConnectionIds: [] }); + if (props.disabled || props.busy || mode === scopeMode.value) return; + if (mode === "all") { + if ((props.allowedGroupIds.length > 0 || (props.allowedConnectionIds?.length ?? 0) > 0) && !window.confirm(t("settings.mcpResourceScopeAllConfirm"))) return; + lastCustomScope.value = copyCustomScope(props.allowedGroupIds, props.allowedConnectionIds); + emit("update:scope", { allowedGroupIds: [], allowedConnectionIds: null }); + return; + } + emit("update:scope", copyCustomScope(lastCustomScope.value?.allowedGroupIds ?? [], lastCustomScope.value?.allowedConnectionIds ?? [])); } function groupHasSelectedDescendant(group: GroupNode): boolean { @@ -266,10 +276,10 @@ function connectionPolicyMode(connectionId: string): ExecutionMode | "inherit" {
- -
diff --git a/apps/desktop/src/components/settings/__tests__/McpResourceScopePicker.spec.ts b/apps/desktop/src/components/settings/__tests__/McpResourceScopePicker.spec.ts new file mode 100644 index 0000000000..480484a68b --- /dev/null +++ b/apps/desktop/src/components/settings/__tests__/McpResourceScopePicker.spec.ts @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { dispatch, findOne, mountComponent } from "@/components/grid/__tests__/vueHostHarness"; +import type { ConnectionConfig, SidebarLayout } from "@/types/database"; + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ + t: (key: string, values?: Record) => `${key}${values ? ` ${JSON.stringify(values)}` : ""}`, + }), +})); + +vi.mock("@lucide/vue", async () => { + const { createPassthroughStub } = await import("@/components/grid/__tests__/vueHostHarness"); + const icon = createPassthroughStub("Icon", "i"); + return { ChevronDown: icon, ChevronRight: icon, Database: icon, Folder: icon, Search: icon }; +}); + +vi.mock("@/components/ui/badge", async () => ({ Badge: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Badge", "span") })); +vi.mock("@/components/ui/button", async () => ({ Button: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Button", "button") })); +vi.mock("@/components/ui/input", async () => ({ Input: (await import("@/components/grid/__tests__/vueHostHarness")).createPassthroughStub("Input", "input") })); + +import McpResourceScopePicker from "@/components/settings/McpResourceScopePicker.vue"; + +function connection(id: string): ConnectionConfig { + return { + id, + name: `${id}-name`, + db_type: "mysql", + host: "127.0.0.1", + port: 3306, + username: "test", + password: "", + }; +} + +const layout: SidebarLayout = { + groups: [{ id: "g1", name: "Group 1", collapsed: false }], + order: [ + { type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }, + { type: "connection", id: "c2" }, + ], +}; + +const connections = [connection("c1"), connection("c2")]; + +function mountPicker(props: Record = {}) { + const update = vi.fn(); + const mounted = mountComponent(McpResourceScopePicker, { + layout, + connections, + allowedGroupIds: [], + allowedConnectionIds: [], + "onUpdate:scope": update, + ...props, + }); + return { mounted, update }; +} + +function modeButton(root: ReturnType["root"], mode: "all" | "custom") { + return findOne(root, (node) => node.type === "button" && node.props["data-scope-mode"] === mode); +} + +describe("McpResourceScopePicker", () => { + let confirmMock: ReturnType; + + beforeEach(() => { + confirmMock = vi.fn().mockReturnValue(true); + vi.stubGlobal("confirm", confirmMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("emits allow-all when switching from a custom group and connection selection", () => { + const { mounted, update } = mountPicker({ + allowedGroupIds: ["g1"], + allowedConnectionIds: ["c2"], + }); + + dispatch(modeButton(mounted.root, "all"), "click"); + + expect(update).toHaveBeenCalledWith({ allowedGroupIds: [], allowedConnectionIds: null }); + }); + + it("restores the previous custom selection after switching to all connections and back", async () => { + const customScope = { allowedGroupIds: ["g1"], allowedConnectionIds: ["c2"] }; + const { mounted, update } = mountPicker(customScope); + + dispatch(modeButton(mounted.root, "all"), "click"); + expect(confirmMock).toHaveBeenCalledWith("settings.mcpResourceScopeAllConfirm"); + await mounted.setProps({ allowedGroupIds: [], allowedConnectionIds: null }); + dispatch(modeButton(mounted.root, "custom"), "click"); + + expect(update).toHaveBeenLastCalledWith(customScope); + }); + + it("keeps the custom scope without emitting when the all-connections confirm is cancelled", () => { + confirmMock.mockReturnValue(false); + const { mounted, update } = mountPicker({ + allowedGroupIds: ["g1"], + allowedConnectionIds: ["c2"], + }); + + dispatch(modeButton(mounted.root, "all"), "click"); + + expect(confirmMock).toHaveBeenCalledWith("settings.mcpResourceScopeAllConfirm"); + expect(modeButton(mounted.root, "all").props["aria-checked"]).toBe(false); + expect(update).not.toHaveBeenCalled(); + }); + + it("starts custom scope empty when there is no previous custom snapshot", () => { + const { mounted, update } = mountPicker({ + allowedGroupIds: [], + allowedConnectionIds: null, + }); + + dispatch(modeButton(mounted.root, "custom"), "click"); + + expect(update).toHaveBeenCalledWith({ allowedGroupIds: [], allowedConnectionIds: [] }); + }); + + it("restores an empty custom selection after switching away from it", async () => { + const { mounted, update } = mountPicker({ + allowedGroupIds: [], + allowedConnectionIds: [], + }); + + dispatch(modeButton(mounted.root, "all"), "click"); + expect(confirmMock).not.toHaveBeenCalled(); + await mounted.setProps({ allowedGroupIds: [], allowedConnectionIds: null }); + dispatch(modeButton(mounted.root, "custom"), "click"); + + expect(update).toHaveBeenLastCalledWith({ allowedGroupIds: [], allowedConnectionIds: [] }); + }); + + it("does not emit while disabled", () => { + const { mounted, update } = mountPicker({ + allowedGroupIds: ["g1"], + allowedConnectionIds: ["c2"], + disabled: true, + }); + + dispatch(modeButton(mounted.root, "all"), "click"); + + expect(update).not.toHaveBeenCalled(); + }); + + it("does not emit while busy", () => { + const { mounted, update } = mountPicker({ + allowedGroupIds: ["g1"], + allowedConnectionIds: ["c2"], + busy: true, + }); + + dispatch(modeButton(mounted.root, "all"), "click"); + + expect(update).not.toHaveBeenCalled(); + }); +});