From 7b1d7723828d4173a7c64da4d88cea53e692ccad Mon Sep 17 00:00:00 2001 From: kipavy Date: Wed, 19 Aug 2026 14:35:57 +0000 Subject: [PATCH] fix(deep-link): install snippets into the vault the sheet disclosed The snippet-install spec read installTargetVault() once for the destination note and again inside accept, so the vault named to the user and the vault written to were two separate reads of a store the spec touches directly. Changing the vault selection while the sheet is open redirected the write away from the disclosure. `load` now returns the entry together with the vault it resolved, and both the note and accept read that one value. --- .../terminal/DeepLinkConfirmModal.test.tsx | 25 +++++++++++++++- .../terminal/deepLinkConfirmSpecs.tsx | 30 +++++++++++-------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/components/terminal/DeepLinkConfirmModal.test.tsx b/src/components/terminal/DeepLinkConfirmModal.test.tsx index f33d53cae..17d12bde9 100644 --- a/src/components/terminal/DeepLinkConfirmModal.test.tsx +++ b/src/components/terminal/DeepLinkConfirmModal.test.tsx @@ -31,8 +31,12 @@ vi.mock("@/services/snippetCatalogInstall", () => ({ installCatalogEntries: (...args: unknown[]) => installEntriesMock(...args), })); +let vaultState = { + selectedVaultIds: ["team-a"], + vaults: [{ id: "team-a", name: "Ops" }, { id: "team-b", name: "Staging" }], +}; vi.mock("@/stores/vaultStore", () => ({ - useVaultStore: { getState: () => ({ selectedVaultIds: ["team-a"], vaults: [{ id: "team-a", name: "Ops" }] }) }, + useVaultStore: { getState: () => vaultState }, })); const joinMock = vi.fn(async (..._args: unknown[]) => "local-1"); @@ -85,6 +89,10 @@ beforeEach(() => { teamConnections = {}; activeLocalSessionId = null; snippetEntries = []; + vaultState = { + selectedVaultIds: ["team-a"], + vaults: [{ id: "team-a", name: "Ops" }, { id: "team-b", name: "Staging" }], + }; fetchSnippetCatalogMock.mockClear(); installEntriesMock.mockClear().mockResolvedValue({ imported: 1, errors: 0 }); marketplaceSources = []; @@ -236,6 +244,21 @@ test("a snippet-install sheet names the entry and its destination vault before i ); }); +test("a snippet-install writes to the vault the sheet disclosed, not the one selected at accept", async () => { + snippetEntries = [{ id: "docker-cleanup", kind: "pack", name: "Docker cleanup", author: "kevin", snippets: [{}] }]; + useDeepLinkStore.setState({ prompt: { route: "snippet-install", entryId: "docker-cleanup" } }); + render(); + await waitFor(() => expect(screen.getByText("snippets.deepLinkInstall.destination")).toBeTruthy()); + + // The sheet named "Ops" while it was open; selecting another vault behind it + // must not silently redirect the write it disclosed. + vaultState = { ...vaultState, selectedVaultIds: ["team-b"] }; + await userEvent.click(screen.getByText("snippets.deepLinkInstall.action")); + + await waitFor(() => expect(installEntriesMock).toHaveBeenCalled()); + expect(installEntriesMock.mock.calls[0][1]).toBe("team-a"); +}); + test("a snippet-install link naming an entry the catalogue does not list cannot be accepted", async () => { snippetEntries = []; useDeepLinkStore.setState({ prompt: { route: "snippet-install", entryId: "docker-cleanup" } }); diff --git a/src/components/terminal/deepLinkConfirmSpecs.tsx b/src/components/terminal/deepLinkConfirmSpecs.tsx index 21d20b24b..0295ab493 100644 --- a/src/components/terminal/deepLinkConfirmSpecs.tsx +++ b/src/components/terminal/deepLinkConfirmSpecs.tsx @@ -65,11 +65,19 @@ export interface PluginInstallLoad { sourceName: string; } +export interface SnippetInstallLoad { + entry: CatalogEntry; + /** Resolved with the entry, not again at accept: the vault the sheet names is + * then provably the vault written to, however the selection moves while the + * sheet is open. */ + vault: { id: string; name: string }; +} + /** What each route's `load` produces. `void` for a route with nothing to fetch. */ export interface ConfirmLoad { join: void; invite: InviteLoad; - "snippet-install": CatalogEntry; + "snippet-install": SnippetInstallLoad; "plugin-install": PluginInstallLoad; } @@ -84,11 +92,6 @@ function shareableSessionId(): string | null { return useTeamSessionStore.getState().connections[id]?.sessionKeyBytes ? id : null; } -/** The vault an install lands in — read directly since the spec is not a component. */ -function installTargetVault(): { id: string; name: string } { - return resolveInstallVault(useVaultStore.getState()); -} - export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec } = { join: { icon: "lucide:users", @@ -149,7 +152,10 @@ export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec ({ title: t("snippets.deepLinkInstall.title"), @@ -158,22 +164,22 @@ export const CONFIRM_SPECS: { [K in ConfirmRoute]: ConfirmSpec loaded ? (

{t("snippets.deepLinkInstall.summary", { - name: loaded.name, - author: loaded.author ?? t("snippets.deepLinkInstall.unknownAuthor"), - count: loaded.snippets.length, + name: loaded.entry.name, + author: loaded.entry.author ?? t("snippets.deepLinkInstall.unknownAuthor"), + count: loaded.entry.snippets.length, })}

) : null, accept: async (_intent, loaded) => { if (!loaded) return; - await installCatalogEntries([{ entry: loaded }], installTargetVault().id); + await installCatalogEntries([{ entry: loaded.entry }], loaded.vault.id); }, }, "plugin-install": {