Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/types/src/vscode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const commandIds = [
"focusInput",
"acceptInput",
"focusPanel",

"generateProjectId",
] as const

export type CommandId = (typeof commandIds)[number]
Expand Down
26 changes: 26 additions & 0 deletions src/activate/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { CodeIndexManager } from "../services/code-index/manager"
import { importSettingsWithFeedback } from "../core/config/importExport"
import { MdmService } from "../services/mdm/MdmService"
import { t } from "../i18n"
import { generateProjectId } from "../utils/projectId"
import { getWorkspacePath } from "../utils/path"

/**
* Helper to get the visible ClineProvider instance or log if not found.
Expand Down Expand Up @@ -218,6 +220,30 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt

visibleProvider.postMessageToWebview({ type: "acceptInput" })
},
generateProjectId: async () => {
const workspacePath = getWorkspacePath()
if (!workspacePath) {
vscode.window.showErrorMessage(t("common:errors.no_workspace"))
return
}

try {
const projectId = await generateProjectId(workspacePath)
vscode.window.showInformationMessage(t("common:info.project_id_generated", { projectId }))

// Notify the provider to update any cached state
const visibleProvider = getVisibleProviderOrLog(outputChannel)
if (visibleProvider) {
await visibleProvider.postStateToWebview()
}
} catch (error) {
vscode.window.showErrorMessage(
t("common:errors.project_id_generation_failed", {
error: error instanceof Error ? error.message : String(error),
}),
)
}
},
})

export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
Expand Down
6 changes: 5 additions & 1 deletion src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
// utils
import { calculateApiCostAnthropic } from "../../shared/cost"
import { getWorkspacePath } from "../../utils/path"
import { getWorkspaceStorageKey } from "../../utils/projectId"

// prompts
import { formatResponse } from "../prompts/responses"
Expand Down Expand Up @@ -575,12 +576,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
})

// Use project ID if available, otherwise fall back to workspace path
const workspaceStorageKey = await getWorkspaceStorageKey(this.cwd)

const { historyItem, tokenUsage } = await taskMetadata({
messages: this.clineMessages,
taskId: this.taskId,
taskNumber: this.taskNumber,
globalStoragePath: this.globalStoragePath,
workspace: this.cwd,
workspace: workspaceStorageKey,
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode
})

Expand Down
5 changes: 5 additions & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@
"title": "%command.importSettings.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.generateProjectId",
"title": "%command.generateProjectId.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.focusInput",
"title": "%command.focusInput.title%",
Expand Down
1 change: 1 addition & 0 deletions src/package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"command.terminal.fixCommand.title": "Fix This Command",
"command.terminal.explainCommand.title": "Explain This Command",
"command.acceptInput.title": "Accept Input/Suggestion",
"command.generateProjectId.title": "Generate Project ID",
"configuration.title": "Roo Code",
"commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled",
"commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.",
Expand Down
112 changes: 112 additions & 0 deletions src/utils/__tests__/projectId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import * as fs from "fs/promises"
import * as path from "path"
import { getProjectId, generateProjectId, getWorkspaceStorageKey } from "../projectId"
import { fileExistsAtPath } from "../fs"

vi.mock("fs/promises")
vi.mock("path")
vi.mock("../fs")

describe("projectId", () => {
const mockWorkspaceRoot = "/test/workspace"
const mockProjectIdPath = "/test/workspace/.rooprojectid"
const mockProjectId = "123e4567-e89b-12d3-a456-426614174000"

beforeEach(() => {
vi.clearAllMocks()
vi.mocked(path.join).mockImplementation((...args) => args.join("/"))
})

afterEach(() => {
vi.restoreAllMocks()
})

describe("getProjectId", () => {
it("should return existing project ID from file", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
vi.mocked(fs.readFile).mockResolvedValue(mockProjectId)

const result = await getProjectId(mockWorkspaceRoot)

expect(result).toBe(mockProjectId)
expect(fileExistsAtPath).toHaveBeenCalledWith(mockProjectIdPath)
expect(fs.readFile).toHaveBeenCalledWith(mockProjectIdPath, "utf8")
})

it("should return null if file does not exist", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(false)

const result = await getProjectId(mockWorkspaceRoot)

expect(result).toBeNull()
expect(fs.readFile).not.toHaveBeenCalled()
})

it("should return null if file is empty", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
vi.mocked(fs.readFile).mockResolvedValue("")

const result = await getProjectId(mockWorkspaceRoot)

expect(result).toBeNull()
})

it("should trim whitespace from project ID", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
vi.mocked(fs.readFile).mockResolvedValue(` ${mockProjectId} \n`)

const result = await getProjectId(mockWorkspaceRoot)

expect(result).toBe(mockProjectId)
})

it("should return null on read error", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
vi.mocked(fs.readFile).mockRejectedValue(new Error("Read error"))
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})

const result = await getProjectId(mockWorkspaceRoot)

expect(result).toBeNull()
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to read project ID: Error: Read error")
consoleErrorSpy.mockRestore()
})
})

describe("generateProjectId", () => {
it("should generate and save a new project ID", async () => {
vi.mocked(fs.writeFile).mockResolvedValue()

const result = await generateProjectId(mockWorkspaceRoot)

expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
expect(fs.writeFile).toHaveBeenCalledWith(mockProjectIdPath, result, "utf8")
})

it("should throw error if write fails", async () => {
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write failed"))

await expect(generateProjectId(mockWorkspaceRoot)).rejects.toThrow("Write failed")
})
})

describe("getWorkspaceStorageKey", () => {
it("should return project ID if it exists", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
vi.mocked(fs.readFile).mockResolvedValue(mockProjectId)

const result = await getWorkspaceStorageKey(mockWorkspaceRoot)

expect(result).toBe(mockProjectId)
})

it("should return workspace root if project ID does not exist", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(false)

const result = await getWorkspaceStorageKey(mockWorkspaceRoot)

expect(result).toBe(mockWorkspaceRoot)
})
})
})
64 changes: 64 additions & 0 deletions src/utils/projectId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import * as fs from "fs/promises"
import * as path from "path"
import { v4 as uuidv4 } from "uuid"
import { fileExistsAtPath } from "./fs"

const PROJECT_ID_FILENAME = ".rooprojectid"

/**
* Gets the project ID from the .rooprojectid file in the workspace root.
* Returns null if the file doesn't exist or can't be read.
*
* @param workspaceRoot The root directory of the workspace
* @returns The project ID string or null
*/
export async function getProjectId(workspaceRoot: string): Promise<string | null> {
try {
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)

if (!(await fileExistsAtPath(projectIdPath))) {
return null
}

const content = await fs.readFile(projectIdPath, "utf8")
const projectId = content.trim()

// Validate that it's a non-empty string
if (!projectId) {
return null
}

return projectId
} catch (error) {
// Silently handle errors and return null
console.error(`Failed to read project ID: ${error}`)
return null
}
}

/**
* Generates a new project ID and writes it to the .rooprojectid file.
*
* @param workspaceRoot The root directory of the workspace
* @returns The generated project ID
*/
export async function generateProjectId(workspaceRoot: string): Promise<string> {
const projectId = uuidv4()
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)

await fs.writeFile(projectIdPath, projectId, "utf8")

return projectId
}

/**
* Gets the storage key for a workspace, using the project ID if available,
* otherwise falling back to the workspace path.
*
* @param workspaceRoot The root directory of the workspace
* @returns The storage key to use for this workspace
*/
export async function getWorkspaceStorageKey(workspaceRoot: string): Promise<string> {
const projectId = await getProjectId(workspaceRoot)
return projectId ?? workspaceRoot
}
Loading