From 8a7596f6746de5a2756122b48cae2dea57ed9464 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 3 Aug 2025 05:32:11 +0000 Subject: [PATCH] feat: implement Sprint 2 and Sprint 3 of stable project IDs Sprint 2: User-facing features - Add autoGenerateProjectId configuration setting - Implement automatic project ID generation on workspace open - Show status bar notification for 15 seconds when ID is generated - Trigger task history migration when ID is generated Sprint 3: Handling moved & copied projects - Detect when project with existing ID is opened at new location - Show modal dialog asking if project was moved or copied - Allow user to keep existing history (moved) or generate new ID (copied) - Link history from previous location when project is moved --- src/extension.ts | 30 +++++ src/package.json | 5 + src/package.nls.json | 3 +- src/utils/__tests__/projectId.spec.ts | 119 ++++++++++++++++++ src/utils/autoGenerateProjectId.ts | 48 +++++++ src/utils/detectMovedProject.ts | 172 ++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/projectId.spec.ts create mode 100644 src/utils/autoGenerateProjectId.ts create mode 100644 src/utils/detectMovedProject.ts diff --git a/src/extension.ts b/src/extension.ts index 60c61aada7..e7a2d9d0f8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -29,6 +29,8 @@ import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" +import { autoGenerateProjectIdIfNeeded } from "./utils/autoGenerateProjectId" +import { detectAndHandleMovedProject } from "./utils/detectMovedProject" import { API } from "./extension/api" import { @@ -179,6 +181,34 @@ export async function activate(context: vscode.ExtensionContext) { registerCodeActions(context) registerTerminalActions(context) + // Check for automatic project ID generation + try { + await autoGenerateProjectIdIfNeeded() + + // If a project ID was generated, trigger migration + const { getProjectId } = await import("./utils/projectId") + const { getWorkspacePath } = await import("./utils/path") + const workspacePath = getWorkspacePath() + const projectId = await getProjectId(workspacePath) + + if (projectId && provider) { + // Migrate existing tasks to use the new project ID + const migratedCount = await provider.migrateTasksToProjectId(workspacePath, projectId) + if (migratedCount > 0) { + outputChannel.appendLine(`Migrated ${migratedCount} tasks to use project ID: ${projectId}`) + } + } + } catch (error) { + outputChannel.appendLine(`Failed to auto-generate project ID: ${error}`) + } + + // Check if this is a moved project + try { + await detectAndHandleMovedProject(provider) + } catch (error) { + outputChannel.appendLine(`Failed to detect moved project: ${error}`) + } + // Allows other extensions to activate once Roo is ready. vscode.commands.executeCommand(`${Package.name}.activationCompleted`) diff --git a/src/package.json b/src/package.json index 8497c6dbfe..38df0ec472 100644 --- a/src/package.json +++ b/src/package.json @@ -396,6 +396,11 @@ "type": "boolean", "default": true, "description": "%settings.useAgentRules.description%" + }, + "roo-cline.autoGenerateProjectId": { + "type": "boolean", + "default": false, + "description": "%settings.autoGenerateProjectId.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index d1988bb106..d2cba2396a 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -38,5 +38,6 @@ "settings.customStoragePath.description": "Custom storage path. Leave empty to use the default location. Supports absolute paths (e.g. 'D:\\RooCodeStorage')", "settings.enableCodeActions.description": "Enable Roo Code quick fixes", "settings.autoImportSettingsPath.description": "Path to a RooCode configuration file to automatically import on extension startup. Supports absolute paths and paths relative to the home directory (e.g. '~/Documents/roo-code-settings.json'). Leave empty to disable auto-import.", - "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)" + "settings.useAgentRules.description": "Enable loading of AGENTS.md files for agent-specific rules (see https://agent-rules.org/)", + "settings.autoGenerateProjectId.description": "Automatically generate a project ID for new projects to preserve chat history when projects are moved or renamed" } diff --git a/src/utils/__tests__/projectId.spec.ts b/src/utils/__tests__/projectId.spec.ts new file mode 100644 index 0000000000..c47b095a80 --- /dev/null +++ b/src/utils/__tests__/projectId.spec.ts @@ -0,0 +1,119 @@ +// npx vitest utils/__tests__/projectId.spec.ts + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import { getProjectId, generateProjectId, getWorkspaceStorageKey } from "../projectId" +import { fileExistsAtPath } from "../fs" + +// Mock dependencies +vi.mock("fs/promises") +vi.mock("../fs") +vi.mock("uuid", () => ({ + v4: vi.fn(() => "test-uuid-1234"), +})) + +describe("projectId utilities", () => { + const mockWorkspaceRoot = "/test/workspace" + const projectIdPath = path.join(mockWorkspaceRoot, ".rooprojectid") + + beforeEach(() => { + vi.clearAllMocks() + // Reset console.error mock + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("getProjectId", () => { + it("should return project ID when file exists", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue("existing-project-id\n") + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBe("existing-project-id") + 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(" \n ") + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + }) + + it("should handle read errors gracefully", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockRejectedValue(new Error("Read error")) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + expect(console.error).toHaveBeenCalledWith(expect.stringContaining("Failed to read project ID")) + }) + }) + + describe("generateProjectId", () => { + it("should generate new ID when none exists", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + vi.mocked(fs.writeFile).mockResolvedValue() + + const result = await generateProjectId(mockWorkspaceRoot) + + expect(result).toBe("test-uuid-1234") + expect(fs.writeFile).toHaveBeenCalledWith(projectIdPath, "test-uuid-1234", "utf8") + }) + + it("should return existing ID if already present", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue("existing-id") + + const result = await generateProjectId(mockWorkspaceRoot) + + expect(result).toBe("existing-id") + expect(fs.writeFile).not.toHaveBeenCalled() + }) + + it("should handle write errors", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write error")) + + await expect(generateProjectId(mockWorkspaceRoot)).rejects.toThrow("Write error") + }) + }) + + describe("getWorkspaceStorageKey", () => { + it("should return project ID when available", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue("project-id-123") + + const result = await getWorkspaceStorageKey(mockWorkspaceRoot) + + expect(result).toBe("project-id-123") + }) + + it("should return workspace path when no project ID exists", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + const result = await getWorkspaceStorageKey(mockWorkspaceRoot) + + expect(result).toBe(mockWorkspaceRoot) + }) + }) +}) diff --git a/src/utils/autoGenerateProjectId.ts b/src/utils/autoGenerateProjectId.ts new file mode 100644 index 0000000000..2b17a305a7 --- /dev/null +++ b/src/utils/autoGenerateProjectId.ts @@ -0,0 +1,48 @@ +import * as vscode from "vscode" +import { getProjectId, generateProjectId } from "./projectId" +import { getWorkspacePath } from "./path" +import { Package } from "../shared/package" + +/** + * Automatically generates a project ID for the workspace if enabled and not already present + * Shows a status bar notification when a project ID is generated + */ +export async function autoGenerateProjectIdIfNeeded(): Promise { + // Check if automatic generation is enabled + const config = vscode.workspace.getConfiguration(Package.name) + const autoGenerateEnabled = config.get("autoGenerateProjectId", false) + + if (!autoGenerateEnabled) { + return + } + + // Get the workspace path + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return + } + + // Check if workspace already has a project ID + const existingId = await getProjectId(workspacePath) + if (existingId) { + return + } + + // Generate a new project ID + const newId = await generateProjectId(workspacePath) + if (!newId) { + return + } + + // Show status bar notification for 15 seconds + const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100) + statusBarItem.text = "$(check) Project ID generated for Roo Code history" + statusBarItem.tooltip = + "A unique project ID has been generated to preserve your chat history when this project is moved or renamed" + statusBarItem.show() + + // Hide the status bar item after 15 seconds + setTimeout(() => { + statusBarItem.dispose() + }, 15000) +} diff --git a/src/utils/detectMovedProject.ts b/src/utils/detectMovedProject.ts new file mode 100644 index 0000000000..1acc7ea580 --- /dev/null +++ b/src/utils/detectMovedProject.ts @@ -0,0 +1,172 @@ +import * as vscode from "vscode" +import * as path from "path" +import { getProjectId, generateProjectId, getWorkspaceStorageKey } from "./projectId" +import { getWorkspacePath } from "./path" +import { Package } from "../shared/package" +import { ClineProvider } from "../core/webview/ClineProvider" + +/** + * Detects if a project with an existing project ID has been moved to a new location + * and handles the user interaction to determine how to proceed + */ +export async function detectAndHandleMovedProject(provider: ClineProvider): Promise { + const workspacePath = getWorkspacePath() + if (!workspacePath) { + return + } + + // Check if this workspace has a project ID + const projectId = await getProjectId(workspacePath) + if (!projectId) { + return + } + + // Check if we have any task history for this project ID + const taskHistory = provider.getValue("taskHistory") ?? [] + const projectTasks = taskHistory.filter((item: any) => item.workspace === projectId) + + if (projectTasks.length === 0) { + // No existing history for this project ID + // But we should check if there are tasks with different workspace paths + // that might indicate this is a copied/moved project + + // Look for any tasks from other workspaces (not the current one) + const otherWorkspaceTasks = taskHistory.filter( + (item: any) => + item.workspace !== workspacePath && item.workspace !== projectId && !item.workspace.includes(projectId), // Ensure it's not a path containing the project ID + ) + + if (otherWorkspaceTasks.length === 0) { + // No tasks from other workspaces, this is likely a new project + return + } + + // Check if we should ask about this being a moved project + // Only ask if there are a significant number of tasks from another workspace + const workspaceCounts = new Map() + otherWorkspaceTasks.forEach((task: any) => { + const count = workspaceCounts.get(task.workspace) || 0 + workspaceCounts.set(task.workspace, count + 1) + }) + + // Find the workspace with the most tasks + let maxCount = 0 + let likelyPreviousWorkspace = "" + workspaceCounts.forEach((count, workspace) => { + if (count > maxCount) { + maxCount = count + likelyPreviousWorkspace = workspace + } + }) + + // Only prompt if there are at least 3 tasks from another workspace + if (maxCount < 3) { + return + } + + // Show dialog asking if this is the same project moved from another location + const options = ["Yes, link the history", "No, this is a new project"] + + const result = await vscode.window.showInformationMessage( + `Found ${maxCount} chat sessions from another location. Is this the same project that was moved here?`, + { modal: true }, + ...options, + ) + + if (result === options[0]) { + // Link the history by migrating tasks from the old workspace + const migratedCount = await provider.migrateTasksToProjectId(likelyPreviousWorkspace, projectId) + if (migratedCount > 0) { + vscode.window.showInformationMessage( + `Successfully linked ${migratedCount} previous chat sessions to this project.`, + ) + } + } + + return + } + + // We have tasks with this project ID already + // This could mean: + // 1. The project is in its original location + // 2. The project was moved and we're opening it again + // 3. This is a copy/fork of the original project + + // To detect if this is a copy/fork, we need to check if there's a .git directory + // and if the git remote or path is different from what we might have stored + // For now, we'll use a simpler heuristic: if the workspace path is different + // from all the task workspace paths, this might be a copy + + // Get unique workspace paths from tasks (excluding the project ID itself) + const workspacePaths = new Set() + taskHistory.forEach((task: any) => { + if (task.workspace && task.workspace !== projectId && !task.workspace.includes(projectId)) { + workspacePaths.add(task.workspace) + } + }) + + // If we have tasks but none of them have workspace paths (all migrated to project ID) + // then we can't determine if this is a moved project + if (workspacePaths.size === 0) { + return + } + + // Check if the current workspace path matches any of the stored paths + const isKnownLocation = Array.from(workspacePaths).some( + (path) => path === workspacePath || workspacePath.includes(path) || path.includes(workspacePath), + ) + + if (!isKnownLocation && workspacePaths.size > 0) { + // This workspace path has never been seen before for this project ID + // It might be a copy/fork + const options = ["This is the same project (moved/renamed)", "This is a new project (copy/fork)"] + + const result = await vscode.window.showInformationMessage( + `This project has a Roo Code project ID with ${projectTasks.length} existing chat sessions from a different location. Is this the same project that was moved/renamed, or a new copy?`, + { modal: true }, + ...options, + ) + + if (!result) { + // User cancelled, do nothing + return + } + + if (result === options[0]) { + // Same project - keep the existing ID and history + vscode.window.showInformationMessage( + `Keeping existing project history. Your ${projectTasks.length} previous chat sessions are available.`, + ) + } else { + // New project - generate a new ID + const newProjectId = await generateNewProjectId(workspacePath) + if (newProjectId) { + vscode.window.showInformationMessage( + "Generated new project ID. This project now has its own separate chat history.", + ) + } + } + } + + return +} + +/** + * Generates a new project ID by first removing the existing one + */ +async function generateNewProjectId(workspacePath: string): Promise { + try { + // Remove the existing .rooprojectid file + const fs = await import("fs/promises") + const projectIdPath = path.join(workspacePath, ".rooprojectid") + await fs.unlink(projectIdPath) + + // Generate a new ID + const newId = await generateProjectId(workspacePath) + return newId + } catch (error) { + console.error("Failed to generate new project ID:", error) + vscode.window.showErrorMessage("Failed to generate new project ID") + return null + } +}