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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
totalCost: z.number(),
size: z.number().optional(),
workspace: z.string().optional(),
projectId: z.string().optional(),
mode: z.string().optional(),
})

Expand Down
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
25 changes: 25 additions & 0 deletions src/activate/registerCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,31 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt

visibleProvider.postMessageToWebview({ type: "acceptInput" })
},
generateProjectId: async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
if (!workspaceFolder) {
vscode.window.showErrorMessage(t("projectId.noWorkspace"))
return
}

try {
const { generateProjectId, hasProjectId } = await import("../utils/projectId")

// Check if project already has an ID
const hasId = await hasProjectId(workspaceFolder.uri.fsPath)
if (hasId) {
vscode.window.showInformationMessage(t("projectId.alreadyExists"))
return
}

// Generate new project ID
await generateProjectId(workspaceFolder.uri.fsPath)
vscode.window.showInformationMessage(t("projectId.generated"))
} catch (error) {
outputChannel.appendLine(`Error generating project ID: ${error}`)
vscode.window.showErrorMessage(t("projectId.generateError", { error: String(error) }))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding telemetry or metrics here to track how often project ID generation fails. This could help identify common issues users face.

}
},
})

export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
Expand Down
5 changes: 5 additions & 0 deletions src/core/task-persistence/taskMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { findLastIndex } from "../../shared/array"
import { getTaskDirectoryPath } from "../../utils/storage"
import { getProjectId } from "../../utils/projectId"
import { t } from "../../i18n"

const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 })
Expand Down Expand Up @@ -79,6 +80,9 @@ export async function taskMetadata({
}
}

// Get project ID if available
const projectId = await getProjectId(workspace)

// Create historyItem once with pre-calculated values
const historyItem: HistoryItem = {
id: taskId,
Expand All @@ -94,6 +98,7 @@ export async function taskMetadata({
totalCost: tokenUsage.totalCost,
size: taskDirSize,
workspace,
projectId: projectId || undefined,
mode,
}

Expand Down
29 changes: 26 additions & 3 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1367,12 +1367,33 @@ export class ClineProvider
apiConversationHistory: Anthropic.MessageParam[]
}> {
const history = this.getGlobalState("taskHistory") ?? []
const historyItem = history.find((item) => item.id === id)

// First try to find by task ID
let historyItem = history.find((item) => item.id === id)

// If not found by ID and the ID looks like a project ID (UUID format),
// try to find by project ID
if (!historyItem && id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This UUID regex pattern appears twice in this file (lines 1376 and 1376). Consider extracting it to a constant like for reusability and consistency.

// Get the current workspace root
const workspaceRoot = this.cwd
if (workspaceRoot) {
// Check if this workspace has the given project ID
const { getProjectId } = await import("../../utils/projectId")
const currentProjectId = await getProjectId(workspaceRoot)

if (currentProjectId === id) {
// Find the most recent task for this project ID
historyItem = history
.filter((item) => item.projectId === id)
.sort((a, b) => (b.ts || 0) - (a.ts || 0))[0]
}
}
}

if (historyItem) {
const { getTaskDirectoryPath } = await import("../../utils/storage")
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id)
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, historyItem.id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
Expand All @@ -1392,7 +1413,9 @@ export class ClineProvider

// if we tried to get a task that doesn't exist, remove it from state
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
await this.deleteTaskFromState(id)
if (historyItem) {
await this.deleteTaskFromState(historyItem.id)
}
throw new Error("Task not found")
}

Expand Down
6 changes: 6 additions & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,5 +190,11 @@
"preventCompletionWithOpenTodos": {
"description": "Prevent task completion when there are incomplete todos in the todo list"
}
},
"projectId": {
"noWorkspace": "No workspace folder found",
"alreadyExists": "Project already has a unique ID",
"generated": "Project ID generated successfully",
"generateError": "Failed to generate project ID: {{error}}"
}
}
5 changes: 5 additions & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@
"command": "roo-cline.acceptInput",
"title": "%command.acceptInput.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.generateProjectId",
"title": "%command.generateProjectId.title%",
"category": "%configuration.title%"
}
],
"menus": {
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
118 changes: 118 additions & 0 deletions src/utils/__tests__/projectId.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import fs from "fs/promises"
import path from "path"
import { v4 as uuidv4 } from "uuid"
import { getProjectId, generateProjectId, hasProjectId } from "../projectId"
import { fileExistsAtPath } from "../fs"

// Mock dependencies
vi.mock("fs/promises")
vi.mock("../fs")
vi.mock("uuid")

describe("projectId", () => {
const mockWorkspaceRoot = "/test/workspace"
const mockProjectId = "12345678-1234-1234-1234-123456789012"
const projectIdPath = path.join(mockWorkspaceRoot, ".rooprojectid")

beforeEach(() => {
vi.clearAllMocks()
})

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

describe("getProjectId", () => {
it("should return project ID when file exists and contains valid ID", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
vi.mocked(fs.readFile).mockResolvedValue(mockProjectId)

const result = await getProjectId(mockWorkspaceRoot)

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

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

const result = await getProjectId(mockWorkspaceRoot)

expect(result).toBeNull()
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
expect(fs.readFile).not.toHaveBeenCalled()
})

it("should return null when 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 handle read errors gracefully", 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")
})
})

describe("generateProjectId", () => {
it("should generate and write a new project ID", async () => {
vi.mocked(uuidv4).mockReturnValue(mockProjectId as any)
vi.mocked(fs.writeFile).mockResolvedValue()

const result = await generateProjectId(mockWorkspaceRoot)

expect(result).toBe(mockProjectId)
expect(uuidv4).toHaveBeenCalled()
expect(fs.writeFile).toHaveBeenCalledWith(projectIdPath, mockProjectId, "utf8")
})

it("should handle write errors", async () => {
vi.mocked(uuidv4).mockReturnValue(mockProjectId as any)
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write error"))

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

describe("hasProjectId", () => {
it("should return true when project ID file exists", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)

const result = await hasProjectId(mockWorkspaceRoot)

expect(result).toBe(true)
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
})

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

const result = await hasProjectId(mockWorkspaceRoot)

expect(result).toBe(false)
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
})
})
})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding test cases for:

  • Concurrent project ID generation attempts
  • File system permission errors
  • Race conditions when multiple VSCode instances try to create the same project ID

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"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file format and purpose could be documented in a README or contributing guide. Also, consider adding a comment here indicating that migration logic will be added in Sprint 2.


/**
* 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)
const exists = await fileExistsAtPath(projectIdPath)

if (!exists) {
return null
}

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

// Validate that it's not empty
if (!projectId) {
return null
}

return projectId
} catch (error) {
// If we can't read the file for any reason, 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
}

/**
* Checks if a project has a project ID file.
*
* @param workspaceRoot The root directory of the workspace
* @returns True if the project has a .rooprojectid file
*/
export async function hasProjectId(workspaceRoot: string): Promise<boolean> {
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)
return await fileExistsAtPath(projectIdPath)
}
Loading