mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: implement stable project ID feature (Sprint 1)
- Add project ID utility functions (getProjectId, generateProjectId, hasProjectId) - Include project ID in task history metadata - Update getTaskWithId to support searching by project ID - Add "Generate Project ID" VSCode command - Add comprehensive unit tests This implements the core functionality for stable project IDs that persist across different project locations. Users can now generate a unique project ID that will be stored in a .rooprojectid file. Part of #6618
This commit is contained in:
parent
1d714c8ce4
commit
72e6abfc67
10 changed files with 253 additions and 3 deletions
|
|
@ -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(),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ export const commandIds = [
|
|||
"focusInput",
|
||||
"acceptInput",
|
||||
"focusPanel",
|
||||
|
||||
"generateProjectId",
|
||||
] as const
|
||||
|
||||
export type CommandId = (typeof commandIds)[number]
|
||||
|
|
|
|||
|
|
@ -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) }))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
@ -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,
|
||||
|
|
@ -94,6 +98,7 @@ export async function taskMetadata({
|
|||
totalCost: tokenUsage.totalCost,
|
||||
size: taskDirSize,
|
||||
workspace,
|
||||
projectId: projectId || undefined,
|
||||
mode,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
// 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)
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
118
src/utils/__tests__/projectId.spec.ts
Normal file
118
src/utils/__tests__/projectId.spec.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
64
src/utils/projectId.ts
Normal file
64
src/utils/projectId.ts
Normal file
|
|
@ -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)
|
||||
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…
Add table
Reference in a new issue