mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
VSCode shim + basic cli (#10452)
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
f2276bebc4
commit
b11d53adf4
75 changed files with 12266 additions and 21 deletions
178
apps/cli/README.md
Normal file
178
apps/cli/README.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# @roo-code/cli
|
||||
|
||||
Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode.
|
||||
|
||||
## Overview
|
||||
|
||||
This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# From the monorepo root.
|
||||
pnpm install
|
||||
|
||||
# Build the main extension first.
|
||||
pnpm --filter roo-cline bundle
|
||||
|
||||
# Build the cli.
|
||||
pnpm --filter @roo-code/cli build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Interactive Mode (Default)
|
||||
|
||||
By default, the CLI prompts for approval before executing actions:
|
||||
|
||||
```bash
|
||||
export OPENROUTER_API_KEY=sk-or-v1-...
|
||||
|
||||
pnpm --filter @roo-code/cli start \
|
||||
-x \
|
||||
-p openrouter \
|
||||
-k $OPENROUTER_API_KEY \
|
||||
-m anthropic/claude-sonnet-4.5 \
|
||||
--workspace ~/Documents/my-project \
|
||||
"What is this project?"
|
||||
```
|
||||
|
||||
In interactive mode:
|
||||
|
||||
- Tool executions prompt for yes/no approval
|
||||
- Commands prompt for yes/no approval
|
||||
- Followup questions show suggestions and wait for user input
|
||||
- Browser and MCP actions prompt for approval
|
||||
|
||||
### Non-Interactive Mode (`-y`)
|
||||
|
||||
For automation and scripts, use `-y` to auto-approve all actions:
|
||||
|
||||
```bash
|
||||
pnpm --filter @roo-code/cli start \
|
||||
-y \
|
||||
-x \
|
||||
-p openrouter \
|
||||
-k $OPENROUTER_API_KEY \
|
||||
-m anthropic/claude-sonnet-4.5 \
|
||||
--workspace ~/Documents/my-project \
|
||||
"Refactor the utils.ts file"
|
||||
```
|
||||
|
||||
In non-interactive mode:
|
||||
|
||||
- Tool, command, browser, and MCP actions are auto-approved
|
||||
- Followup questions show a 10-second timeout, then auto-select the first suggestion
|
||||
- Typing any key cancels the timeout and allows manual input
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description | Default |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------ | ----------------- |
|
||||
| `-w, --workspace <path>` | Workspace path to operate in | Current directory |
|
||||
| `-e, --extension <path>` | Path to the extension bundle directory | Auto-detected |
|
||||
| `-v, --verbose` | Enable verbose output (show VSCode and extension logs) | `false` |
|
||||
| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` |
|
||||
| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` |
|
||||
| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` |
|
||||
| `-k, --api-key <key>` | API key for the LLM provider | From env var |
|
||||
| `-p, --provider <provider>` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` |
|
||||
| `-m, --model <model>` | Model to use | Provider default |
|
||||
| `-M, --mode <mode>` | Mode to start in (code, architect, ask, debug, etc.) | `code` |
|
||||
| `-r, --reasoning-effort <effort>` | Reasoning effort level (none, minimal, low, medium, high, xhigh) | `medium` |
|
||||
|
||||
By default, the CLI runs in quiet mode (suppressing VSCode/extension logs) and only shows assistant output. Use `-v` to see all logs, or `-d` for detailed debug information.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The CLI will look for API keys in environment variables if not provided via `--api-key`:
|
||||
|
||||
| Provider | Environment Variable |
|
||||
| ------------- | -------------------- |
|
||||
| anthropic | `ANTHROPIC_API_KEY` |
|
||||
| openai | `OPENAI_API_KEY` |
|
||||
| openrouter | `OPENROUTER_API_KEY` |
|
||||
| google/gemini | `GOOGLE_API_KEY` |
|
||||
| mistral | `MISTRAL_API_KEY` |
|
||||
| deepseek | `DEEPSEEK_API_KEY` |
|
||||
| bedrock | `AWS_ACCESS_KEY_ID` |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ CLI Entry │
|
||||
│ (index.ts) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ ExtensionHost │
|
||||
│ (extension- │
|
||||
│ host.ts) │
|
||||
└────────┬────────┘
|
||||
│
|
||||
┌────┴────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌───────┐ ┌──────────┐
|
||||
│vscode │ │Extension │
|
||||
│-shim │ │ Bundle │
|
||||
└───────┘ └──────────┘
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **CLI Entry Point** (`index.ts`): Parses command line arguments and initializes the ExtensionHost
|
||||
|
||||
2. **ExtensionHost** (`extension-host.ts`):
|
||||
|
||||
- Creates a VSCode API mock using `@roo-code/vscode-shim`
|
||||
- Intercepts `require('vscode')` to return the mock
|
||||
- Loads and activates the extension bundle
|
||||
- Manages bidirectional message flow
|
||||
|
||||
3. **Message Flow**:
|
||||
- CLI → Extension: `emit("webviewMessage", {...})`
|
||||
- Extension → CLI: `emit("extensionWebviewMessage", {...})`
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- **No TUI**: Output is plain text (no React/Ink UI yet)
|
||||
- **No configuration file**: Settings are passed via command line flags
|
||||
- **No persistence**: Each run is a fresh session
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Watch mode for development
|
||||
pnpm dev
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Type checking
|
||||
pnpm check-types
|
||||
|
||||
# Linting
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Extension bundle not found
|
||||
|
||||
Make sure you've built the main extension first:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
pnpm bundle
|
||||
```
|
||||
|
||||
### Module resolution errors
|
||||
|
||||
The CLI expects the extension to be a CommonJS bundle. Make sure the extension's esbuild config outputs CommonJS.
|
||||
|
||||
### "vscode" module not found
|
||||
|
||||
The CLI intercepts `require('vscode')` calls. If you see this error, the module resolution interception may have failed.
|
||||
4
apps/cli/eslint.config.mjs
Normal file
4
apps/cli/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { config } from "@roo-code/config-eslint/base"
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default [...config]
|
||||
35
apps/cli/package.json
Normal file
35
apps/cli/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@roo-code/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Roo Code CLI - Run the Roo Code agent from the command line",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"roo": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"lint": "eslint src --ext .ts --max-warnings=0",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"build": "tsup",
|
||||
"start": "node dist/index.js",
|
||||
"clean": "rimraf dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@roo-code/types": "workspace:^",
|
||||
"@roo-code/vscode-shim": "workspace:^",
|
||||
"@vscode/ripgrep": "^1.15.9",
|
||||
"commander": "^12.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@roo-code/config-eslint": "workspace:^",
|
||||
"@roo-code/config-typescript": "workspace:^",
|
||||
"@types/node": "^24.1.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"tsup": "^8.4.0",
|
||||
"typescript": "5.8.3",
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
1164
apps/cli/src/__tests__/extension-host.test.ts
Normal file
1164
apps/cli/src/__tests__/extension-host.test.ts
Normal file
File diff suppressed because it is too large
Load diff
144
apps/cli/src/__tests__/integration.test.ts
Normal file
144
apps/cli/src/__tests__/integration.test.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
/**
|
||||
* Integration tests for CLI
|
||||
*
|
||||
* These tests require a valid OPENROUTER_API_KEY environment variable.
|
||||
* They will be skipped if the API key is not available.
|
||||
*
|
||||
* Run with: OPENROUTER_API_KEY=sk-or-v1-... pnpm test
|
||||
*/
|
||||
|
||||
import { ExtensionHost } from "../extension-host.js"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import os from "os"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY
|
||||
const hasApiKey = !!OPENROUTER_API_KEY
|
||||
|
||||
// Find the extension path - we need a built extension for integration tests
|
||||
function findExtensionPath(): string | null {
|
||||
// From apps/cli/src/__tests__, go up to monorepo root then to src/dist
|
||||
const monorepoPath = path.resolve(__dirname, "../../../../src/dist")
|
||||
if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
|
||||
return monorepoPath
|
||||
}
|
||||
// Also try from the apps/cli level
|
||||
const altPath = path.resolve(__dirname, "../../../src/dist")
|
||||
if (fs.existsSync(path.join(altPath, "extension.js"))) {
|
||||
return altPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const extensionPath = findExtensionPath()
|
||||
const hasExtension = !!extensionPath
|
||||
|
||||
// Create a temporary workspace directory for tests
|
||||
function createTempWorkspace(): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-cli-test-"))
|
||||
return tempDir
|
||||
}
|
||||
|
||||
// Clean up temporary workspace
|
||||
function cleanupWorkspace(workspacePath: string): void {
|
||||
try {
|
||||
fs.rmSync(workspacePath, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(!hasApiKey || !hasExtension)(
|
||||
"CLI Integration Tests (requires OPENROUTER_API_KEY and built extension)",
|
||||
() => {
|
||||
let workspacePath: string
|
||||
let host: ExtensionHost
|
||||
|
||||
beforeAll(() => {
|
||||
console.log("Integration tests running with:")
|
||||
console.log(` - API Key: ${OPENROUTER_API_KEY?.substring(0, 12)}...`)
|
||||
console.log(` - Extension Path: ${extensionPath}`)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
workspacePath = createTempWorkspace()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (host) {
|
||||
await host.dispose()
|
||||
}
|
||||
cleanupWorkspace(workspacePath)
|
||||
})
|
||||
|
||||
/**
|
||||
* Main integration test - tests the complete end-to-end flow
|
||||
*
|
||||
* NOTE: Due to the extension using singletons (TelemetryService, etc.),
|
||||
* only one integration test can run per process. This single test covers
|
||||
* the main functionality: activation, task execution, completion, and disposal.
|
||||
*/
|
||||
it("should complete end-to-end task execution with proper lifecycle", async () => {
|
||||
host = new ExtensionHost({
|
||||
mode: "code",
|
||||
apiProvider: "openrouter",
|
||||
apiKey: OPENROUTER_API_KEY!,
|
||||
model: "anthropic/claude-haiku-4.5", // Use fast, cheap model for tests.
|
||||
workspacePath,
|
||||
extensionPath: extensionPath!,
|
||||
quiet: true,
|
||||
})
|
||||
|
||||
// Test activation
|
||||
await host.activate()
|
||||
|
||||
// Track state messages
|
||||
const stateMessages: unknown[] = []
|
||||
host.on("extensionWebviewMessage", (msg: Record<string, unknown>) => {
|
||||
if (msg.type === "state") {
|
||||
stateMessages.push(msg)
|
||||
}
|
||||
})
|
||||
|
||||
// Test task execution with completion
|
||||
// Note: runTask internally waits for webview to be ready before sending messages
|
||||
await expect(host.runTask("Say hello in exactly 5 words")).resolves.toBeUndefined()
|
||||
|
||||
// After task completes, webview should have been ready
|
||||
expect(host.isInInitialSetup()).toBe(false)
|
||||
|
||||
// Verify we received state updates
|
||||
expect(stateMessages.length).toBeGreaterThan(0)
|
||||
|
||||
// Test disposal
|
||||
await host.dispose()
|
||||
expect((global as Record<string, unknown>).vscode).toBeUndefined()
|
||||
expect((global as Record<string, unknown>).__extensionHost).toBeUndefined()
|
||||
}, 120000) // 2 minute timeout
|
||||
},
|
||||
)
|
||||
|
||||
// Additional test to verify skip behavior
|
||||
describe("Integration test skip behavior", () => {
|
||||
it("should have OPENROUTER_API_KEY check", () => {
|
||||
if (hasApiKey) {
|
||||
console.log("OPENROUTER_API_KEY is set, integration tests will run")
|
||||
} else {
|
||||
console.log("OPENROUTER_API_KEY is not set, integration tests will be skipped")
|
||||
}
|
||||
expect(true).toBe(true) // Always passes
|
||||
})
|
||||
|
||||
it("should have extension check", () => {
|
||||
if (hasExtension) {
|
||||
console.log(`Extension found at: ${extensionPath}`)
|
||||
} else {
|
||||
console.log("Extension not found, integration tests will be skipped")
|
||||
}
|
||||
expect(true).toBe(true) // Always passes
|
||||
})
|
||||
})
|
||||
119
apps/cli/src/__tests__/utils.test.ts
Normal file
119
apps/cli/src/__tests__/utils.test.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Unit tests for CLI utility functions
|
||||
*/
|
||||
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
// Mock fs module
|
||||
vi.mock("fs")
|
||||
|
||||
describe("getEnvVarName", () => {
|
||||
it.each([
|
||||
["anthropic", "ANTHROPIC_API_KEY"],
|
||||
["openai", "OPENAI_API_KEY"],
|
||||
["openrouter", "OPENROUTER_API_KEY"],
|
||||
["google", "GOOGLE_API_KEY"],
|
||||
["gemini", "GOOGLE_API_KEY"],
|
||||
["bedrock", "AWS_ACCESS_KEY_ID"],
|
||||
["ollama", "OLLAMA_API_KEY"],
|
||||
["mistral", "MISTRAL_API_KEY"],
|
||||
["deepseek", "DEEPSEEK_API_KEY"],
|
||||
])("should return %s for %s provider", (provider, expectedEnvVar) => {
|
||||
expect(getEnvVarName(provider)).toBe(expectedEnvVar)
|
||||
})
|
||||
|
||||
it("should handle case-insensitive provider names", () => {
|
||||
expect(getEnvVarName("ANTHROPIC")).toBe("ANTHROPIC_API_KEY")
|
||||
expect(getEnvVarName("Anthropic")).toBe("ANTHROPIC_API_KEY")
|
||||
expect(getEnvVarName("OpenRouter")).toBe("OPENROUTER_API_KEY")
|
||||
})
|
||||
|
||||
it("should return uppercase provider name with _API_KEY suffix for unknown providers", () => {
|
||||
expect(getEnvVarName("custom")).toBe("CUSTOM_API_KEY")
|
||||
expect(getEnvVarName("myProvider")).toBe("MYPROVIDER_API_KEY")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getApiKeyFromEnv", () => {
|
||||
const originalEnv = process.env
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset process.env before each test
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv
|
||||
})
|
||||
|
||||
it("should return API key from environment variable for anthropic", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "test-anthropic-key"
|
||||
expect(getApiKeyFromEnv("anthropic")).toBe("test-anthropic-key")
|
||||
})
|
||||
|
||||
it("should return API key from environment variable for openrouter", () => {
|
||||
process.env.OPENROUTER_API_KEY = "test-openrouter-key"
|
||||
expect(getApiKeyFromEnv("openrouter")).toBe("test-openrouter-key")
|
||||
})
|
||||
|
||||
it("should return API key from environment variable for openai", () => {
|
||||
process.env.OPENAI_API_KEY = "test-openai-key"
|
||||
expect(getApiKeyFromEnv("openai")).toBe("test-openai-key")
|
||||
})
|
||||
|
||||
it("should return undefined when API key is not set", () => {
|
||||
delete process.env.ANTHROPIC_API_KEY
|
||||
expect(getApiKeyFromEnv("anthropic")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle custom provider names", () => {
|
||||
process.env.CUSTOM_API_KEY = "test-custom-key"
|
||||
expect(getApiKeyFromEnv("custom")).toBe("test-custom-key")
|
||||
})
|
||||
|
||||
it("should handle case-insensitive provider lookup", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "test-key"
|
||||
expect(getApiKeyFromEnv("ANTHROPIC")).toBe("test-key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getDefaultExtensionPath", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("should return monorepo path when extension.js exists there", () => {
|
||||
const mockDirname = "/test/apps/cli/dist"
|
||||
const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true)
|
||||
|
||||
const result = getDefaultExtensionPath(mockDirname)
|
||||
|
||||
expect(result).toBe(expectedMonorepoPath)
|
||||
expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
|
||||
})
|
||||
|
||||
it("should return package path when extension.js does not exist in monorepo path", () => {
|
||||
const mockDirname = "/test/apps/cli/dist"
|
||||
const expectedPackagePath = path.resolve(mockDirname, "../extension")
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||
|
||||
const result = getDefaultExtensionPath(mockDirname)
|
||||
|
||||
expect(result).toBe(expectedPackagePath)
|
||||
})
|
||||
|
||||
it("should check monorepo path first", () => {
|
||||
const mockDirname = "/some/path"
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false)
|
||||
|
||||
getDefaultExtensionPath(mockDirname)
|
||||
|
||||
const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist")
|
||||
expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js"))
|
||||
})
|
||||
})
|
||||
1663
apps/cli/src/extension-host.ts
Normal file
1663
apps/cli/src/extension-host.ts
Normal file
File diff suppressed because it is too large
Load diff
163
apps/cli/src/index.ts
Normal file
163
apps/cli/src/index.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* @roo-code/cli - Command Line Interface for Roo Code
|
||||
*/
|
||||
|
||||
import { Command } from "commander"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import {
|
||||
type ProviderName,
|
||||
type ReasoningEffortExtended,
|
||||
isProviderName,
|
||||
reasoningEffortsExtended,
|
||||
} from "@roo-code/types"
|
||||
import { setLogger } from "@roo-code/vscode-shim"
|
||||
|
||||
import { ExtensionHost } from "./extension-host.js"
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js"
|
||||
|
||||
const DEFAULTS = {
|
||||
mode: "code",
|
||||
reasoningEffort: "medium" as const,
|
||||
model: "anthropic/claude-sonnet-4.5",
|
||||
}
|
||||
|
||||
const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"]
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program.name("roo").description("Roo Code CLI - Run the Roo Code agent from the command line").version("0.1.0")
|
||||
|
||||
program
|
||||
.argument("<prompt>", "The prompt/task to execute")
|
||||
.option("-w, --workspace <path>", "Workspace path to operate in", process.cwd())
|
||||
.option("-e, --extension <path>", "Path to the extension bundle directory")
|
||||
.option("-v, --verbose", "Enable verbose output (show VSCode and extension logs)", false)
|
||||
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
|
||||
.option("-x, --exit-on-complete", "Exit the process when the task completes (useful for testing)", false)
|
||||
.option("-y, --yes", "Auto-approve all prompts (non-interactive mode)", false)
|
||||
.option("-k, --api-key <key>", "API key for the LLM provider (defaults to ANTHROPIC_API_KEY env var)")
|
||||
.option("-p, --provider <provider>", "API provider (anthropic, openai, openrouter, etc.)", "openrouter")
|
||||
.option("-m, --model <model>", "Model to use", DEFAULTS.model)
|
||||
.option("-M, --mode <mode>", "Mode to start in (code, architect, ask, debug, etc.)", DEFAULTS.mode)
|
||||
.option(
|
||||
"-r, --reasoning-effort <effort>",
|
||||
"Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)",
|
||||
DEFAULTS.reasoningEffort,
|
||||
)
|
||||
.action(
|
||||
async (
|
||||
prompt: string,
|
||||
options: {
|
||||
workspace: string
|
||||
extension?: string
|
||||
verbose: boolean
|
||||
debug: boolean
|
||||
exitOnComplete: boolean
|
||||
yes: boolean
|
||||
apiKey?: string
|
||||
provider: ProviderName
|
||||
model?: string
|
||||
mode?: string
|
||||
reasoningEffort?: ReasoningEffortExtended | "unspecified" | "disabled"
|
||||
},
|
||||
) => {
|
||||
// Default is quiet mode - suppress VSCode shim logs unless verbose
|
||||
// or debug is specified.
|
||||
if (!options.verbose && !options.debug) {
|
||||
setLogger({
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
})
|
||||
}
|
||||
|
||||
const extensionPath = options.extension || getDefaultExtensionPath(__dirname)
|
||||
const apiKey = options.apiKey || getApiKeyFromEnv(options.provider)
|
||||
const workspacePath = path.resolve(options.workspace)
|
||||
|
||||
if (!apiKey) {
|
||||
console.error(
|
||||
`[CLI] Error: No API key provided. Use --api-key or set the appropriate environment variable.`,
|
||||
)
|
||||
console.error(`[CLI] For ${options.provider}, set ${getEnvVarName(options.provider)}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(workspacePath)) {
|
||||
console.error(`[CLI] Error: Workspace path does not exist: ${workspacePath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!isProviderName(options.provider)) {
|
||||
console.error(`[CLI] Error: Invalid provider: ${options.provider}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (options.reasoningEffort && !REASONING_EFFORTS.includes(options.reasoningEffort)) {
|
||||
console.error(
|
||||
`[CLI] Error: Invalid reasoning effort: ${options.reasoningEffort}, must be one of: ${REASONING_EFFORTS.join(", ")}`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`[CLI] Mode: ${options.mode || "default"}`)
|
||||
console.log(`[CLI] Reasoning Effort: ${options.reasoningEffort || "default"}`)
|
||||
console.log(`[CLI] Provider: ${options.provider}`)
|
||||
console.log(`[CLI] Model: ${options.model || "default"}`)
|
||||
console.log(`[CLI] Workspace: ${workspacePath}`)
|
||||
|
||||
const host = new ExtensionHost({
|
||||
mode: options.mode || DEFAULTS.mode,
|
||||
reasoningEffort: options.reasoningEffort === "unspecified" ? undefined : options.reasoningEffort,
|
||||
apiProvider: options.provider,
|
||||
apiKey,
|
||||
model: options.model || DEFAULTS.model,
|
||||
workspacePath,
|
||||
extensionPath: path.resolve(extensionPath),
|
||||
verbose: options.debug,
|
||||
quiet: !options.verbose && !options.debug,
|
||||
nonInteractive: options.yes,
|
||||
})
|
||||
|
||||
// Handle SIGINT (Ctrl+C)
|
||||
process.on("SIGINT", async () => {
|
||||
console.log("\n[CLI] Received SIGINT, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(130)
|
||||
})
|
||||
|
||||
// Handle SIGTERM
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log("\n[CLI] Received SIGTERM, shutting down...")
|
||||
await host.dispose()
|
||||
process.exit(143)
|
||||
})
|
||||
|
||||
try {
|
||||
await host.activate()
|
||||
await host.runTask(prompt)
|
||||
await host.dispose()
|
||||
|
||||
if (options.exitOnComplete) {
|
||||
process.exit(0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CLI] Error:", error instanceof Error ? error.message : String(error))
|
||||
|
||||
if (options.debug && error instanceof Error) {
|
||||
console.error(error.stack)
|
||||
}
|
||||
|
||||
await host.dispose()
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
program.parse()
|
||||
54
apps/cli/src/utils.ts
Normal file
54
apps/cli/src/utils.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Utility functions for the Roo Code CLI
|
||||
*/
|
||||
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
/**
|
||||
* Get the environment variable name for a provider's API key
|
||||
*/
|
||||
export function getEnvVarName(provider: string): string {
|
||||
const envVarMap: Record<string, string> = {
|
||||
anthropic: "ANTHROPIC_API_KEY",
|
||||
openai: "OPENAI_API_KEY",
|
||||
openrouter: "OPENROUTER_API_KEY",
|
||||
google: "GOOGLE_API_KEY",
|
||||
gemini: "GOOGLE_API_KEY",
|
||||
bedrock: "AWS_ACCESS_KEY_ID",
|
||||
ollama: "OLLAMA_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
deepseek: "DEEPSEEK_API_KEY",
|
||||
}
|
||||
return envVarMap[provider.toLowerCase()] || `${provider.toUpperCase()}_API_KEY`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get API key from environment variable based on provider
|
||||
*/
|
||||
export function getApiKeyFromEnv(provider: string): string | undefined {
|
||||
const envVar = getEnvVarName(provider)
|
||||
return process.env[envVar]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default path to the extension bundle.
|
||||
* This assumes the CLI is installed alongside the built extension.
|
||||
*
|
||||
* @param dirname - The __dirname equivalent for the calling module
|
||||
*/
|
||||
export function getDefaultExtensionPath(dirname: string): string {
|
||||
// __dirname is apps/cli/dist when bundled
|
||||
// The extension is at src/dist (relative to monorepo root)
|
||||
// So from apps/cli/dist, we need to go ../../../src/dist
|
||||
const monorepoPath = path.resolve(dirname, "../../../src/dist")
|
||||
|
||||
// Try monorepo path first (for development)
|
||||
if (fs.existsSync(path.join(monorepoPath, "extension.js"))) {
|
||||
return monorepoPath
|
||||
}
|
||||
|
||||
// Fallback: when installed as npm package, extension might be at ../extension
|
||||
const packagePath = path.resolve(dirname, "../extension")
|
||||
return packagePath
|
||||
}
|
||||
9
apps/cli/tsconfig.json
Normal file
9
apps/cli/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "@roo-code/config-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "*.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
22
apps/cli/tsup.config.ts
Normal file
22
apps/cli/tsup.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { defineConfig } from "tsup"
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
target: "node20",
|
||||
platform: "node",
|
||||
banner: {
|
||||
js: "#!/usr/bin/env node",
|
||||
},
|
||||
// Bundle these workspace packages that export TypeScript.
|
||||
noExternal: ["@roo-code/types", "@roo-code/vscode-shim"],
|
||||
external: [
|
||||
// Keep native modules external.
|
||||
"@anthropic-ai/sdk",
|
||||
"@anthropic-ai/bedrock-sdk",
|
||||
"@anthropic-ai/vertex-sdk",
|
||||
],
|
||||
})
|
||||
11
apps/cli/vitest.config.ts
Normal file
11
apps/cli/vitest.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
watch: false,
|
||||
testTimeout: 120_000, // 2m for integration tests.
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
})
|
||||
|
|
@ -53,6 +53,9 @@
|
|||
]
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@vscode/ripgrep"
|
||||
],
|
||||
"overrides": {
|
||||
"tar-fs": ">=3.1.1",
|
||||
"esbuild": ">=0.25.0",
|
||||
|
|
|
|||
4
packages/vscode-shim/eslint.config.mjs
Normal file
4
packages/vscode-shim/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { config } from "@roo-code/config-eslint/base"
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default [...config]
|
||||
20
packages/vscode-shim/package.json
Normal file
20
packages/vscode-shim/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "@roo-code/vscode-shim",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"scripts": {
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"lint": "eslint src --ext .ts --max-warnings=0",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"clean": "rimraf .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@roo-code/config-eslint": "workspace:^",
|
||||
"@roo-code/config-typescript": "workspace:^",
|
||||
"@types/node": "^24.1.0",
|
||||
"vitest": "^3.2.3"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
378
packages/vscode-shim/src/__tests__/Additional.test.ts
Normal file
378
packages/vscode-shim/src/__tests__/Additional.test.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import {
|
||||
Location,
|
||||
DiagnosticRelatedInformation,
|
||||
Diagnostic,
|
||||
ThemeColor,
|
||||
ThemeIcon,
|
||||
CodeActionKind,
|
||||
CodeLens,
|
||||
LanguageModelTextPart,
|
||||
LanguageModelToolCallPart,
|
||||
LanguageModelToolResultPart,
|
||||
FileSystemError,
|
||||
} from "../classes/Additional.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { Range } from "../classes/Range.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
|
||||
describe("Location", () => {
|
||||
it("should create location with URI and Range", () => {
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const range = new Range(0, 0, 5, 10)
|
||||
const location = new Location(uri, range)
|
||||
|
||||
expect(location.uri).toBe(uri)
|
||||
expect(location.range).toBe(range)
|
||||
})
|
||||
|
||||
it("should create location with URI and Position", () => {
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const position = new Position(5, 10)
|
||||
const location = new Location(uri, position)
|
||||
|
||||
expect(location.uri).toBe(uri)
|
||||
expect(location.range).toBe(position)
|
||||
})
|
||||
})
|
||||
|
||||
describe("DiagnosticRelatedInformation", () => {
|
||||
it("should create diagnostic related information", () => {
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const range = new Range(0, 0, 1, 0)
|
||||
const location = new Location(uri, range)
|
||||
const message = "Related issue here"
|
||||
|
||||
const info = new DiagnosticRelatedInformation(location, message)
|
||||
|
||||
expect(info.location).toBe(location)
|
||||
expect(info.message).toBe(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Diagnostic", () => {
|
||||
it("should create diagnostic with default severity (Error)", () => {
|
||||
const range = new Range(0, 0, 0, 10)
|
||||
const message = "Error message"
|
||||
|
||||
const diagnostic = new Diagnostic(range, message)
|
||||
|
||||
expect(diagnostic.range.isEqual(range)).toBe(true)
|
||||
expect(diagnostic.message).toBe(message)
|
||||
expect(diagnostic.severity).toBe(0) // Error
|
||||
})
|
||||
|
||||
it("should create diagnostic with custom severity", () => {
|
||||
const range = new Range(0, 0, 0, 10)
|
||||
const message = "Warning message"
|
||||
|
||||
const diagnostic = new Diagnostic(range, message, 1) // Warning
|
||||
|
||||
expect(diagnostic.severity).toBe(1)
|
||||
})
|
||||
|
||||
it("should allow setting optional properties", () => {
|
||||
const range = new Range(0, 0, 0, 10)
|
||||
const diagnostic = new Diagnostic(range, "Test")
|
||||
|
||||
diagnostic.source = "eslint"
|
||||
diagnostic.code = "no-unused-vars"
|
||||
diagnostic.tags = [1] // Unnecessary
|
||||
|
||||
expect(diagnostic.source).toBe("eslint")
|
||||
expect(diagnostic.code).toBe("no-unused-vars")
|
||||
expect(diagnostic.tags).toEqual([1])
|
||||
})
|
||||
|
||||
it("should allow setting related information", () => {
|
||||
const range = new Range(0, 0, 0, 10)
|
||||
const diagnostic = new Diagnostic(range, "Test")
|
||||
|
||||
const relatedUri = Uri.file("/related.txt")
|
||||
const relatedLocation = new Location(relatedUri, new Range(1, 0, 1, 5))
|
||||
const relatedInfo = new DiagnosticRelatedInformation(relatedLocation, "Related issue")
|
||||
|
||||
diagnostic.relatedInformation = [relatedInfo]
|
||||
|
||||
expect(diagnostic.relatedInformation).toHaveLength(1)
|
||||
expect(diagnostic.relatedInformation[0]?.message).toBe("Related issue")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ThemeColor", () => {
|
||||
it("should create theme color with ID", () => {
|
||||
const color = new ThemeColor("editor.foreground")
|
||||
|
||||
expect(color.id).toBe("editor.foreground")
|
||||
})
|
||||
|
||||
it("should handle custom color IDs", () => {
|
||||
const color = new ThemeColor("myExtension.customColor")
|
||||
|
||||
expect(color.id).toBe("myExtension.customColor")
|
||||
})
|
||||
})
|
||||
|
||||
describe("ThemeIcon", () => {
|
||||
it("should create theme icon with ID", () => {
|
||||
const icon = new ThemeIcon("file")
|
||||
|
||||
expect(icon.id).toBe("file")
|
||||
expect(icon.color).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should create theme icon with ID and color", () => {
|
||||
const color = new ThemeColor("errorForeground")
|
||||
const icon = new ThemeIcon("error", color)
|
||||
|
||||
expect(icon.id).toBe("error")
|
||||
expect(icon.color).toBe(color)
|
||||
expect(icon.color?.id).toBe("errorForeground")
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeActionKind", () => {
|
||||
describe("static properties", () => {
|
||||
it("should have Empty kind", () => {
|
||||
expect(CodeActionKind.Empty.value).toBe("")
|
||||
})
|
||||
|
||||
it("should have QuickFix kind", () => {
|
||||
expect(CodeActionKind.QuickFix.value).toBe("quickfix")
|
||||
})
|
||||
|
||||
it("should have Refactor kind", () => {
|
||||
expect(CodeActionKind.Refactor.value).toBe("refactor")
|
||||
})
|
||||
|
||||
it("should have RefactorExtract kind", () => {
|
||||
expect(CodeActionKind.RefactorExtract.value).toBe("refactor.extract")
|
||||
})
|
||||
|
||||
it("should have RefactorInline kind", () => {
|
||||
expect(CodeActionKind.RefactorInline.value).toBe("refactor.inline")
|
||||
})
|
||||
|
||||
it("should have RefactorRewrite kind", () => {
|
||||
expect(CodeActionKind.RefactorRewrite.value).toBe("refactor.rewrite")
|
||||
})
|
||||
|
||||
it("should have Source kind", () => {
|
||||
expect(CodeActionKind.Source.value).toBe("source")
|
||||
})
|
||||
|
||||
it("should have SourceOrganizeImports kind", () => {
|
||||
expect(CodeActionKind.SourceOrganizeImports.value).toBe("source.organizeImports")
|
||||
})
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create custom kind", () => {
|
||||
const kind = new CodeActionKind("custom.action")
|
||||
expect(kind.value).toBe("custom.action")
|
||||
})
|
||||
})
|
||||
|
||||
describe("append()", () => {
|
||||
it("should append to existing kind", () => {
|
||||
const kind = new CodeActionKind("refactor")
|
||||
const appended = kind.append("extract")
|
||||
|
||||
expect(appended.value).toBe("refactor.extract")
|
||||
})
|
||||
|
||||
it("should handle empty kind", () => {
|
||||
const kind = new CodeActionKind("")
|
||||
const appended = kind.append("quickfix")
|
||||
|
||||
expect(appended.value).toBe("quickfix")
|
||||
})
|
||||
})
|
||||
|
||||
describe("contains()", () => {
|
||||
it("should return true when kind contains another", () => {
|
||||
const parent = CodeActionKind.Refactor
|
||||
const child = CodeActionKind.RefactorExtract
|
||||
|
||||
expect(parent.contains(child)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when kinds are different hierarchies", () => {
|
||||
const quickfix = CodeActionKind.QuickFix
|
||||
const refactor = CodeActionKind.Refactor
|
||||
|
||||
expect(quickfix.contains(refactor)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for equal kinds", () => {
|
||||
const kind = new CodeActionKind("quickfix")
|
||||
expect(kind.contains(CodeActionKind.QuickFix)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("intersects()", () => {
|
||||
it("should return true when one contains the other", () => {
|
||||
const parent = CodeActionKind.Refactor
|
||||
const child = CodeActionKind.RefactorExtract
|
||||
|
||||
expect(parent.intersects(child)).toBe(true)
|
||||
expect(child.intersects(parent)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-intersecting kinds", () => {
|
||||
const quickfix = CodeActionKind.QuickFix
|
||||
const source = CodeActionKind.Source
|
||||
|
||||
expect(quickfix.intersects(source)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeLens", () => {
|
||||
it("should create CodeLens with range only", () => {
|
||||
const range = new Range(0, 0, 0, 10)
|
||||
const lens = new CodeLens(range)
|
||||
|
||||
expect(lens.range.isEqual(range)).toBe(true)
|
||||
expect(lens.command).toBeUndefined()
|
||||
expect(lens.isResolved).toBe(false)
|
||||
})
|
||||
|
||||
it("should create CodeLens with range and command", () => {
|
||||
const range = new Range(5, 0, 5, 20)
|
||||
const command = {
|
||||
command: "myExtension.doSomething",
|
||||
title: "Click me",
|
||||
arguments: [1, 2, 3],
|
||||
}
|
||||
const lens = new CodeLens(range, command)
|
||||
|
||||
expect(lens.range).toBeDefined()
|
||||
expect(lens.command?.command).toBe("myExtension.doSomething")
|
||||
expect(lens.command?.title).toBe("Click me")
|
||||
expect(lens.command?.arguments).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe("LanguageModelTextPart", () => {
|
||||
it("should create text part with value", () => {
|
||||
const part = new LanguageModelTextPart("Hello, world!")
|
||||
|
||||
expect(part.value).toBe("Hello, world!")
|
||||
})
|
||||
})
|
||||
|
||||
describe("LanguageModelToolCallPart", () => {
|
||||
it("should create tool call part", () => {
|
||||
const part = new LanguageModelToolCallPart("call-123", "searchFiles", { query: "test" })
|
||||
|
||||
expect(part.callId).toBe("call-123")
|
||||
expect(part.name).toBe("searchFiles")
|
||||
expect(part.input).toEqual({ query: "test" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("LanguageModelToolResultPart", () => {
|
||||
it("should create tool result part", () => {
|
||||
const part = new LanguageModelToolResultPart("call-123", [{ type: "text", text: "result" }])
|
||||
|
||||
expect(part.callId).toBe("call-123")
|
||||
expect(part.content).toHaveLength(1)
|
||||
expect(part.content[0]).toEqual({ type: "text", text: "result" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("FileSystemError", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create error with message", () => {
|
||||
const error = new FileSystemError("Something went wrong")
|
||||
|
||||
expect(error.message).toBe("Something went wrong")
|
||||
expect(error.code).toBe("Unknown")
|
||||
expect(error.name).toBe("FileSystemError")
|
||||
})
|
||||
|
||||
it("should create error with message and code", () => {
|
||||
const error = new FileSystemError("Custom error", "CustomCode")
|
||||
|
||||
expect(error.message).toBe("Custom error")
|
||||
expect(error.code).toBe("CustomCode")
|
||||
})
|
||||
})
|
||||
|
||||
describe("FileNotFound()", () => {
|
||||
it("should create FileNotFound error from string", () => {
|
||||
const error = FileSystemError.FileNotFound("File not found: /path/to/file")
|
||||
|
||||
expect(error.message).toBe("File not found: /path/to/file")
|
||||
expect(error.code).toBe("FileNotFound")
|
||||
})
|
||||
|
||||
it("should create FileNotFound error from URI", () => {
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const error = FileSystemError.FileNotFound(uri)
|
||||
|
||||
expect(error.message).toContain("/path/to/file.txt")
|
||||
expect(error.code).toBe("FileNotFound")
|
||||
})
|
||||
|
||||
it("should handle undefined input", () => {
|
||||
const error = FileSystemError.FileNotFound()
|
||||
|
||||
expect(error.message).toContain("unknown")
|
||||
expect(error.code).toBe("FileNotFound")
|
||||
})
|
||||
})
|
||||
|
||||
describe("FileExists()", () => {
|
||||
it("should create FileExists error", () => {
|
||||
const error = FileSystemError.FileExists("File already exists")
|
||||
|
||||
expect(error.message).toBe("File already exists")
|
||||
expect(error.code).toBe("FileExists")
|
||||
})
|
||||
|
||||
it("should create FileExists error from URI", () => {
|
||||
const uri = Uri.file("/existing/file.txt")
|
||||
const error = FileSystemError.FileExists(uri)
|
||||
|
||||
expect(error.message).toContain("/existing/file.txt")
|
||||
expect(error.code).toBe("FileExists")
|
||||
})
|
||||
})
|
||||
|
||||
describe("FileNotADirectory()", () => {
|
||||
it("should create FileNotADirectory error", () => {
|
||||
const error = FileSystemError.FileNotADirectory("Not a directory")
|
||||
|
||||
expect(error.message).toBe("Not a directory")
|
||||
expect(error.code).toBe("FileNotADirectory")
|
||||
})
|
||||
})
|
||||
|
||||
describe("FileIsADirectory()", () => {
|
||||
it("should create FileIsADirectory error", () => {
|
||||
const error = FileSystemError.FileIsADirectory("Is a directory")
|
||||
|
||||
expect(error.message).toBe("Is a directory")
|
||||
expect(error.code).toBe("FileIsADirectory")
|
||||
})
|
||||
})
|
||||
|
||||
describe("NoPermissions()", () => {
|
||||
it("should create NoPermissions error", () => {
|
||||
const error = FileSystemError.NoPermissions("Access denied")
|
||||
|
||||
expect(error.message).toBe("Access denied")
|
||||
expect(error.code).toBe("NoPermissions")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Unavailable()", () => {
|
||||
it("should create Unavailable error", () => {
|
||||
const error = FileSystemError.Unavailable("Resource unavailable")
|
||||
|
||||
expect(error.message).toBe("Resource unavailable")
|
||||
expect(error.code).toBe("Unavailable")
|
||||
})
|
||||
})
|
||||
})
|
||||
156
packages/vscode-shim/src/__tests__/CancellationToken.test.ts
Normal file
156
packages/vscode-shim/src/__tests__/CancellationToken.test.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { CancellationTokenSource } from "../classes/CancellationToken.js"
|
||||
|
||||
describe("CancellationToken", () => {
|
||||
describe("initial state", () => {
|
||||
it("should not be cancelled initially", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const token = source.token
|
||||
|
||||
expect(token.isCancellationRequested).toBe(false)
|
||||
})
|
||||
|
||||
it("should have onCancellationRequested function", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const token = source.token
|
||||
|
||||
expect(typeof token.onCancellationRequested).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("CancellationTokenSource", () => {
|
||||
describe("token property", () => {
|
||||
it("should return a CancellationToken", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const token = source.token
|
||||
|
||||
expect(token).toBeDefined()
|
||||
expect(typeof token.isCancellationRequested).toBe("boolean")
|
||||
expect(typeof token.onCancellationRequested).toBe("function")
|
||||
})
|
||||
|
||||
it("should return the same token instance on multiple accesses", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
|
||||
expect(source.token).toBe(source.token)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel()", () => {
|
||||
it("should set isCancellationRequested to true", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
|
||||
source.cancel()
|
||||
|
||||
expect(source.token.isCancellationRequested).toBe(true)
|
||||
})
|
||||
|
||||
it("should fire onCancellationRequested event", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const listener = vi.fn()
|
||||
|
||||
source.token.onCancellationRequested(listener)
|
||||
source.cancel()
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should only fire event once on multiple cancel calls", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const listener = vi.fn()
|
||||
|
||||
source.token.onCancellationRequested(listener)
|
||||
source.cancel()
|
||||
source.cancel()
|
||||
source.cancel()
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should be idempotent", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
|
||||
source.cancel()
|
||||
source.cancel()
|
||||
|
||||
expect(source.token.isCancellationRequested).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose()", () => {
|
||||
it("should cancel the token", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
|
||||
source.dispose()
|
||||
|
||||
expect(source.token.isCancellationRequested).toBe(true)
|
||||
})
|
||||
|
||||
it("should fire onCancellationRequested event", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const listener = vi.fn()
|
||||
|
||||
source.token.onCancellationRequested(listener)
|
||||
source.dispose()
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should be safe to call multiple times", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
|
||||
expect(() => {
|
||||
source.dispose()
|
||||
source.dispose()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("onCancellationRequested", () => {
|
||||
it("should return a disposable", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const listener = vi.fn()
|
||||
|
||||
const disposable = source.token.onCancellationRequested(listener)
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("should stop listening after disposing", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const listener = vi.fn()
|
||||
|
||||
const disposable = source.token.onCancellationRequested(listener)
|
||||
disposable.dispose()
|
||||
source.cancel()
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call listener immediately if already cancelled", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
source.cancel()
|
||||
|
||||
const listener = vi.fn()
|
||||
source.token.onCancellationRequested(listener)
|
||||
|
||||
// Event was already fired, listener added after won't be called
|
||||
// This matches VSCode behavior
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should support multiple listeners", () => {
|
||||
const source = new CancellationTokenSource()
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
source.token.onCancellationRequested(listener1)
|
||||
source.token.onCancellationRequested(listener2)
|
||||
source.cancel()
|
||||
|
||||
expect(listener1).toHaveBeenCalledTimes(1)
|
||||
expect(listener2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
157
packages/vscode-shim/src/__tests__/CommandsAPI.test.ts
Normal file
157
packages/vscode-shim/src/__tests__/CommandsAPI.test.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { CommandsAPI } from "../api/CommandsAPI.js"
|
||||
|
||||
describe("CommandsAPI", () => {
|
||||
let commands: CommandsAPI
|
||||
|
||||
beforeEach(() => {
|
||||
commands = new CommandsAPI()
|
||||
})
|
||||
|
||||
describe("registerCommand()", () => {
|
||||
it("should register a command", () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
commands.registerCommand("test.command", callback)
|
||||
commands.executeCommand("test.command")
|
||||
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return a disposable", () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
const disposable = commands.registerCommand("test.command", callback)
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("should unregister command on dispose", async () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
const disposable = commands.registerCommand("test.command", callback)
|
||||
disposable.dispose()
|
||||
await commands.executeCommand("test.command")
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should allow registering multiple commands", () => {
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
commands.registerCommand("test.command1", callback1)
|
||||
commands.registerCommand("test.command2", callback2)
|
||||
|
||||
commands.executeCommand("test.command1")
|
||||
commands.executeCommand("test.command2")
|
||||
|
||||
expect(callback1).toHaveBeenCalled()
|
||||
expect(callback2).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("executeCommand()", () => {
|
||||
it("should execute registered command", async () => {
|
||||
const callback = vi.fn().mockReturnValue("result")
|
||||
|
||||
commands.registerCommand("test.command", callback)
|
||||
const result = await commands.executeCommand("test.command")
|
||||
|
||||
expect(result).toBe("result")
|
||||
})
|
||||
|
||||
it("should pass arguments to command handler", async () => {
|
||||
const callback = vi.fn()
|
||||
|
||||
commands.registerCommand("test.command", callback)
|
||||
await commands.executeCommand("test.command", "arg1", "arg2", 123)
|
||||
|
||||
expect(callback).toHaveBeenCalledWith("arg1", "arg2", 123)
|
||||
})
|
||||
|
||||
it("should return promise for unknown command", () => {
|
||||
const result = commands.executeCommand("unknown.command")
|
||||
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
})
|
||||
|
||||
it("should resolve to undefined for unknown command", async () => {
|
||||
const result = await commands.executeCommand("unknown.command")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should reject if handler throws", async () => {
|
||||
commands.registerCommand("test.error", () => {
|
||||
throw new Error("Test error")
|
||||
})
|
||||
|
||||
await expect(commands.executeCommand("test.error")).rejects.toThrow("Test error")
|
||||
})
|
||||
|
||||
it("should handle async command handlers", async () => {
|
||||
commands.registerCommand("test.async", async () => {
|
||||
return "async result"
|
||||
})
|
||||
|
||||
const result = await commands.executeCommand("test.async")
|
||||
|
||||
expect(result).toBe("async result")
|
||||
})
|
||||
})
|
||||
|
||||
describe("built-in commands", () => {
|
||||
it("should handle workbench.action.files.saveFiles", async () => {
|
||||
const result = await commands.executeCommand("workbench.action.files.saveFiles")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle workbench.action.closeWindow", async () => {
|
||||
const result = await commands.executeCommand("workbench.action.closeWindow")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle workbench.action.reloadWindow", async () => {
|
||||
const result = await commands.executeCommand("workbench.action.reloadWindow")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("generic type support", () => {
|
||||
it("should support typed return values", async () => {
|
||||
commands.registerCommand("test.typed", () => 42)
|
||||
|
||||
const result = await commands.executeCommand<number>("test.typed")
|
||||
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
it("should support complex return types", async () => {
|
||||
const expected = { name: "test", value: 123 }
|
||||
commands.registerCommand("test.object", () => expected)
|
||||
|
||||
const result = await commands.executeCommand<{ name: string; value: number }>("test.object")
|
||||
|
||||
expect(result).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe("command overwriting", () => {
|
||||
it("should allow registering same command multiple times", () => {
|
||||
const callback1 = vi.fn().mockReturnValue(1)
|
||||
const callback2 = vi.fn().mockReturnValue(2)
|
||||
|
||||
commands.registerCommand("test.command", callback1)
|
||||
commands.registerCommand("test.command", callback2)
|
||||
|
||||
// Last registration wins
|
||||
const result = commands.executeCommand("test.command")
|
||||
|
||||
expect(result).resolves.toBe(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
133
packages/vscode-shim/src/__tests__/EventEmitter.test.ts
Normal file
133
packages/vscode-shim/src/__tests__/EventEmitter.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { EventEmitter } from "../classes/EventEmitter.js"
|
||||
|
||||
describe("EventEmitter", () => {
|
||||
describe("event subscription", () => {
|
||||
it("should subscribe and receive events", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
const listener = vi.fn()
|
||||
|
||||
emitter.event(listener)
|
||||
emitter.fire("test")
|
||||
|
||||
expect(listener).toHaveBeenCalledWith("test")
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should support multiple listeners", () => {
|
||||
const emitter = new EventEmitter<number>()
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
emitter.event(listener1)
|
||||
emitter.event(listener2)
|
||||
emitter.fire(42)
|
||||
|
||||
expect(listener1).toHaveBeenCalledWith(42)
|
||||
expect(listener2).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it("should bind thisArgs when provided", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
const context = { name: "test", capturedThis: null as unknown }
|
||||
|
||||
emitter.event(function (this: typeof context) {
|
||||
this.capturedThis = this
|
||||
}, context)
|
||||
|
||||
emitter.fire("event")
|
||||
expect(context.capturedThis).toBe(context)
|
||||
})
|
||||
|
||||
it("should add disposable to array when provided", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
const disposables: { dispose: () => void }[] = []
|
||||
|
||||
emitter.event(() => {}, undefined, disposables)
|
||||
|
||||
expect(disposables).toHaveLength(1)
|
||||
expect(typeof disposables[0]?.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose subscription", () => {
|
||||
it("should stop receiving events after dispose", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
const listener = vi.fn()
|
||||
|
||||
const disposable = emitter.event(listener)
|
||||
emitter.fire("before")
|
||||
|
||||
disposable.dispose()
|
||||
emitter.fire("after")
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith("before")
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose emitter", () => {
|
||||
it("should remove all listeners on dispose", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
emitter.event(listener1)
|
||||
emitter.event(listener2)
|
||||
|
||||
emitter.dispose()
|
||||
emitter.fire("test")
|
||||
|
||||
expect(listener1).not.toHaveBeenCalled()
|
||||
expect(listener2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should have zero listeners after dispose", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
emitter.event(() => {})
|
||||
emitter.event(() => {})
|
||||
|
||||
expect(emitter.listenerCount).toBe(2)
|
||||
|
||||
emitter.dispose()
|
||||
expect(emitter.listenerCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should not fail if a listener throws", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
const goodListener = vi.fn()
|
||||
|
||||
emitter.event(() => {
|
||||
throw new Error("Listener error")
|
||||
})
|
||||
emitter.event(goodListener)
|
||||
|
||||
// Should not throw
|
||||
expect(() => emitter.fire("test")).not.toThrow()
|
||||
|
||||
// Good listener should still be called
|
||||
expect(goodListener).toHaveBeenCalledWith("test")
|
||||
})
|
||||
})
|
||||
|
||||
describe("listenerCount", () => {
|
||||
it("should track number of listeners", () => {
|
||||
const emitter = new EventEmitter<string>()
|
||||
|
||||
expect(emitter.listenerCount).toBe(0)
|
||||
|
||||
const d1 = emitter.event(() => {})
|
||||
expect(emitter.listenerCount).toBe(1)
|
||||
|
||||
const d2 = emitter.event(() => {})
|
||||
expect(emitter.listenerCount).toBe(2)
|
||||
|
||||
d1.dispose()
|
||||
expect(emitter.listenerCount).toBe(1)
|
||||
|
||||
d2.dispose()
|
||||
expect(emitter.listenerCount).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
343
packages/vscode-shim/src/__tests__/ExtensionContext.test.ts
Normal file
343
packages/vscode-shim/src/__tests__/ExtensionContext.test.ts
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
describe("ExtensionContextImpl", () => {
|
||||
let tempDir: string
|
||||
let extensionPath: string
|
||||
let workspacePath: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "ext-context-test-"))
|
||||
extensionPath = path.join(tempDir, "extension")
|
||||
workspacePath = path.join(tempDir, "workspace")
|
||||
fs.mkdirSync(extensionPath, { recursive: true })
|
||||
fs.mkdirSync(workspacePath, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create context with extension path", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
expect(context.extensionPath).toBe(extensionPath)
|
||||
expect(context.extensionUri.fsPath).toBe(extensionPath)
|
||||
})
|
||||
|
||||
it("should use default extension mode (Production)", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
expect(context.extensionMode).toBe(1) // Production
|
||||
})
|
||||
|
||||
it("should allow custom extension mode", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
extensionMode: 2, // Development
|
||||
})
|
||||
|
||||
expect(context.extensionMode).toBe(2)
|
||||
})
|
||||
|
||||
it("should initialize empty subscriptions array", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
expect(context.subscriptions).toEqual([])
|
||||
})
|
||||
|
||||
it("should initialize environmentVariableCollection", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
expect(context.environmentVariableCollection).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("storage paths", () => {
|
||||
it("should set up global storage path", () => {
|
||||
const customStorageDir = path.join(tempDir, "custom-storage")
|
||||
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: customStorageDir,
|
||||
})
|
||||
|
||||
expect(context.globalStoragePath).toContain("global-storage")
|
||||
expect(context.globalStorageUri.fsPath).toBe(context.globalStoragePath)
|
||||
})
|
||||
|
||||
it("should set up workspace storage path with hash", () => {
|
||||
const customStorageDir = path.join(tempDir, "custom-storage")
|
||||
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: customStorageDir,
|
||||
})
|
||||
|
||||
expect(context.storagePath).toContain("workspace-storage")
|
||||
expect(context.storageUri?.fsPath).toBe(context.storagePath)
|
||||
})
|
||||
|
||||
it("should set up log path", () => {
|
||||
const customStorageDir = path.join(tempDir, "custom-storage")
|
||||
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: customStorageDir,
|
||||
})
|
||||
|
||||
expect(context.logPath).toContain("logs")
|
||||
expect(context.logUri.fsPath).toBe(context.logPath)
|
||||
})
|
||||
|
||||
it("should create storage directories", () => {
|
||||
const customStorageDir = path.join(tempDir, "custom-storage")
|
||||
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: customStorageDir,
|
||||
})
|
||||
|
||||
expect(fs.existsSync(context.globalStoragePath)).toBe(true)
|
||||
expect(fs.existsSync(context.storagePath!)).toBe(true)
|
||||
expect(fs.existsSync(context.logPath)).toBe(true)
|
||||
})
|
||||
|
||||
it("should generate different workspace hashes for different paths", () => {
|
||||
const workspace1 = path.join(tempDir, "workspace1")
|
||||
const workspace2 = path.join(tempDir, "workspace2")
|
||||
fs.mkdirSync(workspace1, { recursive: true })
|
||||
fs.mkdirSync(workspace2, { recursive: true })
|
||||
|
||||
const context1 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath: workspace1,
|
||||
storageDir: path.join(tempDir, "storage1"),
|
||||
})
|
||||
|
||||
const context2 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath: workspace2,
|
||||
storageDir: path.join(tempDir, "storage2"),
|
||||
})
|
||||
|
||||
// The hashes should be different
|
||||
const hash1 = path.basename(context1.storagePath!)
|
||||
const hash2 = path.basename(context2.storagePath!)
|
||||
expect(hash1).not.toBe(hash2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("workspaceState", () => {
|
||||
it("should provide workspaceState memento", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: path.join(tempDir, "storage"),
|
||||
})
|
||||
|
||||
expect(context.workspaceState).toBeDefined()
|
||||
expect(typeof context.workspaceState.get).toBe("function")
|
||||
expect(typeof context.workspaceState.update).toBe("function")
|
||||
expect(typeof context.workspaceState.keys).toBe("function")
|
||||
})
|
||||
|
||||
it("should persist workspace state", async () => {
|
||||
const storageDir = path.join(tempDir, "storage")
|
||||
|
||||
const context1 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir,
|
||||
})
|
||||
|
||||
await context1.workspaceState.update("testKey", "testValue")
|
||||
|
||||
// Create new context with same storage
|
||||
const context2 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir,
|
||||
})
|
||||
|
||||
expect(context2.workspaceState.get("testKey")).toBe("testValue")
|
||||
})
|
||||
})
|
||||
|
||||
describe("globalState", () => {
|
||||
it("should provide globalState memento", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: path.join(tempDir, "storage"),
|
||||
})
|
||||
|
||||
expect(context.globalState).toBeDefined()
|
||||
expect(typeof context.globalState.get).toBe("function")
|
||||
expect(typeof context.globalState.update).toBe("function")
|
||||
expect(typeof context.globalState.keys).toBe("function")
|
||||
})
|
||||
|
||||
it("should have setKeysForSync method", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: path.join(tempDir, "storage"),
|
||||
})
|
||||
|
||||
expect(typeof context.globalState.setKeysForSync).toBe("function")
|
||||
// Should not throw
|
||||
expect(() => context.globalState.setKeysForSync(["key1", "key2"])).not.toThrow()
|
||||
})
|
||||
|
||||
it("should persist global state", async () => {
|
||||
const storageDir = path.join(tempDir, "storage")
|
||||
|
||||
const context1 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir,
|
||||
})
|
||||
|
||||
await context1.globalState.update("globalKey", "globalValue")
|
||||
|
||||
// Create new context with same storage
|
||||
const context2 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir,
|
||||
})
|
||||
|
||||
expect(context2.globalState.get("globalKey")).toBe("globalValue")
|
||||
})
|
||||
})
|
||||
|
||||
describe("secrets", () => {
|
||||
it("should provide secrets storage", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: path.join(tempDir, "storage"),
|
||||
})
|
||||
|
||||
expect(context.secrets).toBeDefined()
|
||||
expect(typeof context.secrets.get).toBe("function")
|
||||
expect(typeof context.secrets.store).toBe("function")
|
||||
expect(typeof context.secrets.delete).toBe("function")
|
||||
})
|
||||
|
||||
it("should persist secrets", async () => {
|
||||
const storageDir = path.join(tempDir, "storage")
|
||||
|
||||
const context1 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir,
|
||||
})
|
||||
|
||||
await context1.secrets.store("apiKey", "secret123")
|
||||
|
||||
// Create new context with same storage
|
||||
const context2 = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir,
|
||||
})
|
||||
|
||||
const secret = await context2.secrets.get("apiKey")
|
||||
expect(secret).toBe("secret123")
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose()", () => {
|
||||
it("should dispose all subscriptions", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
const disposable1 = { dispose: vi.fn() }
|
||||
const disposable2 = { dispose: vi.fn() }
|
||||
|
||||
context.subscriptions.push(disposable1)
|
||||
context.subscriptions.push(disposable2)
|
||||
|
||||
context.dispose()
|
||||
|
||||
expect(disposable1.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(disposable2.dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should clear subscriptions array after dispose", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
context.subscriptions.push({ dispose: () => {} })
|
||||
context.subscriptions.push({ dispose: () => {} })
|
||||
|
||||
context.dispose()
|
||||
|
||||
expect(context.subscriptions).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle disposal errors gracefully", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
// Add a disposable that throws
|
||||
context.subscriptions.push({
|
||||
dispose: () => {
|
||||
throw new Error("Disposal error")
|
||||
},
|
||||
})
|
||||
|
||||
// Add a normal disposable
|
||||
const normalDisposable = { dispose: vi.fn() }
|
||||
context.subscriptions.push(normalDisposable)
|
||||
|
||||
// Should not throw
|
||||
expect(() => context.dispose()).not.toThrow()
|
||||
|
||||
// Normal disposable should still be called
|
||||
expect(normalDisposable.dispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("default storage directory", () => {
|
||||
it("should use home directory based default when no storageDir provided", () => {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
})
|
||||
|
||||
// Should contain .vscode-mock in the path
|
||||
expect(context.globalStoragePath).toContain(".vscode-mock")
|
||||
})
|
||||
})
|
||||
})
|
||||
129
packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts
Normal file
129
packages/vscode-shim/src/__tests__/FileSystemAPI.test.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { FileSystemAPI } from "../api/FileSystemAPI.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
|
||||
describe("FileSystemAPI", () => {
|
||||
let tempDir: string
|
||||
let fsAPI: FileSystemAPI
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "fs-api-test-"))
|
||||
fsAPI = new FileSystemAPI()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("stat()", () => {
|
||||
it("should stat a file", async () => {
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "test content")
|
||||
|
||||
const uri = Uri.file(filePath)
|
||||
const stat = await fsAPI.stat(uri)
|
||||
|
||||
expect(stat.type).toBe(1) // File
|
||||
expect(stat.size).toBeGreaterThan(0)
|
||||
expect(stat.mtime).toBeGreaterThan(0)
|
||||
expect(stat.ctime).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should stat a directory", async () => {
|
||||
const uri = Uri.file(tempDir)
|
||||
const stat = await fsAPI.stat(uri)
|
||||
|
||||
expect(stat.type).toBe(2) // Directory
|
||||
})
|
||||
|
||||
it("should return default stat for non-existent file", async () => {
|
||||
const uri = Uri.file(path.join(tempDir, "nonexistent.txt"))
|
||||
const stat = await fsAPI.stat(uri)
|
||||
|
||||
expect(stat.type).toBe(1) // File (default)
|
||||
expect(stat.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readFile()", () => {
|
||||
it("should read file content", async () => {
|
||||
const filePath = path.join(tempDir, "test.txt")
|
||||
fs.writeFileSync(filePath, "Hello, world!")
|
||||
|
||||
const uri = Uri.file(filePath)
|
||||
const content = await fsAPI.readFile(uri)
|
||||
|
||||
expect(Buffer.from(content).toString()).toBe("Hello, world!")
|
||||
})
|
||||
|
||||
it("should throw FileSystemError for non-existent file", async () => {
|
||||
const uri = Uri.file(path.join(tempDir, "nonexistent.txt"))
|
||||
|
||||
await expect(fsAPI.readFile(uri)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeFile()", () => {
|
||||
it("should write file content", async () => {
|
||||
const filePath = path.join(tempDir, "output.txt")
|
||||
const uri = Uri.file(filePath)
|
||||
|
||||
await fsAPI.writeFile(uri, new TextEncoder().encode("Written content"))
|
||||
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("Written content")
|
||||
})
|
||||
|
||||
it("should create parent directories if they don't exist", async () => {
|
||||
const filePath = path.join(tempDir, "subdir", "nested", "file.txt")
|
||||
const uri = Uri.file(filePath)
|
||||
|
||||
await fsAPI.writeFile(uri, new TextEncoder().encode("Nested content"))
|
||||
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("Nested content")
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete()", () => {
|
||||
it("should delete a file", async () => {
|
||||
const filePath = path.join(tempDir, "to-delete.txt")
|
||||
fs.writeFileSync(filePath, "delete me")
|
||||
|
||||
const uri = Uri.file(filePath)
|
||||
await fsAPI.delete(uri)
|
||||
|
||||
expect(fs.existsSync(filePath)).toBe(false)
|
||||
})
|
||||
|
||||
it("should throw error for non-existent file", async () => {
|
||||
const uri = Uri.file(path.join(tempDir, "nonexistent.txt"))
|
||||
|
||||
await expect(fsAPI.delete(uri)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("createDirectory()", () => {
|
||||
it("should create a directory", async () => {
|
||||
const dirPath = path.join(tempDir, "new-dir")
|
||||
const uri = Uri.file(dirPath)
|
||||
|
||||
await fsAPI.createDirectory(uri)
|
||||
|
||||
expect(fs.existsSync(dirPath)).toBe(true)
|
||||
expect(fs.statSync(dirPath).isDirectory()).toBe(true)
|
||||
})
|
||||
|
||||
it("should create nested directories", async () => {
|
||||
const dirPath = path.join(tempDir, "a", "b", "c")
|
||||
const uri = Uri.file(dirPath)
|
||||
|
||||
await fsAPI.createDirectory(uri)
|
||||
|
||||
expect(fs.existsSync(dirPath)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
117
packages/vscode-shim/src/__tests__/OutputChannel.test.ts
Normal file
117
packages/vscode-shim/src/__tests__/OutputChannel.test.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { OutputChannel } from "../classes/OutputChannel.js"
|
||||
import { setLogger } from "../utils/logger.js"
|
||||
|
||||
describe("OutputChannel", () => {
|
||||
let mockLogger: {
|
||||
debug: ReturnType<typeof vi.fn>
|
||||
info: ReturnType<typeof vi.fn>
|
||||
warn: ReturnType<typeof vi.fn>
|
||||
error: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
setLogger(mockLogger)
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should create an output channel with the given name", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
expect(channel.name).toBe("TestChannel")
|
||||
})
|
||||
})
|
||||
|
||||
describe("name property", () => {
|
||||
it("should return the channel name", () => {
|
||||
const channel = new OutputChannel("MyChannel")
|
||||
|
||||
expect(channel.name).toBe("MyChannel")
|
||||
})
|
||||
})
|
||||
|
||||
describe("append()", () => {
|
||||
it("should log the value with channel name prefix", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
channel.append("test message")
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"[TestChannel] test message",
|
||||
"VSCode.OutputChannel",
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle empty strings", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
channel.append("")
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("[TestChannel] ", "VSCode.OutputChannel", undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe("appendLine()", () => {
|
||||
it("should log the value with channel name prefix", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
channel.appendLine("line message")
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"[TestChannel] line message",
|
||||
"VSCode.OutputChannel",
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle multi-line strings", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
channel.appendLine("line1\nline2")
|
||||
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
"[TestChannel] line1\nline2",
|
||||
"VSCode.OutputChannel",
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear()", () => {
|
||||
it("should not throw when called", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
expect(() => channel.clear()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("show()", () => {
|
||||
it("should not throw when called without arguments", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
expect(() => channel.show()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("hide()", () => {
|
||||
it("should not throw when called", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
expect(() => channel.hide()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose()", () => {
|
||||
it("should not throw when called", () => {
|
||||
const channel = new OutputChannel("TestChannel")
|
||||
|
||||
expect(() => channel.dispose()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
139
packages/vscode-shim/src/__tests__/Position.test.ts
Normal file
139
packages/vscode-shim/src/__tests__/Position.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { Position } from "../classes/Position.js"
|
||||
|
||||
describe("Position", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create a position with line and character", () => {
|
||||
const pos = new Position(5, 10)
|
||||
expect(pos.line).toBe(5)
|
||||
expect(pos.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should reject negative line numbers", () => {
|
||||
expect(() => new Position(-1, 0)).toThrow("Line number must be non-negative")
|
||||
})
|
||||
|
||||
it("should reject negative character offsets", () => {
|
||||
expect(() => new Position(0, -1)).toThrow("Character offset must be non-negative")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isEqual()", () => {
|
||||
it("should return true for equal positions", () => {
|
||||
const pos1 = new Position(5, 10)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.isEqual(pos2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for different positions", () => {
|
||||
const pos1 = new Position(5, 10)
|
||||
const pos2 = new Position(5, 11)
|
||||
expect(pos1.isEqual(pos2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isBefore()", () => {
|
||||
it("should return true when line is before", () => {
|
||||
const pos1 = new Position(3, 10)
|
||||
const pos2 = new Position(5, 5)
|
||||
expect(pos1.isBefore(pos2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return true when same line but character before", () => {
|
||||
const pos1 = new Position(5, 8)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.isBefore(pos2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when equal", () => {
|
||||
const pos1 = new Position(5, 10)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.isBefore(pos2)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false when after", () => {
|
||||
const pos1 = new Position(6, 0)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.isBefore(pos2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAfter()", () => {
|
||||
it("should return true when line is after", () => {
|
||||
const pos1 = new Position(5, 10)
|
||||
const pos2 = new Position(3, 10)
|
||||
expect(pos1.isAfter(pos2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when equal", () => {
|
||||
const pos1 = new Position(5, 10)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.isAfter(pos2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("compareTo()", () => {
|
||||
it("should return -1 when before", () => {
|
||||
const pos1 = new Position(3, 10)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.compareTo(pos2)).toBe(-1)
|
||||
})
|
||||
|
||||
it("should return 0 when equal", () => {
|
||||
const pos1 = new Position(5, 10)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.compareTo(pos2)).toBe(0)
|
||||
})
|
||||
|
||||
it("should return 1 when after", () => {
|
||||
const pos1 = new Position(7, 10)
|
||||
const pos2 = new Position(5, 10)
|
||||
expect(pos1.compareTo(pos2)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("translate()", () => {
|
||||
it("should translate by delta values", () => {
|
||||
const pos = new Position(5, 10)
|
||||
const translated = pos.translate(2, 3)
|
||||
expect(translated.line).toBe(7)
|
||||
expect(translated.character).toBe(13)
|
||||
})
|
||||
|
||||
it("should translate by change object", () => {
|
||||
const pos = new Position(5, 10)
|
||||
const translated = pos.translate({ lineDelta: 1, characterDelta: -2 })
|
||||
expect(translated.line).toBe(6)
|
||||
expect(translated.character).toBe(8)
|
||||
})
|
||||
|
||||
it("should handle omitted deltas as zero", () => {
|
||||
const pos = new Position(5, 10)
|
||||
const translated = pos.translate()
|
||||
expect(translated.line).toBe(5)
|
||||
expect(translated.character).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with()", () => {
|
||||
it("should create new position with changed line", () => {
|
||||
const pos = new Position(5, 10)
|
||||
const modified = pos.with(8)
|
||||
expect(modified.line).toBe(8)
|
||||
expect(modified.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should create new position with change object", () => {
|
||||
const pos = new Position(5, 10)
|
||||
const modified = pos.with({ line: 8, character: 15 })
|
||||
expect(modified.line).toBe(8)
|
||||
expect(modified.character).toBe(15)
|
||||
})
|
||||
|
||||
it("should preserve unchanged properties", () => {
|
||||
const pos = new Position(5, 10)
|
||||
const modified = pos.with({ line: 8 })
|
||||
expect(modified.line).toBe(8)
|
||||
expect(modified.character).toBe(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
153
packages/vscode-shim/src/__tests__/Range.test.ts
Normal file
153
packages/vscode-shim/src/__tests__/Range.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { Range } from "../classes/Range.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
|
||||
describe("Range", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create range from Position objects", () => {
|
||||
const start = new Position(0, 0)
|
||||
const end = new Position(5, 10)
|
||||
const range = new Range(start, end)
|
||||
|
||||
expect(range.start.line).toBe(0)
|
||||
expect(range.start.character).toBe(0)
|
||||
expect(range.end.line).toBe(5)
|
||||
expect(range.end.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should create range from numbers", () => {
|
||||
const range = new Range(0, 0, 5, 10)
|
||||
|
||||
expect(range.start.line).toBe(0)
|
||||
expect(range.start.character).toBe(0)
|
||||
expect(range.end.line).toBe(5)
|
||||
expect(range.end.character).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isEmpty", () => {
|
||||
it("should return true for empty range", () => {
|
||||
const range = new Range(5, 10, 5, 10)
|
||||
expect(range.isEmpty).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for non-empty range", () => {
|
||||
const range = new Range(5, 10, 5, 15)
|
||||
expect(range.isEmpty).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSingleLine", () => {
|
||||
it("should return true for single line range", () => {
|
||||
const range = new Range(5, 0, 5, 10)
|
||||
expect(range.isSingleLine).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for multi-line range", () => {
|
||||
const range = new Range(5, 0, 6, 10)
|
||||
expect(range.isSingleLine).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("contains()", () => {
|
||||
it("should return true when range contains position", () => {
|
||||
const range = new Range(0, 0, 10, 10)
|
||||
const pos = new Position(5, 5)
|
||||
expect(range.contains(pos)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when position is outside range", () => {
|
||||
const range = new Range(0, 0, 10, 10)
|
||||
const pos = new Position(15, 5)
|
||||
expect(range.contains(pos)).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when range contains another range", () => {
|
||||
const outer = new Range(0, 0, 10, 10)
|
||||
const inner = new Range(2, 2, 8, 8)
|
||||
expect(outer.contains(inner)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when range does not contain another range", () => {
|
||||
const range1 = new Range(0, 0, 5, 10)
|
||||
const range2 = new Range(6, 0, 10, 10)
|
||||
expect(range1.contains(range2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isEqual()", () => {
|
||||
it("should return true for equal ranges", () => {
|
||||
const range1 = new Range(0, 0, 5, 10)
|
||||
const range2 = new Range(0, 0, 5, 10)
|
||||
expect(range1.isEqual(range2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for different ranges", () => {
|
||||
const range1 = new Range(0, 0, 5, 10)
|
||||
const range2 = new Range(0, 0, 5, 11)
|
||||
expect(range1.isEqual(range2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("intersection()", () => {
|
||||
it("should return intersection of overlapping ranges", () => {
|
||||
const range1 = new Range(0, 0, 10, 10)
|
||||
const range2 = new Range(5, 5, 15, 15)
|
||||
const intersection = range1.intersection(range2)
|
||||
|
||||
expect(intersection).toBeDefined()
|
||||
expect(intersection!.start.line).toBe(5)
|
||||
expect(intersection!.start.character).toBe(5)
|
||||
expect(intersection!.end.line).toBe(10)
|
||||
expect(intersection!.end.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should return undefined for non-overlapping ranges", () => {
|
||||
const range1 = new Range(0, 0, 5, 10)
|
||||
const range2 = new Range(10, 0, 15, 10)
|
||||
const intersection = range1.intersection(range2)
|
||||
|
||||
expect(intersection).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("union()", () => {
|
||||
it("should return union of two ranges", () => {
|
||||
const range1 = new Range(0, 0, 5, 10)
|
||||
const range2 = new Range(3, 5, 8, 15)
|
||||
const union = range1.union(range2)
|
||||
|
||||
expect(union.start.line).toBe(0)
|
||||
expect(union.start.character).toBe(0)
|
||||
expect(union.end.line).toBe(8)
|
||||
expect(union.end.character).toBe(15)
|
||||
})
|
||||
|
||||
it("should handle non-overlapping ranges", () => {
|
||||
const range1 = new Range(0, 0, 2, 10)
|
||||
const range2 = new Range(5, 0, 8, 10)
|
||||
const union = range1.union(range2)
|
||||
|
||||
expect(union.start.line).toBe(0)
|
||||
expect(union.end.line).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with()", () => {
|
||||
it("should create new range with modified start", () => {
|
||||
const range = new Range(0, 0, 5, 10)
|
||||
const modified = range.with(new Position(1, 0))
|
||||
|
||||
expect(modified.start.line).toBe(1)
|
||||
expect(modified.end.line).toBe(5)
|
||||
})
|
||||
|
||||
it("should create new range with change object", () => {
|
||||
const range = new Range(0, 0, 5, 10)
|
||||
const modified = range.with({ end: new Position(8, 15) })
|
||||
|
||||
expect(modified.start.line).toBe(0)
|
||||
expect(modified.end.line).toBe(8)
|
||||
expect(modified.end.character).toBe(15)
|
||||
})
|
||||
})
|
||||
})
|
||||
123
packages/vscode-shim/src/__tests__/Selection.test.ts
Normal file
123
packages/vscode-shim/src/__tests__/Selection.test.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { Selection } from "../classes/Selection.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
|
||||
describe("Selection", () => {
|
||||
describe("constructor with Position objects", () => {
|
||||
it("should create selection from Position objects", () => {
|
||||
const anchor = new Position(0, 0)
|
||||
const active = new Position(5, 10)
|
||||
const selection = new Selection(anchor, active)
|
||||
|
||||
expect(selection.anchor.line).toBe(0)
|
||||
expect(selection.anchor.character).toBe(0)
|
||||
expect(selection.active.line).toBe(5)
|
||||
expect(selection.active.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should set start and end correctly for non-reversed selection", () => {
|
||||
const anchor = new Position(0, 0)
|
||||
const active = new Position(5, 10)
|
||||
const selection = new Selection(anchor, active)
|
||||
|
||||
expect(selection.start.line).toBe(0)
|
||||
expect(selection.start.character).toBe(0)
|
||||
expect(selection.end.line).toBe(5)
|
||||
expect(selection.end.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should set start and end correctly for reversed selection", () => {
|
||||
const anchor = new Position(5, 10)
|
||||
const active = new Position(0, 0)
|
||||
const selection = new Selection(anchor, active)
|
||||
|
||||
// Start/end are inherited from Range, which normalizes
|
||||
expect(selection.anchor.line).toBe(5)
|
||||
expect(selection.anchor.character).toBe(10)
|
||||
expect(selection.active.line).toBe(0)
|
||||
expect(selection.active.character).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("constructor with line/character numbers", () => {
|
||||
it("should create selection from line and character numbers", () => {
|
||||
const selection = new Selection(0, 0, 5, 10)
|
||||
|
||||
expect(selection.anchor.line).toBe(0)
|
||||
expect(selection.anchor.character).toBe(0)
|
||||
expect(selection.active.line).toBe(5)
|
||||
expect(selection.active.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should handle reversed selection with numbers", () => {
|
||||
const selection = new Selection(5, 10, 0, 0)
|
||||
|
||||
expect(selection.anchor.line).toBe(5)
|
||||
expect(selection.anchor.character).toBe(10)
|
||||
expect(selection.active.line).toBe(0)
|
||||
expect(selection.active.character).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isReversed", () => {
|
||||
it("should return false when active is after anchor", () => {
|
||||
const selection = new Selection(0, 0, 5, 10)
|
||||
expect(selection.isReversed).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when active is before anchor", () => {
|
||||
const selection = new Selection(5, 10, 0, 0)
|
||||
expect(selection.isReversed).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when anchor equals active", () => {
|
||||
const selection = new Selection(5, 10, 5, 10)
|
||||
expect(selection.isReversed).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when same line but active character is before anchor", () => {
|
||||
const selection = new Selection(5, 10, 5, 5)
|
||||
expect(selection.isReversed).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when same line and active character is after anchor", () => {
|
||||
const selection = new Selection(5, 5, 5, 10)
|
||||
expect(selection.isReversed).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("inherited Range properties", () => {
|
||||
it("should have isEmpty property", () => {
|
||||
const emptySelection = new Selection(5, 10, 5, 10)
|
||||
expect(emptySelection.isEmpty).toBe(true)
|
||||
|
||||
const nonEmptySelection = new Selection(0, 0, 5, 10)
|
||||
expect(nonEmptySelection.isEmpty).toBe(false)
|
||||
})
|
||||
|
||||
it("should have isSingleLine property", () => {
|
||||
const singleLineSelection = new Selection(5, 0, 5, 10)
|
||||
expect(singleLineSelection.isSingleLine).toBe(true)
|
||||
|
||||
const multiLineSelection = new Selection(0, 0, 5, 10)
|
||||
expect(multiLineSelection.isSingleLine).toBe(false)
|
||||
})
|
||||
|
||||
it("should support contains method", () => {
|
||||
const selection = new Selection(0, 0, 10, 10)
|
||||
const pos = new Position(5, 5)
|
||||
expect(selection.contains(pos)).toBe(true)
|
||||
|
||||
const outsidePos = new Position(15, 5)
|
||||
expect(selection.contains(outsidePos)).toBe(false)
|
||||
})
|
||||
|
||||
it("should support isEqual method", () => {
|
||||
const selection1 = new Selection(0, 0, 5, 10)
|
||||
const selection2 = new Selection(0, 0, 5, 10)
|
||||
const selection3 = new Selection(0, 0, 5, 11)
|
||||
|
||||
expect(selection1.isEqual(selection2)).toBe(true)
|
||||
expect(selection1.isEqual(selection3)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
214
packages/vscode-shim/src/__tests__/StatusBarItem.test.ts
Normal file
214
packages/vscode-shim/src/__tests__/StatusBarItem.test.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { StatusBarItem } from "../classes/StatusBarItem.js"
|
||||
import { StatusBarAlignment } from "../types.js"
|
||||
|
||||
describe("StatusBarItem", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create with alignment", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.alignment).toBe(StatusBarAlignment.Left)
|
||||
})
|
||||
|
||||
it("should create with alignment and priority", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Right, 100)
|
||||
|
||||
expect(item.alignment).toBe(StatusBarAlignment.Right)
|
||||
expect(item.priority).toBe(100)
|
||||
})
|
||||
|
||||
it("should have undefined priority when not provided", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.priority).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("text property", () => {
|
||||
it("should have empty text initially", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.text).toBe("")
|
||||
})
|
||||
|
||||
it("should allow setting text", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.text = "Hello"
|
||||
|
||||
expect(item.text).toBe("Hello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tooltip property", () => {
|
||||
it("should be undefined initially", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.tooltip).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should allow setting tooltip", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.tooltip = "My tooltip"
|
||||
|
||||
expect(item.tooltip).toBe("My tooltip")
|
||||
})
|
||||
|
||||
it("should allow setting to undefined", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
item.tooltip = "tooltip"
|
||||
|
||||
item.tooltip = undefined
|
||||
|
||||
expect(item.tooltip).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("command property", () => {
|
||||
it("should be undefined initially", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.command).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should allow setting command", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.command = "myExtension.doSomething"
|
||||
|
||||
expect(item.command).toBe("myExtension.doSomething")
|
||||
})
|
||||
})
|
||||
|
||||
describe("color property", () => {
|
||||
it("should be undefined initially", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.color).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should allow setting color", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.color = "#ff0000"
|
||||
|
||||
expect(item.color).toBe("#ff0000")
|
||||
})
|
||||
})
|
||||
|
||||
describe("backgroundColor property", () => {
|
||||
it("should be undefined initially", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.backgroundColor).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should allow setting backgroundColor", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.backgroundColor = "#00ff00"
|
||||
|
||||
expect(item.backgroundColor).toBe("#00ff00")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isVisible property", () => {
|
||||
it("should be false initially", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(item.isVisible).toBe(false)
|
||||
})
|
||||
|
||||
it("should be true after show()", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.show()
|
||||
|
||||
expect(item.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it("should be false after hide()", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
item.show()
|
||||
|
||||
item.hide()
|
||||
|
||||
expect(item.isVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("show()", () => {
|
||||
it("should make item visible", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.show()
|
||||
|
||||
expect(item.isVisible).toBe(true)
|
||||
})
|
||||
|
||||
it("should be idempotent", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
item.show()
|
||||
item.show()
|
||||
|
||||
expect(item.isVisible).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hide()", () => {
|
||||
it("should make item invisible", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
item.show()
|
||||
|
||||
item.hide()
|
||||
|
||||
expect(item.isVisible).toBe(false)
|
||||
})
|
||||
|
||||
it("should be safe to call when already hidden", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(() => item.hide()).not.toThrow()
|
||||
expect(item.isVisible).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose()", () => {
|
||||
it("should make item invisible", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
item.show()
|
||||
|
||||
item.dispose()
|
||||
|
||||
expect(item.isVisible).toBe(false)
|
||||
})
|
||||
|
||||
it("should be safe to call multiple times", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
expect(() => {
|
||||
item.dispose()
|
||||
item.dispose()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("alignment property", () => {
|
||||
it("should be readonly", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left)
|
||||
|
||||
// TypeScript prevents reassignment at compile time
|
||||
// Just verify the value is what we expect
|
||||
expect(item.alignment).toBe(StatusBarAlignment.Left)
|
||||
})
|
||||
})
|
||||
|
||||
describe("priority property", () => {
|
||||
it("should be readonly", () => {
|
||||
const item = new StatusBarItem(StatusBarAlignment.Left, 50)
|
||||
|
||||
expect(item.priority).toBe(50)
|
||||
})
|
||||
})
|
||||
})
|
||||
163
packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts
Normal file
163
packages/vscode-shim/src/__tests__/TabGroupsAPI.test.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { TabGroupsAPI, type Tab, type TabGroup } from "../api/TabGroupsAPI.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
|
||||
describe("TabGroupsAPI", () => {
|
||||
let tabGroups: TabGroupsAPI
|
||||
|
||||
beforeEach(() => {
|
||||
tabGroups = new TabGroupsAPI()
|
||||
})
|
||||
|
||||
describe("all property", () => {
|
||||
it("should return empty array initially", () => {
|
||||
expect(tabGroups.all).toEqual([])
|
||||
})
|
||||
|
||||
it("should return array of TabGroup", () => {
|
||||
expect(Array.isArray(tabGroups.all)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("onDidChangeTabs()", () => {
|
||||
it("should return a disposable", () => {
|
||||
const disposable = tabGroups.onDidChangeTabs(() => {})
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("should call listener when _simulateTabChange is called", () => {
|
||||
const listener = vi.fn()
|
||||
tabGroups.onDidChangeTabs(listener)
|
||||
|
||||
tabGroups._simulateTabChange()
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should not call listener after dispose", () => {
|
||||
const listener = vi.fn()
|
||||
const disposable = tabGroups.onDidChangeTabs(listener)
|
||||
|
||||
disposable.dispose()
|
||||
tabGroups._simulateTabChange()
|
||||
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should support multiple listeners", () => {
|
||||
const listener1 = vi.fn()
|
||||
const listener2 = vi.fn()
|
||||
|
||||
tabGroups.onDidChangeTabs(listener1)
|
||||
tabGroups.onDidChangeTabs(listener2)
|
||||
tabGroups._simulateTabChange()
|
||||
|
||||
expect(listener1).toHaveBeenCalledTimes(1)
|
||||
expect(listener2).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("close()", () => {
|
||||
it("should return false when tab is not found", async () => {
|
||||
const mockTab: Tab = {
|
||||
input: { uri: Uri.file("/test/file.txt") },
|
||||
label: "file.txt",
|
||||
isActive: true,
|
||||
isDirty: false,
|
||||
}
|
||||
|
||||
const result = await tabGroups.close(mockTab)
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return a promise", () => {
|
||||
const mockTab: Tab = {
|
||||
input: { uri: Uri.file("/test/file.txt") },
|
||||
label: "file.txt",
|
||||
isActive: true,
|
||||
isDirty: false,
|
||||
}
|
||||
|
||||
const result = tabGroups.close(mockTab)
|
||||
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
})
|
||||
})
|
||||
|
||||
describe("_simulateTabChange()", () => {
|
||||
it("should fire the onDidChangeTabs event", () => {
|
||||
const listener = vi.fn()
|
||||
tabGroups.onDidChangeTabs(listener)
|
||||
|
||||
tabGroups._simulateTabChange()
|
||||
|
||||
expect(listener).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose()", () => {
|
||||
it("should not throw when called", () => {
|
||||
expect(() => tabGroups.dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it("should stop firing events after dispose", () => {
|
||||
const listener = vi.fn()
|
||||
tabGroups.onDidChangeTabs(listener)
|
||||
|
||||
tabGroups.dispose()
|
||||
// After dispose, internal emitter is disposed so new events shouldn't fire
|
||||
// But existing listeners may still be registered
|
||||
})
|
||||
|
||||
it("should be safe to call multiple times", () => {
|
||||
expect(() => {
|
||||
tabGroups.dispose()
|
||||
tabGroups.dispose()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Tab interface", () => {
|
||||
it("should have required properties", () => {
|
||||
const tab: Tab = {
|
||||
input: { uri: Uri.file("/test/file.txt") },
|
||||
label: "file.txt",
|
||||
isActive: true,
|
||||
isDirty: false,
|
||||
}
|
||||
|
||||
expect(tab.input).toBeDefined()
|
||||
expect(tab.label).toBe("file.txt")
|
||||
expect(tab.isActive).toBe(true)
|
||||
expect(tab.isDirty).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("TabGroup interface", () => {
|
||||
it("should have tabs array", () => {
|
||||
const tabGroup: TabGroup = {
|
||||
tabs: [],
|
||||
}
|
||||
|
||||
expect(Array.isArray(tabGroup.tabs)).toBe(true)
|
||||
})
|
||||
|
||||
it("should contain Tab objects", () => {
|
||||
const tab: Tab = {
|
||||
input: { uri: Uri.file("/test/file.txt") },
|
||||
label: "file.txt",
|
||||
isActive: true,
|
||||
isDirty: false,
|
||||
}
|
||||
|
||||
const tabGroup: TabGroup = {
|
||||
tabs: [tab],
|
||||
}
|
||||
|
||||
expect(tabGroup.tabs).toHaveLength(1)
|
||||
expect(tabGroup.tabs[0]).toBe(tab)
|
||||
})
|
||||
})
|
||||
263
packages/vscode-shim/src/__tests__/TextEdit.test.ts
Normal file
263
packages/vscode-shim/src/__tests__/TextEdit.test.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
import { Range } from "../classes/Range.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
|
||||
describe("TextEdit", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create a TextEdit with range and newText", () => {
|
||||
const range = new Range(0, 0, 0, 5)
|
||||
const edit = new TextEdit(range, "hello")
|
||||
|
||||
expect(edit.range.start.line).toBe(0)
|
||||
expect(edit.range.start.character).toBe(0)
|
||||
expect(edit.range.end.line).toBe(0)
|
||||
expect(edit.range.end.character).toBe(5)
|
||||
expect(edit.newText).toBe("hello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("replace()", () => {
|
||||
it("should create a replace edit", () => {
|
||||
const range = new Range(1, 0, 1, 10)
|
||||
const edit = TextEdit.replace(range, "replacement")
|
||||
|
||||
expect(edit.range.isEqual(range)).toBe(true)
|
||||
expect(edit.newText).toBe("replacement")
|
||||
})
|
||||
|
||||
it("should handle multi-line ranges", () => {
|
||||
const range = new Range(0, 0, 5, 10)
|
||||
const edit = TextEdit.replace(range, "new content")
|
||||
|
||||
expect(edit.range.start.line).toBe(0)
|
||||
expect(edit.range.end.line).toBe(5)
|
||||
expect(edit.newText).toBe("new content")
|
||||
})
|
||||
})
|
||||
|
||||
describe("insert()", () => {
|
||||
it("should create an insert edit at position", () => {
|
||||
const position = new Position(5, 10)
|
||||
const edit = TextEdit.insert(position, "inserted text")
|
||||
|
||||
expect(edit.range.start.line).toBe(5)
|
||||
expect(edit.range.start.character).toBe(10)
|
||||
expect(edit.range.end.line).toBe(5)
|
||||
expect(edit.range.end.character).toBe(10)
|
||||
expect(edit.range.isEmpty).toBe(true)
|
||||
expect(edit.newText).toBe("inserted text")
|
||||
})
|
||||
|
||||
it("should handle insert at beginning of file", () => {
|
||||
const position = new Position(0, 0)
|
||||
const edit = TextEdit.insert(position, "prefix")
|
||||
|
||||
expect(edit.range.start.isEqual(position)).toBe(true)
|
||||
expect(edit.newText).toBe("prefix")
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete()", () => {
|
||||
it("should create a delete edit", () => {
|
||||
const range = new Range(0, 5, 0, 10)
|
||||
const edit = TextEdit.delete(range)
|
||||
|
||||
expect(edit.range.isEqual(range)).toBe(true)
|
||||
expect(edit.newText).toBe("")
|
||||
})
|
||||
|
||||
it("should handle multi-line deletion", () => {
|
||||
const range = new Range(0, 0, 5, 0)
|
||||
const edit = TextEdit.delete(range)
|
||||
|
||||
expect(edit.range.start.line).toBe(0)
|
||||
expect(edit.range.end.line).toBe(5)
|
||||
expect(edit.newText).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("setEndOfLine()", () => {
|
||||
it("should create a setEndOfLine edit", () => {
|
||||
const edit = TextEdit.setEndOfLine()
|
||||
|
||||
expect(edit.range.start.line).toBe(0)
|
||||
expect(edit.range.start.character).toBe(0)
|
||||
expect(edit.newText).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("WorkspaceEdit", () => {
|
||||
describe("set() and get()", () => {
|
||||
it("should set and get edits for a URI", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const edits = [
|
||||
TextEdit.replace(new Range(0, 0, 0, 5), "hello"),
|
||||
TextEdit.insert(new Position(1, 0), "world"),
|
||||
]
|
||||
|
||||
workspaceEdit.set(uri, edits)
|
||||
const retrieved = workspaceEdit.get(uri)
|
||||
|
||||
expect(retrieved).toHaveLength(2)
|
||||
expect(retrieved[0]?.newText).toBe("hello")
|
||||
expect(retrieved[1]?.newText).toBe("world")
|
||||
})
|
||||
|
||||
it("should return empty array for unknown URI", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/nonexistent.txt")
|
||||
|
||||
expect(workspaceEdit.get(uri)).toEqual([])
|
||||
})
|
||||
|
||||
it("should overwrite edits when setting same URI", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
|
||||
workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "first")])
|
||||
workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "second")])
|
||||
|
||||
const edits = workspaceEdit.get(uri)
|
||||
expect(edits).toHaveLength(1)
|
||||
expect(edits[0]?.newText).toBe("second")
|
||||
})
|
||||
})
|
||||
|
||||
describe("has()", () => {
|
||||
it("should return true when URI has edits", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
|
||||
workspaceEdit.set(uri, [TextEdit.insert(new Position(0, 0), "text")])
|
||||
|
||||
expect(workspaceEdit.has(uri)).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when URI has no edits", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
|
||||
expect(workspaceEdit.has(uri)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete()", () => {
|
||||
it("should add a delete edit for URI", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const range = new Range(0, 5, 0, 10)
|
||||
|
||||
workspaceEdit.delete(uri, range)
|
||||
|
||||
const edits = workspaceEdit.get(uri)
|
||||
expect(edits).toHaveLength(1)
|
||||
expect(edits[0]?.newText).toBe("")
|
||||
expect(edits[0]?.range.start.character).toBe(5)
|
||||
expect(edits[0]?.range.end.character).toBe(10)
|
||||
})
|
||||
|
||||
it("should append to existing edits", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
|
||||
workspaceEdit.insert(uri, new Position(0, 0), "text")
|
||||
workspaceEdit.delete(uri, new Range(1, 0, 1, 5))
|
||||
|
||||
const edits = workspaceEdit.get(uri)
|
||||
expect(edits).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("insert()", () => {
|
||||
it("should add an insert edit for URI", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const position = new Position(5, 10)
|
||||
|
||||
workspaceEdit.insert(uri, position, "inserted")
|
||||
|
||||
const edits = workspaceEdit.get(uri)
|
||||
expect(edits).toHaveLength(1)
|
||||
expect(edits[0]?.newText).toBe("inserted")
|
||||
expect(edits[0]?.range.start.line).toBe(5)
|
||||
expect(edits[0]?.range.start.character).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("replace()", () => {
|
||||
it("should add a replace edit for URI", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const range = new Range(0, 0, 0, 10)
|
||||
|
||||
workspaceEdit.replace(uri, range, "replacement")
|
||||
|
||||
const edits = workspaceEdit.get(uri)
|
||||
expect(edits).toHaveLength(1)
|
||||
expect(edits[0]?.newText).toBe("replacement")
|
||||
expect(edits[0]?.range.start.line).toBe(0)
|
||||
expect(edits[0]?.range.end.character).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe("size", () => {
|
||||
it("should return 0 for empty WorkspaceEdit", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
expect(workspaceEdit.size).toBe(0)
|
||||
})
|
||||
|
||||
it("should return number of documents with edits", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri1 = Uri.file("/path/to/file1.txt")
|
||||
const uri2 = Uri.file("/path/to/file2.txt")
|
||||
const uri3 = Uri.file("/path/to/file3.txt")
|
||||
|
||||
workspaceEdit.insert(uri1, new Position(0, 0), "text1")
|
||||
workspaceEdit.insert(uri2, new Position(0, 0), "text2")
|
||||
workspaceEdit.insert(uri3, new Position(0, 0), "text3")
|
||||
|
||||
expect(workspaceEdit.size).toBe(3)
|
||||
})
|
||||
|
||||
it("should count same URI only once", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
|
||||
workspaceEdit.insert(uri, new Position(0, 0), "text1")
|
||||
workspaceEdit.insert(uri, new Position(1, 0), "text2")
|
||||
workspaceEdit.insert(uri, new Position(2, 0), "text3")
|
||||
|
||||
expect(workspaceEdit.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("entries()", () => {
|
||||
it("should return empty array for empty WorkspaceEdit", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
expect(workspaceEdit.entries()).toEqual([])
|
||||
})
|
||||
|
||||
it("should return all URI/edits pairs", () => {
|
||||
const workspaceEdit = new WorkspaceEdit()
|
||||
const uri1 = Uri.file("/path/to/file1.txt")
|
||||
const uri2 = Uri.file("/path/to/file2.txt")
|
||||
|
||||
workspaceEdit.insert(uri1, new Position(0, 0), "text1")
|
||||
workspaceEdit.replace(uri2, new Range(0, 0, 0, 5), "text2")
|
||||
|
||||
const entries = workspaceEdit.entries()
|
||||
expect(entries).toHaveLength(2)
|
||||
|
||||
// Entries should have URI-like objects with toString and fsPath
|
||||
expect(typeof entries[0]?.[0]?.toString).toBe("function")
|
||||
expect(typeof entries[0]?.[0]?.fsPath).toBe("string")
|
||||
|
||||
// Should contain the edits
|
||||
expect(entries.some((e) => e[1][0]?.newText === "text1")).toBe(true)
|
||||
expect(entries.some((e) => e[1][0]?.newText === "text2")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js"
|
||||
|
||||
describe("TextEditorDecorationType", () => {
|
||||
describe("constructor", () => {
|
||||
it("should create with a key", () => {
|
||||
const decoration = new TextEditorDecorationType("my-decoration")
|
||||
|
||||
expect(decoration.key).toBe("my-decoration")
|
||||
})
|
||||
|
||||
it("should allow any string key", () => {
|
||||
const decoration = new TextEditorDecorationType("decoration-12345")
|
||||
|
||||
expect(decoration.key).toBe("decoration-12345")
|
||||
})
|
||||
})
|
||||
|
||||
describe("key property", () => {
|
||||
it("should be accessible", () => {
|
||||
const decoration = new TextEditorDecorationType("test-key")
|
||||
|
||||
expect(decoration.key).toBe("test-key")
|
||||
})
|
||||
|
||||
it("should be mutable", () => {
|
||||
const decoration = new TextEditorDecorationType("original")
|
||||
|
||||
decoration.key = "modified"
|
||||
|
||||
expect(decoration.key).toBe("modified")
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispose()", () => {
|
||||
it("should not throw when called", () => {
|
||||
const decoration = new TextEditorDecorationType("test")
|
||||
|
||||
expect(() => decoration.dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
it("should be safe to call multiple times", () => {
|
||||
const decoration = new TextEditorDecorationType("test")
|
||||
|
||||
expect(() => {
|
||||
decoration.dispose()
|
||||
decoration.dispose()
|
||||
decoration.dispose()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Disposable interface", () => {
|
||||
it("should implement Disposable interface", () => {
|
||||
const decoration = new TextEditorDecorationType("test")
|
||||
|
||||
expect(typeof decoration.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
102
packages/vscode-shim/src/__tests__/Uri.test.ts
Normal file
102
packages/vscode-shim/src/__tests__/Uri.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { Uri } from "../classes/Uri.js"
|
||||
|
||||
describe("Uri", () => {
|
||||
describe("file()", () => {
|
||||
it("should create a file URI", () => {
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
expect(uri.scheme).toBe("file")
|
||||
expect(uri.path).toBe("/path/to/file.txt")
|
||||
expect(uri.fsPath).toBe("/path/to/file.txt")
|
||||
})
|
||||
|
||||
it("should handle Windows paths", () => {
|
||||
const uri = Uri.file("C:\\Users\\test\\file.txt")
|
||||
expect(uri.scheme).toBe("file")
|
||||
expect(uri.fsPath).toBe("C:\\Users\\test\\file.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parse()", () => {
|
||||
it("should parse HTTP URLs", () => {
|
||||
const uri = Uri.parse("https://example.com/path?query=1#fragment")
|
||||
expect(uri.scheme).toBe("https")
|
||||
expect(uri.authority).toBe("example.com")
|
||||
expect(uri.path).toBe("/path")
|
||||
expect(uri.query).toBe("query=1")
|
||||
expect(uri.fragment).toBe("fragment")
|
||||
})
|
||||
|
||||
it("should parse file URLs", () => {
|
||||
const uri = Uri.parse("file:///path/to/file.txt")
|
||||
expect(uri.scheme).toBe("file")
|
||||
expect(uri.path).toBe("/path/to/file.txt")
|
||||
})
|
||||
|
||||
it("should handle invalid URLs by treating as file paths", () => {
|
||||
const uri = Uri.parse("/just/a/path")
|
||||
expect(uri.scheme).toBe("file")
|
||||
expect(uri.fsPath).toBe("/just/a/path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("joinPath()", () => {
|
||||
it("should join path segments", () => {
|
||||
const base = Uri.file("/base/path")
|
||||
const joined = Uri.joinPath(base, "sub", "file.txt")
|
||||
expect(joined.fsPath).toContain("sub")
|
||||
expect(joined.fsPath).toContain("file.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with()", () => {
|
||||
it("should create new URI with modified scheme", () => {
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const modified = uri.with({ scheme: "vscode" })
|
||||
expect(modified.scheme).toBe("vscode")
|
||||
expect(modified.path).toBe("/path/to/file.txt")
|
||||
})
|
||||
|
||||
it("should create new URI with modified path", () => {
|
||||
const uri = Uri.parse("https://example.com/old/path")
|
||||
const modified = uri.with({ path: "/new/path" })
|
||||
expect(modified.path).toBe("/new/path")
|
||||
expect(modified.scheme).toBe("https")
|
||||
})
|
||||
|
||||
it("should preserve unchanged properties", () => {
|
||||
const uri = Uri.parse("https://example.com/path?query=1#fragment")
|
||||
const modified = uri.with({ path: "/newpath" })
|
||||
expect(modified.scheme).toBe("https")
|
||||
expect(modified.query).toBe("query=1")
|
||||
expect(modified.fragment).toBe("fragment")
|
||||
})
|
||||
})
|
||||
|
||||
describe("toString()", () => {
|
||||
it("should convert to URI string", () => {
|
||||
const uri = Uri.parse("https://example.com/path?query=1#fragment")
|
||||
const str = uri.toString()
|
||||
expect(str).toBe("https://example.com/path?query=1#fragment")
|
||||
})
|
||||
|
||||
it("should handle file URIs", () => {
|
||||
const uri = Uri.file("/path/to/file.txt")
|
||||
const str = uri.toString()
|
||||
expect(str).toBe("file:///path/to/file.txt")
|
||||
})
|
||||
})
|
||||
|
||||
describe("toJSON()", () => {
|
||||
it("should convert to JSON object", () => {
|
||||
const uri = Uri.parse("https://example.com/path?query=1#fragment")
|
||||
const json = uri.toJSON()
|
||||
expect(json).toEqual({
|
||||
scheme: "https",
|
||||
authority: "example.com",
|
||||
path: "/path",
|
||||
query: "query=1",
|
||||
fragment: "fragment",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
305
packages/vscode-shim/src/__tests__/WindowAPI.test.ts
Normal file
305
packages/vscode-shim/src/__tests__/WindowAPI.test.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { WindowAPI } from "../api/WindowAPI.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { StatusBarAlignment } from "../types.js"
|
||||
|
||||
describe("WindowAPI", () => {
|
||||
let windowAPI: WindowAPI
|
||||
|
||||
beforeEach(() => {
|
||||
windowAPI = new WindowAPI()
|
||||
})
|
||||
|
||||
describe("tabGroups property", () => {
|
||||
it("should have tabGroups", () => {
|
||||
expect(windowAPI.tabGroups).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return TabGroupsAPI instance", () => {
|
||||
expect(typeof windowAPI.tabGroups.onDidChangeTabs).toBe("function")
|
||||
expect(Array.isArray(windowAPI.tabGroups.all)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("visibleTextEditors property", () => {
|
||||
it("should be an empty array initially", () => {
|
||||
expect(windowAPI.visibleTextEditors).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("createOutputChannel()", () => {
|
||||
it("should create an output channel with the given name", () => {
|
||||
const channel = windowAPI.createOutputChannel("TestChannel")
|
||||
|
||||
expect(channel.name).toBe("TestChannel")
|
||||
})
|
||||
|
||||
it("should return an OutputChannel instance", () => {
|
||||
const channel = windowAPI.createOutputChannel("Test")
|
||||
|
||||
expect(typeof channel.append).toBe("function")
|
||||
expect(typeof channel.appendLine).toBe("function")
|
||||
expect(typeof channel.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStatusBarItem()", () => {
|
||||
it("should create with default alignment", () => {
|
||||
const item = windowAPI.createStatusBarItem()
|
||||
|
||||
expect(item.alignment).toBe(StatusBarAlignment.Left)
|
||||
})
|
||||
|
||||
it("should create with specified alignment", () => {
|
||||
const item = windowAPI.createStatusBarItem(StatusBarAlignment.Right)
|
||||
|
||||
expect(item.alignment).toBe(StatusBarAlignment.Right)
|
||||
})
|
||||
|
||||
it("should create with alignment and priority", () => {
|
||||
const item = windowAPI.createStatusBarItem(StatusBarAlignment.Left, 100)
|
||||
|
||||
expect(item.alignment).toBe(StatusBarAlignment.Left)
|
||||
expect(item.priority).toBe(100)
|
||||
})
|
||||
|
||||
it("should handle overloaded signature with id", () => {
|
||||
const item = windowAPI.createStatusBarItem("myId", StatusBarAlignment.Right, 50)
|
||||
|
||||
expect(item.alignment).toBe(StatusBarAlignment.Right)
|
||||
expect(item.priority).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createTextEditorDecorationType()", () => {
|
||||
it("should create a decoration type", () => {
|
||||
const decoration = windowAPI.createTextEditorDecorationType({})
|
||||
|
||||
expect(decoration).toBeDefined()
|
||||
expect(decoration.key).toContain("decoration-")
|
||||
})
|
||||
|
||||
it("should return unique keys", () => {
|
||||
const decoration1 = windowAPI.createTextEditorDecorationType({})
|
||||
const decoration2 = windowAPI.createTextEditorDecorationType({})
|
||||
|
||||
expect(decoration1.key).not.toBe(decoration2.key)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createTerminal()", () => {
|
||||
it("should create a terminal with default name", () => {
|
||||
const terminal = windowAPI.createTerminal()
|
||||
|
||||
expect(terminal.name).toBe("Terminal")
|
||||
})
|
||||
|
||||
it("should create a terminal with specified name", () => {
|
||||
const terminal = windowAPI.createTerminal({ name: "MyTerminal" })
|
||||
|
||||
expect(terminal.name).toBe("MyTerminal")
|
||||
})
|
||||
|
||||
it("should return terminal with expected methods", () => {
|
||||
const terminal = windowAPI.createTerminal()
|
||||
|
||||
expect(typeof terminal.sendText).toBe("function")
|
||||
expect(typeof terminal.show).toBe("function")
|
||||
expect(typeof terminal.hide).toBe("function")
|
||||
expect(typeof terminal.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("should have processId promise", async () => {
|
||||
const terminal = windowAPI.createTerminal()
|
||||
|
||||
const processId = await terminal.processId
|
||||
|
||||
expect(processId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("showInformationMessage()", () => {
|
||||
it("should return a promise", () => {
|
||||
const result = windowAPI.showInformationMessage("Test message")
|
||||
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
})
|
||||
|
||||
it("should resolve to undefined", async () => {
|
||||
const result = await windowAPI.showInformationMessage("Test message")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("showWarningMessage()", () => {
|
||||
it("should return a promise", () => {
|
||||
const result = windowAPI.showWarningMessage("Warning message")
|
||||
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
})
|
||||
|
||||
it("should resolve to undefined", async () => {
|
||||
const result = await windowAPI.showWarningMessage("Warning message")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("showErrorMessage()", () => {
|
||||
it("should return a promise", () => {
|
||||
const result = windowAPI.showErrorMessage("Error message")
|
||||
|
||||
expect(result).toBeInstanceOf(Promise)
|
||||
})
|
||||
|
||||
it("should resolve to undefined", async () => {
|
||||
const result = await windowAPI.showErrorMessage("Error message")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("showQuickPick()", () => {
|
||||
it("should return first item", async () => {
|
||||
const result = await windowAPI.showQuickPick(["item1", "item2", "item3"])
|
||||
|
||||
expect(result).toBe("item1")
|
||||
})
|
||||
|
||||
it("should return undefined for empty array", async () => {
|
||||
const result = await windowAPI.showQuickPick([])
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("showInputBox()", () => {
|
||||
it("should return empty string", async () => {
|
||||
const result = await windowAPI.showInputBox()
|
||||
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("showOpenDialog()", () => {
|
||||
it("should return empty array", async () => {
|
||||
const result = await windowAPI.showOpenDialog()
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("showTextDocument()", () => {
|
||||
it("should return an editor", async () => {
|
||||
const uri = Uri.file("/test/file.txt")
|
||||
const editor = await windowAPI.showTextDocument(uri)
|
||||
|
||||
expect(editor).toBeDefined()
|
||||
expect(editor.document).toBeDefined()
|
||||
})
|
||||
|
||||
it("should add editor to visibleTextEditors", async () => {
|
||||
const uri = Uri.file("/test/file.txt")
|
||||
await windowAPI.showTextDocument(uri)
|
||||
|
||||
expect(windowAPI.visibleTextEditors.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("registerWebviewViewProvider()", () => {
|
||||
it("should return a disposable", () => {
|
||||
const mockProvider = {
|
||||
resolveWebviewView: vi.fn(),
|
||||
}
|
||||
|
||||
const disposable = windowAPI.registerWebviewViewProvider("myView", mockProvider)
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("registerUriHandler()", () => {
|
||||
it("should return a disposable", () => {
|
||||
const mockHandler = {
|
||||
handleUri: vi.fn(),
|
||||
}
|
||||
|
||||
const disposable = windowAPI.registerUriHandler(mockHandler)
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("onDidChangeTextEditorSelection()", () => {
|
||||
it("should return a disposable", () => {
|
||||
const disposable = windowAPI.onDidChangeTextEditorSelection(() => {})
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("onDidChangeActiveTextEditor()", () => {
|
||||
it("should return a disposable", () => {
|
||||
const disposable = windowAPI.onDidChangeActiveTextEditor(() => {})
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("onDidChangeVisibleTextEditors()", () => {
|
||||
it("should return a disposable", () => {
|
||||
const disposable = windowAPI.onDidChangeVisibleTextEditors(() => {})
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("terminal events", () => {
|
||||
it("onDidCloseTerminal should return disposable", () => {
|
||||
const disposable = windowAPI.onDidCloseTerminal(() => {})
|
||||
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("onDidOpenTerminal should return disposable", () => {
|
||||
const disposable = windowAPI.onDidOpenTerminal(() => {})
|
||||
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("onDidChangeActiveTerminal should return disposable", () => {
|
||||
const disposable = windowAPI.onDidChangeActiveTerminal(() => {})
|
||||
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("onDidChangeTerminalDimensions should return disposable", () => {
|
||||
const disposable = windowAPI.onDidChangeTerminalDimensions(() => {})
|
||||
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
|
||||
it("onDidWriteTerminalData should return disposable", () => {
|
||||
const disposable = windowAPI.onDidWriteTerminalData(() => {})
|
||||
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("activeTerminal property", () => {
|
||||
it("should return undefined", () => {
|
||||
expect(windowAPI.activeTerminal).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("terminals property", () => {
|
||||
it("should return empty array", () => {
|
||||
expect(windowAPI.terminals).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
290
packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts
Normal file
290
packages/vscode-shim/src/__tests__/WorkspaceAPI.test.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { WorkspaceAPI } from "../api/WorkspaceAPI.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { Range } from "../classes/Range.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
import { WorkspaceEdit } from "../classes/TextEdit.js"
|
||||
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
|
||||
|
||||
describe("WorkspaceAPI", () => {
|
||||
let tempDir: string
|
||||
let extensionPath: string
|
||||
let workspacePath: string
|
||||
let context: ExtensionContextImpl
|
||||
let workspaceAPI: WorkspaceAPI
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "workspace-api-test-"))
|
||||
extensionPath = path.join(tempDir, "extension")
|
||||
workspacePath = path.join(tempDir, "workspace")
|
||||
fs.mkdirSync(extensionPath, { recursive: true })
|
||||
fs.mkdirSync(workspacePath, { recursive: true })
|
||||
|
||||
context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: path.join(tempDir, "storage"),
|
||||
})
|
||||
|
||||
workspaceAPI = new WorkspaceAPI(workspacePath, context)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("workspaceFolders", () => {
|
||||
it("should have workspace folder set", () => {
|
||||
expect(workspaceAPI.workspaceFolders).toHaveLength(1)
|
||||
expect(workspaceAPI.workspaceFolders?.[0]?.uri.fsPath).toBe(workspacePath)
|
||||
expect(workspaceAPI.workspaceFolders?.[0]?.index).toBe(0)
|
||||
})
|
||||
|
||||
it("should have workspace name set", () => {
|
||||
expect(workspaceAPI.name).toBe(path.basename(workspacePath))
|
||||
})
|
||||
})
|
||||
|
||||
describe("asRelativePath()", () => {
|
||||
it("should convert absolute path to relative", () => {
|
||||
const absolutePath = path.join(workspacePath, "subdir", "file.txt")
|
||||
const relativePath = workspaceAPI.asRelativePath(absolutePath)
|
||||
|
||||
expect(relativePath).toBe(path.join("subdir", "file.txt"))
|
||||
})
|
||||
|
||||
it("should handle URI input", () => {
|
||||
const uri = Uri.file(path.join(workspacePath, "file.txt"))
|
||||
const relativePath = workspaceAPI.asRelativePath(uri)
|
||||
|
||||
expect(relativePath).toBe("file.txt")
|
||||
})
|
||||
|
||||
it("should return original path if outside workspace", () => {
|
||||
const outsidePath = "/outside/workspace/file.txt"
|
||||
const result = workspaceAPI.asRelativePath(outsidePath)
|
||||
|
||||
expect(result).toBe(outsidePath)
|
||||
})
|
||||
|
||||
it("should handle empty workspace folders", () => {
|
||||
workspaceAPI.workspaceFolders = undefined
|
||||
const absolutePath = "/some/path/file.txt"
|
||||
const result = workspaceAPI.asRelativePath(absolutePath)
|
||||
|
||||
expect(result).toBe(absolutePath)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConfiguration()", () => {
|
||||
it("should return configuration object", () => {
|
||||
const config = workspaceAPI.getConfiguration("myExtension")
|
||||
|
||||
expect(config).toBeDefined()
|
||||
expect(typeof config.get).toBe("function")
|
||||
expect(typeof config.has).toBe("function")
|
||||
expect(typeof config.update).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("findFiles()", () => {
|
||||
it("should return empty array (minimal implementation)", async () => {
|
||||
const result = await workspaceAPI.findFiles("**/*.txt")
|
||||
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("openTextDocument()", () => {
|
||||
it("should open and return a text document", async () => {
|
||||
const filePath = path.join(workspacePath, "test.txt")
|
||||
fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3")
|
||||
|
||||
const uri = Uri.file(filePath)
|
||||
const document = await workspaceAPI.openTextDocument(uri)
|
||||
|
||||
expect(document.uri.fsPath).toBe(filePath)
|
||||
expect(document.fileName).toBe(filePath)
|
||||
expect(document.lineCount).toBe(3)
|
||||
expect(document.getText()).toBe("Line 1\nLine 2\nLine 3")
|
||||
})
|
||||
|
||||
it("should handle getText with range", async () => {
|
||||
const filePath = path.join(workspacePath, "test.txt")
|
||||
fs.writeFileSync(filePath, "Line 1\nLine 2\nLine 3")
|
||||
|
||||
const uri = Uri.file(filePath)
|
||||
const document = await workspaceAPI.openTextDocument(uri)
|
||||
|
||||
const range = new Range(0, 0, 1, 6)
|
||||
const text = document.getText(range)
|
||||
|
||||
expect(text).toContain("Line 1")
|
||||
expect(text).toContain("Line 2")
|
||||
})
|
||||
|
||||
it("should provide lineAt method", async () => {
|
||||
const filePath = path.join(workspacePath, "test.txt")
|
||||
fs.writeFileSync(filePath, "Hello\nWorld")
|
||||
|
||||
const uri = Uri.file(filePath)
|
||||
const document = await workspaceAPI.openTextDocument(uri)
|
||||
|
||||
const line = document.lineAt(0)
|
||||
|
||||
expect(line.text).toBe("Hello")
|
||||
expect(line.isEmptyOrWhitespace).toBe(false)
|
||||
})
|
||||
|
||||
it("should add document to textDocuments", async () => {
|
||||
const filePath = path.join(workspacePath, "test.txt")
|
||||
fs.writeFileSync(filePath, "content")
|
||||
|
||||
const uri = Uri.file(filePath)
|
||||
await workspaceAPI.openTextDocument(uri)
|
||||
|
||||
expect(workspaceAPI.textDocuments).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should handle non-existent file gracefully", async () => {
|
||||
const uri = Uri.file(path.join(workspacePath, "nonexistent.txt"))
|
||||
const document = await workspaceAPI.openTextDocument(uri)
|
||||
|
||||
expect(document.getText()).toBe("")
|
||||
expect(document.lineCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyEdit()", () => {
|
||||
it("should apply single edit to file", async () => {
|
||||
const filePath = path.join(workspacePath, "edit-test.txt")
|
||||
fs.writeFileSync(filePath, "Hello World")
|
||||
|
||||
const edit = new WorkspaceEdit()
|
||||
const uri = Uri.file(filePath)
|
||||
edit.replace(uri, new Range(0, 0, 0, 5), "Hi")
|
||||
|
||||
const result = await workspaceAPI.applyEdit(edit)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("Hi World")
|
||||
})
|
||||
|
||||
it("should apply insert edit", async () => {
|
||||
const filePath = path.join(workspacePath, "insert-test.txt")
|
||||
fs.writeFileSync(filePath, "World")
|
||||
|
||||
const edit = new WorkspaceEdit()
|
||||
const uri = Uri.file(filePath)
|
||||
edit.insert(uri, new Position(0, 0), "Hello ")
|
||||
|
||||
const result = await workspaceAPI.applyEdit(edit)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello World")
|
||||
})
|
||||
|
||||
it("should apply delete edit", async () => {
|
||||
const filePath = path.join(workspacePath, "delete-test.txt")
|
||||
fs.writeFileSync(filePath, "Hello World")
|
||||
|
||||
const edit = new WorkspaceEdit()
|
||||
const uri = Uri.file(filePath)
|
||||
edit.delete(uri, new Range(0, 5, 0, 11))
|
||||
|
||||
const result = await workspaceAPI.applyEdit(edit)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("Hello")
|
||||
})
|
||||
|
||||
it("should create file if it doesn't exist", async () => {
|
||||
const filePath = path.join(workspacePath, "new-file.txt")
|
||||
|
||||
const edit = new WorkspaceEdit()
|
||||
const uri = Uri.file(filePath)
|
||||
edit.insert(uri, new Position(0, 0), "New content")
|
||||
|
||||
const result = await workspaceAPI.applyEdit(edit)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fs.readFileSync(filePath, "utf-8")).toBe("New content")
|
||||
})
|
||||
|
||||
it("should update in-memory document", async () => {
|
||||
const filePath = path.join(workspacePath, "memory-test.txt")
|
||||
fs.writeFileSync(filePath, "Original")
|
||||
|
||||
// First open the document
|
||||
const uri = Uri.file(filePath)
|
||||
const document = await workspaceAPI.openTextDocument(uri)
|
||||
expect(document.getText()).toBe("Original")
|
||||
|
||||
// Apply edit
|
||||
const edit = new WorkspaceEdit()
|
||||
edit.replace(uri, new Range(0, 0, 0, 8), "Modified")
|
||||
await workspaceAPI.applyEdit(edit)
|
||||
|
||||
// Check in-memory document is updated
|
||||
expect(document.getText()).toBe("Modified")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createFileSystemWatcher()", () => {
|
||||
it("should return a file system watcher object", () => {
|
||||
const watcher = workspaceAPI.createFileSystemWatcher()
|
||||
|
||||
expect(typeof watcher.onDidChange).toBe("function")
|
||||
expect(typeof watcher.onDidCreate).toBe("function")
|
||||
expect(typeof watcher.onDidDelete).toBe("function")
|
||||
expect(typeof watcher.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("events", () => {
|
||||
it("should have onDidChangeWorkspaceFolders event", () => {
|
||||
expect(typeof workspaceAPI.onDidChangeWorkspaceFolders).toBe("function")
|
||||
})
|
||||
|
||||
it("should have onDidOpenTextDocument event", () => {
|
||||
expect(typeof workspaceAPI.onDidOpenTextDocument).toBe("function")
|
||||
})
|
||||
|
||||
it("should have onDidChangeTextDocument event", () => {
|
||||
expect(typeof workspaceAPI.onDidChangeTextDocument).toBe("function")
|
||||
})
|
||||
|
||||
it("should have onDidCloseTextDocument event", () => {
|
||||
expect(typeof workspaceAPI.onDidCloseTextDocument).toBe("function")
|
||||
})
|
||||
|
||||
it("should have onDidChangeConfiguration event", () => {
|
||||
expect(typeof workspaceAPI.onDidChangeConfiguration).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fs property", () => {
|
||||
it("should have FileSystemAPI instance", () => {
|
||||
expect(workspaceAPI.fs).toBeDefined()
|
||||
expect(typeof workspaceAPI.fs.stat).toBe("function")
|
||||
expect(typeof workspaceAPI.fs.readFile).toBe("function")
|
||||
expect(typeof workspaceAPI.fs.writeFile).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("registerTextDocumentContentProvider()", () => {
|
||||
it("should return a disposable", () => {
|
||||
const disposable = workspaceAPI.registerTextDocumentContentProvider("test", {
|
||||
provideTextDocumentContent: () => Promise.resolve("content"),
|
||||
})
|
||||
|
||||
expect(disposable).toBeDefined()
|
||||
expect(typeof disposable.dispose).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import {
|
||||
MockWorkspaceConfiguration,
|
||||
setRuntimeConfig,
|
||||
setRuntimeConfigValues,
|
||||
getRuntimeConfig,
|
||||
clearRuntimeConfig,
|
||||
} from "../api/WorkspaceConfiguration.js"
|
||||
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
|
||||
|
||||
describe("MockWorkspaceConfiguration", () => {
|
||||
let tempDir: string
|
||||
let extensionPath: string
|
||||
let workspacePath: string
|
||||
let context: ExtensionContextImpl
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "config-test-"))
|
||||
extensionPath = path.join(tempDir, "extension")
|
||||
workspacePath = path.join(tempDir, "workspace")
|
||||
fs.mkdirSync(extensionPath, { recursive: true })
|
||||
fs.mkdirSync(workspacePath, { recursive: true })
|
||||
|
||||
context = new ExtensionContextImpl({
|
||||
extensionPath,
|
||||
workspacePath,
|
||||
storageDir: path.join(tempDir, "storage"),
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("get()", () => {
|
||||
it("should return default value when key doesn't exist", () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
expect(config.get("nonexistent", "default")).toBe("default")
|
||||
})
|
||||
|
||||
it("should return undefined when key doesn't exist and no default provided", () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
expect(config.get("nonexistent")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return stored value", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
await config.update("setting", "value")
|
||||
|
||||
expect(config.get("setting")).toBe("value")
|
||||
})
|
||||
|
||||
it("should use section prefix", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
await config.update("nested.setting", "nested value")
|
||||
|
||||
expect(config.get("nested.setting")).toBe("nested value")
|
||||
})
|
||||
|
||||
it("should handle complex values", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
const complexValue = { nested: { array: [1, 2, 3] } }
|
||||
|
||||
await config.update("complex", complexValue)
|
||||
|
||||
expect(config.get("complex")).toEqual(complexValue)
|
||||
})
|
||||
})
|
||||
|
||||
describe("has()", () => {
|
||||
it("should return false for non-existent key", () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
expect(config.has("nonexistent")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true for existing key", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
await config.update("exists", "value")
|
||||
|
||||
expect(config.has("exists")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("inspect()", () => {
|
||||
it("should return undefined for non-existent key", () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
expect(config.inspect("nonexistent")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return inspection result for existing key", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
await config.update("setting", "global value", 1) // Global
|
||||
|
||||
const inspection = config.inspect<string>("setting")
|
||||
|
||||
expect(inspection).toBeDefined()
|
||||
expect(inspection?.key).toBe("myExtension.setting")
|
||||
expect(inspection?.globalValue).toBe("global value")
|
||||
})
|
||||
|
||||
it("should return workspace value when set", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
await config.update("workspaceSetting", "workspace value", 2) // Workspace
|
||||
|
||||
const inspection = config.inspect<string>("workspaceSetting")
|
||||
|
||||
expect(inspection).toBeDefined()
|
||||
expect(inspection?.workspaceValue).toBe("workspace value")
|
||||
})
|
||||
})
|
||||
|
||||
describe("update()", () => {
|
||||
it("should update global configuration", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
await config.update("globalSetting", "global value", 1) // Global
|
||||
|
||||
expect(config.get("globalSetting")).toBe("global value")
|
||||
})
|
||||
|
||||
it("should update workspace configuration", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
await config.update("workspaceSetting", "workspace value", 2) // Workspace
|
||||
|
||||
expect(config.get("workspaceSetting")).toBe("workspace value")
|
||||
})
|
||||
|
||||
it("should persist configuration across instances", async () => {
|
||||
const config1 = new MockWorkspaceConfiguration("myExtension", context)
|
||||
await config1.update("persistent", "value")
|
||||
|
||||
// Create new config instance
|
||||
const config2 = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
expect(config2.get("persistent")).toBe("value")
|
||||
})
|
||||
|
||||
it("should allow updating with null/undefined to clear value", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
await config.update("toDelete", "value")
|
||||
|
||||
expect(config.get("toDelete")).toBe("value")
|
||||
|
||||
await config.update("toDelete", undefined)
|
||||
|
||||
expect(config.get("toDelete")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("reload()", () => {
|
||||
it("should not throw when called", () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
|
||||
expect(() => config.reload()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllConfig()", () => {
|
||||
it("should return all configuration values", async () => {
|
||||
const config = new MockWorkspaceConfiguration("myExtension", context)
|
||||
await config.update("key1", "value1")
|
||||
await config.update("key2", "value2")
|
||||
|
||||
const allConfig = config.getAllConfig()
|
||||
|
||||
expect(allConfig["myExtension.key1"]).toBe("value1")
|
||||
expect(allConfig["myExtension.key2"]).toBe("value2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Runtime Configuration", () => {
|
||||
beforeEach(() => {
|
||||
// Clear runtime config before each test
|
||||
clearRuntimeConfig()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
clearRuntimeConfig()
|
||||
})
|
||||
|
||||
it("should return runtime config value over disk-based values", async () => {
|
||||
const config = new MockWorkspaceConfiguration("roo-cline", context)
|
||||
|
||||
// Set a value in disk-based storage
|
||||
await config.update("commandExecutionTimeout", 10)
|
||||
|
||||
// Verify disk value is returned
|
||||
expect(config.get("commandExecutionTimeout")).toBe(10)
|
||||
|
||||
// Set runtime config (should take precedence)
|
||||
setRuntimeConfig("roo-cline", "commandExecutionTimeout", 20)
|
||||
|
||||
// Now runtime value should be returned
|
||||
expect(config.get("commandExecutionTimeout")).toBe(20)
|
||||
})
|
||||
|
||||
it("should set and get runtime config values", () => {
|
||||
setRuntimeConfig("roo-cline", "testSetting", "testValue")
|
||||
|
||||
expect(getRuntimeConfig("roo-cline.testSetting")).toBe("testValue")
|
||||
})
|
||||
|
||||
it("should set multiple runtime config values at once", () => {
|
||||
setRuntimeConfigValues("roo-cline", {
|
||||
setting1: "value1",
|
||||
setting2: 42,
|
||||
setting3: true,
|
||||
})
|
||||
|
||||
expect(getRuntimeConfig("roo-cline.setting1")).toBe("value1")
|
||||
expect(getRuntimeConfig("roo-cline.setting2")).toBe(42)
|
||||
expect(getRuntimeConfig("roo-cline.setting3")).toBe(true)
|
||||
})
|
||||
|
||||
it("should ignore undefined values in setRuntimeConfigValues", () => {
|
||||
setRuntimeConfigValues("roo-cline", {
|
||||
defined: "value",
|
||||
notDefined: undefined,
|
||||
})
|
||||
|
||||
expect(getRuntimeConfig("roo-cline.defined")).toBe("value")
|
||||
expect(getRuntimeConfig("roo-cline.notDefined")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should clear all runtime config values", () => {
|
||||
setRuntimeConfig("roo-cline", "setting1", "value1")
|
||||
setRuntimeConfig("roo-cline", "setting2", "value2")
|
||||
|
||||
clearRuntimeConfig()
|
||||
|
||||
expect(getRuntimeConfig("roo-cline.setting1")).toBeUndefined()
|
||||
expect(getRuntimeConfig("roo-cline.setting2")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return default value when no runtime config is set", () => {
|
||||
const config = new MockWorkspaceConfiguration("roo-cline", context)
|
||||
|
||||
expect(config.get("nonexistent", 0)).toBe(0)
|
||||
expect(config.get("nonexistent", "default")).toBe("default")
|
||||
})
|
||||
|
||||
it("should work with MockWorkspaceConfiguration.get() for CLI settings", () => {
|
||||
// Simulate CLI setting commandExecutionTimeout
|
||||
setRuntimeConfigValues("roo-cline", {
|
||||
commandExecutionTimeout: 20,
|
||||
commandTimeoutAllowlist: ["npm", "yarn"],
|
||||
})
|
||||
|
||||
const config = new MockWorkspaceConfiguration("roo-cline", context)
|
||||
|
||||
// These should return the runtime config values
|
||||
expect(config.get<number>("commandExecutionTimeout", 0)).toBe(20)
|
||||
expect(config.get<string[]>("commandTimeoutAllowlist", [])).toEqual(["npm", "yarn"])
|
||||
})
|
||||
})
|
||||
})
|
||||
198
packages/vscode-shim/src/__tests__/logger.test.ts
Normal file
198
packages/vscode-shim/src/__tests__/logger.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { logs, setLogger, type Logger } from "../utils/logger.js"
|
||||
|
||||
describe("Logger", () => {
|
||||
let originalEnv: string | undefined
|
||||
let consoleSpy: {
|
||||
log: ReturnType<typeof vi.spyOn>
|
||||
warn: ReturnType<typeof vi.spyOn>
|
||||
error: ReturnType<typeof vi.spyOn>
|
||||
debug: ReturnType<typeof vi.spyOn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = process.env.DEBUG
|
||||
consoleSpy = {
|
||||
log: vi.spyOn(console, "log").mockImplementation(() => {}),
|
||||
warn: vi.spyOn(console, "warn").mockImplementation(() => {}),
|
||||
error: vi.spyOn(console, "error").mockImplementation(() => {}),
|
||||
debug: vi.spyOn(console, "debug").mockImplementation(() => {}),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.DEBUG = originalEnv
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe("logs object (default ConsoleLogger)", () => {
|
||||
describe("info()", () => {
|
||||
it("should log info message", () => {
|
||||
logs.info("Info message")
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("Info message")
|
||||
})
|
||||
|
||||
it("should include context in log", () => {
|
||||
logs.info("Info message", "MyContext")
|
||||
|
||||
expect(consoleSpy.log).toHaveBeenCalled()
|
||||
expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("MyContext")
|
||||
})
|
||||
|
||||
it("should use INFO as default context", () => {
|
||||
logs.info("Info message")
|
||||
|
||||
expect(consoleSpy.log.mock.calls[0]?.[0]).toContain("INFO")
|
||||
})
|
||||
})
|
||||
|
||||
describe("warn()", () => {
|
||||
it("should log warning message", () => {
|
||||
logs.warn("Warning message")
|
||||
|
||||
expect(consoleSpy.warn).toHaveBeenCalled()
|
||||
expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("Warning message")
|
||||
})
|
||||
|
||||
it("should include context in warning", () => {
|
||||
logs.warn("Warning message", "MyContext")
|
||||
|
||||
expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("MyContext")
|
||||
})
|
||||
|
||||
it("should use WARN as default context", () => {
|
||||
logs.warn("Warning message")
|
||||
|
||||
expect(consoleSpy.warn.mock.calls[0]?.[0]).toContain("WARN")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error()", () => {
|
||||
it("should log error message", () => {
|
||||
logs.error("Error message")
|
||||
|
||||
expect(consoleSpy.error).toHaveBeenCalled()
|
||||
expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("Error message")
|
||||
})
|
||||
|
||||
it("should include context in error", () => {
|
||||
logs.error("Error message", "MyContext")
|
||||
|
||||
expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("MyContext")
|
||||
})
|
||||
|
||||
it("should use ERROR as default context", () => {
|
||||
logs.error("Error message")
|
||||
|
||||
expect(consoleSpy.error.mock.calls[0]?.[0]).toContain("ERROR")
|
||||
})
|
||||
})
|
||||
|
||||
describe("debug()", () => {
|
||||
it("should not log debug message when DEBUG env is not set", () => {
|
||||
delete process.env.DEBUG
|
||||
|
||||
logs.debug("Debug message")
|
||||
|
||||
expect(consoleSpy.debug).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should log debug message when DEBUG env is set", () => {
|
||||
process.env.DEBUG = "true"
|
||||
|
||||
logs.debug("Debug message")
|
||||
|
||||
expect(consoleSpy.debug).toHaveBeenCalled()
|
||||
expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("Debug message")
|
||||
})
|
||||
|
||||
it("should include context in debug when DEBUG is set", () => {
|
||||
process.env.DEBUG = "true"
|
||||
|
||||
logs.debug("Debug message", "MyContext")
|
||||
|
||||
expect(consoleSpy.debug.mock.calls[0]?.[0]).toContain("MyContext")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("setLogger()", () => {
|
||||
it("should replace default logger with custom logger", () => {
|
||||
const customLogger: Logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
|
||||
setLogger(customLogger)
|
||||
|
||||
logs.info("Test message", "TestContext")
|
||||
|
||||
expect(customLogger.info).toHaveBeenCalledWith("Test message", "TestContext", undefined)
|
||||
})
|
||||
|
||||
it("should use custom logger for all log levels", () => {
|
||||
const customLogger: Logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
|
||||
setLogger(customLogger)
|
||||
|
||||
logs.info("Info")
|
||||
logs.warn("Warn")
|
||||
logs.error("Error")
|
||||
logs.debug("Debug")
|
||||
|
||||
expect(customLogger.info).toHaveBeenCalledTimes(1)
|
||||
expect(customLogger.warn).toHaveBeenCalledTimes(1)
|
||||
expect(customLogger.error).toHaveBeenCalledTimes(1)
|
||||
expect(customLogger.debug).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should pass meta parameter to custom logger", () => {
|
||||
const customLogger: Logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}
|
||||
|
||||
setLogger(customLogger)
|
||||
|
||||
const meta = { requestId: "123", userId: "456" }
|
||||
logs.info("Info with meta", "Context", meta)
|
||||
|
||||
expect(customLogger.info).toHaveBeenCalledWith("Info with meta", "Context", meta)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Logger interface", () => {
|
||||
it("should accept custom logger implementing Logger interface", () => {
|
||||
// Create a custom logger that collects messages
|
||||
const messages: string[] = []
|
||||
const customLogger: Logger = {
|
||||
info: (message) => messages.push(`INFO: ${message}`),
|
||||
warn: (message) => messages.push(`WARN: ${message}`),
|
||||
error: (message) => messages.push(`ERROR: ${message}`),
|
||||
debug: (message) => messages.push(`DEBUG: ${message}`),
|
||||
}
|
||||
|
||||
setLogger(customLogger)
|
||||
|
||||
logs.info("Test info")
|
||||
logs.warn("Test warn")
|
||||
logs.error("Test error")
|
||||
logs.debug("Test debug")
|
||||
|
||||
expect(messages).toContain("INFO: Test info")
|
||||
expect(messages).toContain("WARN: Test warn")
|
||||
expect(messages).toContain("ERROR: Test error")
|
||||
expect(messages).toContain("DEBUG: Test debug")
|
||||
})
|
||||
})
|
||||
})
|
||||
143
packages/vscode-shim/src/__tests__/machine-id.test.ts
Normal file
143
packages/vscode-shim/src/__tests__/machine-id.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { machineIdSync } from "../utils/machine-id.js"
|
||||
|
||||
describe("machineIdSync", () => {
|
||||
let originalHome: string | undefined
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalHome = process.env.HOME
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "machine-id-test-"))
|
||||
process.env.HOME = tempDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("should generate a machine ID", () => {
|
||||
const machineId = machineIdSync()
|
||||
|
||||
expect(machineId).toBeDefined()
|
||||
expect(typeof machineId).toBe("string")
|
||||
expect(machineId.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should return a hexadecimal string", () => {
|
||||
const machineId = machineIdSync()
|
||||
|
||||
// SHA256 hash produces 64 hex characters
|
||||
expect(machineId).toMatch(/^[a-f0-9]+$/)
|
||||
expect(machineId.length).toBe(64)
|
||||
})
|
||||
|
||||
it("should persist machine ID to file", () => {
|
||||
const machineId = machineIdSync()
|
||||
|
||||
const idPath = path.join(tempDir, ".vscode-mock", ".machine-id")
|
||||
expect(fs.existsSync(idPath)).toBe(true)
|
||||
|
||||
const storedId = fs.readFileSync(idPath, "utf-8").trim()
|
||||
expect(storedId).toBe(machineId)
|
||||
})
|
||||
|
||||
it("should return same ID on subsequent calls", () => {
|
||||
const machineId1 = machineIdSync()
|
||||
const machineId2 = machineIdSync()
|
||||
|
||||
expect(machineId1).toBe(machineId2)
|
||||
})
|
||||
|
||||
it("should read existing ID from file", () => {
|
||||
// Create the directory and file first
|
||||
const idDir = path.join(tempDir, ".vscode-mock")
|
||||
const idPath = path.join(idDir, ".machine-id")
|
||||
fs.mkdirSync(idDir, { recursive: true })
|
||||
fs.writeFileSync(idPath, "existing-machine-id-12345")
|
||||
|
||||
const machineId = machineIdSync()
|
||||
|
||||
expect(machineId).toBe("existing-machine-id-12345")
|
||||
})
|
||||
|
||||
it("should create directory if it doesn't exist", () => {
|
||||
const idDir = path.join(tempDir, ".vscode-mock")
|
||||
|
||||
expect(fs.existsSync(idDir)).toBe(false)
|
||||
|
||||
machineIdSync()
|
||||
|
||||
expect(fs.existsSync(idDir)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle missing HOME environment variable", () => {
|
||||
// Use USERPROFILE instead (Windows fallback)
|
||||
delete process.env.HOME
|
||||
process.env.USERPROFILE = tempDir
|
||||
|
||||
const machineId = machineIdSync()
|
||||
|
||||
expect(machineId).toBeDefined()
|
||||
expect(machineId.length).toBeGreaterThan(0)
|
||||
|
||||
// Restore
|
||||
process.env.HOME = tempDir
|
||||
})
|
||||
|
||||
it("should generate unique IDs for different hosts", () => {
|
||||
// This test verifies that the ID generation includes random data
|
||||
// Since we can't easily change the hostname, we verify multiple generations
|
||||
// in fresh environments produce unique results (due to random component)
|
||||
|
||||
// First call generates and saves
|
||||
const machineId1 = machineIdSync()
|
||||
|
||||
// Delete the saved file to force regeneration
|
||||
const idPath = path.join(tempDir, ".vscode-mock", ".machine-id")
|
||||
fs.unlinkSync(idPath)
|
||||
|
||||
// Second call should generate a new ID (random component)
|
||||
const machineId2 = machineIdSync()
|
||||
|
||||
// The IDs should be different due to the random component
|
||||
expect(machineId1).not.toBe(machineId2)
|
||||
})
|
||||
|
||||
it("should handle read errors gracefully", () => {
|
||||
// Create an unreadable file (directory instead of file)
|
||||
const idDir = path.join(tempDir, ".vscode-mock")
|
||||
const idPath = path.join(idDir, ".machine-id")
|
||||
fs.mkdirSync(idPath, { recursive: true }) // Create directory instead of file
|
||||
|
||||
// Should not throw, should generate new ID
|
||||
expect(() => machineIdSync()).not.toThrow()
|
||||
|
||||
const machineId = machineIdSync()
|
||||
expect(machineId).toBeDefined()
|
||||
expect(machineId.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should handle write errors gracefully", () => {
|
||||
// Make the directory read-only (Unix only)
|
||||
if (process.platform !== "win32") {
|
||||
const idDir = path.join(tempDir, ".vscode-mock")
|
||||
fs.mkdirSync(idDir, { recursive: true })
|
||||
fs.chmodSync(idDir, 0o444) // Read-only
|
||||
|
||||
// Should not throw, should still generate ID
|
||||
expect(() => machineIdSync()).not.toThrow()
|
||||
|
||||
const machineId = machineIdSync()
|
||||
expect(machineId).toBeDefined()
|
||||
|
||||
// Restore permissions for cleanup
|
||||
fs.chmodSync(idDir, 0o755)
|
||||
}
|
||||
})
|
||||
})
|
||||
208
packages/vscode-shim/src/__tests__/paths.test.ts
Normal file
208
packages/vscode-shim/src/__tests__/paths.test.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { VSCodeMockPaths } from "../utils/paths.js"
|
||||
|
||||
describe("VSCodeMockPaths", () => {
|
||||
let originalHome: string | undefined
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalHome = process.env.HOME
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "paths-test-"))
|
||||
process.env.HOME = tempDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env.HOME = originalHome
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("getGlobalStorageDir()", () => {
|
||||
it("should return path containing .vscode-mock", () => {
|
||||
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
|
||||
|
||||
expect(globalDir).toContain(".vscode-mock")
|
||||
})
|
||||
|
||||
it("should return path containing global-storage", () => {
|
||||
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
|
||||
|
||||
expect(globalDir).toContain("global-storage")
|
||||
})
|
||||
|
||||
it("should use HOME environment variable", () => {
|
||||
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
|
||||
|
||||
expect(globalDir).toContain(tempDir)
|
||||
})
|
||||
|
||||
it("should return consistent path on multiple calls", () => {
|
||||
const dir1 = VSCodeMockPaths.getGlobalStorageDir()
|
||||
const dir2 = VSCodeMockPaths.getGlobalStorageDir()
|
||||
|
||||
expect(dir1).toBe(dir2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getWorkspaceStorageDir()", () => {
|
||||
it("should return path containing .vscode-mock", () => {
|
||||
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace")
|
||||
|
||||
expect(workspaceDir).toContain(".vscode-mock")
|
||||
})
|
||||
|
||||
it("should return path containing workspace-storage", () => {
|
||||
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace")
|
||||
|
||||
expect(workspaceDir).toContain("workspace-storage")
|
||||
})
|
||||
|
||||
it("should include hashed workspace path", () => {
|
||||
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("/test/workspace")
|
||||
|
||||
// Should end with a hash (hex string)
|
||||
const hash = path.basename(workspaceDir)
|
||||
expect(hash).toMatch(/^[a-f0-9]+$/)
|
||||
})
|
||||
|
||||
it("should return different paths for different workspaces", () => {
|
||||
const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/one")
|
||||
const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/workspace/two")
|
||||
|
||||
expect(dir1).not.toBe(dir2)
|
||||
})
|
||||
|
||||
it("should return same path for same workspace", () => {
|
||||
const dir1 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace")
|
||||
const dir2 = VSCodeMockPaths.getWorkspaceStorageDir("/same/workspace")
|
||||
|
||||
expect(dir1).toBe(dir2)
|
||||
})
|
||||
|
||||
it("should handle Windows-style paths", () => {
|
||||
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("C:\\Users\\test\\workspace")
|
||||
|
||||
expect(workspaceDir).toContain("workspace-storage")
|
||||
// Should still produce a valid hash
|
||||
const hash = path.basename(workspaceDir)
|
||||
expect(hash).toMatch(/^[a-f0-9]+$/)
|
||||
})
|
||||
|
||||
it("should handle empty workspace path", () => {
|
||||
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir("")
|
||||
|
||||
expect(workspaceDir).toContain("workspace-storage")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLogsDir()", () => {
|
||||
it("should return path containing .vscode-mock", () => {
|
||||
const logsDir = VSCodeMockPaths.getLogsDir()
|
||||
|
||||
expect(logsDir).toContain(".vscode-mock")
|
||||
})
|
||||
|
||||
it("should return path containing logs", () => {
|
||||
const logsDir = VSCodeMockPaths.getLogsDir()
|
||||
|
||||
expect(logsDir).toContain("logs")
|
||||
})
|
||||
|
||||
it("should return consistent path on multiple calls", () => {
|
||||
const dir1 = VSCodeMockPaths.getLogsDir()
|
||||
const dir2 = VSCodeMockPaths.getLogsDir()
|
||||
|
||||
expect(dir1).toBe(dir2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("initializeWorkspace()", () => {
|
||||
it("should create global storage directory", () => {
|
||||
VSCodeMockPaths.initializeWorkspace("/test/workspace")
|
||||
|
||||
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
|
||||
expect(fs.existsSync(globalDir)).toBe(true)
|
||||
})
|
||||
|
||||
it("should create workspace storage directory", () => {
|
||||
const workspacePath = "/test/workspace"
|
||||
VSCodeMockPaths.initializeWorkspace(workspacePath)
|
||||
|
||||
const workspaceDir = VSCodeMockPaths.getWorkspaceStorageDir(workspacePath)
|
||||
expect(fs.existsSync(workspaceDir)).toBe(true)
|
||||
})
|
||||
|
||||
it("should create logs directory", () => {
|
||||
VSCodeMockPaths.initializeWorkspace("/test/workspace")
|
||||
|
||||
const logsDir = VSCodeMockPaths.getLogsDir()
|
||||
expect(fs.existsSync(logsDir)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not fail if directories already exist", () => {
|
||||
// Initialize twice
|
||||
VSCodeMockPaths.initializeWorkspace("/test/workspace")
|
||||
|
||||
expect(() => {
|
||||
VSCodeMockPaths.initializeWorkspace("/test/workspace")
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it("should create directories with correct structure", () => {
|
||||
VSCodeMockPaths.initializeWorkspace("/test/workspace")
|
||||
|
||||
const baseDir = path.join(tempDir, ".vscode-mock")
|
||||
expect(fs.existsSync(baseDir)).toBe(true)
|
||||
expect(fs.existsSync(path.join(baseDir, "global-storage"))).toBe(true)
|
||||
expect(fs.existsSync(path.join(baseDir, "workspace-storage"))).toBe(true)
|
||||
expect(fs.existsSync(path.join(baseDir, "logs"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hash consistency", () => {
|
||||
it("should produce deterministic hashes", () => {
|
||||
// The same workspace path should always produce the same hash
|
||||
const workspace = "/project/my-project"
|
||||
|
||||
const hash1 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace))
|
||||
const hash2 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace))
|
||||
const hash3 = path.basename(VSCodeMockPaths.getWorkspaceStorageDir(workspace))
|
||||
|
||||
expect(hash1).toBe(hash2)
|
||||
expect(hash2).toBe(hash3)
|
||||
})
|
||||
|
||||
it("should handle special characters in workspace path", () => {
|
||||
const workspaces = [
|
||||
"/path/with spaces/project",
|
||||
"/path/with-dashes/project",
|
||||
"/path/with_underscores/project",
|
||||
"/path/with.dots/project",
|
||||
]
|
||||
|
||||
for (const workspace of workspaces) {
|
||||
const dir = VSCodeMockPaths.getWorkspaceStorageDir(workspace)
|
||||
// Should produce valid directory name
|
||||
expect(path.basename(dir)).toMatch(/^[a-f0-9]+$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("USERPROFILE fallback (Windows)", () => {
|
||||
it("should use USERPROFILE when HOME is not set", () => {
|
||||
delete process.env.HOME
|
||||
process.env.USERPROFILE = tempDir
|
||||
|
||||
const globalDir = VSCodeMockPaths.getGlobalStorageDir()
|
||||
|
||||
expect(globalDir).toContain(tempDir)
|
||||
|
||||
// Restore for cleanup
|
||||
process.env.HOME = tempDir
|
||||
})
|
||||
})
|
||||
})
|
||||
178
packages/vscode-shim/src/__tests__/storage.test.ts
Normal file
178
packages/vscode-shim/src/__tests__/storage.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
import { FileMemento } from "../storage/Memento.js"
|
||||
import { FileSecretStorage } from "../storage/SecretStorage.js"
|
||||
|
||||
describe("FileMemento", () => {
|
||||
let tempDir: string
|
||||
let mementoPath: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "memento-test-"))
|
||||
mementoPath = path.join(tempDir, "state.json")
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("should store and retrieve values", async () => {
|
||||
const memento = new FileMemento(mementoPath)
|
||||
|
||||
await memento.update("key1", "value1")
|
||||
await memento.update("key2", 42)
|
||||
|
||||
expect(memento.get("key1")).toBe("value1")
|
||||
expect(memento.get("key2")).toBe(42)
|
||||
})
|
||||
|
||||
it("should return default value when key doesn't exist", () => {
|
||||
const memento = new FileMemento(mementoPath)
|
||||
|
||||
expect(memento.get("nonexistent", "default")).toBe("default")
|
||||
expect(memento.get<number>("missing", 0)).toBe(0)
|
||||
})
|
||||
|
||||
it("should persist data to file", async () => {
|
||||
const memento1 = new FileMemento(mementoPath)
|
||||
await memento1.update("persisted", "value")
|
||||
|
||||
// Create new instance to verify persistence
|
||||
const memento2 = new FileMemento(mementoPath)
|
||||
expect(memento2.get("persisted")).toBe("value")
|
||||
})
|
||||
|
||||
it("should delete values when updated with undefined", async () => {
|
||||
const memento = new FileMemento(mementoPath)
|
||||
|
||||
await memento.update("key", "value")
|
||||
expect(memento.get("key")).toBe("value")
|
||||
|
||||
await memento.update("key", undefined)
|
||||
expect(memento.get("key")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should return all keys", async () => {
|
||||
const memento = new FileMemento(mementoPath)
|
||||
|
||||
await memento.update("key1", "value1")
|
||||
await memento.update("key2", "value2")
|
||||
await memento.update("key3", "value3")
|
||||
|
||||
const keys = memento.keys()
|
||||
expect(keys).toHaveLength(3)
|
||||
expect(keys).toContain("key1")
|
||||
expect(keys).toContain("key2")
|
||||
expect(keys).toContain("key3")
|
||||
})
|
||||
|
||||
it("should clear all data", async () => {
|
||||
const memento = new FileMemento(mementoPath)
|
||||
|
||||
await memento.update("key1", "value1")
|
||||
await memento.update("key2", "value2")
|
||||
|
||||
memento.clear()
|
||||
|
||||
expect(memento.keys()).toHaveLength(0)
|
||||
expect(memento.get("key1")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("FileSecretStorage", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(tmpdir(), "secrets-test-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("should store and retrieve secrets", async () => {
|
||||
const storage = new FileSecretStorage(tempDir)
|
||||
|
||||
await storage.store("apiKey", "sk-test-123")
|
||||
const retrieved = await storage.get("apiKey")
|
||||
|
||||
expect(retrieved).toBe("sk-test-123")
|
||||
})
|
||||
|
||||
it("should return undefined for non-existent secrets", async () => {
|
||||
const storage = new FileSecretStorage(tempDir)
|
||||
const result = await storage.get("nonexistent")
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should delete secrets", async () => {
|
||||
const storage = new FileSecretStorage(tempDir)
|
||||
|
||||
await storage.store("apiKey", "sk-test-123")
|
||||
expect(await storage.get("apiKey")).toBe("sk-test-123")
|
||||
|
||||
await storage.delete("apiKey")
|
||||
expect(await storage.get("apiKey")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should persist secrets across instances", async () => {
|
||||
const storage1 = new FileSecretStorage(tempDir)
|
||||
await storage1.store("token", "persistent-value")
|
||||
|
||||
const storage2 = new FileSecretStorage(tempDir)
|
||||
const value = await storage2.get("token")
|
||||
|
||||
expect(value).toBe("persistent-value")
|
||||
})
|
||||
|
||||
it("should fire onDidChange event when secret changes", async () => {
|
||||
const storage = new FileSecretStorage(tempDir)
|
||||
const events: string[] = []
|
||||
|
||||
storage.onDidChange((e) => {
|
||||
events.push(e.key)
|
||||
})
|
||||
|
||||
await storage.store("key1", "value1")
|
||||
await storage.store("key2", "value2")
|
||||
await storage.delete("key1")
|
||||
|
||||
expect(events).toEqual(["key1", "key2", "key1"])
|
||||
})
|
||||
|
||||
it("should clear all secrets", async () => {
|
||||
const storage = new FileSecretStorage(tempDir)
|
||||
|
||||
await storage.store("key1", "value1")
|
||||
await storage.store("key2", "value2")
|
||||
|
||||
storage.clearAll()
|
||||
|
||||
expect(await storage.get("key1")).toBeUndefined()
|
||||
expect(await storage.get("key2")).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should create secrets.json file with restrictive permissions on Unix", async () => {
|
||||
if (process.platform === "win32") {
|
||||
// Skip on Windows
|
||||
return
|
||||
}
|
||||
|
||||
const storage = new FileSecretStorage(tempDir)
|
||||
await storage.store("key", "value")
|
||||
|
||||
const secretsPath = path.join(tempDir, "secrets.json")
|
||||
const stats = fs.statSync(secretsPath)
|
||||
const mode = stats.mode & 0o777
|
||||
|
||||
// Should be 0600 (owner read/write only)
|
||||
expect(mode).toBe(0o600)
|
||||
})
|
||||
})
|
||||
181
packages/vscode-shim/src/api/CommandsAPI.ts
Normal file
181
packages/vscode-shim/src/api/CommandsAPI.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* CommandsAPI class for VSCode API
|
||||
*/
|
||||
|
||||
import { logs } from "../utils/logger.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
import { Range } from "../classes/Range.js"
|
||||
import { Selection } from "../classes/Selection.js"
|
||||
import { ViewColumn, EndOfLine } from "../types.js"
|
||||
import type { Thenable } from "../types.js"
|
||||
import type { TextEditor, TextEditorEdit } from "../interfaces/editor.js"
|
||||
import type { TextDocument } from "../interfaces/document.js"
|
||||
import type { Disposable } from "../interfaces/workspace.js"
|
||||
import type { WorkspaceAPI } from "./WorkspaceAPI.js"
|
||||
import type { WindowAPI } from "./WindowAPI.js"
|
||||
|
||||
/**
|
||||
* Commands API mock for CLI mode
|
||||
*/
|
||||
export class CommandsAPI {
|
||||
private commands: Map<string, (...args: unknown[]) => unknown> = new Map()
|
||||
|
||||
registerCommand(command: string, callback: (...args: unknown[]) => unknown): Disposable {
|
||||
this.commands.set(command, callback)
|
||||
return {
|
||||
dispose: () => {
|
||||
this.commands.delete(command)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
executeCommand<T = unknown>(command: string, ...rest: unknown[]): Thenable<T> {
|
||||
const handler = this.commands.get(command)
|
||||
if (handler) {
|
||||
try {
|
||||
const result = handler(...rest)
|
||||
return Promise.resolve(result as T)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle built-in commands
|
||||
switch (command) {
|
||||
case "workbench.action.files.saveFiles":
|
||||
case "workbench.action.closeWindow":
|
||||
case "workbench.action.reloadWindow":
|
||||
return Promise.resolve(undefined as T)
|
||||
case "vscode.diff":
|
||||
// Simulate opening a diff view for the CLI
|
||||
// The extension's DiffViewProvider expects this to create a diff editor
|
||||
return this.handleDiffCommand(
|
||||
rest[0] as Uri,
|
||||
rest[1] as Uri,
|
||||
rest[2] as string | undefined,
|
||||
rest[3],
|
||||
) as Thenable<T>
|
||||
default:
|
||||
logs.warn(`Unknown command: ${command}`, "VSCode.Commands")
|
||||
return Promise.resolve(undefined as T)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleDiffCommand(
|
||||
originalUri: Uri,
|
||||
modifiedUri: Uri,
|
||||
title?: string,
|
||||
_options?: unknown,
|
||||
): Promise<void> {
|
||||
// The DiffViewProvider is waiting for the modified document to appear in visibleTextEditors
|
||||
// We need to simulate this by opening the document and adding it to visible editors
|
||||
|
||||
logs.info(`[DIFF] Handling vscode.diff command`, "VSCode.Commands", {
|
||||
originalUri: originalUri?.toString(),
|
||||
modifiedUri: modifiedUri?.toString(),
|
||||
title,
|
||||
})
|
||||
|
||||
if (!modifiedUri) {
|
||||
logs.warn("[DIFF] vscode.diff called without modified URI", "VSCode.Commands")
|
||||
return
|
||||
}
|
||||
|
||||
// Get the workspace API to open the document
|
||||
const workspace = (global as unknown as { vscode?: { workspace?: WorkspaceAPI } }).vscode?.workspace
|
||||
const window = (global as unknown as { vscode?: { window?: WindowAPI } }).vscode?.window
|
||||
|
||||
if (!workspace || !window) {
|
||||
logs.warn("[DIFF] VSCode APIs not available for diff command", "VSCode.Commands")
|
||||
return
|
||||
}
|
||||
|
||||
logs.info(
|
||||
`[DIFF] Current visibleTextEditors count: ${window.visibleTextEditors?.length || 0}`,
|
||||
"VSCode.Commands",
|
||||
)
|
||||
|
||||
try {
|
||||
// The document should already be open from the showTextDocument call
|
||||
// Find it in the existing textDocuments
|
||||
logs.info(`[DIFF] Looking for already-opened document: ${modifiedUri.fsPath}`, "VSCode.Commands")
|
||||
let document = workspace.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === modifiedUri.fsPath)
|
||||
|
||||
if (!document) {
|
||||
// If not found, open it now
|
||||
logs.info(`[DIFF] Document not found, opening: ${modifiedUri.fsPath}`, "VSCode.Commands")
|
||||
document = await workspace.openTextDocument(modifiedUri)
|
||||
logs.info(`[DIFF] Document opened successfully, lineCount: ${document.lineCount}`, "VSCode.Commands")
|
||||
} else {
|
||||
logs.info(`[DIFF] Found existing document, lineCount: ${document.lineCount}`, "VSCode.Commands")
|
||||
}
|
||||
|
||||
// Create a mock editor for the diff view
|
||||
const mockEditor: TextEditor = {
|
||||
document,
|
||||
selection: new Selection(new Position(0, 0), new Position(0, 0)),
|
||||
selections: [new Selection(new Position(0, 0), new Position(0, 0))],
|
||||
visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))],
|
||||
options: {},
|
||||
viewColumn: ViewColumn.One,
|
||||
edit: async (callback: (editBuilder: TextEditorEdit) => void) => {
|
||||
// Create a mock edit builder
|
||||
const editBuilder: TextEditorEdit = {
|
||||
replace: (_range: Range | Position | Selection, _text: string) => {
|
||||
// In CLI mode, we don't actually edit here
|
||||
// The DiffViewProvider will handle the actual edits
|
||||
logs.debug("Mock edit builder replace called", "VSCode.Commands")
|
||||
},
|
||||
insert: (_position: Position, _text: string) => {
|
||||
logs.debug("Mock edit builder insert called", "VSCode.Commands")
|
||||
},
|
||||
delete: (_range: Range | Selection) => {
|
||||
logs.debug("Mock edit builder delete called", "VSCode.Commands")
|
||||
},
|
||||
setEndOfLine: (_endOfLine: EndOfLine) => {
|
||||
logs.debug("Mock edit builder setEndOfLine called", "VSCode.Commands")
|
||||
},
|
||||
}
|
||||
callback(editBuilder)
|
||||
return true
|
||||
},
|
||||
insertSnippet: () => Promise.resolve(true),
|
||||
setDecorations: () => {},
|
||||
revealRange: () => {},
|
||||
show: () => {},
|
||||
hide: () => {},
|
||||
}
|
||||
|
||||
// Add the editor to visible editors
|
||||
if (!window.visibleTextEditors) {
|
||||
window.visibleTextEditors = []
|
||||
}
|
||||
|
||||
// Check if this editor is already in visibleTextEditors (from showTextDocument)
|
||||
const existingEditor = window.visibleTextEditors.find(
|
||||
(e: TextEditor) => e.document.uri.fsPath === modifiedUri.fsPath,
|
||||
)
|
||||
|
||||
if (existingEditor) {
|
||||
logs.info(`[DIFF] Editor already in visibleTextEditors, updating it`, "VSCode.Commands")
|
||||
// Update the existing editor with the mock editor properties
|
||||
Object.assign(existingEditor, mockEditor)
|
||||
} else {
|
||||
logs.info(`[DIFF] Adding new mock editor to visibleTextEditors`, "VSCode.Commands")
|
||||
window.visibleTextEditors.push(mockEditor)
|
||||
}
|
||||
|
||||
logs.info(`[DIFF] visibleTextEditors count: ${window.visibleTextEditors.length}`, "VSCode.Commands")
|
||||
|
||||
// The onDidChangeVisibleTextEditors event was already fired by showTextDocument
|
||||
// We don't need to fire it again here
|
||||
logs.info(
|
||||
`[DIFF] Diff view simulation complete (events already fired by showTextDocument)`,
|
||||
"VSCode.Commands",
|
||||
)
|
||||
} catch (error) {
|
||||
logs.error("[DIFF] Error simulating diff view", "VSCode.Commands", { error })
|
||||
}
|
||||
}
|
||||
}
|
||||
77
packages/vscode-shim/src/api/FileSystemAPI.ts
Normal file
77
packages/vscode-shim/src/api/FileSystemAPI.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
/**
|
||||
* FileSystemAPI class for VSCode API
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { FileSystemError } from "../classes/Additional.js"
|
||||
import { ensureDirectoryExists } from "../utils/paths.js"
|
||||
import type { FileStat } from "../types.js"
|
||||
|
||||
/**
|
||||
* File system API mock for CLI mode
|
||||
* Provides file operations using Node.js fs module
|
||||
*/
|
||||
export class FileSystemAPI {
|
||||
async stat(uri: Uri): Promise<FileStat> {
|
||||
try {
|
||||
const stats = fs.statSync(uri.fsPath)
|
||||
return {
|
||||
type: stats.isDirectory() ? 2 : 1, // Directory = 2, File = 1
|
||||
ctime: stats.ctimeMs,
|
||||
mtime: stats.mtimeMs,
|
||||
size: stats.size,
|
||||
}
|
||||
} catch {
|
||||
// If file doesn't exist, assume it's a file for CLI purposes
|
||||
return {
|
||||
type: 1, // File
|
||||
ctime: Date.now(),
|
||||
mtime: Date.now(),
|
||||
size: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(uri: Uri): Promise<Uint8Array> {
|
||||
try {
|
||||
const content = fs.readFileSync(uri.fsPath)
|
||||
return new Uint8Array(content)
|
||||
} catch (error) {
|
||||
// Check if it's a file not found error (ENOENT)
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
throw FileSystemError.FileNotFound(uri)
|
||||
}
|
||||
// For other errors, throw a generic FileSystemError
|
||||
throw new FileSystemError(`Failed to read file: ${uri.fsPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async writeFile(uri: Uri, content: Uint8Array): Promise<void> {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(uri.fsPath)
|
||||
ensureDirectoryExists(dir)
|
||||
fs.writeFileSync(uri.fsPath, content)
|
||||
} catch {
|
||||
throw new Error(`Failed to write file: ${uri.fsPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async delete(uri: Uri): Promise<void> {
|
||||
try {
|
||||
fs.unlinkSync(uri.fsPath)
|
||||
} catch {
|
||||
throw new Error(`Failed to delete file: ${uri.fsPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
async createDirectory(uri: Uri): Promise<void> {
|
||||
try {
|
||||
fs.mkdirSync(uri.fsPath, { recursive: true })
|
||||
} catch {
|
||||
throw new Error(`Failed to create directory: ${uri.fsPath}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
69
packages/vscode-shim/src/api/TabGroupsAPI.ts
Normal file
69
packages/vscode-shim/src/api/TabGroupsAPI.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* TabGroupsAPI class for VSCode API
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "../classes/EventEmitter.js"
|
||||
import type { Uri } from "../classes/Uri.js"
|
||||
import type { Disposable } from "../interfaces/workspace.js"
|
||||
|
||||
/**
|
||||
* Tab interface representing an open tab
|
||||
*/
|
||||
export interface Tab {
|
||||
input: TabInputText | unknown
|
||||
label: string
|
||||
isActive: boolean
|
||||
isDirty: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab input for text files
|
||||
*/
|
||||
export interface TabInputText {
|
||||
uri: Uri
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab group interface
|
||||
*/
|
||||
export interface TabGroup {
|
||||
tabs: Tab[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab groups API mock for CLI mode
|
||||
*/
|
||||
export class TabGroupsAPI {
|
||||
private _onDidChangeTabs = new EventEmitter<void>()
|
||||
private _tabGroups: TabGroup[] = []
|
||||
|
||||
get all(): TabGroup[] {
|
||||
return this._tabGroups
|
||||
}
|
||||
|
||||
onDidChangeTabs(listener: () => void): Disposable {
|
||||
return this._onDidChangeTabs.event(listener)
|
||||
}
|
||||
|
||||
async close(tab: Tab): Promise<boolean> {
|
||||
// Find and remove the tab from all groups
|
||||
for (const group of this._tabGroups) {
|
||||
const index = group.tabs.indexOf(tab)
|
||||
if (index !== -1) {
|
||||
group.tabs.splice(index, 1)
|
||||
this._onDidChangeTabs.fire()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Internal method to simulate tab changes for CLI
|
||||
_simulateTabChange(): void {
|
||||
this._onDidChangeTabs.fire()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._onDidChangeTabs.dispose()
|
||||
}
|
||||
}
|
||||
362
packages/vscode-shim/src/api/WindowAPI.ts
Normal file
362
packages/vscode-shim/src/api/WindowAPI.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* WindowAPI class for VSCode API
|
||||
*/
|
||||
|
||||
import { logs } from "../utils/logger.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
import { Range } from "../classes/Range.js"
|
||||
import { Selection } from "../classes/Selection.js"
|
||||
import { EventEmitter } from "../classes/EventEmitter.js"
|
||||
import { ThemeIcon } from "../classes/Additional.js"
|
||||
import { OutputChannel } from "../classes/OutputChannel.js"
|
||||
import { StatusBarItem } from "../classes/StatusBarItem.js"
|
||||
import { TextEditorDecorationType } from "../classes/TextEditorDecorationType.js"
|
||||
import { TabGroupsAPI } from "./TabGroupsAPI.js"
|
||||
import { StatusBarAlignment, ViewColumn } from "../types.js"
|
||||
import type { WorkspaceAPI } from "./WorkspaceAPI.js"
|
||||
import type { Thenable } from "../types.js"
|
||||
import type {
|
||||
TextEditor,
|
||||
TextEditorSelectionChangeEvent,
|
||||
TextDocumentShowOptions,
|
||||
DecorationRenderOptions,
|
||||
} from "../interfaces/editor.js"
|
||||
import type { TextDocument } from "../interfaces/document.js"
|
||||
import type { Terminal, TerminalDimensionsChangeEvent, TerminalDataWriteEvent } from "../interfaces/terminal.js"
|
||||
import type {
|
||||
WebviewViewProvider,
|
||||
WebviewView,
|
||||
Webview,
|
||||
ViewBadge,
|
||||
WebviewViewProviderOptions,
|
||||
UriHandler,
|
||||
} from "../interfaces/webview.js"
|
||||
import type { QuickPickOptions, InputBoxOptions, OpenDialogOptions, Disposable } from "../interfaces/workspace.js"
|
||||
import type { CancellationToken } from "../interfaces/document.js"
|
||||
|
||||
/**
|
||||
* Window API mock for CLI mode
|
||||
*/
|
||||
export class WindowAPI {
|
||||
public tabGroups: TabGroupsAPI
|
||||
public visibleTextEditors: TextEditor[] = []
|
||||
public _onDidChangeVisibleTextEditors = new EventEmitter<TextEditor[]>()
|
||||
private _workspace?: WorkspaceAPI
|
||||
private static _decorationCounter = 0
|
||||
|
||||
constructor() {
|
||||
this.tabGroups = new TabGroupsAPI()
|
||||
}
|
||||
|
||||
setWorkspace(workspace: WorkspaceAPI) {
|
||||
this._workspace = workspace
|
||||
}
|
||||
|
||||
createOutputChannel(name: string): OutputChannel {
|
||||
return new OutputChannel(name)
|
||||
}
|
||||
|
||||
createStatusBarItem(alignment?: StatusBarAlignment, priority?: number): StatusBarItem
|
||||
createStatusBarItem(id?: string, alignment?: StatusBarAlignment, priority?: number): StatusBarItem
|
||||
createStatusBarItem(
|
||||
idOrAlignment?: string | StatusBarAlignment,
|
||||
alignmentOrPriority?: StatusBarAlignment | number,
|
||||
priority?: number,
|
||||
): StatusBarItem {
|
||||
// Handle overloaded signatures
|
||||
let actualAlignment: StatusBarAlignment
|
||||
let actualPriority: number | undefined
|
||||
|
||||
if (typeof idOrAlignment === "string") {
|
||||
// Called with id, alignment, priority
|
||||
actualAlignment = (alignmentOrPriority as StatusBarAlignment) ?? StatusBarAlignment.Left
|
||||
actualPriority = priority
|
||||
} else {
|
||||
// Called with alignment, priority
|
||||
actualAlignment = (idOrAlignment as StatusBarAlignment) ?? StatusBarAlignment.Left
|
||||
actualPriority = alignmentOrPriority as number | undefined
|
||||
}
|
||||
|
||||
return new StatusBarItem(actualAlignment, actualPriority)
|
||||
}
|
||||
|
||||
createTextEditorDecorationType(_options: DecorationRenderOptions): TextEditorDecorationType {
|
||||
return new TextEditorDecorationType(`decoration-${++WindowAPI._decorationCounter}`)
|
||||
}
|
||||
|
||||
createTerminal(options?: {
|
||||
name?: string
|
||||
shellPath?: string
|
||||
shellArgs?: string[]
|
||||
cwd?: string
|
||||
env?: { [key: string]: string | null | undefined }
|
||||
iconPath?: ThemeIcon
|
||||
hideFromUser?: boolean
|
||||
message?: string
|
||||
strictEnv?: boolean
|
||||
}): Terminal {
|
||||
// Return a mock terminal object
|
||||
return {
|
||||
name: options?.name || "Terminal",
|
||||
processId: Promise.resolve(undefined),
|
||||
creationOptions: options || {},
|
||||
exitStatus: undefined,
|
||||
state: { isInteractedWith: false },
|
||||
sendText: (text: string, _addNewLine?: boolean) => {
|
||||
logs.debug(`Terminal sendText: ${text}`, "VSCode.Terminal")
|
||||
},
|
||||
show: (_preserveFocus?: boolean) => {
|
||||
logs.debug("Terminal show called", "VSCode.Terminal")
|
||||
},
|
||||
hide: () => {
|
||||
logs.debug("Terminal hide called", "VSCode.Terminal")
|
||||
},
|
||||
dispose: () => {
|
||||
logs.debug("Terminal disposed", "VSCode.Terminal")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
showInformationMessage(message: string, ..._items: string[]): Thenable<string | undefined> {
|
||||
logs.info(message, "VSCode.Window")
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
showWarningMessage(message: string, ..._items: string[]): Thenable<string | undefined> {
|
||||
logs.warn(message, "VSCode.Window")
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
showErrorMessage(message: string, ..._items: string[]): Thenable<string | undefined> {
|
||||
logs.error(message, "VSCode.Window")
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
showQuickPick(items: string[], _options?: QuickPickOptions): Thenable<string | undefined> {
|
||||
// Return first item for CLI
|
||||
return Promise.resolve(items[0])
|
||||
}
|
||||
|
||||
showInputBox(_options?: InputBoxOptions): Thenable<string | undefined> {
|
||||
// Return empty string for CLI
|
||||
return Promise.resolve("")
|
||||
}
|
||||
|
||||
showOpenDialog(_options?: OpenDialogOptions): Thenable<Uri[] | undefined> {
|
||||
// Return empty array for CLI
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
async showTextDocument(
|
||||
documentOrUri: TextDocument | Uri,
|
||||
columnOrOptions?: ViewColumn | TextDocumentShowOptions,
|
||||
_preserveFocus?: boolean,
|
||||
): Promise<TextEditor> {
|
||||
// Mock implementation for CLI
|
||||
// In a real VSCode environment, this would open the document in an editor
|
||||
const uri = documentOrUri instanceof Uri ? documentOrUri : documentOrUri.uri
|
||||
logs.debug(`showTextDocument called for: ${uri?.toString() || "unknown"}`, "VSCode.Window")
|
||||
|
||||
// Create a placeholder editor first so it's in visibleTextEditors when onDidOpenTextDocument fires
|
||||
const placeholderEditor: TextEditor = {
|
||||
document: { uri } as TextDocument,
|
||||
selection: new Selection(new Position(0, 0), new Position(0, 0)),
|
||||
selections: [new Selection(new Position(0, 0), new Position(0, 0))],
|
||||
visibleRanges: [new Range(new Position(0, 0), new Position(0, 0))],
|
||||
options: {},
|
||||
viewColumn: typeof columnOrOptions === "number" ? columnOrOptions : ViewColumn.One,
|
||||
edit: () => Promise.resolve(true),
|
||||
insertSnippet: () => Promise.resolve(true),
|
||||
setDecorations: () => {},
|
||||
revealRange: () => {},
|
||||
show: () => {},
|
||||
hide: () => {},
|
||||
}
|
||||
|
||||
// Add placeholder to visible editors BEFORE opening document
|
||||
this.visibleTextEditors.push(placeholderEditor)
|
||||
logs.debug(
|
||||
`Placeholder editor added to visibleTextEditors, total: ${this.visibleTextEditors.length}`,
|
||||
"VSCode.Window",
|
||||
)
|
||||
|
||||
// If we have a URI, open the document (this will fire onDidOpenTextDocument)
|
||||
let document: TextDocument | Uri = documentOrUri
|
||||
if (documentOrUri instanceof Uri && this._workspace) {
|
||||
logs.debug("Opening document via workspace.openTextDocument", "VSCode.Window")
|
||||
document = await this._workspace.openTextDocument(uri)
|
||||
logs.debug("Document opened successfully", "VSCode.Window")
|
||||
|
||||
// Update the placeholder editor with the real document
|
||||
placeholderEditor.document = document
|
||||
}
|
||||
|
||||
// Fire events immediately using setImmediate
|
||||
setImmediate(() => {
|
||||
logs.debug("Firing onDidChangeVisibleTextEditors event", "VSCode.Window")
|
||||
this._onDidChangeVisibleTextEditors.fire(this.visibleTextEditors)
|
||||
logs.debug("onDidChangeVisibleTextEditors event fired", "VSCode.Window")
|
||||
})
|
||||
|
||||
logs.debug("Returning editor from showTextDocument", "VSCode.Window")
|
||||
return placeholderEditor
|
||||
}
|
||||
|
||||
registerWebviewViewProvider(
|
||||
viewId: string,
|
||||
provider: WebviewViewProvider,
|
||||
_options?: WebviewViewProviderOptions,
|
||||
): Disposable {
|
||||
// Store the provider for later use by ExtensionHost
|
||||
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
|
||||
const extensionHost = (
|
||||
global as unknown as {
|
||||
__extensionHost: {
|
||||
registerWebviewProvider: (viewId: string, provider: WebviewViewProvider) => void
|
||||
isInInitialSetup: () => boolean
|
||||
markWebviewReady: () => void
|
||||
}
|
||||
}
|
||||
).__extensionHost
|
||||
extensionHost.registerWebviewProvider(viewId, provider)
|
||||
|
||||
// Set up webview mock that captures messages from the extension
|
||||
const mockWebview = {
|
||||
postMessage: (message: unknown): Thenable<boolean> => {
|
||||
// Forward extension messages to ExtensionHost for CLI consumption
|
||||
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
|
||||
;(
|
||||
global as unknown as {
|
||||
__extensionHost: { emit: (event: string, message: unknown) => void }
|
||||
}
|
||||
).__extensionHost.emit("extensionWebviewMessage", message)
|
||||
}
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
onDidReceiveMessage: (listener: (message: unknown) => void) => {
|
||||
// This is how the extension listens for messages from the webview
|
||||
// We need to connect this to our message bridge
|
||||
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
|
||||
;(
|
||||
global as unknown as {
|
||||
__extensionHost: { on: (event: string, listener: (message: unknown) => void) => void }
|
||||
}
|
||||
).__extensionHost.on("webviewMessage", listener)
|
||||
}
|
||||
return { dispose: () => {} }
|
||||
},
|
||||
asWebviewUri: (uriArg: Uri) => {
|
||||
// Convert file URIs to webview-compatible URIs
|
||||
// For CLI, we can just return a mock webview URI
|
||||
return Uri.parse(`vscode-webview://webview/${uriArg.path}`)
|
||||
},
|
||||
html: "",
|
||||
options: {},
|
||||
cspSource: "vscode-webview:",
|
||||
}
|
||||
|
||||
// Provide the mock webview to the provider
|
||||
if (provider.resolveWebviewView) {
|
||||
const mockWebviewView = {
|
||||
webview: mockWebview as Webview,
|
||||
viewType: viewId,
|
||||
title: viewId,
|
||||
description: undefined as string | undefined,
|
||||
badge: undefined as ViewBadge | undefined,
|
||||
show: () => {},
|
||||
onDidChangeVisibility: () => ({ dispose: () => {} }),
|
||||
onDidDispose: () => ({ dispose: () => {} }),
|
||||
visible: true,
|
||||
}
|
||||
|
||||
// Call resolveWebviewView immediately with initialization context
|
||||
// No setTimeout needed - use event-based synchronization instead
|
||||
;(async () => {
|
||||
try {
|
||||
// Pass isInitialSetup flag in context to prevent task abortion
|
||||
const context = {
|
||||
preserveFocus: false,
|
||||
isInitialSetup: extensionHost.isInInitialSetup(),
|
||||
}
|
||||
|
||||
logs.debug(
|
||||
`Calling resolveWebviewView with isInitialSetup=${context.isInitialSetup}`,
|
||||
"VSCode.Window",
|
||||
)
|
||||
|
||||
// Await the result to ensure webview is fully initialized before marking ready
|
||||
await provider.resolveWebviewView(mockWebviewView as WebviewView, {}, {} as CancellationToken)
|
||||
|
||||
// Mark webview as ready after resolution completes
|
||||
extensionHost.markWebviewReady()
|
||||
logs.debug("Webview resolution complete, marked as ready", "VSCode.Window")
|
||||
} catch (error) {
|
||||
logs.error("Error resolving webview view", "VSCode.Window", { error })
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
return {
|
||||
dispose: () => {
|
||||
if ((global as unknown as { __extensionHost?: unknown }).__extensionHost) {
|
||||
;(
|
||||
global as unknown as {
|
||||
__extensionHost: { unregisterWebviewProvider: (viewId: string) => void }
|
||||
}
|
||||
).__extensionHost.unregisterWebviewProvider(viewId)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
registerUriHandler(_handler: UriHandler): Disposable {
|
||||
// Store the URI handler for later use
|
||||
return {
|
||||
dispose: () => {},
|
||||
}
|
||||
}
|
||||
|
||||
onDidChangeTextEditorSelection(listener: (event: TextEditorSelectionChangeEvent) => void): Disposable {
|
||||
const emitter = new EventEmitter<TextEditorSelectionChangeEvent>()
|
||||
return emitter.event(listener)
|
||||
}
|
||||
|
||||
onDidChangeActiveTextEditor(listener: (event: TextEditor | undefined) => void): Disposable {
|
||||
const emitter = new EventEmitter<TextEditor | undefined>()
|
||||
return emitter.event(listener)
|
||||
}
|
||||
|
||||
onDidChangeVisibleTextEditors(listener: (editors: TextEditor[]) => void): Disposable {
|
||||
return this._onDidChangeVisibleTextEditors.event(listener)
|
||||
}
|
||||
|
||||
// Terminal event handlers
|
||||
onDidCloseTerminal(_listener: (terminal: Terminal) => void): Disposable {
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
|
||||
onDidOpenTerminal(_listener: (terminal: Terminal) => void): Disposable {
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
|
||||
onDidChangeActiveTerminal(_listener: (terminal: Terminal | undefined) => void): Disposable {
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
|
||||
onDidChangeTerminalDimensions(_listener: (event: TerminalDimensionsChangeEvent) => void): Disposable {
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
|
||||
onDidWriteTerminalData(_listener: (event: TerminalDataWriteEvent) => void): Disposable {
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
|
||||
get activeTerminal(): Terminal | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
get terminals(): Terminal[] {
|
||||
return []
|
||||
}
|
||||
}
|
||||
322
packages/vscode-shim/src/api/WorkspaceAPI.ts
Normal file
322
packages/vscode-shim/src/api/WorkspaceAPI.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/**
|
||||
* WorkspaceAPI class for VSCode API
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { logs } from "../utils/logger.js"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
import { Range } from "../classes/Range.js"
|
||||
import { EventEmitter } from "../classes/EventEmitter.js"
|
||||
import { WorkspaceEdit } from "../classes/TextEdit.js"
|
||||
import { FileSystemAPI } from "./FileSystemAPI.js"
|
||||
import { MockWorkspaceConfiguration } from "./WorkspaceConfiguration.js"
|
||||
import type { ExtensionContextImpl } from "../context/ExtensionContext.js"
|
||||
import type {
|
||||
TextDocument,
|
||||
TextLine,
|
||||
WorkspaceFoldersChangeEvent,
|
||||
WorkspaceFolder,
|
||||
TextDocumentChangeEvent,
|
||||
ConfigurationChangeEvent,
|
||||
TextDocumentContentProvider,
|
||||
FileSystemWatcher,
|
||||
RelativePattern,
|
||||
} from "../interfaces/document.js"
|
||||
import type { Disposable, WorkspaceConfiguration } from "../interfaces/workspace.js"
|
||||
import type { Thenable } from "../types.js"
|
||||
|
||||
/**
|
||||
* Workspace API mock for CLI mode
|
||||
*/
|
||||
export class WorkspaceAPI {
|
||||
public workspaceFolders: WorkspaceFolder[] | undefined
|
||||
public name: string | undefined
|
||||
public workspaceFile: Uri | undefined
|
||||
public fs: FileSystemAPI
|
||||
public textDocuments: TextDocument[] = []
|
||||
private _onDidChangeWorkspaceFolders = new EventEmitter<WorkspaceFoldersChangeEvent>()
|
||||
private _onDidOpenTextDocument = new EventEmitter<TextDocument>()
|
||||
private _onDidChangeTextDocument = new EventEmitter<TextDocumentChangeEvent>()
|
||||
private _onDidCloseTextDocument = new EventEmitter<TextDocument>()
|
||||
private context: ExtensionContextImpl
|
||||
|
||||
constructor(workspacePath: string, context: ExtensionContextImpl) {
|
||||
this.context = context
|
||||
this.workspaceFolders = [
|
||||
{
|
||||
uri: Uri.file(workspacePath),
|
||||
name: path.basename(workspacePath),
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
this.name = path.basename(workspacePath)
|
||||
this.fs = new FileSystemAPI()
|
||||
}
|
||||
|
||||
asRelativePath(pathOrUri: string | Uri, includeWorkspaceFolder?: boolean): string {
|
||||
const fsPath = typeof pathOrUri === "string" ? pathOrUri : pathOrUri.fsPath
|
||||
|
||||
// If no workspace folders, return the original path
|
||||
if (!this.workspaceFolders || this.workspaceFolders.length === 0) {
|
||||
return fsPath
|
||||
}
|
||||
|
||||
// Try to find a workspace folder that contains this path
|
||||
for (const folder of this.workspaceFolders) {
|
||||
const workspacePath = folder.uri.fsPath
|
||||
|
||||
// Normalize paths for comparison (handle different path separators)
|
||||
const normalizedFsPath = path.normalize(fsPath)
|
||||
const normalizedWorkspacePath = path.normalize(workspacePath)
|
||||
|
||||
// Check if the path is within this workspace folder
|
||||
if (normalizedFsPath.startsWith(normalizedWorkspacePath)) {
|
||||
// Get the relative path
|
||||
let relativePath = path.relative(normalizedWorkspacePath, normalizedFsPath)
|
||||
|
||||
// If includeWorkspaceFolder is true and there are multiple workspace folders,
|
||||
// prepend the workspace folder name
|
||||
if (includeWorkspaceFolder && this.workspaceFolders.length > 1) {
|
||||
relativePath = path.join(folder.name, relativePath)
|
||||
}
|
||||
|
||||
return relativePath
|
||||
}
|
||||
}
|
||||
|
||||
// If not within any workspace folder, return the original path
|
||||
return fsPath
|
||||
}
|
||||
|
||||
onDidChangeWorkspaceFolders(listener: (event: WorkspaceFoldersChangeEvent) => void): Disposable {
|
||||
return this._onDidChangeWorkspaceFolders.event(listener)
|
||||
}
|
||||
|
||||
onDidChangeConfiguration(listener: (event: ConfigurationChangeEvent) => void): Disposable {
|
||||
// Create a mock configuration change event emitter
|
||||
const emitter = new EventEmitter<ConfigurationChangeEvent>()
|
||||
return emitter.event(listener)
|
||||
}
|
||||
|
||||
onDidChangeTextDocument(listener: (event: TextDocumentChangeEvent) => void): Disposable {
|
||||
return this._onDidChangeTextDocument.event(listener)
|
||||
}
|
||||
|
||||
onDidOpenTextDocument(listener: (event: TextDocument) => void): Disposable {
|
||||
logs.debug("Registering onDidOpenTextDocument listener", "VSCode.Workspace")
|
||||
return this._onDidOpenTextDocument.event(listener)
|
||||
}
|
||||
|
||||
onDidCloseTextDocument(listener: (event: TextDocument) => void): Disposable {
|
||||
return this._onDidCloseTextDocument.event(listener)
|
||||
}
|
||||
|
||||
getConfiguration(section?: string): WorkspaceConfiguration {
|
||||
return new MockWorkspaceConfiguration(section, this.context)
|
||||
}
|
||||
|
||||
findFiles(_include: string, _exclude?: string): Thenable<Uri[]> {
|
||||
// Basic implementation - could be enhanced with glob patterns
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
async openTextDocument(uri: Uri): Promise<TextDocument> {
|
||||
logs.debug(`openTextDocument called for: ${uri.fsPath}`, "VSCode.Workspace")
|
||||
|
||||
// Read file content
|
||||
let content = ""
|
||||
try {
|
||||
content = fs.readFileSync(uri.fsPath, "utf-8")
|
||||
logs.debug(`File content read successfully, length: ${content.length}`, "VSCode.Workspace")
|
||||
} catch (error) {
|
||||
logs.warn(`Failed to read file: ${uri.fsPath}`, "VSCode.Workspace", { error })
|
||||
}
|
||||
|
||||
const lines = content.split("\n")
|
||||
const document: TextDocument = {
|
||||
uri,
|
||||
fileName: uri.fsPath,
|
||||
languageId: "plaintext",
|
||||
version: 1,
|
||||
isDirty: false,
|
||||
isClosed: false,
|
||||
lineCount: lines.length,
|
||||
getText: (range?: Range) => {
|
||||
if (!range) {
|
||||
return content
|
||||
}
|
||||
return lines.slice(range.start.line, range.end.line + 1).join("\n")
|
||||
},
|
||||
lineAt: (line: number): TextLine => {
|
||||
const text = lines[line] || ""
|
||||
return {
|
||||
text,
|
||||
range: new Range(new Position(line, 0), new Position(line, text.length)),
|
||||
rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)),
|
||||
firstNonWhitespaceCharacterIndex: text.search(/\S/),
|
||||
isEmptyOrWhitespace: text.trim().length === 0,
|
||||
}
|
||||
},
|
||||
offsetAt: (position: Position) => {
|
||||
let offset = 0
|
||||
for (let i = 0; i < position.line && i < lines.length; i++) {
|
||||
offset += (lines[i]?.length || 0) + 1 // +1 for newline
|
||||
}
|
||||
offset += position.character
|
||||
return offset
|
||||
},
|
||||
positionAt: (offset: number) => {
|
||||
let currentOffset = 0
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineLength = (lines[i]?.length || 0) + 1 // +1 for newline
|
||||
if (currentOffset + lineLength > offset) {
|
||||
return new Position(i, offset - currentOffset)
|
||||
}
|
||||
currentOffset += lineLength
|
||||
}
|
||||
return new Position(lines.length - 1, lines[lines.length - 1]?.length || 0)
|
||||
},
|
||||
save: () => Promise.resolve(true),
|
||||
validateRange: (range: Range) => range,
|
||||
validatePosition: (position: Position) => position,
|
||||
}
|
||||
|
||||
// Add to textDocuments array
|
||||
this.textDocuments.push(document)
|
||||
logs.debug(`Document added to textDocuments array, total: ${this.textDocuments.length}`, "VSCode.Workspace")
|
||||
|
||||
// Fire the event after a small delay to ensure listeners are fully registered
|
||||
logs.debug("Waiting before firing onDidOpenTextDocument", "VSCode.Workspace")
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
logs.debug("Firing onDidOpenTextDocument event", "VSCode.Workspace")
|
||||
this._onDidOpenTextDocument.fire(document)
|
||||
logs.debug("onDidOpenTextDocument event fired", "VSCode.Workspace")
|
||||
|
||||
return document
|
||||
}
|
||||
|
||||
async applyEdit(edit: WorkspaceEdit): Promise<boolean> {
|
||||
// In CLI mode, we need to apply the edits to the actual files
|
||||
try {
|
||||
for (const [uri, edits] of edit.entries()) {
|
||||
let filePath = uri.fsPath
|
||||
|
||||
// On Windows, strip leading slash if present (e.g., /C:/path becomes C:/path)
|
||||
if (process.platform === "win32" && filePath.startsWith("/")) {
|
||||
filePath = filePath.slice(1)
|
||||
}
|
||||
|
||||
let content = ""
|
||||
|
||||
// Read existing content if file exists
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf-8")
|
||||
} catch {
|
||||
// File doesn't exist, start with empty content
|
||||
}
|
||||
|
||||
// Apply edits in reverse order to maintain correct positions
|
||||
const sortedEdits = edits.sort((a, b) => {
|
||||
const lineDiff = b.range.start.line - a.range.start.line
|
||||
if (lineDiff !== 0) return lineDiff
|
||||
return b.range.start.character - a.range.start.character
|
||||
})
|
||||
|
||||
const lines = content.split("\n")
|
||||
for (const textEdit of sortedEdits) {
|
||||
const startLine = textEdit.range.start.line
|
||||
const startChar = textEdit.range.start.character
|
||||
const endLine = textEdit.range.end.line
|
||||
const endChar = textEdit.range.end.character
|
||||
|
||||
if (startLine === endLine) {
|
||||
// Single line edit
|
||||
const line = lines[startLine] || ""
|
||||
lines[startLine] = line.substring(0, startChar) + textEdit.newText + line.substring(endChar)
|
||||
} else {
|
||||
// Multi-line edit
|
||||
const firstLine = lines[startLine] || ""
|
||||
const lastLine = lines[endLine] || ""
|
||||
const newContent =
|
||||
firstLine.substring(0, startChar) + textEdit.newText + lastLine.substring(endChar)
|
||||
lines.splice(startLine, endLine - startLine + 1, newContent)
|
||||
}
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
const newContent = lines.join("\n")
|
||||
fs.writeFileSync(filePath, newContent, "utf-8")
|
||||
|
||||
// Update the in-memory document object to reflect the new content
|
||||
// This is critical for CLI mode where DiffViewProvider reads from the document object
|
||||
const document = this.textDocuments.find((doc: TextDocument) => doc.uri.fsPath === filePath)
|
||||
if (document) {
|
||||
const newLines = newContent.split("\n")
|
||||
|
||||
// Update document properties with new content
|
||||
document.lineCount = newLines.length
|
||||
document.getText = (range?: Range) => {
|
||||
if (!range) {
|
||||
return newContent
|
||||
}
|
||||
return newLines.slice(range.start.line, range.end.line + 1).join("\n")
|
||||
}
|
||||
document.lineAt = (line: number): TextLine => {
|
||||
const text = newLines[line] || ""
|
||||
return {
|
||||
text,
|
||||
range: new Range(new Position(line, 0), new Position(line, text.length)),
|
||||
rangeIncludingLineBreak: new Range(new Position(line, 0), new Position(line + 1, 0)),
|
||||
firstNonWhitespaceCharacterIndex: text.search(/\S/),
|
||||
isEmptyOrWhitespace: text.trim().length === 0,
|
||||
}
|
||||
}
|
||||
document.offsetAt = (position: Position) => {
|
||||
let offset = 0
|
||||
for (let i = 0; i < position.line && i < newLines.length; i++) {
|
||||
offset += (newLines[i]?.length || 0) + 1 // +1 for newline
|
||||
}
|
||||
offset += position.character
|
||||
return offset
|
||||
}
|
||||
document.positionAt = (offset: number) => {
|
||||
let currentOffset = 0
|
||||
for (let i = 0; i < newLines.length; i++) {
|
||||
const lineLength = (newLines[i]?.length || 0) + 1 // +1 for newline
|
||||
if (currentOffset + lineLength > offset) {
|
||||
return new Position(i, offset - currentOffset)
|
||||
}
|
||||
currentOffset += lineLength
|
||||
}
|
||||
return new Position(newLines.length - 1, newLines[newLines.length - 1]?.length || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
logs.error("Failed to apply workspace edit", "VSCode.Workspace", { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
createFileSystemWatcher(
|
||||
_globPattern?: string | RelativePattern,
|
||||
_ignoreCreateEvents?: boolean,
|
||||
_ignoreChangeEvents?: boolean,
|
||||
_ignoreDeleteEvents?: boolean,
|
||||
): FileSystemWatcher {
|
||||
const emitter = new EventEmitter<Uri>()
|
||||
return {
|
||||
onDidChange: (listener: (e: Uri) => void) => emitter.event(listener),
|
||||
onDidCreate: (listener: (e: Uri) => void) => emitter.event(listener),
|
||||
onDidDelete: (listener: (e: Uri) => void) => emitter.event(listener),
|
||||
dispose: () => emitter.dispose(),
|
||||
}
|
||||
}
|
||||
|
||||
registerTextDocumentContentProvider(_scheme: string, _provider: TextDocumentContentProvider): Disposable {
|
||||
return { dispose: () => {} }
|
||||
}
|
||||
}
|
||||
195
packages/vscode-shim/src/api/WorkspaceConfiguration.ts
Normal file
195
packages/vscode-shim/src/api/WorkspaceConfiguration.ts
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/**
|
||||
* MockWorkspaceConfiguration class for VSCode API
|
||||
*/
|
||||
|
||||
import * as path from "path"
|
||||
import { logs } from "../utils/logger.js"
|
||||
import { VSCodeMockPaths, ensureDirectoryExists } from "../utils/paths.js"
|
||||
import { FileMemento } from "../storage/Memento.js"
|
||||
import { ConfigurationTarget } from "../types.js"
|
||||
import type { ConfigurationInspect } from "../types.js"
|
||||
import type { WorkspaceConfiguration } from "../interfaces/workspace.js"
|
||||
import type { ExtensionContextImpl } from "../context/ExtensionContext.js"
|
||||
|
||||
/**
|
||||
* In-memory runtime configuration store shared across all MockWorkspaceConfiguration instances.
|
||||
* This allows configuration to be updated at runtime (e.g., from CLI settings) without
|
||||
* persisting to disk. Values in this store take precedence over disk-based mementos.
|
||||
*/
|
||||
const runtimeConfig: Map<string, unknown> = new Map()
|
||||
|
||||
/**
|
||||
* Set a runtime configuration value.
|
||||
* @param section The configuration section (e.g., "roo-cline")
|
||||
* @param key The configuration key (e.g., "commandExecutionTimeout")
|
||||
* @param value The value to set
|
||||
*/
|
||||
export function setRuntimeConfig(section: string, key: string, value: unknown): void {
|
||||
const fullKey = `${section}.${key}`
|
||||
runtimeConfig.set(fullKey, value)
|
||||
logs.debug(`Runtime config set: ${fullKey} = ${JSON.stringify(value)}`, "VSCode.MockWorkspaceConfiguration")
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple runtime configuration values at once.
|
||||
* @param section The configuration section (e.g., "roo-cline")
|
||||
* @param values Object containing key-value pairs to set
|
||||
*/
|
||||
export function setRuntimeConfigValues(section: string, values: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value !== undefined) {
|
||||
setRuntimeConfig(section, key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all runtime configuration values.
|
||||
*/
|
||||
export function clearRuntimeConfig(): void {
|
||||
runtimeConfig.clear()
|
||||
logs.debug("Runtime config cleared", "VSCode.MockWorkspaceConfiguration")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a runtime configuration value.
|
||||
* @param fullKey The full configuration key (e.g., "roo-cline.commandExecutionTimeout")
|
||||
* @returns The value or undefined if not set
|
||||
*/
|
||||
export function getRuntimeConfig(fullKey: string): unknown {
|
||||
return runtimeConfig.get(fullKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock workspace configuration for CLI mode
|
||||
* Persists configuration to JSON files
|
||||
*/
|
||||
export class MockWorkspaceConfiguration implements WorkspaceConfiguration {
|
||||
private section: string | undefined
|
||||
private globalMemento: FileMemento
|
||||
private workspaceMemento: FileMemento
|
||||
|
||||
constructor(section?: string, context?: ExtensionContextImpl) {
|
||||
this.section = section
|
||||
|
||||
if (context) {
|
||||
// Use the extension context's mementos
|
||||
this.globalMemento = context.globalState as unknown as FileMemento
|
||||
this.workspaceMemento = context.workspaceState as unknown as FileMemento
|
||||
} else {
|
||||
// Fallback: create our own mementos (shouldn't happen in normal usage)
|
||||
const globalStoragePath = VSCodeMockPaths.getGlobalStorageDir()
|
||||
const workspaceStoragePath = VSCodeMockPaths.getWorkspaceStorageDir(process.cwd())
|
||||
|
||||
ensureDirectoryExists(globalStoragePath)
|
||||
ensureDirectoryExists(workspaceStoragePath)
|
||||
|
||||
this.globalMemento = new FileMemento(path.join(globalStoragePath, "configuration.json"))
|
||||
this.workspaceMemento = new FileMemento(path.join(workspaceStoragePath, "configuration.json"))
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(section: string, defaultValue?: T): T | undefined {
|
||||
const fullSection = this.section ? `${this.section}.${section}` : section
|
||||
|
||||
// Check runtime configuration first (highest priority - set by CLI at runtime)
|
||||
const runtimeValue = runtimeConfig.get(fullSection)
|
||||
if (runtimeValue !== undefined) {
|
||||
return runtimeValue as T
|
||||
}
|
||||
|
||||
// Check workspace configuration (persisted to disk)
|
||||
const workspaceValue = this.workspaceMemento.get(fullSection)
|
||||
if (workspaceValue !== undefined && workspaceValue !== null) {
|
||||
return workspaceValue as T
|
||||
}
|
||||
|
||||
// Check global configuration (persisted to disk)
|
||||
const globalValue = this.globalMemento.get(fullSection)
|
||||
if (globalValue !== undefined && globalValue !== null) {
|
||||
return globalValue as T
|
||||
}
|
||||
|
||||
// Return default value
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
has(section: string): boolean {
|
||||
const fullSection = this.section ? `${this.section}.${section}` : section
|
||||
return this.workspaceMemento.get(fullSection) !== undefined || this.globalMemento.get(fullSection) !== undefined
|
||||
}
|
||||
|
||||
inspect<T>(section: string): ConfigurationInspect<T> | undefined {
|
||||
const fullSection = this.section ? `${this.section}.${section}` : section
|
||||
const workspaceValue = this.workspaceMemento.get(fullSection)
|
||||
const globalValue = this.globalMemento.get(fullSection)
|
||||
|
||||
if (workspaceValue !== undefined || globalValue !== undefined) {
|
||||
return {
|
||||
key: fullSection,
|
||||
defaultValue: undefined,
|
||||
globalValue: globalValue as T | undefined,
|
||||
workspaceValue: workspaceValue as T | undefined,
|
||||
workspaceFolderValue: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
async update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Promise<void> {
|
||||
const fullSection = this.section ? `${this.section}.${section}` : section
|
||||
|
||||
try {
|
||||
// Determine which memento to use based on configuration target
|
||||
const memento =
|
||||
configurationTarget === ConfigurationTarget.Workspace ? this.workspaceMemento : this.globalMemento
|
||||
|
||||
const scope = configurationTarget === ConfigurationTarget.Workspace ? "workspace" : "global"
|
||||
|
||||
// Update the memento (this automatically persists to disk)
|
||||
await memento.update(fullSection, value)
|
||||
|
||||
logs.debug(
|
||||
`Configuration updated: ${fullSection} = ${JSON.stringify(value)} (${scope})`,
|
||||
"VSCode.MockWorkspaceConfiguration",
|
||||
)
|
||||
} catch (error) {
|
||||
logs.error(`Failed to update configuration: ${fullSection}`, "VSCode.MockWorkspaceConfiguration", {
|
||||
error,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Additional method to reload configuration from disk
|
||||
public reload(): void {
|
||||
// FileMemento automatically loads from disk, so we don't need to do anything special
|
||||
logs.debug("Configuration reload requested", "VSCode.MockWorkspaceConfiguration")
|
||||
}
|
||||
|
||||
// Method to get all configuration data (useful for debugging and generic config loading)
|
||||
public getAllConfig(): Record<string, unknown> {
|
||||
const globalKeys = this.globalMemento.keys()
|
||||
const workspaceKeys = this.workspaceMemento.keys()
|
||||
const allConfig: Record<string, unknown> = {}
|
||||
|
||||
// Add global settings first
|
||||
for (const key of globalKeys) {
|
||||
const value = this.globalMemento.get(key)
|
||||
if (value !== undefined && value !== null) {
|
||||
allConfig[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Add workspace settings (these override global)
|
||||
for (const key of workspaceKeys) {
|
||||
const value = this.workspaceMemento.get(key)
|
||||
if (value !== undefined && value !== null) {
|
||||
allConfig[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return allConfig
|
||||
}
|
||||
}
|
||||
315
packages/vscode-shim/src/api/create-vscode-api-mock.ts
Normal file
315
packages/vscode-shim/src/api/create-vscode-api-mock.ts
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/**
|
||||
* Main factory function for creating VSCode API mock
|
||||
*/
|
||||
|
||||
import { machineIdSync } from "../utils/machine-id.js"
|
||||
import { logs } from "../utils/logger.js"
|
||||
|
||||
// Import classes
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { Position } from "../classes/Position.js"
|
||||
import { Range } from "../classes/Range.js"
|
||||
import { Selection } from "../classes/Selection.js"
|
||||
import { EventEmitter } from "../classes/EventEmitter.js"
|
||||
import { TextEdit, WorkspaceEdit } from "../classes/TextEdit.js"
|
||||
import {
|
||||
Location,
|
||||
Diagnostic,
|
||||
DiagnosticRelatedInformation,
|
||||
ThemeColor,
|
||||
ThemeIcon,
|
||||
CodeActionKind,
|
||||
CodeLens,
|
||||
LanguageModelTextPart,
|
||||
LanguageModelToolCallPart,
|
||||
LanguageModelToolResultPart,
|
||||
FileSystemError,
|
||||
} from "../classes/Additional.js"
|
||||
import { CancellationTokenSource } from "../classes/CancellationToken.js"
|
||||
import { StatusBarItem } from "../classes/StatusBarItem.js"
|
||||
import { ExtensionContextImpl } from "../context/ExtensionContext.js"
|
||||
|
||||
// Import APIs
|
||||
import { WorkspaceAPI } from "./WorkspaceAPI.js"
|
||||
import { WindowAPI } from "./WindowAPI.js"
|
||||
import { CommandsAPI } from "./CommandsAPI.js"
|
||||
|
||||
// Import types and enums
|
||||
import {
|
||||
ConfigurationTarget,
|
||||
ViewColumn,
|
||||
TextEditorRevealType,
|
||||
StatusBarAlignment,
|
||||
DiagnosticSeverity,
|
||||
DiagnosticTag,
|
||||
EndOfLine,
|
||||
UIKind,
|
||||
ExtensionMode,
|
||||
FileType,
|
||||
DecorationRangeBehavior,
|
||||
OverviewRulerLane,
|
||||
} from "../types.js"
|
||||
|
||||
// Import interfaces
|
||||
import type { CancellationToken } from "../interfaces/document.js"
|
||||
import type { Disposable, DiagnosticCollection, IdentityInfo } from "../interfaces/workspace.js"
|
||||
import type { RelativePattern } from "../interfaces/document.js"
|
||||
import type { UriHandler } from "../interfaces/webview.js"
|
||||
|
||||
// Package version constant
|
||||
const Package = { version: "1.0.0" }
|
||||
|
||||
/**
|
||||
* Options for creating the VSCode API mock
|
||||
*/
|
||||
export interface VSCodeAPIMockOptions {
|
||||
/**
|
||||
* Custom app root path (for locating ripgrep and other VSCode resources).
|
||||
* Defaults to the directory containing this module.
|
||||
*/
|
||||
appRoot?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete VSCode API mock for CLI mode
|
||||
*/
|
||||
export function createVSCodeAPIMock(
|
||||
extensionRootPath: string,
|
||||
workspacePath: string,
|
||||
identity?: IdentityInfo,
|
||||
options?: VSCodeAPIMockOptions,
|
||||
) {
|
||||
const context = new ExtensionContextImpl({
|
||||
extensionPath: extensionRootPath,
|
||||
workspacePath: workspacePath,
|
||||
})
|
||||
const workspace = new WorkspaceAPI(workspacePath, context)
|
||||
const window = new WindowAPI()
|
||||
const commands = new CommandsAPI()
|
||||
|
||||
// Link window and workspace for cross-API calls
|
||||
window.setWorkspace(workspace)
|
||||
|
||||
// Environment mock with identity values
|
||||
const env = {
|
||||
appName: `wrapper|cli|cli|${Package.version}`,
|
||||
appRoot: options?.appRoot || import.meta.dirname,
|
||||
language: "en",
|
||||
machineId: identity?.machineId || machineIdSync(),
|
||||
sessionId: identity?.sessionId || "cli-session-id",
|
||||
remoteName: undefined,
|
||||
shell: process.env.SHELL || "/bin/bash",
|
||||
uriScheme: "vscode",
|
||||
uiKind: 1, // Desktop
|
||||
openExternal: async (uri: Uri): Promise<boolean> => {
|
||||
logs.info(`Would open external URL: ${uri.toString()}`, "VSCode.Env")
|
||||
return true
|
||||
},
|
||||
clipboard: {
|
||||
readText: async (): Promise<string> => {
|
||||
logs.debug("Clipboard read requested", "VSCode.Clipboard")
|
||||
return ""
|
||||
},
|
||||
writeText: async (text: string): Promise<void> => {
|
||||
logs.debug(
|
||||
`Clipboard write: ${text.substring(0, 100)}${text.length > 100 ? "..." : ""}`,
|
||||
"VSCode.Clipboard",
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
version: "1.84.0",
|
||||
Uri,
|
||||
EventEmitter,
|
||||
ConfigurationTarget,
|
||||
ViewColumn,
|
||||
TextEditorRevealType,
|
||||
StatusBarAlignment,
|
||||
DiagnosticSeverity,
|
||||
DiagnosticTag,
|
||||
Position,
|
||||
Range,
|
||||
Selection,
|
||||
Location,
|
||||
Diagnostic,
|
||||
DiagnosticRelatedInformation,
|
||||
TextEdit,
|
||||
WorkspaceEdit,
|
||||
EndOfLine,
|
||||
UIKind,
|
||||
ExtensionMode,
|
||||
CodeActionKind,
|
||||
ThemeColor,
|
||||
ThemeIcon,
|
||||
DecorationRangeBehavior,
|
||||
OverviewRulerLane,
|
||||
StatusBarItem,
|
||||
CancellationToken: class CancellationTokenClass implements CancellationToken {
|
||||
isCancellationRequested = false
|
||||
onCancellationRequested = (_listener: (e: unknown) => void) => ({ dispose: () => {} })
|
||||
},
|
||||
CancellationTokenSource,
|
||||
CodeLens,
|
||||
LanguageModelTextPart,
|
||||
LanguageModelToolCallPart,
|
||||
LanguageModelToolResultPart,
|
||||
ExtensionContext: ExtensionContextImpl,
|
||||
FileType,
|
||||
FileSystemError,
|
||||
Disposable: class DisposableClass implements Disposable {
|
||||
dispose(): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
|
||||
static from(...disposables: Disposable[]): Disposable {
|
||||
return {
|
||||
dispose: () => {
|
||||
disposables.forEach((d) => d.dispose())
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
TabInputText: class TabInputText {
|
||||
constructor(public uri: Uri) {}
|
||||
},
|
||||
TabInputTextDiff: class TabInputTextDiff {
|
||||
constructor(
|
||||
public original: Uri,
|
||||
public modified: Uri,
|
||||
) {}
|
||||
},
|
||||
workspace,
|
||||
window,
|
||||
commands,
|
||||
env,
|
||||
context,
|
||||
// Add more APIs as needed
|
||||
languages: {
|
||||
registerCodeActionsProvider: () => ({ dispose: () => {} }),
|
||||
registerCodeLensProvider: () => ({ dispose: () => {} }),
|
||||
registerCompletionItemProvider: () => ({ dispose: () => {} }),
|
||||
registerHoverProvider: () => ({ dispose: () => {} }),
|
||||
registerDefinitionProvider: () => ({ dispose: () => {} }),
|
||||
registerReferenceProvider: () => ({ dispose: () => {} }),
|
||||
registerDocumentSymbolProvider: () => ({ dispose: () => {} }),
|
||||
registerWorkspaceSymbolProvider: () => ({ dispose: () => {} }),
|
||||
registerRenameProvider: () => ({ dispose: () => {} }),
|
||||
registerDocumentFormattingEditProvider: () => ({ dispose: () => {} }),
|
||||
registerDocumentRangeFormattingEditProvider: () => ({ dispose: () => {} }),
|
||||
registerSignatureHelpProvider: () => ({ dispose: () => {} }),
|
||||
getDiagnostics: (uri?: Uri): [Uri, Diagnostic[]][] | Diagnostic[] => {
|
||||
// In CLI mode, we don't have real diagnostics
|
||||
// Return empty array or empty diagnostics for the specific URI
|
||||
if (uri) {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
},
|
||||
createDiagnosticCollection: (name?: string): DiagnosticCollection => {
|
||||
const diagnostics = new Map<string, Diagnostic[]>()
|
||||
const collection: DiagnosticCollection = {
|
||||
name: name || "default",
|
||||
set: (
|
||||
uriOrEntries: Uri | [Uri, Diagnostic[] | undefined][],
|
||||
diagnosticsOrUndefined?: Diagnostic[] | undefined,
|
||||
) => {
|
||||
if (Array.isArray(uriOrEntries)) {
|
||||
// Handle array of entries
|
||||
for (const [uri, diags] of uriOrEntries) {
|
||||
if (diags === undefined) {
|
||||
diagnostics.delete(uri.toString())
|
||||
} else {
|
||||
diagnostics.set(uri.toString(), diags)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle single URI
|
||||
if (diagnosticsOrUndefined === undefined) {
|
||||
diagnostics.delete(uriOrEntries.toString())
|
||||
} else {
|
||||
diagnostics.set(uriOrEntries.toString(), diagnosticsOrUndefined)
|
||||
}
|
||||
}
|
||||
},
|
||||
delete: (uri: Uri) => {
|
||||
diagnostics.delete(uri.toString())
|
||||
},
|
||||
clear: () => {
|
||||
diagnostics.clear()
|
||||
},
|
||||
forEach: (
|
||||
callback: (uri: Uri, diagnostics: Diagnostic[], collection: DiagnosticCollection) => void,
|
||||
thisArg?: unknown,
|
||||
) => {
|
||||
diagnostics.forEach((diags, uriString) => {
|
||||
callback.call(thisArg, Uri.parse(uriString), diags, collection)
|
||||
})
|
||||
},
|
||||
get: (uri: Uri) => {
|
||||
return diagnostics.get(uri.toString())
|
||||
},
|
||||
has: (uri: Uri) => {
|
||||
return diagnostics.has(uri.toString())
|
||||
},
|
||||
dispose: () => {
|
||||
diagnostics.clear()
|
||||
},
|
||||
}
|
||||
return collection
|
||||
},
|
||||
},
|
||||
debug: {
|
||||
onDidStartDebugSession: () => ({ dispose: () => {} }),
|
||||
onDidTerminateDebugSession: () => ({ dispose: () => {} }),
|
||||
},
|
||||
tasks: {
|
||||
onDidStartTask: () => ({ dispose: () => {} }),
|
||||
onDidEndTask: () => ({ dispose: () => {} }),
|
||||
},
|
||||
extensions: {
|
||||
all: [],
|
||||
getExtension: (extensionId: string) => {
|
||||
// Mock the extension object with extensionUri for theme loading
|
||||
if (extensionId === "RooVeterinaryInc.roo-cline") {
|
||||
return {
|
||||
id: extensionId,
|
||||
extensionUri: context.extensionUri,
|
||||
extensionPath: context.extensionPath,
|
||||
isActive: true,
|
||||
packageJSON: {},
|
||||
exports: undefined,
|
||||
activate: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
onDidChange: () => ({ dispose: () => {} }),
|
||||
},
|
||||
// Add file system watcher
|
||||
FileSystemWatcher: class {
|
||||
onDidChange = () => ({ dispose: () => {} })
|
||||
onDidCreate = () => ({ dispose: () => {} })
|
||||
onDidDelete = () => ({ dispose: () => {} })
|
||||
dispose = () => {}
|
||||
},
|
||||
// Add relative pattern
|
||||
RelativePattern: class implements RelativePattern {
|
||||
constructor(
|
||||
public base: string,
|
||||
public pattern: string,
|
||||
) {}
|
||||
},
|
||||
// Add progress location
|
||||
ProgressLocation: {
|
||||
SourceControl: 1,
|
||||
Window: 10,
|
||||
Notification: 15,
|
||||
},
|
||||
// Add URI handler
|
||||
UriHandler: class implements UriHandler {
|
||||
handleUri = (_uri: Uri) => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
181
packages/vscode-shim/src/classes/Additional.ts
Normal file
181
packages/vscode-shim/src/classes/Additional.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Additional VSCode API classes for extension support
|
||||
*
|
||||
* This file contains supplementary classes and types that extensions may need.
|
||||
*/
|
||||
|
||||
import { Range } from "./Range.js"
|
||||
import type { IUri, IRange, IPosition, DiagnosticSeverity, DiagnosticTag } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a location in source code (URI + Range or Position)
|
||||
*/
|
||||
export class Location {
|
||||
constructor(
|
||||
public uri: IUri,
|
||||
public range: IRange | IPosition,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Related diagnostic information
|
||||
*/
|
||||
export class DiagnosticRelatedInformation {
|
||||
constructor(
|
||||
public location: Location,
|
||||
public message: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a diagnostic (error, warning, etc.)
|
||||
*/
|
||||
export class Diagnostic {
|
||||
range: Range
|
||||
message: string
|
||||
severity: DiagnosticSeverity
|
||||
source?: string
|
||||
code?: string | number | { value: string | number; target: IUri }
|
||||
relatedInformation?: DiagnosticRelatedInformation[]
|
||||
tags?: DiagnosticTag[]
|
||||
|
||||
constructor(range: IRange, message: string, severity?: DiagnosticSeverity) {
|
||||
this.range = range as Range
|
||||
this.message = message
|
||||
this.severity = severity !== undefined ? severity : 0 // Error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme color reference
|
||||
*/
|
||||
export class ThemeColor {
|
||||
constructor(public id: string) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme icon reference
|
||||
*/
|
||||
export class ThemeIcon {
|
||||
constructor(
|
||||
public id: string,
|
||||
public color?: ThemeColor,
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Code action kind for categorizing code actions
|
||||
*/
|
||||
export class CodeActionKind {
|
||||
static readonly Empty = new CodeActionKind("")
|
||||
static readonly QuickFix = new CodeActionKind("quickfix")
|
||||
static readonly Refactor = new CodeActionKind("refactor")
|
||||
static readonly RefactorExtract = new CodeActionKind("refactor.extract")
|
||||
static readonly RefactorInline = new CodeActionKind("refactor.inline")
|
||||
static readonly RefactorRewrite = new CodeActionKind("refactor.rewrite")
|
||||
static readonly Source = new CodeActionKind("source")
|
||||
static readonly SourceOrganizeImports = new CodeActionKind("source.organizeImports")
|
||||
|
||||
constructor(public value: string) {}
|
||||
|
||||
append(parts: string): CodeActionKind {
|
||||
return new CodeActionKind(this.value ? `${this.value}.${parts}` : parts)
|
||||
}
|
||||
|
||||
intersects(other: CodeActionKind): boolean {
|
||||
return this.contains(other) || other.contains(this)
|
||||
}
|
||||
|
||||
contains(other: CodeActionKind): boolean {
|
||||
return this.value === other.value || other.value.startsWith(this.value + ".")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Code lens for displaying inline information
|
||||
*/
|
||||
export class CodeLens {
|
||||
public range: Range
|
||||
public command?: { command: string; title: string; arguments?: unknown[] } | undefined
|
||||
public isResolved: boolean = false
|
||||
|
||||
constructor(range: IRange, command?: { command: string; title: string; arguments?: unknown[] } | undefined) {
|
||||
this.range = range as Range
|
||||
this.command = command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Language Model API parts
|
||||
*/
|
||||
export class LanguageModelTextPart {
|
||||
constructor(public value: string) {}
|
||||
}
|
||||
|
||||
export class LanguageModelToolCallPart {
|
||||
constructor(
|
||||
public callId: string,
|
||||
public name: string,
|
||||
public input: unknown,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class LanguageModelToolResultPart {
|
||||
constructor(
|
||||
public callId: string,
|
||||
public content: unknown[],
|
||||
) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* File system error with specific error codes
|
||||
*/
|
||||
export class FileSystemError extends Error {
|
||||
public code: string
|
||||
|
||||
constructor(message: string, code: string = "Unknown") {
|
||||
super(message)
|
||||
this.name = "FileSystemError"
|
||||
this.code = code
|
||||
}
|
||||
|
||||
static FileNotFound(messageOrUri?: string | IUri): FileSystemError {
|
||||
const message =
|
||||
typeof messageOrUri === "string" ? messageOrUri : `File not found: ${messageOrUri?.fsPath || "unknown"}`
|
||||
return new FileSystemError(message, "FileNotFound")
|
||||
}
|
||||
|
||||
static FileExists(messageOrUri?: string | IUri): FileSystemError {
|
||||
const message =
|
||||
typeof messageOrUri === "string" ? messageOrUri : `File exists: ${messageOrUri?.fsPath || "unknown"}`
|
||||
return new FileSystemError(message, "FileExists")
|
||||
}
|
||||
|
||||
static FileNotADirectory(messageOrUri?: string | IUri): FileSystemError {
|
||||
const message =
|
||||
typeof messageOrUri === "string"
|
||||
? messageOrUri
|
||||
: `File is not a directory: ${messageOrUri?.fsPath || "unknown"}`
|
||||
return new FileSystemError(message, "FileNotADirectory")
|
||||
}
|
||||
|
||||
static FileIsADirectory(messageOrUri?: string | IUri): FileSystemError {
|
||||
const message =
|
||||
typeof messageOrUri === "string"
|
||||
? messageOrUri
|
||||
: `File is a directory: ${messageOrUri?.fsPath || "unknown"}`
|
||||
return new FileSystemError(message, "FileIsADirectory")
|
||||
}
|
||||
|
||||
static NoPermissions(messageOrUri?: string | IUri): FileSystemError {
|
||||
const message =
|
||||
typeof messageOrUri === "string" ? messageOrUri : `No permissions: ${messageOrUri?.fsPath || "unknown"}`
|
||||
return new FileSystemError(message, "NoPermissions")
|
||||
}
|
||||
|
||||
static Unavailable(messageOrUri?: string | IUri): FileSystemError {
|
||||
const message =
|
||||
typeof messageOrUri === "string" ? messageOrUri : `Unavailable: ${messageOrUri?.fsPath || "unknown"}`
|
||||
return new FileSystemError(message, "Unavailable")
|
||||
}
|
||||
}
|
||||
48
packages/vscode-shim/src/classes/CancellationToken.ts
Normal file
48
packages/vscode-shim/src/classes/CancellationToken.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* CancellationToken and CancellationTokenSource for VSCode API
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "./EventEmitter.js"
|
||||
import type { Disposable } from "../interfaces/workspace.js"
|
||||
|
||||
/**
|
||||
* Cancellation token interface
|
||||
*/
|
||||
export interface CancellationToken {
|
||||
isCancellationRequested: boolean
|
||||
onCancellationRequested: (listener: (e: unknown) => void) => Disposable
|
||||
}
|
||||
|
||||
/**
|
||||
* CancellationTokenSource creates and controls a CancellationToken
|
||||
*/
|
||||
export class CancellationTokenSource {
|
||||
private _token: CancellationToken
|
||||
private _isCancelled = false
|
||||
private _onCancellationRequestedEmitter = new EventEmitter<void>()
|
||||
|
||||
constructor() {
|
||||
this._token = {
|
||||
isCancellationRequested: false,
|
||||
onCancellationRequested: this._onCancellationRequestedEmitter.event,
|
||||
}
|
||||
}
|
||||
|
||||
get token(): CancellationToken {
|
||||
return this._token
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (!this._isCancelled) {
|
||||
this._isCancelled = true
|
||||
// Type assertion needed to modify readonly property
|
||||
;(this._token as { isCancellationRequested: boolean }).isCancellationRequested = true
|
||||
this._onCancellationRequestedEmitter.fire(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.cancel()
|
||||
this._onCancellationRequestedEmitter.dispose()
|
||||
}
|
||||
}
|
||||
88
packages/vscode-shim/src/classes/EventEmitter.ts
Normal file
88
packages/vscode-shim/src/classes/EventEmitter.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import type { Disposable, Event } from "../types.js"
|
||||
|
||||
/**
|
||||
* VSCode-compatible EventEmitter implementation
|
||||
*
|
||||
* Provides a type-safe event emitter that matches VSCode's EventEmitter API.
|
||||
* Listeners can subscribe to events and will be notified when events are fired.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const emitter = new EventEmitter<string>()
|
||||
*
|
||||
* // Subscribe to events
|
||||
* const disposable = emitter.event((value) => {
|
||||
* console.log('Event fired:', value)
|
||||
* })
|
||||
*
|
||||
* // Fire an event
|
||||
* emitter.fire('Hello, world!')
|
||||
*
|
||||
* // Clean up
|
||||
* disposable.dispose()
|
||||
* emitter.dispose()
|
||||
* ```
|
||||
*/
|
||||
export class EventEmitter<T> {
|
||||
readonly #listeners = new Set<(e: T) => void>()
|
||||
|
||||
/**
|
||||
* The event that listeners can subscribe to
|
||||
*
|
||||
* @param listener - The callback function to invoke when the event fires
|
||||
* @param thisArgs - Optional 'this' context for the listener
|
||||
* @param disposables - Optional array to add the disposable to
|
||||
* @returns A disposable to unsubscribe from the event
|
||||
*/
|
||||
event: Event<T> = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]): Disposable => {
|
||||
const fn = thisArgs ? listener.bind(thisArgs) : listener
|
||||
this.#listeners.add(fn)
|
||||
|
||||
const disposable: Disposable = {
|
||||
dispose: () => {
|
||||
this.#listeners.delete(fn)
|
||||
},
|
||||
}
|
||||
|
||||
if (disposables) {
|
||||
disposables.push(disposable)
|
||||
}
|
||||
|
||||
return disposable
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire the event, notifying all subscribers
|
||||
*
|
||||
* Failure of one or more listeners will not fail this function call.
|
||||
* Failed listeners will be caught and ignored to prevent one listener
|
||||
* from breaking others.
|
||||
*
|
||||
* @param data - The event data to pass to listeners
|
||||
*/
|
||||
fire(data: T): void {
|
||||
for (const listener of this.#listeners) {
|
||||
try {
|
||||
listener(data)
|
||||
} catch (error) {
|
||||
// Silently ignore listener errors to prevent one failing listener
|
||||
// from affecting others. Consumers can add error handling in their listeners.
|
||||
console.error("EventEmitter listener error:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose this event emitter and remove all listeners
|
||||
*/
|
||||
dispose(): void {
|
||||
this.#listeners.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current number of listeners (useful for debugging)
|
||||
*/
|
||||
get listenerCount(): number {
|
||||
return this.#listeners.size
|
||||
}
|
||||
}
|
||||
46
packages/vscode-shim/src/classes/OutputChannel.ts
Normal file
46
packages/vscode-shim/src/classes/OutputChannel.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* OutputChannel class for VSCode API
|
||||
*/
|
||||
|
||||
import { logs } from "../utils/logger.js"
|
||||
import type { Disposable } from "../interfaces/workspace.js"
|
||||
|
||||
/**
|
||||
* Output channel mock for CLI mode
|
||||
* Logs output to the configured logger instead of VSCode's output panel
|
||||
*/
|
||||
export class OutputChannel implements Disposable {
|
||||
private _name: string
|
||||
|
||||
constructor(name: string) {
|
||||
this._name = name
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this._name
|
||||
}
|
||||
|
||||
append(value: string): void {
|
||||
logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel")
|
||||
}
|
||||
|
||||
appendLine(value: string): void {
|
||||
logs.info(`[${this._name}] ${value}`, "VSCode.OutputChannel")
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
|
||||
show(): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
}
|
||||
148
packages/vscode-shim/src/classes/Position.ts
Normal file
148
packages/vscode-shim/src/classes/Position.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import type { IPosition } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a position in a text document
|
||||
*
|
||||
* A position is defined by a zero-based line number and a zero-based character offset.
|
||||
* This class is immutable - all methods that modify the position return a new instance.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const pos = new Position(5, 10) // Line 5, character 10
|
||||
* const next = pos.translate(1, 0) // Line 6, character 10
|
||||
* ```
|
||||
*/
|
||||
export class Position implements IPosition {
|
||||
/**
|
||||
* The zero-based line number
|
||||
*/
|
||||
public readonly line: number
|
||||
|
||||
/**
|
||||
* The zero-based character offset
|
||||
*/
|
||||
public readonly character: number
|
||||
|
||||
/**
|
||||
* Create a new Position
|
||||
*
|
||||
* @param line - The zero-based line number
|
||||
* @param character - The zero-based character offset
|
||||
*/
|
||||
constructor(line: number, character: number) {
|
||||
if (line < 0) {
|
||||
throw new Error("Line number must be non-negative")
|
||||
}
|
||||
if (character < 0) {
|
||||
throw new Error("Character offset must be non-negative")
|
||||
}
|
||||
this.line = line
|
||||
this.character = character
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this position is equal to another position
|
||||
*/
|
||||
isEqual(other: IPosition): boolean {
|
||||
return this.line === other.line && this.character === other.character
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this position is before another position
|
||||
*/
|
||||
isBefore(other: IPosition): boolean {
|
||||
if (this.line < other.line) {
|
||||
return true
|
||||
}
|
||||
if (this.line === other.line) {
|
||||
return this.character < other.character
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this position is before or equal to another position
|
||||
*/
|
||||
isBeforeOrEqual(other: IPosition): boolean {
|
||||
return this.isBefore(other) || this.isEqual(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this position is after another position
|
||||
*/
|
||||
isAfter(other: IPosition): boolean {
|
||||
return !this.isBeforeOrEqual(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this position is after or equal to another position
|
||||
*/
|
||||
isAfterOrEqual(other: IPosition): boolean {
|
||||
return !this.isBefore(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare this position to another
|
||||
*
|
||||
* @returns -1 if this position is before, 0 if equal, 1 if after
|
||||
*/
|
||||
compareTo(other: IPosition): number {
|
||||
if (this.line < other.line) {
|
||||
return -1
|
||||
}
|
||||
if (this.line > other.line) {
|
||||
return 1
|
||||
}
|
||||
if (this.character < other.character) {
|
||||
return -1
|
||||
}
|
||||
if (this.character > other.character) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new position relative to this position
|
||||
*
|
||||
* @param lineDelta - The line delta (default: 0)
|
||||
* @param characterDelta - The character delta (default: 0)
|
||||
* @returns A new Position
|
||||
*/
|
||||
translate(lineDelta?: number, characterDelta?: number): Position
|
||||
translate(change: { lineDelta?: number; characterDelta?: number }): Position
|
||||
translate(
|
||||
lineDeltaOrChange?: number | { lineDelta?: number; characterDelta?: number },
|
||||
characterDelta?: number,
|
||||
): Position {
|
||||
if (typeof lineDeltaOrChange === "object") {
|
||||
return new Position(
|
||||
this.line + (lineDeltaOrChange.lineDelta || 0),
|
||||
this.character + (lineDeltaOrChange.characterDelta || 0),
|
||||
)
|
||||
}
|
||||
return new Position(this.line + (lineDeltaOrChange || 0), this.character + (characterDelta || 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new position with changed line or character
|
||||
*
|
||||
* @param line - The new line number (or undefined to keep current)
|
||||
* @param character - The new character offset (or undefined to keep current)
|
||||
* @returns A new Position
|
||||
*/
|
||||
with(line?: number, character?: number): Position
|
||||
with(change: { line?: number; character?: number }): Position
|
||||
with(lineOrChange?: number | { line?: number; character?: number }, character?: number): Position {
|
||||
if (typeof lineOrChange === "object") {
|
||||
return new Position(
|
||||
lineOrChange.line !== undefined ? lineOrChange.line : this.line,
|
||||
lineOrChange.character !== undefined ? lineOrChange.character : this.character,
|
||||
)
|
||||
}
|
||||
return new Position(
|
||||
lineOrChange !== undefined ? lineOrChange : this.line,
|
||||
character !== undefined ? character : this.character,
|
||||
)
|
||||
}
|
||||
}
|
||||
137
packages/vscode-shim/src/classes/Range.ts
Normal file
137
packages/vscode-shim/src/classes/Range.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { Position } from "./Position.js"
|
||||
import type { IRange, IPosition } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a range in a text document
|
||||
*
|
||||
* A range is defined by two positions: a start and an end position.
|
||||
* This class is immutable - all methods that modify the range return a new instance.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a range from line 0 to line 5
|
||||
* const range = new Range(
|
||||
* new Position(0, 0),
|
||||
* new Position(5, 10)
|
||||
* )
|
||||
*
|
||||
* // Or use the overload with line/character numbers
|
||||
* const range2 = new Range(0, 0, 5, 10)
|
||||
* ```
|
||||
*/
|
||||
export class Range implements IRange {
|
||||
public readonly start: Position
|
||||
public readonly end: Position
|
||||
|
||||
/**
|
||||
* Create a new Range
|
||||
*
|
||||
* @param start - The start position
|
||||
* @param end - The end position
|
||||
*/
|
||||
constructor(start: IPosition, end: IPosition)
|
||||
/**
|
||||
* Create a new Range from line and character numbers
|
||||
*
|
||||
* @param startLine - The start line number
|
||||
* @param startCharacter - The start character offset
|
||||
* @param endLine - The end line number
|
||||
* @param endCharacter - The end character offset
|
||||
*/
|
||||
constructor(startLine: number, startCharacter: number, endLine: number, endCharacter: number)
|
||||
constructor(
|
||||
startOrStartLine: IPosition | number,
|
||||
endOrStartCharacter: IPosition | number,
|
||||
endLine?: number,
|
||||
endCharacter?: number,
|
||||
) {
|
||||
if (typeof startOrStartLine === "number") {
|
||||
this.start = new Position(startOrStartLine, endOrStartCharacter as number)
|
||||
this.end = new Position(endLine!, endCharacter!)
|
||||
} else {
|
||||
this.start = startOrStartLine as Position
|
||||
this.end = endOrStartCharacter as Position
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this range is empty (start equals end)
|
||||
*/
|
||||
get isEmpty(): boolean {
|
||||
return this.start.isEqual(this.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this range is on a single line
|
||||
*/
|
||||
get isSingleLine(): boolean {
|
||||
return this.start.line === this.end.line
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this range contains a position or range
|
||||
*
|
||||
* @param positionOrRange - The position or range to check
|
||||
* @returns true if the position/range is within this range
|
||||
*/
|
||||
contains(positionOrRange: IPosition | IRange): boolean {
|
||||
if ("start" in positionOrRange && "end" in positionOrRange) {
|
||||
// It's a range
|
||||
return this.contains(positionOrRange.start) && this.contains(positionOrRange.end)
|
||||
}
|
||||
// It's a position
|
||||
return positionOrRange.isAfterOrEqual(this.start) && positionOrRange.isBeforeOrEqual(this.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this range is equal to another range
|
||||
*/
|
||||
isEqual(other: IRange): boolean {
|
||||
return this.start.isEqual(other.start) && this.end.isEqual(other.end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the intersection of this range with another range
|
||||
*
|
||||
* @param other - The other range
|
||||
* @returns The intersection range, or undefined if they don't intersect
|
||||
*/
|
||||
intersection(other: IRange): Range | undefined {
|
||||
const start = this.start.isAfter(other.start) ? this.start : other.start
|
||||
const end = this.end.isBefore(other.end) ? this.end : other.end
|
||||
if (start.isAfter(end)) {
|
||||
return undefined
|
||||
}
|
||||
return new Range(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the union of this range with another range
|
||||
*
|
||||
* @param other - The other range
|
||||
* @returns A new range that spans both ranges
|
||||
*/
|
||||
union(other: IRange): Range {
|
||||
const start = this.start.isBefore(other.start) ? this.start : other.start
|
||||
const end = this.end.isAfter(other.end) ? this.end : other.end
|
||||
return new Range(start, end)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new range with modified start or end positions
|
||||
*
|
||||
* @param start - The new start position (or undefined to keep current)
|
||||
* @param end - The new end position (or undefined to keep current)
|
||||
* @returns A new Range
|
||||
*/
|
||||
with(start?: IPosition, end?: IPosition): Range
|
||||
with(change: { start?: IPosition; end?: IPosition }): Range
|
||||
with(startOrChange?: IPosition | { start?: IPosition; end?: IPosition }, end?: IPosition): Range {
|
||||
// Check if it's a change object (has start or end property, but not line/character like a Position)
|
||||
if (startOrChange && typeof startOrChange === "object" && !("line" in startOrChange)) {
|
||||
const change = startOrChange as { start?: IPosition; end?: IPosition }
|
||||
return new Range(change.start || this.start, change.end || this.end)
|
||||
}
|
||||
return new Range((startOrChange as IPosition) || this.start, end || this.end)
|
||||
}
|
||||
}
|
||||
79
packages/vscode-shim/src/classes/Selection.ts
Normal file
79
packages/vscode-shim/src/classes/Selection.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Range } from "./Range.js"
|
||||
import { Position } from "./Position.js"
|
||||
import type { ISelection, IPosition } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a text selection in an editor
|
||||
*
|
||||
* A selection extends Range with anchor and active positions.
|
||||
* The anchor is where the selection starts, and the active is where it ends.
|
||||
* The selection can be reversed if the active position is before the anchor.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a selection from position 0,0 to 5,10
|
||||
* const selection = new Selection(
|
||||
* new Position(0, 0),
|
||||
* new Position(5, 10)
|
||||
* )
|
||||
*
|
||||
* console.log(selection.isReversed) // false
|
||||
* ```
|
||||
*/
|
||||
export class Selection extends Range implements ISelection {
|
||||
/**
|
||||
* The anchor position (where the selection started)
|
||||
*/
|
||||
public readonly anchor: Position
|
||||
|
||||
/**
|
||||
* The active position (where the selection currently ends)
|
||||
*/
|
||||
public readonly active: Position
|
||||
|
||||
/**
|
||||
* Create a new Selection
|
||||
*
|
||||
* @param anchor - The anchor position
|
||||
* @param active - The active position
|
||||
*/
|
||||
constructor(anchor: IPosition, active: IPosition)
|
||||
/**
|
||||
* Create a new Selection from line and character numbers
|
||||
*
|
||||
* @param anchorLine - The anchor line number
|
||||
* @param anchorCharacter - The anchor character offset
|
||||
* @param activeLine - The active line number
|
||||
* @param activeCharacter - The active character offset
|
||||
*/
|
||||
constructor(anchorLine: number, anchorCharacter: number, activeLine: number, activeCharacter: number)
|
||||
constructor(
|
||||
anchorOrAnchorLine: IPosition | number,
|
||||
activeOrAnchorCharacter: IPosition | number,
|
||||
activeLine?: number,
|
||||
activeCharacter?: number,
|
||||
) {
|
||||
let anchor: Position
|
||||
let active: Position
|
||||
|
||||
if (typeof anchorOrAnchorLine === "number") {
|
||||
anchor = new Position(anchorOrAnchorLine, activeOrAnchorCharacter as number)
|
||||
active = new Position(activeLine!, activeCharacter!)
|
||||
} else {
|
||||
anchor = anchorOrAnchorLine as Position
|
||||
active = activeOrAnchorCharacter as Position
|
||||
}
|
||||
|
||||
super(anchor, active)
|
||||
this.anchor = anchor
|
||||
this.active = active
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the selection is reversed
|
||||
* A reversed selection has the active position before the anchor position
|
||||
*/
|
||||
get isReversed(): boolean {
|
||||
return this.anchor.isAfter(this.active)
|
||||
}
|
||||
}
|
||||
79
packages/vscode-shim/src/classes/StatusBarItem.ts
Normal file
79
packages/vscode-shim/src/classes/StatusBarItem.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* StatusBarItem class for VSCode API
|
||||
*/
|
||||
|
||||
import { StatusBarAlignment } from "../types.js"
|
||||
import type { Disposable } from "../interfaces/workspace.js"
|
||||
|
||||
/**
|
||||
* Status bar item mock for CLI mode
|
||||
*/
|
||||
export class StatusBarItem implements Disposable {
|
||||
private _text: string = ""
|
||||
private _tooltip: string | undefined
|
||||
private _command: string | undefined
|
||||
private _color: string | undefined
|
||||
private _backgroundColor: string | undefined
|
||||
private _isVisible: boolean = false
|
||||
|
||||
constructor(
|
||||
public readonly alignment: StatusBarAlignment,
|
||||
public readonly priority?: number,
|
||||
) {}
|
||||
|
||||
get text(): string {
|
||||
return this._text
|
||||
}
|
||||
|
||||
set text(value: string) {
|
||||
this._text = value
|
||||
}
|
||||
|
||||
get tooltip(): string | undefined {
|
||||
return this._tooltip
|
||||
}
|
||||
|
||||
set tooltip(value: string | undefined) {
|
||||
this._tooltip = value
|
||||
}
|
||||
|
||||
get command(): string | undefined {
|
||||
return this._command
|
||||
}
|
||||
|
||||
set command(value: string | undefined) {
|
||||
this._command = value
|
||||
}
|
||||
|
||||
get color(): string | undefined {
|
||||
return this._color
|
||||
}
|
||||
|
||||
set color(value: string | undefined) {
|
||||
this._color = value
|
||||
}
|
||||
|
||||
get backgroundColor(): string | undefined {
|
||||
return this._backgroundColor
|
||||
}
|
||||
|
||||
set backgroundColor(value: string | undefined) {
|
||||
this._backgroundColor = value
|
||||
}
|
||||
|
||||
get isVisible(): boolean {
|
||||
return this._isVisible
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this._isVisible = true
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this._isVisible = false
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._isVisible = false
|
||||
}
|
||||
}
|
||||
209
packages/vscode-shim/src/classes/TextEdit.ts
Normal file
209
packages/vscode-shim/src/classes/TextEdit.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import { Position } from "./Position.js"
|
||||
import { Range } from "./Range.js"
|
||||
import type { IRange, IPosition } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a text edit operation
|
||||
*
|
||||
* A text edit replaces text in a specific range with new text.
|
||||
* This is used to modify documents programmatically.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Replace text in a range
|
||||
* const edit = TextEdit.replace(
|
||||
* new Range(0, 0, 0, 5),
|
||||
* 'Hello'
|
||||
* )
|
||||
*
|
||||
* // Insert text at a position
|
||||
* const insert = TextEdit.insert(
|
||||
* new Position(0, 0),
|
||||
* 'New text'
|
||||
* )
|
||||
*
|
||||
* // Delete text in a range
|
||||
* const deletion = TextEdit.delete(
|
||||
* new Range(0, 0, 0, 10)
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export class TextEdit {
|
||||
/**
|
||||
* The range to replace
|
||||
*/
|
||||
public readonly range: Range
|
||||
|
||||
/**
|
||||
* The new text (empty string for deletion)
|
||||
*/
|
||||
public readonly newText: string
|
||||
|
||||
/**
|
||||
* Create a new TextEdit
|
||||
*
|
||||
* @param range - The range to replace
|
||||
* @param newText - The new text
|
||||
*/
|
||||
constructor(range: IRange, newText: string) {
|
||||
this.range = range as Range
|
||||
this.newText = newText
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a replace edit
|
||||
*
|
||||
* @param range - The range to replace
|
||||
* @param newText - The new text
|
||||
* @returns A new TextEdit
|
||||
*/
|
||||
static replace(range: IRange, newText: string): TextEdit {
|
||||
return new TextEdit(range, newText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an insert edit
|
||||
*
|
||||
* @param position - The position to insert at
|
||||
* @param newText - The text to insert
|
||||
* @returns A new TextEdit
|
||||
*/
|
||||
static insert(position: IPosition, newText: string): TextEdit {
|
||||
return new TextEdit(new Range(position, position), newText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a delete edit
|
||||
*
|
||||
* @param range - The range to delete
|
||||
* @returns A new TextEdit
|
||||
*/
|
||||
static delete(range: IRange): TextEdit {
|
||||
return new TextEdit(range, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an edit to set the end of line sequence
|
||||
*
|
||||
* @returns A new TextEdit (simplified implementation)
|
||||
*/
|
||||
static setEndOfLine(): TextEdit {
|
||||
return new TextEdit(new Range(new Position(0, 0), new Position(0, 0)), "")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a collection of text edits for a document
|
||||
*
|
||||
* A WorkspaceEdit can contain edits for multiple documents.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const edit = new WorkspaceEdit()
|
||||
*
|
||||
* // Add edits for a file
|
||||
* edit.set(uri, [
|
||||
* TextEdit.replace(range1, 'new text'),
|
||||
* TextEdit.insert(pos, 'inserted')
|
||||
* ])
|
||||
*
|
||||
* // Apply the edit
|
||||
* await vscode.workspace.applyEdit(edit)
|
||||
* ```
|
||||
*/
|
||||
export class WorkspaceEdit {
|
||||
private _edits: Map<string, TextEdit[]> = new Map()
|
||||
|
||||
/**
|
||||
* Set edits for a specific URI
|
||||
*
|
||||
* @param uri - The document URI
|
||||
* @param edits - Array of text edits
|
||||
*/
|
||||
set(uri: { toString(): string }, edits: TextEdit[]): void {
|
||||
this._edits.set(uri.toString(), edits)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get edits for a specific URI
|
||||
*
|
||||
* @param uri - The document URI
|
||||
* @returns Array of text edits, or empty array if none
|
||||
*/
|
||||
get(uri: { toString(): string }): TextEdit[] {
|
||||
return this._edits.get(uri.toString()) || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if edits exist for a URI
|
||||
*
|
||||
* @param uri - The document URI
|
||||
* @returns true if edits exist
|
||||
*/
|
||||
has(uri: { toString(): string }): boolean {
|
||||
return this._edits.has(uri.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a delete edit for a range
|
||||
*
|
||||
* @param uri - The document URI
|
||||
* @param range - The range to delete
|
||||
*/
|
||||
delete(uri: { toString(): string }, range: IRange): void {
|
||||
const key = uri.toString()
|
||||
if (!this._edits.has(key)) {
|
||||
this._edits.set(key, [])
|
||||
}
|
||||
this._edits.get(key)!.push(TextEdit.delete(range))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an insert edit
|
||||
*
|
||||
* @param uri - The document URI
|
||||
* @param position - The position to insert at
|
||||
* @param newText - The text to insert
|
||||
*/
|
||||
insert(uri: { toString(): string }, position: IPosition, newText: string): void {
|
||||
const key = uri.toString()
|
||||
if (!this._edits.has(key)) {
|
||||
this._edits.set(key, [])
|
||||
}
|
||||
this._edits.get(key)!.push(TextEdit.insert(position, newText))
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a replace edit
|
||||
*
|
||||
* @param uri - The document URI
|
||||
* @param range - The range to replace
|
||||
* @param newText - The new text
|
||||
*/
|
||||
replace(uri: { toString(): string }, range: IRange, newText: string): void {
|
||||
const key = uri.toString()
|
||||
if (!this._edits.has(key)) {
|
||||
this._edits.set(key, [])
|
||||
}
|
||||
this._edits.get(key)!.push(TextEdit.replace(range, newText))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of documents with edits
|
||||
*/
|
||||
get size(): number {
|
||||
return this._edits.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all URI and edits pairs
|
||||
*
|
||||
* @returns Array of [URI, TextEdit[]] pairs
|
||||
*/
|
||||
entries(): [{ toString(): string; fsPath: string }, TextEdit[]][] {
|
||||
return Array.from(this._edits.entries()).map(([uriString, edits]) => {
|
||||
// Parse the URI string back to a URI-like object
|
||||
return [{ toString: () => uriString, fsPath: uriString.replace(/^file:\/\//, "") }, edits]
|
||||
})
|
||||
}
|
||||
}
|
||||
20
packages/vscode-shim/src/classes/TextEditorDecorationType.ts
Normal file
20
packages/vscode-shim/src/classes/TextEditorDecorationType.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* TextEditorDecorationType class for VSCode API
|
||||
*/
|
||||
|
||||
import type { Disposable } from "../interfaces/workspace.js"
|
||||
|
||||
/**
|
||||
* Text editor decoration type mock for CLI mode
|
||||
*/
|
||||
export class TextEditorDecorationType implements Disposable {
|
||||
public key: string
|
||||
|
||||
constructor(key: string) {
|
||||
this.key = key
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
// No-op for CLI
|
||||
}
|
||||
}
|
||||
124
packages/vscode-shim/src/classes/Uri.ts
Normal file
124
packages/vscode-shim/src/classes/Uri.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import * as path from "path"
|
||||
|
||||
/**
|
||||
* Uniform Resource Identifier (URI) implementation
|
||||
*
|
||||
* Represents a URI following the RFC 3986 standard.
|
||||
* This class is compatible with VSCode's Uri class and provides
|
||||
* file system path handling for cross-platform compatibility.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Create a file URI
|
||||
* const fileUri = Uri.file('/path/to/file.txt')
|
||||
* console.log(fileUri.fsPath) // '/path/to/file.txt'
|
||||
*
|
||||
* // Parse a URI string
|
||||
* const uri = Uri.parse('https://example.com/path?query=1#fragment')
|
||||
* console.log(uri.scheme) // 'https'
|
||||
* console.log(uri.path) // '/path'
|
||||
* ```
|
||||
*/
|
||||
export class Uri {
|
||||
public readonly scheme: string
|
||||
public readonly authority: string
|
||||
public readonly path: string
|
||||
public readonly query: string
|
||||
public readonly fragment: string
|
||||
|
||||
constructor(scheme: string, authority: string, path: string, query: string, fragment: string) {
|
||||
this.scheme = scheme
|
||||
this.authority = authority
|
||||
this.path = path
|
||||
this.query = query
|
||||
this.fragment = fragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a URI from a file system path
|
||||
*
|
||||
* @param path - The file system path
|
||||
* @returns A new Uri instance with 'file' scheme
|
||||
*/
|
||||
static file(fsPath: string): Uri {
|
||||
return new Uri("file", "", fsPath, "", "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a URI string
|
||||
*
|
||||
* @param value - The URI string to parse
|
||||
* @returns A new Uri instance
|
||||
*/
|
||||
static parse(value: string): Uri {
|
||||
try {
|
||||
const url = new URL(value)
|
||||
return new Uri(
|
||||
url.protocol.slice(0, -1),
|
||||
url.hostname,
|
||||
url.pathname,
|
||||
url.search.slice(1),
|
||||
url.hash.slice(1),
|
||||
)
|
||||
} catch {
|
||||
// If URL parsing fails, treat as file path
|
||||
return Uri.file(value)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a URI with path segments
|
||||
*
|
||||
* @param base - The base URI
|
||||
* @param pathSegments - Path segments to join
|
||||
* @returns A new Uri with the joined path
|
||||
*/
|
||||
static joinPath(base: Uri, ...pathSegments: string[]): Uri {
|
||||
const joinedPath = path.join(base.path, ...pathSegments)
|
||||
return new Uri(base.scheme, base.authority, joinedPath, base.query, base.fragment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new URI with modifications
|
||||
*
|
||||
* @param change - The changes to apply
|
||||
* @returns A new Uri instance with the changes applied
|
||||
*/
|
||||
with(change: { scheme?: string; authority?: string; path?: string; query?: string; fragment?: string }): Uri {
|
||||
return new Uri(
|
||||
change.scheme !== undefined ? change.scheme : this.scheme,
|
||||
change.authority !== undefined ? change.authority : this.authority,
|
||||
change.path !== undefined ? change.path : this.path,
|
||||
change.query !== undefined ? change.query : this.query,
|
||||
change.fragment !== undefined ? change.fragment : this.fragment,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file system path representation
|
||||
* Compatible with both Unix and Windows paths
|
||||
*/
|
||||
get fsPath(): string {
|
||||
return this.path
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the URI to a string representation
|
||||
*/
|
||||
toString(): string {
|
||||
return `${this.scheme}://${this.authority}${this.path}${this.query ? "?" + this.query : ""}${this.fragment ? "#" + this.fragment : ""}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JSON representation
|
||||
*/
|
||||
toJSON(): object {
|
||||
return {
|
||||
scheme: this.scheme,
|
||||
authority: this.authority,
|
||||
path: this.path,
|
||||
query: this.query,
|
||||
fragment: this.fragment,
|
||||
}
|
||||
}
|
||||
}
|
||||
158
packages/vscode-shim/src/context/ExtensionContext.ts
Normal file
158
packages/vscode-shim/src/context/ExtensionContext.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import * as path from "path"
|
||||
import * as fs from "fs"
|
||||
import { Uri } from "../classes/Uri.js"
|
||||
import { FileMemento } from "../storage/Memento.js"
|
||||
import { FileSecretStorage } from "../storage/SecretStorage.js"
|
||||
import { hashWorkspacePath, ensureDirectoryExists } from "../utils/paths.js"
|
||||
import type {
|
||||
ExtensionContext,
|
||||
Extension,
|
||||
Disposable,
|
||||
Memento,
|
||||
SecretStorage,
|
||||
ExtensionMode,
|
||||
ExtensionKind,
|
||||
} from "../types.js"
|
||||
|
||||
/**
|
||||
* Options for creating an ExtensionContext
|
||||
*/
|
||||
export interface ExtensionContextOptions {
|
||||
/**
|
||||
* Path to the extension's root directory
|
||||
*/
|
||||
extensionPath: string
|
||||
|
||||
/**
|
||||
* Path to the workspace directory
|
||||
*/
|
||||
workspacePath: string
|
||||
|
||||
/**
|
||||
* Optional custom storage directory (defaults to ~/.vscode-mock)
|
||||
*/
|
||||
storageDir?: string
|
||||
|
||||
/**
|
||||
* Extension mode (Production, Development, or Test)
|
||||
*/
|
||||
extensionMode?: ExtensionMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of VSCode's ExtensionContext
|
||||
*
|
||||
* Provides the context object passed to extension activation functions.
|
||||
* This includes state storage, secrets, and extension metadata.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const context = new ExtensionContextImpl({
|
||||
* extensionPath: '/path/to/extension',
|
||||
* workspacePath: '/path/to/workspace'
|
||||
* })
|
||||
*
|
||||
* // Use in extension activation
|
||||
* const api = await extension.activate(context)
|
||||
* ```
|
||||
*/
|
||||
export class ExtensionContextImpl implements ExtensionContext {
|
||||
public subscriptions: Disposable[] = []
|
||||
public workspaceState: Memento
|
||||
public globalState: Memento & { setKeysForSync(keys: readonly string[]): void }
|
||||
public secrets: SecretStorage
|
||||
public extensionUri: Uri
|
||||
public extensionPath: string
|
||||
public environmentVariableCollection: Record<string, unknown> = {}
|
||||
public storageUri: Uri | undefined
|
||||
public storagePath: string | undefined
|
||||
public globalStorageUri: Uri
|
||||
public globalStoragePath: string
|
||||
public logUri: Uri
|
||||
public logPath: string
|
||||
public extensionMode: ExtensionMode
|
||||
public extension: Extension<unknown> | undefined
|
||||
|
||||
constructor(options: ExtensionContextOptions) {
|
||||
this.extensionPath = options.extensionPath
|
||||
this.extensionUri = Uri.file(options.extensionPath)
|
||||
this.extensionMode = options.extensionMode || 1 // Default to Production
|
||||
|
||||
// Setup storage paths
|
||||
const baseStorageDir =
|
||||
options.storageDir || path.join(process.env.HOME || process.env.USERPROFILE || ".", ".vscode-mock")
|
||||
const workspaceHash = hashWorkspacePath(options.workspacePath)
|
||||
|
||||
this.globalStoragePath = path.join(baseStorageDir, "global-storage")
|
||||
this.globalStorageUri = Uri.file(this.globalStoragePath)
|
||||
|
||||
const workspaceStoragePath = path.join(baseStorageDir, "workspace-storage", workspaceHash)
|
||||
this.storagePath = workspaceStoragePath
|
||||
this.storageUri = Uri.file(workspaceStoragePath)
|
||||
|
||||
this.logPath = path.join(baseStorageDir, "logs")
|
||||
this.logUri = Uri.file(this.logPath)
|
||||
|
||||
// Ensure directories exist
|
||||
ensureDirectoryExists(this.globalStoragePath)
|
||||
ensureDirectoryExists(workspaceStoragePath)
|
||||
ensureDirectoryExists(this.logPath)
|
||||
|
||||
// Initialize state storage
|
||||
this.workspaceState = new FileMemento(path.join(workspaceStoragePath, "workspace-state.json"))
|
||||
|
||||
const globalMemento = new FileMemento(path.join(this.globalStoragePath, "global-state.json"))
|
||||
this.globalState = Object.assign(globalMemento, {
|
||||
setKeysForSync: (_keys: readonly string[]) => {
|
||||
// No-op for mock implementation
|
||||
},
|
||||
})
|
||||
|
||||
this.secrets = new FileSecretStorage(this.globalStoragePath)
|
||||
|
||||
// Load extension metadata (packageJSON)
|
||||
this.extension = this.loadExtensionMetadata()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load extension metadata from package.json
|
||||
*/
|
||||
private loadExtensionMetadata(): Extension<unknown> | undefined {
|
||||
try {
|
||||
// Try to load package.json from extension path
|
||||
const packageJsonPath = path.join(this.extensionPath, "package.json")
|
||||
if (fs.existsSync(packageJsonPath)) {
|
||||
const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
|
||||
const extensionId = `${packageJSON.publisher || "unknown"}.${packageJSON.name || "unknown"}`
|
||||
|
||||
return {
|
||||
id: extensionId,
|
||||
extensionUri: this.extensionUri,
|
||||
extensionPath: this.extensionPath,
|
||||
isActive: true,
|
||||
packageJSON,
|
||||
exports: undefined,
|
||||
extensionKind: 1 as ExtensionKind, // UI
|
||||
activate: () => Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors loading package.json
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose all subscriptions
|
||||
*/
|
||||
dispose(): void {
|
||||
for (const subscription of this.subscriptions) {
|
||||
try {
|
||||
subscription.dispose()
|
||||
} catch (error) {
|
||||
console.error("Error disposing subscription:", error)
|
||||
}
|
||||
}
|
||||
this.subscriptions = []
|
||||
}
|
||||
}
|
||||
112
packages/vscode-shim/src/index.ts
Normal file
112
packages/vscode-shim/src/index.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/**
|
||||
* @roo-code/vscode-shim
|
||||
*
|
||||
* A production-ready VSCode API mock for running VSCode extensions in Node.js CLI applications.
|
||||
* This package provides a complete implementation of the VSCode Extension API, allowing you to
|
||||
* run VSCode extensions without VSCode installed.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
// Export the complete VSCode API implementation
|
||||
export {
|
||||
// Main factory function
|
||||
createVSCodeAPIMock,
|
||||
|
||||
// Classes
|
||||
Uri,
|
||||
Position,
|
||||
Range,
|
||||
Selection,
|
||||
EventEmitter,
|
||||
Location,
|
||||
Diagnostic,
|
||||
DiagnosticRelatedInformation,
|
||||
TextEdit,
|
||||
WorkspaceEdit,
|
||||
ThemeColor,
|
||||
ThemeIcon,
|
||||
CodeActionKind,
|
||||
CancellationTokenSource,
|
||||
CodeLens,
|
||||
LanguageModelTextPart,
|
||||
LanguageModelToolCallPart,
|
||||
LanguageModelToolResultPart,
|
||||
FileSystemError,
|
||||
OutputChannel,
|
||||
StatusBarItem,
|
||||
TextEditorDecorationType,
|
||||
ExtensionContext,
|
||||
|
||||
// API classes
|
||||
WorkspaceAPI,
|
||||
WindowAPI,
|
||||
CommandsAPI,
|
||||
TabGroupsAPI,
|
||||
FileSystemAPI,
|
||||
MockWorkspaceConfiguration,
|
||||
|
||||
// Runtime configuration utilities
|
||||
setRuntimeConfig,
|
||||
setRuntimeConfigValues,
|
||||
clearRuntimeConfig,
|
||||
getRuntimeConfig,
|
||||
|
||||
// Enums
|
||||
ConfigurationTarget,
|
||||
ViewColumn,
|
||||
TextEditorRevealType,
|
||||
StatusBarAlignment,
|
||||
DiagnosticSeverity,
|
||||
DiagnosticTag,
|
||||
EndOfLine,
|
||||
UIKind,
|
||||
ExtensionMode,
|
||||
ExtensionKind,
|
||||
FileType,
|
||||
DecorationRangeBehavior,
|
||||
OverviewRulerLane,
|
||||
|
||||
// Types
|
||||
type IdentityInfo,
|
||||
type Thenable,
|
||||
type Disposable,
|
||||
type TextDocument,
|
||||
type TextLine,
|
||||
type WorkspaceFolder,
|
||||
type WorkspaceConfiguration,
|
||||
type Memento,
|
||||
type SecretStorage,
|
||||
type FileStat,
|
||||
type Terminal,
|
||||
type CancellationToken,
|
||||
} from "./vscode.js"
|
||||
|
||||
// Export utilities
|
||||
export { logs, setLogger, type Logger } from "./utils/logger.js"
|
||||
export { VSCodeMockPaths } from "./utils/paths.js"
|
||||
export { machineIdSync } from "./utils/machine-id.js"
|
||||
|
||||
// Re-export as createVSCodeAPI for simpler API
|
||||
export { createVSCodeAPIMock as createVSCodeAPI } from "./vscode.js"
|
||||
|
||||
/**
|
||||
* Quick start function to create a complete VSCode API mock
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createVSCodeAPI } from '@roo-code/vscode-shim'
|
||||
*
|
||||
* const vscode = createVSCodeAPI({
|
||||
* extensionPath: '/path/to/extension',
|
||||
* workspacePath: '/path/to/workspace'
|
||||
* })
|
||||
*
|
||||
* // Set global vscode for extension to use
|
||||
* global.vscode = vscode
|
||||
*
|
||||
* // Load and activate extension
|
||||
* const extension = require('/path/to/extension.js')
|
||||
* const api = await extension.activate(vscode.context)
|
||||
* ```
|
||||
*/
|
||||
114
packages/vscode-shim/src/interfaces/document.ts
Normal file
114
packages/vscode-shim/src/interfaces/document.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Document-related interfaces for VSCode API
|
||||
*/
|
||||
|
||||
import type { Range } from "../classes/Range.js"
|
||||
import type { Position } from "../classes/Position.js"
|
||||
import type { Uri } from "../classes/Uri.js"
|
||||
import type { Thenable, Disposable } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a text document in VSCode
|
||||
*/
|
||||
export interface TextDocument {
|
||||
uri: Uri
|
||||
fileName: string
|
||||
languageId: string
|
||||
version: number
|
||||
isDirty: boolean
|
||||
isClosed: boolean
|
||||
lineCount: number
|
||||
getText(range?: Range): string
|
||||
lineAt(line: number): TextLine
|
||||
offsetAt(position: Position): number
|
||||
positionAt(offset: number): Position
|
||||
save(): Thenable<boolean>
|
||||
validateRange(range: Range): Range
|
||||
validatePosition(position: Position): Position
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a line of text in a document
|
||||
*/
|
||||
export interface TextLine {
|
||||
text: string
|
||||
range: Range
|
||||
rangeIncludingLineBreak: Range
|
||||
firstNonWhitespaceCharacterIndex: number
|
||||
isEmptyOrWhitespace: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when workspace folders change
|
||||
*/
|
||||
export interface WorkspaceFoldersChangeEvent {
|
||||
added: WorkspaceFolder[]
|
||||
removed: WorkspaceFolder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a workspace folder
|
||||
*/
|
||||
export interface WorkspaceFolder {
|
||||
uri: Uri
|
||||
name: string
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a text document changes
|
||||
*/
|
||||
export interface TextDocumentChangeEvent {
|
||||
document: TextDocument
|
||||
contentChanges: readonly TextDocumentContentChangeEvent[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a change in a text document
|
||||
*/
|
||||
export interface TextDocumentContentChangeEvent {
|
||||
range: Range
|
||||
rangeOffset: number
|
||||
rangeLength: number
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when configuration changes
|
||||
*/
|
||||
export interface ConfigurationChangeEvent {
|
||||
affectsConfiguration(section: string, scope?: Uri): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for text document content
|
||||
*/
|
||||
export interface TextDocumentContentProvider {
|
||||
provideTextDocumentContent(uri: Uri, token: CancellationToken): Thenable<string>
|
||||
onDidChange?: (listener: (e: Uri) => void) => Disposable
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancellation token interface (must be local to avoid conflict with ES2023 built-in)
|
||||
*/
|
||||
export interface CancellationToken {
|
||||
isCancellationRequested: boolean
|
||||
onCancellationRequested: (listener: (e: unknown) => void) => Disposable
|
||||
}
|
||||
|
||||
/**
|
||||
* File system watcher interface
|
||||
*/
|
||||
export interface FileSystemWatcher extends Disposable {
|
||||
onDidChange: (listener: (e: Uri) => void) => Disposable
|
||||
onDidCreate: (listener: (e: Uri) => void) => Disposable
|
||||
onDidDelete: (listener: (e: Uri) => void) => Disposable
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative pattern for file matching
|
||||
*/
|
||||
export interface RelativePattern {
|
||||
base: string
|
||||
pattern: string
|
||||
}
|
||||
107
packages/vscode-shim/src/interfaces/editor.ts
Normal file
107
packages/vscode-shim/src/interfaces/editor.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* Editor-related interfaces for VSCode API
|
||||
*/
|
||||
|
||||
import type { Range } from "../classes/Range.js"
|
||||
import type { Position } from "../classes/Position.js"
|
||||
import type { Selection } from "../classes/Selection.js"
|
||||
import type { Uri } from "../classes/Uri.js"
|
||||
import type { ThemeColor } from "../classes/Additional.js"
|
||||
import type {
|
||||
Thenable,
|
||||
ViewColumn,
|
||||
TextEditorRevealType,
|
||||
EndOfLine,
|
||||
DecorationRangeBehavior,
|
||||
OverviewRulerLane,
|
||||
TextEditorOptions,
|
||||
} from "../types.js"
|
||||
import type { TextDocument } from "./document.js"
|
||||
import type { Disposable } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a text editor in VSCode
|
||||
*/
|
||||
export interface TextEditor {
|
||||
document: TextDocument
|
||||
selection: Selection
|
||||
selections: Selection[]
|
||||
visibleRanges: Range[]
|
||||
options: TextEditorOptions
|
||||
viewColumn?: ViewColumn
|
||||
edit(callback: (editBuilder: TextEditorEdit) => void): Thenable<boolean>
|
||||
insertSnippet(
|
||||
snippet: unknown,
|
||||
location?: Position | Range | readonly Position[] | readonly Range[],
|
||||
): Thenable<boolean>
|
||||
setDecorations(decorationType: TextEditorDecorationType, rangesOrOptions: readonly Range[]): void
|
||||
revealRange(range: Range, revealType?: TextEditorRevealType): void
|
||||
show(column?: ViewColumn): void
|
||||
hide(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for text editor edits
|
||||
*/
|
||||
export interface TextEditorEdit {
|
||||
replace(location: Position | Range | Selection, value: string): void
|
||||
insert(location: Position, value: string): void
|
||||
delete(location: Range | Selection): void
|
||||
setEndOfLine(endOfLine: EndOfLine): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when text editor selection changes
|
||||
*/
|
||||
export interface TextEditorSelectionChangeEvent {
|
||||
textEditor: TextEditor
|
||||
selections: readonly Selection[]
|
||||
kind?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for showing a text document
|
||||
*/
|
||||
export interface TextDocumentShowOptions {
|
||||
viewColumn?: ViewColumn
|
||||
preserveFocus?: boolean
|
||||
preview?: boolean
|
||||
selection?: Range
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for rendering decorations
|
||||
*/
|
||||
export interface DecorationRenderOptions {
|
||||
backgroundColor?: string | ThemeColor
|
||||
border?: string
|
||||
borderColor?: string | ThemeColor
|
||||
borderRadius?: string
|
||||
borderSpacing?: string
|
||||
borderStyle?: string
|
||||
borderWidth?: string
|
||||
color?: string | ThemeColor
|
||||
cursor?: string
|
||||
fontStyle?: string
|
||||
fontWeight?: string
|
||||
gutterIconPath?: string | Uri
|
||||
gutterIconSize?: string
|
||||
isWholeLine?: boolean
|
||||
letterSpacing?: string
|
||||
opacity?: string
|
||||
outline?: string
|
||||
outlineColor?: string | ThemeColor
|
||||
outlineStyle?: string
|
||||
outlineWidth?: string
|
||||
overviewRulerColor?: string | ThemeColor
|
||||
overviewRulerLane?: OverviewRulerLane
|
||||
rangeBehavior?: DecorationRangeBehavior
|
||||
textDecoration?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Text editor decoration type interface
|
||||
*/
|
||||
export interface TextEditorDecorationType extends Disposable {
|
||||
key: string
|
||||
}
|
||||
76
packages/vscode-shim/src/interfaces/terminal.ts
Normal file
76
packages/vscode-shim/src/interfaces/terminal.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Terminal-related interfaces for VSCode API
|
||||
*/
|
||||
|
||||
import type { Uri } from "../classes/Uri.js"
|
||||
import type { ThemeIcon } from "../classes/Additional.js"
|
||||
import type { Thenable } from "../types.js"
|
||||
|
||||
/**
|
||||
* Represents a terminal in VSCode
|
||||
*/
|
||||
export interface Terminal {
|
||||
name: string
|
||||
processId: Thenable<number | undefined>
|
||||
creationOptions: Readonly<TerminalOptions>
|
||||
exitStatus: TerminalExitStatus | undefined
|
||||
state: TerminalState
|
||||
sendText(text: string, addNewLine?: boolean): void
|
||||
show(preserveFocus?: boolean): void
|
||||
hide(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for creating a terminal
|
||||
*/
|
||||
export interface TerminalOptions {
|
||||
name?: string
|
||||
shellPath?: string
|
||||
shellArgs?: string[] | string
|
||||
cwd?: string | Uri
|
||||
env?: { [key: string]: string | null | undefined }
|
||||
iconPath?: Uri | ThemeIcon
|
||||
hideFromUser?: boolean
|
||||
message?: string
|
||||
strictEnv?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit status of a terminal
|
||||
*/
|
||||
export interface TerminalExitStatus {
|
||||
code: number | undefined
|
||||
reason: number
|
||||
}
|
||||
|
||||
/**
|
||||
* State of a terminal
|
||||
*/
|
||||
export interface TerminalState {
|
||||
isInteractedWith: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when terminal dimensions change
|
||||
*/
|
||||
export interface TerminalDimensionsChangeEvent {
|
||||
terminal: Terminal
|
||||
dimensions: TerminalDimensions
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal dimensions
|
||||
*/
|
||||
export interface TerminalDimensions {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when data is written to terminal
|
||||
*/
|
||||
export interface TerminalDataWriteEvent {
|
||||
terminal: Terminal
|
||||
data: string
|
||||
}
|
||||
92
packages/vscode-shim/src/interfaces/webview.ts
Normal file
92
packages/vscode-shim/src/interfaces/webview.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/**
|
||||
* Webview-related interfaces for VSCode API
|
||||
*/
|
||||
|
||||
import type { Uri } from "../classes/Uri.js"
|
||||
import type { Thenable, Disposable } from "../types.js"
|
||||
import type { CancellationToken } from "./document.js"
|
||||
|
||||
/**
|
||||
* Webview view provider interface
|
||||
*/
|
||||
export interface WebviewViewProvider {
|
||||
resolveWebviewView(
|
||||
webviewView: WebviewView,
|
||||
context: WebviewViewResolveContext,
|
||||
token: CancellationToken,
|
||||
): Thenable<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* Webview view interface
|
||||
*/
|
||||
export interface WebviewView {
|
||||
webview: Webview
|
||||
viewType: string
|
||||
title?: string
|
||||
description?: string
|
||||
badge?: ViewBadge
|
||||
show(preserveFocus?: boolean): void
|
||||
onDidChangeVisibility: (listener: () => void) => Disposable
|
||||
onDidDispose: (listener: () => void) => Disposable
|
||||
visible: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Webview interface
|
||||
*/
|
||||
export interface Webview {
|
||||
html: string
|
||||
options: WebviewOptions
|
||||
cspSource: string
|
||||
postMessage(message: unknown): Thenable<boolean>
|
||||
onDidReceiveMessage: (listener: (message: unknown) => void) => Disposable
|
||||
asWebviewUri(localResource: Uri): Uri
|
||||
}
|
||||
|
||||
/**
|
||||
* Webview options interface
|
||||
*/
|
||||
export interface WebviewOptions {
|
||||
enableScripts?: boolean
|
||||
enableForms?: boolean
|
||||
localResourceRoots?: readonly Uri[]
|
||||
portMapping?: readonly WebviewPortMapping[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Webview port mapping interface
|
||||
*/
|
||||
export interface WebviewPortMapping {
|
||||
webviewPort: number
|
||||
extensionHostPort: number
|
||||
}
|
||||
|
||||
/**
|
||||
* View badge interface
|
||||
*/
|
||||
export interface ViewBadge {
|
||||
tooltip: string
|
||||
value: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Webview view resolve context
|
||||
*/
|
||||
export interface WebviewViewResolveContext {
|
||||
state?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Webview view provider options
|
||||
*/
|
||||
export interface WebviewViewProviderOptions {
|
||||
retainContextWhenHidden?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* URI handler interface
|
||||
*/
|
||||
export interface UriHandler {
|
||||
handleUri(uri: Uri): void
|
||||
}
|
||||
91
packages/vscode-shim/src/interfaces/workspace.ts
Normal file
91
packages/vscode-shim/src/interfaces/workspace.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Workspace-related interfaces for VSCode API
|
||||
*/
|
||||
|
||||
import type { Uri } from "../classes/Uri.js"
|
||||
import type { Thenable, ConfigurationTarget, ConfigurationInspect } from "../types.js"
|
||||
|
||||
/**
|
||||
* Workspace configuration interface
|
||||
*/
|
||||
export interface WorkspaceConfiguration {
|
||||
get<T>(section: string): T | undefined
|
||||
get<T>(section: string, defaultValue: T): T
|
||||
has(section: string): boolean
|
||||
inspect<T>(section: string): ConfigurationInspect<T> | undefined
|
||||
update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick pick options interface
|
||||
*/
|
||||
export interface QuickPickOptions {
|
||||
placeHolder?: string
|
||||
canPickMany?: boolean
|
||||
ignoreFocusOut?: boolean
|
||||
matchOnDescription?: boolean
|
||||
matchOnDetail?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Input box options interface
|
||||
*/
|
||||
export interface InputBoxOptions {
|
||||
value?: string
|
||||
valueSelection?: [number, number]
|
||||
prompt?: string
|
||||
placeHolder?: string
|
||||
password?: boolean
|
||||
ignoreFocusOut?: boolean
|
||||
validateInput?(value: string): string | undefined | null | Thenable<string | undefined | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Open dialog options interface
|
||||
*/
|
||||
export interface OpenDialogOptions {
|
||||
defaultUri?: Uri
|
||||
openLabel?: string
|
||||
canSelectFiles?: boolean
|
||||
canSelectFolders?: boolean
|
||||
canSelectMany?: boolean
|
||||
filters?: { [name: string]: string[] }
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposable interface for VSCode API (must be local to avoid conflict with ES2023 built-in Disposable)
|
||||
*/
|
||||
export interface Disposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic collection interface
|
||||
*/
|
||||
export interface DiagnosticCollection extends Disposable {
|
||||
name: string
|
||||
set(uri: Uri, diagnostics: import("../classes/Additional.js").Diagnostic[] | undefined): void
|
||||
set(entries: [Uri, import("../classes/Additional.js").Diagnostic[] | undefined][]): void
|
||||
delete(uri: Uri): void
|
||||
clear(): void
|
||||
forEach(
|
||||
callback: (
|
||||
uri: Uri,
|
||||
diagnostics: import("../classes/Additional.js").Diagnostic[],
|
||||
collection: DiagnosticCollection,
|
||||
) => void,
|
||||
thisArg?: unknown,
|
||||
): void
|
||||
get(uri: Uri): import("../classes/Additional.js").Diagnostic[] | undefined
|
||||
has(uri: Uri): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity information for VSCode environment
|
||||
*/
|
||||
export interface IdentityInfo {
|
||||
machineId: string
|
||||
sessionId: string
|
||||
cliUserId?: string
|
||||
}
|
||||
115
packages/vscode-shim/src/storage/Memento.ts
Normal file
115
packages/vscode-shim/src/storage/Memento.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { ensureDirectoryExists } from "../utils/paths.js"
|
||||
import type { Memento } from "../types.js"
|
||||
|
||||
/**
|
||||
* File-based implementation of VSCode's Memento interface
|
||||
*
|
||||
* Provides persistent key-value storage backed by a JSON file.
|
||||
* This implementation automatically loads from and saves to disk.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const memento = new FileMemento('/path/to/state.json')
|
||||
*
|
||||
* // Store a value
|
||||
* await memento.update('lastOpenFile', '/path/to/file.txt')
|
||||
*
|
||||
* // Retrieve a value
|
||||
* const file = memento.get<string>('lastOpenFile')
|
||||
*
|
||||
* // With default value
|
||||
* const count = memento.get<number>('count', 0)
|
||||
* ```
|
||||
*/
|
||||
export class FileMemento implements Memento {
|
||||
private data: Record<string, unknown> = {}
|
||||
private filePath: string
|
||||
|
||||
/**
|
||||
* Create a new FileMemento
|
||||
*
|
||||
* @param filePath - Path to the JSON file for persistence
|
||||
*/
|
||||
constructor(filePath: string) {
|
||||
this.filePath = filePath
|
||||
this.loadFromFile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data from the JSON file
|
||||
*/
|
||||
private loadFromFile(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
const content = fs.readFileSync(this.filePath, "utf-8")
|
||||
this.data = JSON.parse(content)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load state from ${this.filePath}:`, error)
|
||||
this.data = {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save data to the JSON file
|
||||
*/
|
||||
private saveToFile(): void {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.filePath)
|
||||
ensureDirectoryExists(dir)
|
||||
fs.writeFileSync(this.filePath, JSON.stringify(this.data, null, 2))
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save state to ${this.filePath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a value from storage
|
||||
*
|
||||
* @param key - The key to retrieve
|
||||
* @param defaultValue - Optional default value if key doesn't exist
|
||||
* @returns The stored value or default value
|
||||
*/
|
||||
get<T>(key: string): T | undefined
|
||||
get<T>(key: string, defaultValue: T): T
|
||||
get<T>(key: string, defaultValue?: T): T | undefined {
|
||||
const value = this.data[key]
|
||||
return value !== undefined && value !== null ? (value as T) : defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a value in storage
|
||||
*
|
||||
* @param key - The key to update
|
||||
* @param value - The value to store (undefined to delete)
|
||||
* @returns A promise that resolves when the update is complete
|
||||
*/
|
||||
async update(key: string, value: unknown): Promise<void> {
|
||||
if (value === undefined) {
|
||||
delete this.data[key]
|
||||
} else {
|
||||
this.data[key] = value
|
||||
}
|
||||
this.saveToFile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all keys in storage
|
||||
*
|
||||
* @returns An array of all keys
|
||||
*/
|
||||
keys(): readonly string[] {
|
||||
return Object.keys(this.data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all data from storage
|
||||
*/
|
||||
clear(): void {
|
||||
this.data = {}
|
||||
this.saveToFile()
|
||||
}
|
||||
}
|
||||
138
packages/vscode-shim/src/storage/SecretStorage.ts
Normal file
138
packages/vscode-shim/src/storage/SecretStorage.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import { EventEmitter } from "../classes/EventEmitter.js"
|
||||
import { ensureDirectoryExists } from "../utils/paths.js"
|
||||
import type { SecretStorage, SecretStorageChangeEvent } from "../types.js"
|
||||
|
||||
/**
|
||||
* File-based implementation of VSCode's SecretStorage interface
|
||||
*
|
||||
* Stores secrets in a JSON file on disk. While not encrypted like VSCode's
|
||||
* native keychain integration, this provides a simple, cross-platform solution
|
||||
* suitable for CLI applications.
|
||||
*
|
||||
* **Security Notes:**
|
||||
* - Secrets are stored as plain JSON (not encrypted)
|
||||
* - File permissions should be set restrictive (0600)
|
||||
* - For production, consider using environment variables instead
|
||||
* - Suitable for development and non-critical secrets
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const storage = new FileSecretStorage('/path/to/secrets.json')
|
||||
*
|
||||
* // Store a secret
|
||||
* await storage.store('apiKey', 'sk-...')
|
||||
*
|
||||
* // Retrieve a secret
|
||||
* const key = await storage.get('apiKey')
|
||||
*
|
||||
* // Listen for changes
|
||||
* storage.onDidChange((e) => {
|
||||
* console.log(`Secret ${e.key} changed`)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export class FileSecretStorage implements SecretStorage {
|
||||
private secrets: Record<string, string> = {}
|
||||
private _onDidChange = new EventEmitter<SecretStorageChangeEvent>()
|
||||
private filePath: string
|
||||
|
||||
/**
|
||||
* Create a new FileSecretStorage
|
||||
*
|
||||
* @param storagePath - Directory path where secrets.json will be stored
|
||||
*/
|
||||
constructor(storagePath: string) {
|
||||
this.filePath = path.join(storagePath, "secrets.json")
|
||||
this.loadFromFile()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load secrets from the JSON file
|
||||
*/
|
||||
private loadFromFile(): void {
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
const content = fs.readFileSync(this.filePath, "utf-8")
|
||||
this.secrets = JSON.parse(content)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load secrets from ${this.filePath}:`, error)
|
||||
this.secrets = {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save secrets to the JSON file with restrictive permissions
|
||||
*/
|
||||
private saveToFile(): void {
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(this.filePath)
|
||||
ensureDirectoryExists(dir)
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(this.filePath, JSON.stringify(this.secrets, null, 2))
|
||||
|
||||
// Set restrictive permissions (owner read/write only) on Unix-like systems
|
||||
if (process.platform !== "win32") {
|
||||
try {
|
||||
fs.chmodSync(this.filePath, 0o600)
|
||||
} catch {
|
||||
// Ignore chmod errors (might not be supported on some filesystems)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to save secrets to ${this.filePath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a secret by key
|
||||
*
|
||||
* @param key - The secret key
|
||||
* @returns The secret value or undefined if not found
|
||||
*/
|
||||
async get(key: string): Promise<string | undefined> {
|
||||
return this.secrets[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a secret
|
||||
*
|
||||
* @param key - The secret key
|
||||
* @param value - The secret value
|
||||
*/
|
||||
async store(key: string, value: string): Promise<void> {
|
||||
this.secrets[key] = value
|
||||
this.saveToFile()
|
||||
this._onDidChange.fire({ key })
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a secret
|
||||
*
|
||||
* @param key - The secret key to delete
|
||||
*/
|
||||
async delete(key: string): Promise<void> {
|
||||
delete this.secrets[key]
|
||||
this.saveToFile()
|
||||
this._onDidChange.fire({ key })
|
||||
}
|
||||
|
||||
/**
|
||||
* Event fired when a secret changes
|
||||
*/
|
||||
get onDidChange() {
|
||||
return this._onDidChange.event
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all secrets (useful for testing)
|
||||
*/
|
||||
clearAll(): void {
|
||||
this.secrets = {}
|
||||
this.saveToFile()
|
||||
}
|
||||
}
|
||||
344
packages/vscode-shim/src/types.ts
Normal file
344
packages/vscode-shim/src/types.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/**
|
||||
* Core VSCode API type definitions
|
||||
*
|
||||
* This file contains TypeScript type definitions that match the VSCode Extension API.
|
||||
* These types allow VSCode extensions to run in Node.js without VSCode installed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a thenable (Promise-like) value
|
||||
*/
|
||||
export type Thenable<T> = Promise<T>
|
||||
|
||||
/**
|
||||
* Represents a disposable resource that can be cleaned up
|
||||
*/
|
||||
export interface Disposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a Uniform Resource Identifier (URI)
|
||||
*/
|
||||
export interface IUri {
|
||||
scheme: string
|
||||
authority: string
|
||||
path: string
|
||||
query: string
|
||||
fragment: string
|
||||
fsPath: string
|
||||
toString(): string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a position in a text document (line and character)
|
||||
*/
|
||||
export interface IPosition {
|
||||
line: number
|
||||
character: number
|
||||
isEqual(other: IPosition): boolean
|
||||
isBefore(other: IPosition): boolean
|
||||
isBeforeOrEqual(other: IPosition): boolean
|
||||
isAfter(other: IPosition): boolean
|
||||
isAfterOrEqual(other: IPosition): boolean
|
||||
compareTo(other: IPosition): number
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a range in a text document (start and end positions)
|
||||
*/
|
||||
export interface IRange {
|
||||
start: IPosition
|
||||
end: IPosition
|
||||
isEmpty: boolean
|
||||
isSingleLine: boolean
|
||||
contains(positionOrRange: IPosition | IRange): boolean
|
||||
isEqual(other: IRange): boolean
|
||||
intersection(other: IRange): IRange | undefined
|
||||
union(other: IRange): IRange
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a selection in a text editor (extends Range with anchor and active positions)
|
||||
*/
|
||||
export interface ISelection extends IRange {
|
||||
anchor: IPosition
|
||||
active: IPosition
|
||||
isReversed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a line of text in a document
|
||||
*/
|
||||
export interface TextLine {
|
||||
text: string
|
||||
range: IRange
|
||||
rangeIncludingLineBreak: IRange
|
||||
firstNonWhitespaceCharacterIndex: number
|
||||
isEmptyOrWhitespace: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a text document
|
||||
*/
|
||||
export interface TextDocument {
|
||||
uri: IUri
|
||||
fileName: string
|
||||
languageId: string
|
||||
version: number
|
||||
isDirty: boolean
|
||||
isClosed: boolean
|
||||
lineCount: number
|
||||
getText(range?: IRange): string
|
||||
lineAt(line: number): TextLine
|
||||
offsetAt(position: IPosition): number
|
||||
positionAt(offset: number): IPosition
|
||||
save(): Thenable<boolean>
|
||||
validateRange(range: IRange): IRange
|
||||
validatePosition(position: IPosition): IPosition
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration target for settings
|
||||
*/
|
||||
export enum ConfigurationTarget {
|
||||
Global = 1,
|
||||
Workspace = 2,
|
||||
WorkspaceFolder = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace folder representation
|
||||
*/
|
||||
export interface WorkspaceFolder {
|
||||
uri: IUri
|
||||
name: string
|
||||
index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace configuration interface
|
||||
*/
|
||||
export interface WorkspaceConfiguration {
|
||||
get<T>(section: string): T | undefined
|
||||
get<T>(section: string, defaultValue: T): T
|
||||
has(section: string): boolean
|
||||
inspect<T>(section: string): ConfigurationInspect<T> | undefined
|
||||
update(section: string, value: unknown, configurationTarget?: ConfigurationTarget): Thenable<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration inspection result
|
||||
*/
|
||||
export interface ConfigurationInspect<T> {
|
||||
key: string
|
||||
defaultValue?: T
|
||||
globalValue?: T
|
||||
workspaceValue?: T
|
||||
workspaceFolderValue?: T
|
||||
}
|
||||
|
||||
/**
|
||||
* Memento (state storage) interface
|
||||
*/
|
||||
export interface Memento {
|
||||
get<T>(key: string): T | undefined
|
||||
get<T>(key: string, defaultValue: T): T
|
||||
update(key: string, value: unknown): Thenable<void>
|
||||
keys(): readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret storage interface for secure credential storage
|
||||
*/
|
||||
export interface SecretStorage {
|
||||
get(key: string): Thenable<string | undefined>
|
||||
store(key: string, value: string): Thenable<void>
|
||||
delete(key: string): Thenable<void>
|
||||
onDidChange: Event<SecretStorageChangeEvent>
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret storage change event
|
||||
*/
|
||||
export interface SecretStorageChangeEvent {
|
||||
key: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an extension
|
||||
*/
|
||||
export interface Extension<T> {
|
||||
id: string
|
||||
extensionUri: IUri
|
||||
extensionPath: string
|
||||
isActive: boolean
|
||||
packageJSON: Record<string, unknown>
|
||||
exports: T
|
||||
extensionKind: ExtensionKind
|
||||
activate(): Thenable<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension kind enum
|
||||
*/
|
||||
export enum ExtensionKind {
|
||||
UI = 1,
|
||||
Workspace = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension context provided to extension activation
|
||||
*/
|
||||
export interface ExtensionContext {
|
||||
subscriptions: Disposable[]
|
||||
workspaceState: Memento
|
||||
globalState: Memento & { setKeysForSync(keys: readonly string[]): void }
|
||||
secrets: SecretStorage
|
||||
extensionUri: IUri
|
||||
extensionPath: string
|
||||
environmentVariableCollection: Record<string, unknown>
|
||||
storageUri: IUri | undefined
|
||||
storagePath: string | undefined
|
||||
globalStorageUri: IUri
|
||||
globalStoragePath: string
|
||||
logUri: IUri
|
||||
logPath: string
|
||||
extensionMode: ExtensionMode
|
||||
extension: Extension<unknown> | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension mode enum
|
||||
*/
|
||||
export enum ExtensionMode {
|
||||
Production = 1,
|
||||
Development = 2,
|
||||
Test = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitter event type
|
||||
*/
|
||||
export type Event<T> = (listener: (e: T) => void, thisArgs?: unknown, disposables?: Disposable[]) => Disposable
|
||||
|
||||
/**
|
||||
* Cancellation token for async operations
|
||||
*/
|
||||
export interface CancellationToken {
|
||||
isCancellationRequested: boolean
|
||||
onCancellationRequested: Event<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* File system file type enum
|
||||
*/
|
||||
export enum FileType {
|
||||
Unknown = 0,
|
||||
File = 1,
|
||||
Directory = 2,
|
||||
SymbolicLink = 64,
|
||||
}
|
||||
|
||||
/**
|
||||
* File system stat information
|
||||
*/
|
||||
export interface FileStat {
|
||||
type: FileType
|
||||
ctime: number
|
||||
mtime: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Text editor options
|
||||
*/
|
||||
export interface TextEditorOptions {
|
||||
tabSize?: number
|
||||
insertSpaces?: boolean
|
||||
cursorStyle?: number
|
||||
lineNumbers?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* View column enum for editor placement
|
||||
*/
|
||||
export enum ViewColumn {
|
||||
Active = -1,
|
||||
Beside = -2,
|
||||
One = 1,
|
||||
Two = 2,
|
||||
Three = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* UI Kind enum
|
||||
*/
|
||||
export enum UIKind {
|
||||
Desktop = 1,
|
||||
Web = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* End of line sequence enum
|
||||
*/
|
||||
export enum EndOfLine {
|
||||
LF = 1,
|
||||
CRLF = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Status bar alignment
|
||||
*/
|
||||
export enum StatusBarAlignment {
|
||||
Left = 1,
|
||||
Right = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic severity levels
|
||||
*/
|
||||
export enum DiagnosticSeverity {
|
||||
Error = 0,
|
||||
Warning = 1,
|
||||
Information = 2,
|
||||
Hint = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic tags
|
||||
*/
|
||||
export enum DiagnosticTag {
|
||||
Unnecessary = 1,
|
||||
Deprecated = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Overview ruler lane
|
||||
*/
|
||||
export enum OverviewRulerLane {
|
||||
Left = 1,
|
||||
Center = 2,
|
||||
Right = 4,
|
||||
Full = 7,
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoration range behavior
|
||||
*/
|
||||
export enum DecorationRangeBehavior {
|
||||
OpenOpen = 0,
|
||||
ClosedClosed = 1,
|
||||
OpenClosed = 2,
|
||||
ClosedOpen = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Text editor reveal type
|
||||
*/
|
||||
export enum TextEditorRevealType {
|
||||
Default = 0,
|
||||
InCenter = 1,
|
||||
InCenterIfOutsideViewport = 2,
|
||||
AtTop = 3,
|
||||
}
|
||||
52
packages/vscode-shim/src/utils/logger.ts
Normal file
52
packages/vscode-shim/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Simple logger stub for VSCode mock
|
||||
* Users can provide their own logger by calling setLogger()
|
||||
*/
|
||||
|
||||
export interface Logger {
|
||||
info(message: string, context?: string, meta?: unknown): void
|
||||
warn(message: string, context?: string, meta?: unknown): void
|
||||
error(message: string, context?: string, meta?: unknown): void
|
||||
debug(message: string, context?: string, meta?: unknown): void
|
||||
}
|
||||
|
||||
class ConsoleLogger implements Logger {
|
||||
info(message: string, context?: string, _meta?: unknown): void {
|
||||
console.log(`[${context || "INFO"}] ${message}`)
|
||||
}
|
||||
|
||||
warn(message: string, context?: string, _meta?: unknown): void {
|
||||
console.warn(`[${context || "WARN"}] ${message}`)
|
||||
}
|
||||
|
||||
error(message: string, context?: string, _meta?: unknown): void {
|
||||
console.error(`[${context || "ERROR"}] ${message}`)
|
||||
}
|
||||
|
||||
debug(message: string, context?: string, _meta?: unknown): void {
|
||||
if (process.env.DEBUG) {
|
||||
console.debug(`[${context || "DEBUG"}] ${message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let logger: Logger = new ConsoleLogger()
|
||||
|
||||
/**
|
||||
* Set a custom logger
|
||||
*
|
||||
* @param customLogger - Your logger implementation
|
||||
*/
|
||||
export function setLogger(customLogger: Logger): void {
|
||||
logger = customLogger
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current logger
|
||||
*/
|
||||
export const logs = {
|
||||
info: (message: string, context?: string, meta?: unknown) => logger.info(message, context, meta),
|
||||
warn: (message: string, context?: string, meta?: unknown) => logger.warn(message, context, meta),
|
||||
error: (message: string, context?: string, meta?: unknown) => logger.error(message, context, meta),
|
||||
debug: (message: string, context?: string, meta?: unknown) => logger.debug(message, context, meta),
|
||||
}
|
||||
44
packages/vscode-shim/src/utils/machine-id.ts
Normal file
44
packages/vscode-shim/src/utils/machine-id.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Machine ID generation
|
||||
* Simple implementation to replace node-machine-id dependency
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as crypto from "crypto"
|
||||
import * as os from "os"
|
||||
import { ensureDirectoryExists } from "./paths.js"
|
||||
|
||||
/**
|
||||
* Get or create a unique machine ID
|
||||
* Stores in ~/.vscode-mock/.machine-id for persistence
|
||||
*/
|
||||
export function machineIdSync(): string {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || "."
|
||||
const idPath = path.join(homeDir, ".vscode-mock", ".machine-id")
|
||||
|
||||
// Try to read existing ID
|
||||
try {
|
||||
if (fs.existsSync(idPath)) {
|
||||
return fs.readFileSync(idPath, "utf-8").trim()
|
||||
}
|
||||
} catch {
|
||||
// Fall through to generate new ID
|
||||
}
|
||||
|
||||
// Generate new ID based on hostname and random data
|
||||
const hostname = os.hostname()
|
||||
const randomData = crypto.randomBytes(16).toString("hex")
|
||||
const machineId = crypto.createHash("sha256").update(`${hostname}-${randomData}`).digest("hex")
|
||||
|
||||
// Save for future use
|
||||
try {
|
||||
const dir = path.dirname(idPath)
|
||||
ensureDirectoryExists(dir)
|
||||
fs.writeFileSync(idPath, machineId)
|
||||
} catch {
|
||||
// Ignore save errors
|
||||
}
|
||||
|
||||
return machineId
|
||||
}
|
||||
89
packages/vscode-shim/src/utils/paths.ts
Normal file
89
packages/vscode-shim/src/utils/paths.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Path utilities for VSCode mock storage
|
||||
*/
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
|
||||
const STORAGE_BASE_DIR = ".vscode-mock"
|
||||
|
||||
/**
|
||||
* Get the base storage directory
|
||||
*/
|
||||
function getBaseStorageDir(): string {
|
||||
const homeDir = process.env.HOME || process.env.USERPROFILE || "."
|
||||
return path.join(homeDir, STORAGE_BASE_DIR)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a workspace path to create a unique directory name
|
||||
*
|
||||
* @param workspacePath - The workspace path to hash
|
||||
* @returns A hexadecimal hash string
|
||||
*/
|
||||
export function hashWorkspacePath(workspacePath: string): string {
|
||||
let hash = 0
|
||||
for (let i = 0; i < workspacePath.length; i++) {
|
||||
const char = workspacePath.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // Convert to 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a directory exists, creating it if necessary
|
||||
*
|
||||
* @param dirPath - The directory path to ensure exists
|
||||
*/
|
||||
export function ensureDirectoryExists(dirPath: string): void {
|
||||
try {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to create directory ${dirPath}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize workspace directories
|
||||
*/
|
||||
export function initializeWorkspace(workspacePath: string): void {
|
||||
const dirs = [getGlobalStorageDir(), getWorkspaceStorageDir(workspacePath), getLogsDir()]
|
||||
|
||||
for (const dir of dirs) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get global storage directory
|
||||
*/
|
||||
export function getGlobalStorageDir(): string {
|
||||
return path.join(getBaseStorageDir(), "global-storage")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace-specific storage directory
|
||||
*/
|
||||
export function getWorkspaceStorageDir(workspacePath: string): string {
|
||||
const hash = hashWorkspacePath(workspacePath)
|
||||
return path.join(getBaseStorageDir(), "workspace-storage", hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs directory
|
||||
*/
|
||||
export function getLogsDir(): string {
|
||||
return path.join(getBaseStorageDir(), "logs")
|
||||
}
|
||||
|
||||
export const VSCodeMockPaths = {
|
||||
initializeWorkspace,
|
||||
getGlobalStorageDir,
|
||||
getWorkspaceStorageDir,
|
||||
getLogsDir,
|
||||
}
|
||||
153
packages/vscode-shim/src/vscode.ts
Normal file
153
packages/vscode-shim/src/vscode.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/**
|
||||
* VSCode API Mock - Barrel Export File
|
||||
*
|
||||
* This file re-exports all components from the modular files for backwards compatibility.
|
||||
* All imports from this file will continue to work as before.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Classes from ./classes/
|
||||
// ============================================================================
|
||||
export { Position } from "./classes/Position.js"
|
||||
export { Range } from "./classes/Range.js"
|
||||
export { Selection } from "./classes/Selection.js"
|
||||
export { Uri } from "./classes/Uri.js"
|
||||
export { EventEmitter } from "./classes/EventEmitter.js"
|
||||
export { TextEdit, WorkspaceEdit } from "./classes/TextEdit.js"
|
||||
export {
|
||||
Location,
|
||||
Diagnostic,
|
||||
DiagnosticRelatedInformation,
|
||||
ThemeColor,
|
||||
ThemeIcon,
|
||||
CodeActionKind,
|
||||
CodeLens,
|
||||
LanguageModelTextPart,
|
||||
LanguageModelToolCallPart,
|
||||
LanguageModelToolResultPart,
|
||||
FileSystemError,
|
||||
} from "./classes/Additional.js"
|
||||
export { CancellationTokenSource, type CancellationToken } from "./classes/CancellationToken.js"
|
||||
export { OutputChannel } from "./classes/OutputChannel.js"
|
||||
export { StatusBarItem } from "./classes/StatusBarItem.js"
|
||||
export { TextEditorDecorationType } from "./classes/TextEditorDecorationType.js"
|
||||
|
||||
// ============================================================================
|
||||
// Context
|
||||
// ============================================================================
|
||||
export { ExtensionContextImpl as ExtensionContext } from "./context/ExtensionContext.js"
|
||||
|
||||
// ============================================================================
|
||||
// API Classes from ./api/
|
||||
// ============================================================================
|
||||
export { FileSystemAPI } from "./api/FileSystemAPI.js"
|
||||
export {
|
||||
MockWorkspaceConfiguration,
|
||||
setRuntimeConfig,
|
||||
setRuntimeConfigValues,
|
||||
clearRuntimeConfig,
|
||||
getRuntimeConfig,
|
||||
} from "./api/WorkspaceConfiguration.js"
|
||||
export { WorkspaceAPI } from "./api/WorkspaceAPI.js"
|
||||
export { TabGroupsAPI, type Tab, type TabInputText, type TabGroup } from "./api/TabGroupsAPI.js"
|
||||
export { WindowAPI } from "./api/WindowAPI.js"
|
||||
export { CommandsAPI } from "./api/CommandsAPI.js"
|
||||
export { createVSCodeAPIMock } from "./api/create-vscode-api-mock.js"
|
||||
|
||||
// ============================================================================
|
||||
// Enums from ./types.ts
|
||||
// ============================================================================
|
||||
export {
|
||||
ConfigurationTarget,
|
||||
ViewColumn,
|
||||
TextEditorRevealType,
|
||||
StatusBarAlignment,
|
||||
DiagnosticSeverity,
|
||||
DiagnosticTag,
|
||||
EndOfLine,
|
||||
UIKind,
|
||||
ExtensionMode,
|
||||
ExtensionKind,
|
||||
FileType,
|
||||
DecorationRangeBehavior,
|
||||
OverviewRulerLane,
|
||||
} from "./types.js"
|
||||
|
||||
// ============================================================================
|
||||
// Types from ./types.ts
|
||||
// ============================================================================
|
||||
export type { Thenable, Memento, FileStat, TextEditorOptions, ConfigurationInspect } from "./types.js"
|
||||
|
||||
// ============================================================================
|
||||
// Interfaces from ./interfaces/
|
||||
// ============================================================================
|
||||
|
||||
// Document interfaces
|
||||
export type {
|
||||
TextDocument,
|
||||
TextLine,
|
||||
WorkspaceFoldersChangeEvent,
|
||||
WorkspaceFolder,
|
||||
TextDocumentChangeEvent,
|
||||
TextDocumentContentChangeEvent,
|
||||
ConfigurationChangeEvent,
|
||||
TextDocumentContentProvider,
|
||||
FileSystemWatcher,
|
||||
RelativePattern,
|
||||
} from "./interfaces/document.js"
|
||||
|
||||
// Editor interfaces
|
||||
export type {
|
||||
TextEditor,
|
||||
TextEditorEdit,
|
||||
TextEditorSelectionChangeEvent,
|
||||
TextDocumentShowOptions,
|
||||
DecorationRenderOptions,
|
||||
} from "./interfaces/editor.js"
|
||||
|
||||
// Terminal interfaces
|
||||
export type {
|
||||
Terminal,
|
||||
TerminalOptions,
|
||||
TerminalExitStatus,
|
||||
TerminalState,
|
||||
TerminalDimensionsChangeEvent,
|
||||
TerminalDimensions,
|
||||
TerminalDataWriteEvent,
|
||||
} from "./interfaces/terminal.js"
|
||||
|
||||
// Webview interfaces
|
||||
export type {
|
||||
WebviewViewProvider,
|
||||
WebviewView,
|
||||
Webview,
|
||||
WebviewOptions,
|
||||
WebviewPortMapping,
|
||||
ViewBadge,
|
||||
WebviewViewResolveContext,
|
||||
WebviewViewProviderOptions,
|
||||
UriHandler,
|
||||
} from "./interfaces/webview.js"
|
||||
|
||||
// Workspace interfaces
|
||||
export type {
|
||||
WorkspaceConfiguration,
|
||||
QuickPickOptions,
|
||||
InputBoxOptions,
|
||||
OpenDialogOptions,
|
||||
Disposable,
|
||||
DiagnosticCollection,
|
||||
IdentityInfo,
|
||||
} from "./interfaces/workspace.js"
|
||||
|
||||
// ============================================================================
|
||||
// Secret Storage interface (backwards compatibility)
|
||||
// ============================================================================
|
||||
export interface SecretStorage {
|
||||
get(key: string): Thenable<string | undefined>
|
||||
store(key: string, value: string): Thenable<void>
|
||||
delete(key: string): Thenable<void>
|
||||
}
|
||||
|
||||
// Import Thenable for SecretStorage interface
|
||||
import type { Thenable } from "./types.js"
|
||||
9
packages/vscode-shim/tsconfig.json
Normal file
9
packages/vscode-shim/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "@roo-code/config-typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals"],
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src", "scripts", "*.config.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
9
packages/vscode-shim/vitest.config.ts
Normal file
9
packages/vscode-shim/vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from "vitest/config"
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
watch: false,
|
||||
},
|
||||
})
|
||||
71
pnpm-lock.yaml
generated
71
pnpm-lock.yaml
generated
|
|
@ -78,6 +78,43 @@ importers:
|
|||
specifier: ^5.4.5
|
||||
version: 5.8.3
|
||||
|
||||
apps/cli:
|
||||
dependencies:
|
||||
'@roo-code/types':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/types
|
||||
'@roo-code/vscode-shim':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/vscode-shim
|
||||
'@vscode/ripgrep':
|
||||
specifier: ^1.15.9
|
||||
version: 1.17.0
|
||||
commander:
|
||||
specifier: ^12.1.0
|
||||
version: 12.1.0
|
||||
devDependencies:
|
||||
'@roo-code/config-eslint':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/config-eslint
|
||||
'@roo-code/config-typescript':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/config-typescript
|
||||
'@types/node':
|
||||
specifier: ^24.1.0
|
||||
version: 24.2.1
|
||||
rimraf:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
tsup:
|
||||
specifier: ^8.4.0
|
||||
version: 8.5.0(jiti@2.4.2)(postcss@8.5.4)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.8.0)
|
||||
typescript:
|
||||
specifier: 5.8.3
|
||||
version: 5.8.3
|
||||
vitest:
|
||||
specifier: ^3.2.3
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
apps/vscode-e2e:
|
||||
devDependencies:
|
||||
'@roo-code/config-eslint':
|
||||
|
|
@ -650,6 +687,21 @@ importers:
|
|||
specifier: ^3.2.3
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
packages/vscode-shim:
|
||||
devDependencies:
|
||||
'@roo-code/config-eslint':
|
||||
specifier: workspace:^
|
||||
version: link:../config-eslint
|
||||
'@roo-code/config-typescript':
|
||||
specifier: workspace:^
|
||||
version: link:../config-typescript
|
||||
'@types/node':
|
||||
specifier: ^24.1.0
|
||||
version: 24.2.1
|
||||
vitest:
|
||||
specifier: ^3.2.3
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
src:
|
||||
dependencies:
|
||||
'@anthropic-ai/bedrock-sdk':
|
||||
|
|
@ -4362,6 +4414,9 @@ packages:
|
|||
'@vscode/codicons@0.0.36':
|
||||
resolution: {integrity: sha512-wsNOvNMMJ2BY8rC2N2MNBG7yOowV3ov8KlvUE/AiVUlHKTfWsw3OgAOQduX7h0Un6GssKD3aoTVH+TF3DSQwKQ==}
|
||||
|
||||
'@vscode/ripgrep@1.17.0':
|
||||
resolution: {integrity: sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g==}
|
||||
|
||||
'@vscode/test-cli@0.0.11':
|
||||
resolution: {integrity: sha512-qO332yvzFqGhBMJrp6TdwbIydiHgCtxXc2Nl6M58mbH/Z+0CyLR76Jzv4YWPEthhrARprzCRJUqzFvTHFhTj7Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -14180,6 +14235,14 @@ snapshots:
|
|||
|
||||
'@vscode/codicons@0.0.36': {}
|
||||
|
||||
'@vscode/ripgrep@1.17.0':
|
||||
dependencies:
|
||||
https-proxy-agent: 7.0.6
|
||||
proxy-from-env: 1.1.0
|
||||
yauzl: 2.10.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vscode/test-cli@0.0.11':
|
||||
dependencies:
|
||||
'@types/mocha': 10.0.10
|
||||
|
|
@ -17568,7 +17631,7 @@ snapshots:
|
|||
ansi-escapes: 7.0.0
|
||||
cli-cursor: 5.0.0
|
||||
slice-ansi: 7.1.0
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
wrap-ansi: 9.0.0
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
|
@ -18485,7 +18548,7 @@ snapshots:
|
|||
log-symbols: 6.0.0
|
||||
stdin-discarder: 0.2.2
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
os-name@6.1.0:
|
||||
dependencies:
|
||||
|
|
@ -19891,7 +19954,7 @@ snapshots:
|
|||
dependencies:
|
||||
emoji-regex: 10.4.0
|
||||
get-east-asian-width: 1.3.0
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
string.prototype.codepointat@0.2.1: {}
|
||||
|
||||
|
|
@ -21124,7 +21187,7 @@ snapshots:
|
|||
dependencies:
|
||||
ansi-styles: 6.2.1
|
||||
string-width: 7.2.0
|
||||
strip-ansi: 7.1.0
|
||||
strip-ansi: 7.1.2
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -822,8 +822,6 @@ export class NativeToolCallParser {
|
|||
default:
|
||||
if (customToolRegistry.has(resolvedName)) {
|
||||
nativeArgs = args as NativeArgsFor<TName>
|
||||
} else {
|
||||
console.error(`Unhandled tool: ${resolvedName}`)
|
||||
}
|
||||
|
||||
break
|
||||
|
|
|
|||
|
|
@ -1090,7 +1090,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// state.
|
||||
askTs = Date.now()
|
||||
this.lastMessageTs = askTs
|
||||
console.log(`Task#ask: new partial ask -> ${type} @ ${askTs}`)
|
||||
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected })
|
||||
// console.log("Task#ask: current ask promise was ignored (#2)")
|
||||
throw new AskIgnoredError("new partial")
|
||||
|
|
@ -1115,7 +1114,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// So in this case we must make sure that the message ts is
|
||||
// never altered after first setting it.
|
||||
askTs = lastMessage.ts
|
||||
console.log(`Task#ask: updating previous partial ask -> ${type} @ ${askTs}`)
|
||||
this.lastMessageTs = askTs
|
||||
lastMessage.text = text
|
||||
lastMessage.partial = false
|
||||
|
|
@ -1129,7 +1127,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.askResponseText = undefined
|
||||
this.askResponseImages = undefined
|
||||
askTs = Date.now()
|
||||
console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`)
|
||||
this.lastMessageTs = askTs
|
||||
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected })
|
||||
}
|
||||
|
|
@ -1140,7 +1137,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.askResponseText = undefined
|
||||
this.askResponseImages = undefined
|
||||
askTs = Date.now()
|
||||
console.log(`Task#ask: new complete ask -> ${type} @ ${askTs}`)
|
||||
this.lastMessageTs = askTs
|
||||
await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected })
|
||||
}
|
||||
|
|
@ -1170,15 +1166,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// block (via the `pWaitFor`).
|
||||
const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs)
|
||||
const isMessageQueued = !this.messageQueueService.isEmpty()
|
||||
|
||||
const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask"
|
||||
|
||||
if (isBlocking) {
|
||||
console.log(`Task#ask will block -> type: ${type}`)
|
||||
}
|
||||
|
||||
if (isStatusMutable) {
|
||||
console.log(`Task#ask: status is mutable -> type: ${type}`)
|
||||
const statusMutationTimeout = 2_000
|
||||
|
||||
if (isInteractiveAsk(type)) {
|
||||
|
|
@ -1217,8 +1207,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
)
|
||||
}
|
||||
} else if (isMessageQueued) {
|
||||
console.log(`Task#ask: will process message queue -> type: ${type}`)
|
||||
|
||||
const message = this.messageQueueService.dequeueMessage()
|
||||
|
||||
if (message) {
|
||||
|
|
@ -1277,7 +1265,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Could happen if we send multiple asks in a row i.e. with
|
||||
// command_output. It's important that when we know an ask could
|
||||
// fail, it is handled gracefully.
|
||||
console.log("Task#ask: current ask promise was ignored")
|
||||
throw new AskIgnoredError("superseded")
|
||||
}
|
||||
|
||||
|
|
@ -2515,7 +2502,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list
|
||||
lastMessage.partial = false
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
console.log("updating partial message", lastMessage)
|
||||
}
|
||||
|
||||
// Update `api_req_started` to have cancelled and cost, so that
|
||||
|
|
|
|||
|
|
@ -199,7 +199,6 @@ export class ExecaTerminalProcess extends BaseTerminalProcess {
|
|||
psTree(this.pid, async (err, children) => {
|
||||
if (!err) {
|
||||
const pids = children.map((p) => parseInt(p.PID))
|
||||
console.error(`[ExecaTerminalProcess#abort] SIGKILL children -> ${pids.join(", ")}`)
|
||||
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue