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
This commit is contained in:
Roo Code 2025-08-03 05:32:11 +00:00
parent fc7b68b261
commit 8a7596f674
6 changed files with 376 additions and 1 deletions

View file

@ -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`)

View file

@ -396,6 +396,11 @@
"type": "boolean",
"default": true,
"description": "%settings.useAgentRules.description%"
},
"roo-cline.autoGenerateProjectId": {
"type": "boolean",
"default": false,
"description": "%settings.autoGenerateProjectId.description%"
}
}
}

View file

@ -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"
}

View file

@ -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)
})
})
})

View file

@ -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<void> {
// Check if automatic generation is enabled
const config = vscode.workspace.getConfiguration(Package.name)
const autoGenerateEnabled = config.get<boolean>("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)
}

View file

@ -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<void> {
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<string, number>()
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<string>()
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<string | null> {
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
}
}