Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions packages/types/src/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export const commandIds = [
"acceptInput",
"focusPanel",
"toggleAutoApprove",

"showRipgrepDiagnostic",
] as const

export type CommandId = (typeof commandIds)[number]
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 20 additions & 1 deletion src/activate/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { handleNewTask } from "./handleTask"
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 { t } from "../i18n"

/**
Expand Down Expand Up @@ -68,9 +69,27 @@ export const registerCommands = (options: RegisterCommandOptions) => {
const command = getCommand(id as CommandId)
context.subscriptions.push(vscode.commands.registerCommand(command, callback))
}

context.subscriptions.push(registerRipgrepDiagnosticCommand())
}

const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions): Record<CommandId, any> => ({
// `showRipgrepDiagnostic` is registered separately by
// `registerRipgrepDiagnosticCommand` (above), which owns the OutputChannel
// lifecycle alongside the command registration, so it's intentionally
// excluded from this map.
//
// Callback shape mirrors VS Code's own `commands.registerCommand` signature
// (`(...args: any[]) => any`), with the return narrowed to `unknown` so
// callers must inspect before using. `any[]` for args is unavoidable: the
// callbacks here are heterogeneous (`importSettings` takes an optional
// `filePath?: string`, others take none) and VS Code dispatches positional
// args dynamically.
type CommandCallback = (...args: any[]) => unknown
const getCommandsMap = ({
context,
outputChannel,
provider,
}: RegisterCommandOptions): Record<Exclude<CommandId, "showRipgrepDiagnostic">, CommandCallback> => ({
activationCompleted: () => {},
plusButtonClicked: async () => {
const visibleProvider = getVisibleProviderOrLog(outputChannel)
Expand Down
2 changes: 1 addition & 1 deletion src/esbuild.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async function main() {
// global-agent must be external because it dynamically patches Node.js http/https modules
// which breaks when bundled. It needs access to the actual Node.js module instances.
// undici must be bundled because our VSIX is packaged with `--no-dependencies`.
external: ["vscode", "esbuild", "global-agent"],
external: ["vscode", "esbuild", "global-agent", "@vscode/ripgrep"],
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@
"title": "%command.acceptInput.title%",
"category": "%configuration.title%"
},
{
"command": "zoo-code.showRipgrepDiagnostic",
"title": "%command.showRipgrepDiagnostic.title%",
"category": "%configuration.title%"
},
{
"command": "zoo-code.toggleAutoApprove",
"title": "%command.toggleAutoApprove.title%",
Expand Down Expand Up @@ -554,6 +559,7 @@
"@types/tmp": "^0.2.6",
"@types/turndown": "^5.0.5",
"@types/vscode": "^1.84.0",
"@vscode/ripgrep": "^1.17.0",
Comment thread
edelauna marked this conversation as resolved.
Outdated
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "3.3.2",
"ai": "^6.0.75",
Expand Down
1 change: 1 addition & 0 deletions src/package.nls.ca.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.de.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.es.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.fr.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.hi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.id.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.it.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.ja.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"command.terminal.fixCommand.title": "Fix This Command",
"command.terminal.explainCommand.title": "Explain This Command",
"command.acceptInput.title": "Accept Input/Suggestion",
"command.showRipgrepDiagnostic.title": "Show Ripgrep Diagnostic",
"command.toggleAutoApprove.title": "Toggle Auto-Approve",
"configuration.title": "Zoo Code",
"commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled",
Expand Down
1 change: 1 addition & 0 deletions src/package.nls.ko.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.nl.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.pl.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.pt-BR.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.ru.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.tr.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.vi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.zh-CN.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/package.nls.zh-TW.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

156 changes: 156 additions & 0 deletions src/services/ripgrep/__tests__/diagnostic.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// npx vitest run src/services/ripgrep/__tests__/diagnostic.spec.ts

import * as path from "path"

import { vi, describe, it, expect, beforeEach } from "vitest"

import { getRipgrepDiagnostic } from "../diagnostic"

const ripgrepMock = vi.hoisted(() => ({
value: undefined as { rgPath?: string; loadError?: string } | undefined,
}))

const fsMock = vi.hoisted(() => ({
existing: new Set<string>(),
}))

vi.mock("../internal/loadRipgrep", () => ({
loadRipgrep: () => ripgrepMock.value,
}))

vi.mock("../../../utils/fs", () => ({
fileExistsAtPath: (p: string) => Promise.resolve(fsMock.existing.has(p)),
}))

const APP_ROOT = "/app"

const binName = process.platform.startsWith("win") ? "rg.exe" : "rg"
const universalRelBin = `bin/${process.platform}-${process.arch}/${binName}`

const expectedCandidates = [
path.join(APP_ROOT, "node_modules", "@vscode", "ripgrep", "bin", binName),
path.join(APP_ROOT, "node_modules", "vscode-ripgrep", "bin", binName),
path.join(APP_ROOT, "node_modules.asar.unpacked", "vscode-ripgrep", "bin", binName),
path.join(APP_ROOT, "node_modules.asar.unpacked", "@vscode", "ripgrep", "bin", binName),
path.join(APP_ROOT, "node_modules", "@vscode", "ripgrep-universal", ...universalRelBin.split("/")),
path.join(APP_ROOT, "node_modules.asar.unpacked", "@vscode", "ripgrep-universal", ...universalRelBin.split("/")),
]

describe("getRipgrepDiagnostic", () => {
beforeEach(() => {
ripgrepMock.value = undefined
fsMock.existing = new Set<string>()
})

it("includes rgPath and fileExistsAtPath: true when loadRipgrep returns an existing path", async () => {
const rgPath = "/some/path"
ripgrepMock.value = { rgPath }
fsMock.existing = new Set([rgPath])

const report = await getRipgrepDiagnostic(APP_ROOT)

expect(report).toContain("rgPath: /some/path")
expect(report).toContain("fileExistsAtPath: true")
expect(report).toContain("after .asar→.asar.unpacked: /some/path")
})

it("rewrites node_modules.asar to node_modules.asar.unpacked in the report", async () => {
const rgPath = "/app/node_modules.asar/foo/rg"
const substituted = "/app/node_modules.asar.unpacked/foo/rg"
ripgrepMock.value = { rgPath }
fsMock.existing = new Set([substituted])

const report = await getRipgrepDiagnostic(APP_ROOT)

expect(report).toContain(`after .asar→.asar.unpacked: ${substituted}`)
expect(report).toContain("fileExistsAtPath: true")
})

it("does not double-substitute when rgPath already contains node_modules.asar.unpacked", async () => {
// A previous `\b` regex matched the `r`/`.` boundary inside
// `node_modules.asar.unpacked`, producing `.unpacked.unpacked`.
const rgPath = "/app/node_modules.asar.unpacked/foo/rg"
ripgrepMock.value = { rgPath }
fsMock.existing = new Set([rgPath])

const report = await getRipgrepDiagnostic(APP_ROOT)

expect(report).toContain(`after .asar→.asar.unpacked: ${rgPath}`)
expect(report).not.toContain("node_modules.asar.unpacked.unpacked")
expect(report).toContain("fileExistsAtPath: true")
})

it("reports require failure when loadRipgrep returns undefined", async () => {
ripgrepMock.value = undefined

const report = await getRipgrepDiagnostic(APP_ROOT)

expect(report).toContain("loadRipgrep() returned undefined (require threw)")
})

it("reports the loadError when loadRipgrep returns a loadError field", async () => {
ripgrepMock.value = { loadError: "Cannot find module '@vscode/ripgrep'" }

const report = await getRipgrepDiagnostic(APP_ROOT)

expect(report).toContain("loadRipgrep() returned loadError: Cannot find module '@vscode/ripgrep'")
// Should not also push the success-branch lines.
expect(report).not.toContain("after .asar→.asar.unpacked:")
expect(report).not.toContain("rgPath:")
})

it("reports rgPath: (undefined) when loadRipgrep returns an object without rgPath", async () => {
ripgrepMock.value = {}

const report = await getRipgrepDiagnostic(APP_ROOT)

expect(report).toContain("rgPath: (undefined)")
expect(report).not.toContain("after .asar→.asar.unpacked:")
})

it("marks only the first probe candidate as found when only it exists", async () => {
fsMock.existing = new Set([expectedCandidates[0]])

const report = await getRipgrepDiagnostic(APP_ROOT)

const found = expectedCandidates.filter((c) => report.includes(`✓ ${c}`))
const missing = expectedCandidates.filter((c) => report.includes(`✗ ${c}`))

expect(found).toEqual([expectedCandidates[0]])
expect(missing).toEqual(expectedCandidates.slice(1))
})

it("marks all probe candidates as missing when none exist", async () => {
fsMock.existing = new Set<string>()

const report = await getRipgrepDiagnostic(APP_ROOT)

for (const candidate of expectedCandidates) {
expect(report).toContain(`✗ ${candidate}`)
}
expect(report).not.toContain("✓ ")
})

it("rewrites node_modules.asar to node_modules.asar.unpacked on Windows paths (backslash separator)", async () => {
// Pure string-substitution test: the literal `win32-x64` segment is
// not derived from process.platform/arch, so this runs on any host.
const rgPath = "C:\\app\\node_modules.asar\\@vscode\\ripgrep-universal\\bin\\win32-x64\\rg.exe"
const substituted = "C:\\app\\node_modules.asar.unpacked\\@vscode\\ripgrep-universal\\bin\\win32-x64\\rg.exe"
ripgrepMock.value = { rgPath }
fsMock.existing = new Set([substituted])

const report = await getRipgrepDiagnostic("C:\\app")

expect(report).toContain(`after .asar→.asar.unpacked: ${substituted}`)
expect(report).toContain("fileExistsAtPath: true")
})

it("returns an explanatory report when vscode.env.appRoot is empty", async () => {
const report = await getRipgrepDiagnostic("")

expect(report).toContain("vscode.env.appRoot: (empty)")
expect(report).toContain("Cannot run diagnostic")
// path probe should NOT have run
expect(report).not.toContain("--- step 2: path probe under appRoot ---")
})
})
Loading
Loading