mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
feat: implement stable project IDs (Sprint 1) - fixes #6618
Sprint 1 implementation includes: - Added projectId utility module with getProjectId, generateProjectId, and getWorkspaceStorageKey functions - Modified Task.ts to use project ID instead of workspace path for task metadata storage - Added "Generate Project ID" command to VSCode extension - Added comprehensive tests for projectId utility functions This is the foundation for stable project IDs that will preserve chat history when projects are moved or renamed.
This commit is contained in:
parent
3f966dfaa3
commit
64fbf59d86
7 changed files with 215 additions and 1 deletions
|
|
@ -53,6 +53,8 @@ export const commandIds = [
|
|||
"focusInput",
|
||||
"acceptInput",
|
||||
"focusPanel",
|
||||
|
||||
"generateProjectId",
|
||||
] as const
|
||||
|
||||
export type CommandId = (typeof commandIds)[number]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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">) => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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%",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
112
src/utils/__tests__/projectId.test.ts
Normal file
112
src/utils/__tests__/projectId.test.ts
Normal file
|
|
@ -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
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)
|
||||
|
||||
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…
Add table
Reference in a new issue