diff --git a/packages/types/src/history.ts b/packages/types/src/history.ts index ace134566e..a6f12287a1 100644 --- a/packages/types/src/history.ts +++ b/packages/types/src/history.ts @@ -16,6 +16,7 @@ export const historyItemSchema = z.object({ totalCost: z.number(), size: z.number().optional(), workspace: z.string().optional(), + workspaceHash: z.string().optional(), mode: z.string().optional(), }) diff --git a/src/core/task-persistence/taskMetadata.ts b/src/core/task-persistence/taskMetadata.ts index 7b93b5c14a..3d8ebe3653 100644 --- a/src/core/task-persistence/taskMetadata.ts +++ b/src/core/task-persistence/taskMetadata.ts @@ -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 { getWorkspaceHash } from "../../utils/workspaceHash" import { t } from "../../i18n" const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 }) @@ -18,6 +19,7 @@ export type TaskMetadataOptions = { taskNumber: number globalStoragePath: string workspace: string + workspaceHash?: string mode?: string } @@ -27,6 +29,7 @@ export async function taskMetadata({ taskNumber, globalStoragePath, workspace, + workspaceHash, mode, }: TaskMetadataOptions) { const taskDir = await getTaskDirectoryPath(globalStoragePath, taskId) @@ -79,6 +82,9 @@ export async function taskMetadata({ } } + // Generate workspace hash if not provided, convert null to undefined + const finalWorkspaceHash = workspaceHash || getWorkspaceHash() || undefined + // Create historyItem once with pre-calculated values const historyItem: HistoryItem = { id: taskId, @@ -94,6 +100,7 @@ export async function taskMetadata({ totalCost: tokenUsage.totalCost, size: taskDirSize, workspace, + workspaceHash: finalWorkspaceHash, mode, } diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index edbde32ea7..68ee3745f1 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -62,6 +62,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" // utils import { calculateApiCostAnthropic } from "../../shared/cost" import { getWorkspacePath } from "../../utils/path" +import { getWorkspaceHash } from "../../utils/workspaceHash" // prompts import { formatResponse } from "../prompts/responses" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 280ab61a06..54bcbc1425 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -66,6 +66,7 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" import { getWorkspacePath } from "../../utils/path" +import { migrateHistoryToWorkspaceHash, isMigrationNeeded } from "../../utils/historyMigration" import { webviewMessageHandler } from "./webviewMessageHandler" import { WebviewMessage } from "../../shared/WebviewMessage" import { EMBEDDING_MODEL_PROFILES } from "../../shared/embeddingModels" @@ -1826,6 +1827,24 @@ export class ClineProvider return history } + /** + * Migrates existing task history to include workspace hashes if needed + */ + async migrateHistoryIfNeeded(): Promise { + try { + const taskHistory = this.getGlobalState("taskHistory") ?? [] + + if (isMigrationNeeded(taskHistory)) { + this.log("Migrating task history to include workspace hashes...") + const migratedHistory = migrateHistoryToWorkspaceHash(taskHistory) + await this.updateGlobalState("taskHistory", migratedHistory) + this.log(`Successfully migrated ${migratedHistory.length} history items`) + } + } catch (error) { + this.log(`Error during history migration: ${error instanceof Error ? error.message : String(error)}`) + } + } + // ContextProxy // @deprecated - Use `ContextProxy#setValue` instead. diff --git a/src/utils/__tests__/historyMigration.spec.ts b/src/utils/__tests__/historyMigration.spec.ts new file mode 100644 index 0000000000..51a6bc3579 --- /dev/null +++ b/src/utils/__tests__/historyMigration.spec.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" +import type { HistoryItem } from "@roo-code/types" +import { + migrateHistoryToWorkspaceHash, + isMigrationNeeded, + findOrphanedHistory, + relinkHistoryItem, +} from "../historyMigration" +import * as workspaceHashModule from "../workspaceHash" +import * as pathModule from "../path" + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: undefined, + }, +})) + +// Mock workspaceHash module +vi.mock("../workspaceHash", () => ({ + getWorkspaceHash: vi.fn(), + areWorkspaceHashesEqual: vi.fn(), +})) + +// Mock path module +vi.mock("../path", () => ({ + arePathsEqual: vi.fn(), +})) + +describe("historyMigration", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("migrateHistoryToWorkspaceHash", () => { + it("should skip items that already have workspace hash", () => { + vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash") + + const historyItems: HistoryItem[] = [ + { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/test/workspace", + workspaceHash: "existing-hash", + }, + ] + + const result = migrateHistoryToWorkspaceHash(historyItems) + expect(result[0].workspaceHash).toBe("existing-hash") + }) + + it("should add workspace hash for items matching current workspace", () => { + vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash") + vi.mocked(pathModule.arePathsEqual).mockReturnValue(true) + ;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const historyItems: HistoryItem[] = [ + { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/test/workspace", + }, + ] + + const result = migrateHistoryToWorkspaceHash(historyItems) + expect(result[0].workspaceHash).toBe("current-hash") + }) + + it("should not add workspace hash for items from different workspace", () => { + vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash") + vi.mocked(pathModule.arePathsEqual).mockReturnValue(false) + ;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const historyItems: HistoryItem[] = [ + { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/different/workspace", + }, + ] + + const result = migrateHistoryToWorkspaceHash(historyItems) + expect(result[0].workspaceHash).toBeUndefined() + }) + }) + + describe("isMigrationNeeded", () => { + it("should return true when items without workspace hash exist", () => { + const historyItems: HistoryItem[] = [ + { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/test/workspace", + }, + ] + + expect(isMigrationNeeded(historyItems)).toBe(true) + }) + + it("should return false when all items have workspace hash", () => { + const historyItems: HistoryItem[] = [ + { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/test/workspace", + workspaceHash: "hash", + }, + ] + + expect(isMigrationNeeded(historyItems)).toBe(false) + }) + + it("should return false for empty history", () => { + expect(isMigrationNeeded([])).toBe(false) + }) + }) + + describe("findOrphanedHistory", () => { + it("should find items with different workspace hash", () => { + vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash") + vi.mocked(workspaceHashModule.areWorkspaceHashesEqual).mockReturnValue(false) + + const historyItems: HistoryItem[] = [ + { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/test/workspace", + workspaceHash: "different-hash", + }, + ] + + const result = findOrphanedHistory(historyItems) + expect(result).toHaveLength(1) + expect(result[0].id).toBe("1") + }) + + it("should find items with different workspace path", () => { + vi.mocked(workspaceHashModule.getWorkspaceHash).mockReturnValue("current-hash") + vi.mocked(pathModule.arePathsEqual).mockReturnValue(false) + ;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] + + const historyItems: HistoryItem[] = [ + { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/different/workspace", + }, + ] + + const result = findOrphanedHistory(historyItems) + expect(result).toHaveLength(1) + expect(result[0].id).toBe("1") + }) + }) + + describe("relinkHistoryItem", () => { + it("should update workspace information", () => { + const item: HistoryItem = { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/old/workspace", + workspaceHash: "old-hash", + } + + const result = relinkHistoryItem(item, "/new/workspace", "new-hash") + + expect(result.workspace).toBe("/new/workspace") + expect(result.workspaceHash).toBe("new-hash") + expect(result.id).toBe("1") // Other properties preserved + }) + + it("should handle null workspace hash", () => { + const item: HistoryItem = { + id: "1", + number: 1, + ts: Date.now(), + task: "Test task", + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/old/workspace", + } + + const result = relinkHistoryItem(item, "/new/workspace", null) + + expect(result.workspace).toBe("/new/workspace") + expect(result.workspaceHash).toBeUndefined() + }) + }) +}) diff --git a/src/utils/__tests__/workspaceHash.spec.ts b/src/utils/__tests__/workspaceHash.spec.ts new file mode 100644 index 0000000000..f9a5cb5484 --- /dev/null +++ b/src/utils/__tests__/workspaceHash.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" +import { getWorkspaceHash, getWorkspaceStoragePath, areWorkspaceHashesEqual } from "../workspaceHash" + +// Mock vscode +vi.mock("vscode", () => ({ + workspace: { + workspaceFolders: undefined, + }, +})) + +describe("workspaceHash", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("getWorkspaceHash", () => { + it("should return null when no workspace folders exist", () => { + ;(vscode.workspace as any).workspaceFolders = undefined + expect(getWorkspaceHash()).toBeNull() + }) + + it("should return null when workspace folders array is empty", () => { + ;(vscode.workspace as any).workspaceFolders = [] + expect(getWorkspaceHash()).toBeNull() + }) + + it("should return a hash when workspace folder exists", () => { + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + toString: () => "file:///test/workspace", + }, + }, + ] + + const hash = getWorkspaceHash() + expect(hash).toBeTruthy() + expect(typeof hash).toBe("string") + expect(hash).toHaveLength(40) // SHA1 hash length + }) + + it("should return consistent hash for same workspace URI", () => { + const mockUri = "file:///test/workspace" + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + toString: () => mockUri, + }, + }, + ] + + const hash1 = getWorkspaceHash() + const hash2 = getWorkspaceHash() + expect(hash1).toBe(hash2) + }) + + it("should return different hashes for different workspace URIs", () => { + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + toString: () => "file:///test/workspace1", + }, + }, + ] + const hash1 = getWorkspaceHash() + + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + toString: () => "file:///test/workspace2", + }, + }, + ] + const hash2 = getWorkspaceHash() + + expect(hash1).not.toBe(hash2) + }) + }) + + describe("getWorkspaceStoragePath", () => { + it("should return null when no workspace hash is available", () => { + ;(vscode.workspace as any).workspaceFolders = undefined + expect(getWorkspaceStoragePath()).toBeNull() + }) + + it("should return the hash when workspace is available", () => { + ;(vscode.workspace as any).workspaceFolders = [ + { + uri: { + toString: () => "file:///test/workspace", + }, + }, + ] + + const storagePath = getWorkspaceStoragePath() + const hash = getWorkspaceHash() + expect(storagePath).toBe(hash) + }) + }) + + describe("areWorkspaceHashesEqual", () => { + it("should return true when both hashes are null", () => { + expect(areWorkspaceHashesEqual(null, null)).toBe(true) + }) + + it("should return false when one hash is null and the other is not", () => { + expect(areWorkspaceHashesEqual(null, "hash")).toBe(false) + expect(areWorkspaceHashesEqual("hash", null)).toBe(false) + }) + + it("should return true when hashes are identical", () => { + const hash = "abc123def456" + expect(areWorkspaceHashesEqual(hash, hash)).toBe(true) + }) + + it("should return false when hashes are different", () => { + expect(areWorkspaceHashesEqual("hash1", "hash2")).toBe(false) + }) + }) +}) diff --git a/src/utils/historyMigration.ts b/src/utils/historyMigration.ts new file mode 100644 index 0000000000..f3e5b35af1 --- /dev/null +++ b/src/utils/historyMigration.ts @@ -0,0 +1,90 @@ +import * as vscode from "vscode" +import type { HistoryItem } from "@roo-code/types" +import { getWorkspaceHash, areWorkspaceHashesEqual } from "./workspaceHash" +import { arePathsEqual } from "./path" + +/** + * Migrates existing path-based history items to include workspace hashes + * @param taskHistory Array of history items to migrate + * @returns Migrated history items with workspace hashes added + */ +export function migrateHistoryToWorkspaceHash(taskHistory: HistoryItem[]): HistoryItem[] { + const currentWorkspaceHash = getWorkspaceHash() + const currentWorkspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + + return taskHistory.map((item) => { + // Skip items that already have a workspace hash + if (item.workspaceHash) { + return item + } + + // Try to determine the workspace hash for this item + let workspaceHash: string | undefined + + // If the item's workspace path matches the current workspace path, + // use the current workspace hash + if (currentWorkspacePath && item.workspace && arePathsEqual(item.workspace, currentWorkspacePath)) { + workspaceHash = currentWorkspaceHash || undefined + } + + // Return the item with the workspace hash added (if determined) + return { + ...item, + workspaceHash, + } + }) +} + +/** + * Checks if migration is needed for the given task history + * @param taskHistory Array of history items to check + * @returns True if migration is needed, false otherwise + */ +export function isMigrationNeeded(taskHistory: HistoryItem[]): boolean { + // Migration is needed if there are items without workspace hashes + return taskHistory.some((item) => !item.workspaceHash && item.workspace) +} + +/** + * Finds orphaned history items that don't match the current workspace + * @param taskHistory Array of history items to check + * @returns Array of orphaned history items + */ +export function findOrphanedHistory(taskHistory: HistoryItem[]): HistoryItem[] { + const currentWorkspaceHash = getWorkspaceHash() + const currentWorkspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + + return taskHistory.filter((item) => { + // If item has a workspace hash, check if it matches current workspace + if (item.workspaceHash) { + return !areWorkspaceHashesEqual(item.workspaceHash, currentWorkspaceHash) + } + + // If item only has workspace path, check if it matches current workspace path + if (item.workspace && currentWorkspacePath) { + return !arePathsEqual(item.workspace, currentWorkspacePath) + } + + // If no workspace info, consider it orphaned + return true + }) +} + +/** + * Updates a history item's workspace information for re-linking + * @param item History item to update + * @param newWorkspacePath New workspace path + * @param newWorkspaceHash New workspace hash + * @returns Updated history item + */ +export function relinkHistoryItem( + item: HistoryItem, + newWorkspacePath: string, + newWorkspaceHash: string | null, +): HistoryItem { + return { + ...item, + workspace: newWorkspacePath, + workspaceHash: newWorkspaceHash || undefined, + } +} diff --git a/src/utils/workspaceHash.ts b/src/utils/workspaceHash.ts new file mode 100644 index 0000000000..8e98c8ba4b --- /dev/null +++ b/src/utils/workspaceHash.ts @@ -0,0 +1,35 @@ +import * as vscode from "vscode" +import * as crypto from "crypto" + +/** + * Generates a stable workspace hash based on the workspace URI + * This hash is the same one VS Code uses to manage its own workspace storage + * @returns The workspace hash or null if no workspace is available + */ +export function getWorkspaceHash(): string | null { + const folderUri = vscode.workspace.workspaceFolders?.[0]?.uri.toString() + if (!folderUri) return null + return crypto.createHash("sha1").update(folderUri).digest("hex") +} + +/** + * Generates a workspace storage path using the stable workspace hash + * @returns The hash-based storage path or null if no workspace is available + */ +export function getWorkspaceStoragePath(): string | null { + const hash = getWorkspaceHash() + if (!hash) return null + return hash +} + +/** + * Checks if two workspace hashes represent the same workspace + * @param hash1 First workspace hash + * @param hash2 Second workspace hash + * @returns True if the hashes match (both null counts as a match) + */ +export function areWorkspaceHashesEqual(hash1: string | null, hash2: string | null): boolean { + if (hash1 === null && hash2 === null) return true + if (hash1 === null || hash2 === null) return false + return hash1 === hash2 +} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 2f156d0418..ce5aca1616 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -106,7 +106,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
{ diff --git a/webview-ui/src/components/history/__tests__/HistoryView.spec.tsx b/webview-ui/src/components/history/__tests__/HistoryView.spec.tsx index 3079844aad..2ffdbd315a 100644 --- a/webview-ui/src/components/history/__tests__/HistoryView.spec.tsx +++ b/webview-ui/src/components/history/__tests__/HistoryView.spec.tsx @@ -50,7 +50,7 @@ describe("HistoryView", () => { // Check for main UI elements expect(screen.getByText("history:history")).toBeInTheDocument() expect(screen.getByText("history:done")).toBeInTheDocument() - expect(screen.getByPlaceholderText("history:searchPlaceholder")).toBeInTheDocument() + expect(screen.getByPlaceholderText("Search history or use 'path:' to filter by workspace")).toBeInTheDocument() }) it("calls onDone when done button is clicked", () => { diff --git a/webview-ui/src/components/history/useTaskSearch.ts b/webview-ui/src/components/history/useTaskSearch.ts index 3969985b98..0521ed85e4 100644 --- a/webview-ui/src/components/history/useTaskSearch.ts +++ b/webview-ui/src/components/history/useTaskSearch.ts @@ -4,6 +4,23 @@ import { Fzf } from "fzf" import { highlightFzfMatch } from "@/utils/highlight" import { useExtensionState } from "@/context/ExtensionStateContext" +// Helper function to get workspace hash from current workspace +const getCurrentWorkspaceHash = (): string | null => { + // This will be populated by the extension state + return null // Placeholder - will be updated when we add workspace hash to extension state +} + +// Helper function to check if a task belongs to the current workspace +const isTaskInCurrentWorkspace = (item: any, cwd: string | undefined, currentWorkspaceHash: string | null): boolean => { + // Primary method: Use workspace hash if available for both current workspace and task + if (currentWorkspaceHash && item.workspaceHash) { + return item.workspaceHash === currentWorkspaceHash + } + + // Fallback method: Use path-based matching for legacy items or when hash is unavailable + return cwd ? item.workspace === cwd : false +} + type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant" export const useTaskSearch = () => { @@ -26,7 +43,8 @@ export const useTaskSearch = () => { const presentableTasks = useMemo(() => { let tasks = taskHistory.filter((item) => item.ts && item.task) if (!showAllWorkspaces) { - tasks = tasks.filter((item) => item.workspace === cwd) + const currentWorkspaceHash = getCurrentWorkspaceHash() + tasks = tasks.filter((item) => isTaskInCurrentWorkspace(item, cwd, currentWorkspaceHash)) } return tasks }, [taskHistory, showAllWorkspaces, cwd]) @@ -41,20 +59,27 @@ export const useTaskSearch = () => { let results = presentableTasks if (searchQuery) { - const searchResults = fzf.find(searchQuery) - results = searchResults.map((result) => { - const positions = Array.from(result.positions) - const taskEndIndex = result.item.task.length + // Check if this is a path search (prefixed with "path:") + if (searchQuery.startsWith("path:")) { + const pathQuery = searchQuery.substring(5).trim() + results = presentableTasks.filter((item) => item.workspace && item.workspace.includes(pathQuery)) + } else { + // Regular fuzzy search + const searchResults = fzf.find(searchQuery) + results = searchResults.map((result) => { + const positions = Array.from(result.positions) + const taskEndIndex = result.item.task.length - return { - ...result.item, - highlight: highlightFzfMatch( - result.item.task, - positions.filter((p) => p < taskEndIndex), - ), - workspace: result.item.workspace, - } - }) + return { + ...result.item, + highlight: highlightFzfMatch( + result.item.task, + positions.filter((p) => p < taskEndIndex), + ), + workspace: result.item.workspace, + } + }) + } } // Then sort the results