mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
fc7b68b261
commit
8a7596f674
6 changed files with 376 additions and 1 deletions
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -396,6 +396,11 @@
|
|||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "%settings.useAgentRules.description%"
|
||||
},
|
||||
"roo-cline.autoGenerateProjectId": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%settings.autoGenerateProjectId.description%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
119
src/utils/__tests__/projectId.spec.ts
Normal file
119
src/utils/__tests__/projectId.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
48
src/utils/autoGenerateProjectId.ts
Normal file
48
src/utils/autoGenerateProjectId.ts
Normal 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)
|
||||
}
|
||||
172
src/utils/detectMovedProject.ts
Normal file
172
src/utils/detectMovedProject.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue