From c373f8cf452897a2d1c91c5e2ba9873e2500aee7 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Mon, 25 May 2026 23:23:15 +0800 Subject: [PATCH] fix: preserve Windows drive-root project identity Normalize realpath results for Windows drive roots so D:\ does not round-trip to drive-relative D: and resolve back into the current repository. Also tighten Sidebar hidden-project matching so a drive root does not match every child project on the same drive. Tested: bun test src/server/__tests__/windows-drive-path.test.ts src/server/__tests__/sessions.test.ts src/server/__tests__/workspace-service.test.ts Tested: cd desktop && bun run test -- --run src/components/layout/Sidebar.test.tsx Tested: cd desktop && bun run build:windows-x64 Tested: bun run check:desktop Not-tested: bun run check:server (failed on existing/environment failures including the pre-existing untracked FileReadTool test) Confidence: high Scope-risk: narrow --- .../src/components/layout/Sidebar.test.tsx | 27 +++++++++++ desktop/src/components/layout/Sidebar.tsx | 14 ++++-- .../__tests__/windows-drive-path.test.ts | 47 +++++++++++++++++++ .../services/repositoryLaunchService.ts | 2 +- src/server/services/sessionService.ts | 2 +- src/server/services/workspaceService.ts | 2 +- 6 files changed, 86 insertions(+), 8 deletions(-) diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index 62960d1e..f80b585a 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -799,6 +799,33 @@ describe('Sidebar', () => { expect(screen.getByRole('button', { name: /Drive Project Session/ })).toBeInTheDocument() }) + it('does not restore a hidden Windows drive root when creating a child project session', async () => { + window.localStorage.setItem(PROJECT_HIDDEN_STORAGE_KEY, JSON.stringify(['D:\\'])) + createSession.mockResolvedValue('child-new') + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [ + makeSession('child-1', 'Child Session', 'D:\\workspace\\code\\cc-haha', now), + ], + }) + useTabStore.setState({ + tabs: [{ sessionId: 'child-1', title: 'Child Session', type: 'session', status: 'idle' }], + activeTabId: 'child-1', + }) + + render() + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'New Session' })) + }) + + await waitFor(() => { + expect(createSession).toHaveBeenCalledWith('D:\\workspace\\code\\cc-haha') + }) + expect(JSON.parse(window.localStorage.getItem(PROJECT_HIDDEN_STORAGE_KEY) ?? '[]')).toEqual(['D:\\']) + expect(desktopUiPreferencesApiMock.updateSidebarPreferences).not.toHaveBeenCalled() + }) + it('right-aligns running status, worktree marker, and update time on session rows', () => { vi.useFakeTimers() vi.setSystemTime(new Date('2026-05-19T12:00:00.000Z')) diff --git a/desktop/src/components/layout/Sidebar.tsx b/desktop/src/components/layout/Sidebar.tsx index 9860caeb..9e836484 100644 --- a/desktop/src/components/layout/Sidebar.tsx +++ b/desktop/src/components/layout/Sidebar.tsx @@ -1657,17 +1657,21 @@ function normalizeProjectKeyList(values: unknown): string[] { } function normalizeProjectPathForComparison(value: string): string { - const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '') - return normalized || value + const normalized = value.replace(/\\/g, '/').replace(/\/+$/g, '') || value + return isWindows ? normalized.toLowerCase() : normalized +} + +function isDriveRootComparisonPath(value: string): boolean { + return /^[a-z]:$/i.test(value) } function projectPathMatches(projectKey: string, workDir: string): boolean { const normalizedProjectKey = normalizeProjectPathForComparison(projectKey) const normalizedWorkDir = normalizeProjectPathForComparison(workDir) - return normalizedProjectKey === normalizedWorkDir - || normalizedWorkDir.startsWith(`${normalizedProjectKey}/`) - || normalizedProjectKey.startsWith(`${normalizedWorkDir}/`) + if (normalizedProjectKey === normalizedWorkDir) return true + if (isDriveRootComparisonPath(normalizedProjectKey)) return false + return normalizedWorkDir.startsWith(`${normalizedProjectKey}/`) } function hasSidebarProjectPreferences(preferences: SidebarProjectPreferences): boolean { diff --git a/src/server/__tests__/windows-drive-path.test.ts b/src/server/__tests__/windows-drive-path.test.ts index b48a9c7c..6d5c3fa0 100644 --- a/src/server/__tests__/windows-drive-path.test.ts +++ b/src/server/__tests__/windows-drive-path.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' import { isSameOrInsidePathForPlatform, normalizeDriveRootPathForPlatform, } from '../services/windowsDrivePath.js' +import { getRepositoryContext } from '../services/repositoryLaunchService.js' import { SessionService } from '../services/sessionService.js' +import { sanitizePath } from '../../utils/sessionStoragePortable.js' describe('Windows drive root path handling', () => { it('normalizes drive-relative root inputs to absolute drive roots on Windows', () => { @@ -27,4 +32,46 @@ describe('Windows drive root path handling', () => { expect(isSameOrInsidePathForPlatform('D:\\project-extra', 'D:\\project', 'win32')).toBe(false) expect(isSameOrInsidePathForPlatform('E:\\child', 'D:\\', 'win32')).toBe(false) }) + + it('keeps realpathed drive roots from resolving to the current drive directory', async () => { + if (process.platform !== 'win32') return + + const originalConfigDir = process.env.CLAUDE_CONFIG_DIR + const configDir = await fs.mkdtemp(path.join(os.tmpdir(), 'drive-root-session-')) + const driveRoot = path.parse(process.cwd()).root + const sessionId = crypto.randomUUID() + + try { + process.env.CLAUDE_CONFIG_DIR = configDir + const projectDir = path.join(configDir, 'projects', sanitizePath(driveRoot)) + await fs.mkdir(projectDir, { recursive: true }) + await fs.writeFile( + path.join(projectDir, `${sessionId}.jsonl`), + JSON.stringify({ + type: 'session-meta', + isMeta: true, + workDir: driveRoot, + timestamp: new Date().toISOString(), + }) + '\n', + 'utf-8', + ) + + const service = new SessionService() + const { sessions } = await service.listSessions({ limit: 5 }) + const session = sessions.find((item) => item.id === sessionId) + expect(session?.workDir).toBe(driveRoot) + expect(session?.projectRoot).toBe(driveRoot) + + const context = await getRepositoryContext(driveRoot) + expect(context.workDir).toBe(driveRoot) + expect(context.repoRoot).not.toBe(process.cwd()) + } finally { + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } + await fs.rm(configDir, { recursive: true, force: true }) + } + }) }) diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index b2487732..aa4c6590 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -172,7 +172,7 @@ async function resolveDirectory(workDir: string): Promise { const resolved = path.resolve(normalizeDriveRootPathForPlatform(workDir)) let realPath: string try { - realPath = await fs.realpath(resolved) + realPath = normalizeDriveRootPathForPlatform(await fs.realpath(resolved)) } catch { throw repositoryBadRequest( REPOSITORY_ERROR.workdirMissing, diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 03e46a28..50dfdd97 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -366,7 +366,7 @@ export class SessionService { private async canonicalizeProjectPath(projectPath: string): Promise { try { - return (await fs.realpath(projectPath)).normalize('NFC') + return normalizeDriveRootPathForPlatform(await fs.realpath(projectPath)).normalize('NFC') } catch { return projectPath.normalize('NFC') } diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index b9ba02cb..196bd14f 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -1030,7 +1030,7 @@ export class WorkspaceService { return { kind: 'ok', workspaceRoot: workDir, - canonicalWorkspaceRoot: await fs.realpath(workDir), + canonicalWorkspaceRoot: normalizeDriveRootPathForPlatform(await fs.realpath(workDir)), } } catch (error) { return {