Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/build/src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// npx vitest run src/__tests__/types.test.ts

import { contributesSchema } from "../types.js"

describe("contributes commands schema", () => {
// Reached through `.shape` so this stays focused on the icon field, without needing a whole
// valid `contributes` object around it.
const commandsSchema = contributesSchema.shape.commands

const command = (icon: unknown) => [
{ command: "zoo-code.generateCommitMessage", title: "%command.generateCommitMessage.title%", icon },
]

it("accepts a codicon reference", () => {
expect(commandsSchema.safeParse(command("$(edit)")).success).toBe(true)
})

// The Source Control button ships a PNG per theme rather than a codicon. This field used to
// allow only a string, which rejected the manifest outright when generating the nightly build.
it("accepts a pair of theme-specific icon paths", () => {
const icon = { light: "assets/icons/panel_light.png", dark: "assets/icons/panel_dark.png" }

expect(commandsSchema.safeParse(command(icon)).success).toBe(true)
})

it("rejects an icon pair that is missing a theme", () => {
expect(commandsSchema.safeParse(command({ light: "assets/icons/panel_light.png" })).success).toBe(false)
})
})

describe("contributes menus schema", () => {
const menusSchema = contributesSchema.shape.menus

it("accepts a grouped menu item", () => {
const menus = {
"scm/title": [
{
command: "zoo-code.generateCommitMessage",
group: "navigation",
when: "scmProvider == git && !zoo-code.generatingCommitMessage",
},
],
}

expect(menusSchema.safeParse(menus).success).toBe(true)
})

// `commandPalette` items have no group. This field used to be required, which rejected the
// manifest outright when generating the nightly build.
it("accepts a menu item with no group", () => {
const menus = {
commandPalette: [
{ command: "zoo-code.stopGeneratingCommitMessage", when: "zoo-code.generatingCommitMessage" },
],
}

expect(menusSchema.safeParse(menus).success).toBe(true)
})
})
6 changes: 4 additions & 2 deletions packages/build/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@ const commandsSchema = z.array(
command: z.string(),
title: z.string(),
category: z.string().optional(),
icon: z.string().optional(),
// Either a codicon reference (e.g. `$(edit)`) or a pair of theme-specific image paths.
icon: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(),
}),
)

export type Commands = z.infer<typeof commandsSchema>

const menuItemSchema = z.object({
group: z.string(),
// Absent on menus that do not group their items, such as `commandPalette`.
group: z.string().optional(),
command: z.string().optional(),
submenu: z.string().optional(),
when: z.string().optional(),
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,13 @@ export const globalSettingsSchema = z.object({
customSupportPrompts: customSupportPromptsSchema.optional(),
enhancementApiConfigId: z.string().optional(),
includeTaskHistoryInEnhance: z.boolean().optional(),
commitMessageApiConfigId: z.string().optional(),
/**
* Seconds to wait for a commit message before giving up. Most providers ignore the abort
* signal, so without a bound a request that never answers leaves the indicator up until the
* window is reloaded.
*/
commitMessageTimeout: z.number().int().min(10).max(600).optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
/**
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ export type ExtensionState = Pick<
| "customModePrompts"
| "customSupportPrompts"
| "enhancementApiConfigId"
| "commitMessageApiConfigId"
| "commitMessageTimeout"
| "customCondensingPrompt"
| "codebaseIndexConfig"
| "codebaseIndexModels"
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ export const commandIds = [
"focusPanel",
"toggleAutoApprove",

"generateCommitMessage",
"stopGeneratingCommitMessage",

"showRipgrepDiagnostic",
] as const

Expand Down
26 changes: 26 additions & 0 deletions src/activate/__tests__/registerCommands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ vi.mock("../../i18n", () => ({
t: (key: string) => key,
}))

vi.mock("../../services/commit-message", () => ({
generateCommitMessage: vi.fn().mockResolvedValue(undefined),
stopGeneratingCommitMessage: vi.fn().mockResolvedValue(undefined),
}))

vi.mock("../../services/ripgrep/diagnostic", () => ({
registerRipgrepDiagnosticCommand: vi.fn().mockReturnValue({ dispose: vi.fn() }),
}))
Expand Down Expand Up @@ -192,6 +197,27 @@ describe("registerCommands handlers", () => {
expect(mockContext.subscriptions).toContain(disposable)
})

it("generateCommitMessage forwards the clicked source control to the generator", async () => {
const { generateCommitMessage } = await import("../../services/commit-message")
const sourceControl = { rootUri: { fsPath: "/repo" } }

await handlers["zoo-code.generateCommitMessage"](sourceControl)

// Uses the registered provider rather than the visible one, so the Source Control button
// still works while the Zoo Code sidebar is closed.
expect(vi.mocked(generateCommitMessage)).toHaveBeenCalledWith(mockProvider, sourceControl)
})

it("stopGeneratingCommitMessage forwards the clicked source control", async () => {
const { stopGeneratingCommitMessage } = await import("../../services/commit-message")
const sourceControl = { rootUri: { fsPath: "/repo" } }

await handlers["zoo-code.stopGeneratingCommitMessage"](sourceControl)

// No provider: it only aborts the request the button above it started.
expect(vi.mocked(stopGeneratingCommitMessage)).toHaveBeenCalledWith(sourceControl)
})

it("settingsButtonClicked posts both settingsButtonClicked and didBecomeVisible actions", () => {
handlers["zoo-code.settingsButtonClicked"]()

Expand Down
7 changes: 7 additions & 0 deletions src/activate/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { CodeIndexManager } from "../services/code-index/manager"
import { importSettingsWithFeedback } from "../core/config/importExport"
import { MdmService } from "../services/mdm/MdmService"
import { registerRipgrepDiagnosticCommand } from "../services/ripgrep/diagnostic"
import { generateCommitMessage, stopGeneratingCommitMessage } from "../services/commit-message"
import { t } from "../i18n"

/**
Expand Down Expand Up @@ -219,6 +220,12 @@ const getCommandsMap = ({
outputChannel.appendLine(`[toggleAutoApprove] postMessageToWebview failed: ${error}`)
}
},
// Uses `provider` rather than the visible instance so the Source Control button still works
// while the Zoo Code sidebar is closed.
generateCommitMessage: (sourceControl?: vscode.SourceControl) => generateCommitMessage(provider, sourceControl),
// Replaces the button above while a message is generating, so it needs no provider - it only
// aborts the request that button started.
stopGeneratingCommitMessage: (sourceControl?: vscode.SourceControl) => stopGeneratingCommitMessage(sourceControl),
})

export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
Expand Down
8 changes: 8 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2461,6 +2461,8 @@ export class ClineProvider
customModePrompts,
customSupportPrompts,
enhancementApiConfigId,
commitMessageApiConfigId,
commitMessageTimeout,
autoApprovalEnabled,
customModes,
experiments,
Expand Down Expand Up @@ -2619,6 +2621,10 @@ export class ClineProvider
customModePrompts: customModePrompts ?? {},
customSupportPrompts: customSupportPrompts ?? {},
enhancementApiConfigId,
commitMessageApiConfigId,
// Left undefined when unset so the webview shows its own default rather than a value
// the user never chose.
commitMessageTimeout,
autoApprovalEnabled: autoApprovalEnabled ?? false,
customModes,
experiments: experiments ?? experimentDefault,
Expand Down Expand Up @@ -2852,6 +2858,8 @@ export class ClineProvider
customModePrompts: stateValues.customModePrompts ?? {},
customSupportPrompts: stateValues.customSupportPrompts ?? {},
enhancementApiConfigId: stateValues.enhancementApiConfigId,
commitMessageApiConfigId: stateValues.commitMessageApiConfigId,
commitMessageTimeout: stateValues.commitMessageTimeout,
experiments: stateValues.experiments ?? experimentDefault,
autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false,
customModes,
Expand Down
79 changes: 79 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,85 @@ describe("ClineProvider", () => {
})
})

describe("commit message model selection is included in state", () => {
// Both paths matter: the webview reads the posted state to show the current selection, and
// the generator reads getState() to pick a profile. Dropping either one makes a saved
// selection look like it reverted.
it("getStateToPostToWebview returns the saved commitMessageApiConfigId", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageApiConfigId", "config-2")

const state = await provider.getStateToPostToWebview()

expect(state.commitMessageApiConfigId).toBe("config-2")
})

it("getStateToPostToWebview leaves commitMessageApiConfigId unset when no profile is chosen", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageApiConfigId", undefined)

const state = await provider.getStateToPostToWebview()

expect(state.commitMessageApiConfigId).toBeUndefined()
})

it("getState returns the saved commitMessageApiConfigId", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageApiConfigId", "config-2")

const state = await provider.getState()

expect(state.commitMessageApiConfigId).toBe("config-2")
})

it("getState leaves commitMessageApiConfigId unset when no profile is chosen", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageApiConfigId", undefined)

const state = await provider.getState()

expect(state.commitMessageApiConfigId).toBeUndefined()
})

// The timeout has to survive the same round trip. Without it the settings input reads back
// `undefined` after every save and snaps to its default, discarding what the user chose.
it("getStateToPostToWebview returns the saved commitMessageTimeout", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageTimeout", 23)

const state = await provider.getStateToPostToWebview()

expect(state.commitMessageTimeout).toBe(23)
})

it("getStateToPostToWebview leaves commitMessageTimeout unset when none is configured", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageTimeout", undefined)

const state = await provider.getStateToPostToWebview()

expect(state.commitMessageTimeout).toBeUndefined()
})

it("getState returns the saved commitMessageTimeout", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageTimeout", 23)

const state = await provider.getState()

expect(state.commitMessageTimeout).toBe(23)
})

it("getState leaves commitMessageTimeout unset when none is configured", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("commitMessageTimeout", undefined)

const state = await provider.getState()

expect(state.commitMessageTimeout).toBeUndefined()
})
})
Comment thread
Rafael-Silva-Oliveira marked this conversation as resolved.

it("getStateToPostToWebview passes through defined diffFuzzyThreshold value", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("diffFuzzyThreshold", 0.5)
Expand Down
50 changes: 50 additions & 0 deletions src/core/webview/__tests__/webviewMessageHandler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1064,6 +1064,10 @@ describe("webviewMessageHandler - mcpEnabled", () => {

// Ensure provider exposes getMcpHub and returns our mock
;(mockClineProvider as any).getMcpHub = vi.fn().mockReturnValue(mockMcpHub)

// `clearAllMocks` keeps implementations, so an earlier suite's return value would
// otherwise decide whether these tests see the flag as changed.
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(undefined)
})

it("delegates enable=true to McpHub and posts updated state", async () => {
Expand Down Expand Up @@ -1101,6 +1105,52 @@ describe("webviewMessageHandler - mcpEnabled", () => {
expect((mockClineProvider as any).getMcpHub).toHaveBeenCalledTimes(1)
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})

it("leaves the servers alone when the flag has not changed", async () => {
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(true)

await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { mcpEnabled: true },
})

expect(mockMcpHub.handleMcpEnabledChange).not.toHaveBeenCalled()
expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("mcpEnabled", true)
})

// `refreshAllConnections` reads the flag back out of state to decide what to reconcile to, so
// reconciling before the new value is stored reconnects the servers it was meant to close.
it("stores the new flag before asking the hub to reconcile", async () => {
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(true)

await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { mcpEnabled: false },
})

const setValue = vi.mocked(mockClineProvider.contextProxy.setValue)
const stored = setValue.mock.calls.findIndex(([key]) => key === "mcpEnabled")

expect(stored).toBeGreaterThanOrEqual(0)
expect(setValue.mock.invocationCallOrder[stored]).toBeLessThan(
mockMcpHub.handleMcpEnabledChange.mock.invocationCallOrder[0],
)
})

// Settings are written one after another, so anything listed after `mcpEnabled` used to be
// saved only once every server had reconnected - which is why a newly picked commit message
// profile could take seconds to take effect.
it("writes the settings that follow mcpEnabled without waiting on the servers", async () => {
vi.mocked(mockClineProvider.contextProxy.getValue).mockReturnValue(true)
mockMcpHub.handleMcpEnabledChange.mockReturnValue(new Promise(() => {}))

await webviewMessageHandler(mockClineProvider, {
type: "updateSettings",
updatedSettings: { mcpEnabled: true, commitMessageApiConfigId: "config-1" },
})

expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("commitMessageApiConfigId", "config-1")
})
})

describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => {
Expand Down
18 changes: 15 additions & 3 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -778,10 +778,22 @@ export const webviewMessageHandler = async (
Terminal.setExecaShellPath(value as string | undefined)
} else if (key === "mcpEnabled") {
newValue = value ?? true
const mcpHub = provider.getMcpHub()

if (mcpHub) {
await mcpHub.handleMcpEnabledChange(newValue as boolean)
// Settings are written one after another, so every key after this one waits
// for whatever it does. Reconnecting every MCP server takes seconds, and
// saving the panel is not a request to restart them - only a change is.
if (newValue !== getGlobalState("mcpEnabled")) {
// The hub reads the flag back out of state to decide what to reconcile
// to, so it has to be stored before the servers are told about it -
// otherwise disabling MCP reconnects the servers it just closed. The
// write at the end of the loop then repeats it harmlessly.
await provider.contextProxy.setValue("mcpEnabled", newValue as boolean)

const mcpHub = provider.getMcpHub()

if (mcpHub) {
await mcpHub.handleMcpEnabledChange(newValue as boolean)
}
}
} else if (key === "experiments") {
if (!value) {
Expand Down
Loading
Loading