diff --git a/desktop/src/components/layout/Sidebar.test.tsx b/desktop/src/components/layout/Sidebar.test.tsx index e6e41dbf..62960d1e 100644 --- a/desktop/src/components/layout/Sidebar.test.tsx +++ b/desktop/src/components/layout/Sidebar.test.tsx @@ -782,6 +782,23 @@ describe('Sidebar', () => { expect(screen.getAllByText('worktree')).toHaveLength(1) }) + it('keeps a Windows drive root session separate from sessions in child projects', () => { + const now = new Date().toISOString() + useSessionStore.setState({ + sessions: [ + makeSession('drive-root', 'Drive Root Session', 'D:\\', now), + makeSession('drive-project', 'Drive Project Session', 'D:\\SomeProject', now), + ], + }) + + render() + + expect(screen.getByText('D:')).toBeInTheDocument() + expect(screen.getByText('SomeProject')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Drive Root Session/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Drive Project Session/ })).toBeInTheDocument() + }) + 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/src/server/__tests__/windows-drive-path.test.ts b/src/server/__tests__/windows-drive-path.test.ts new file mode 100644 index 00000000..b48a9c7c --- /dev/null +++ b/src/server/__tests__/windows-drive-path.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from '../services/windowsDrivePath.js' +import { SessionService } from '../services/sessionService.js' + +describe('Windows drive root path handling', () => { + it('normalizes drive-relative root inputs to absolute drive roots on Windows', () => { + expect(normalizeDriveRootPathForPlatform('D:', 'win32')).toBe('D:\\') + expect(normalizeDriveRootPathForPlatform('d:', 'win32')).toBe('d:\\') + expect(normalizeDriveRootPathForPlatform('D:\\', 'win32')).toBe('D:\\') + expect(normalizeDriveRootPathForPlatform('D:\\project', 'win32')).toBe('D:\\project') + expect(normalizeDriveRootPathForPlatform('D:', 'darwin')).toBe('D:') + }) + + it('recovers sanitized Windows drive-root transcript directories', () => { + const service = new SessionService() + expect(service.desanitizePath('D--')).toBe('D:\\') + expect(service.desanitizePath('D--project')).toBe('D:\\project') + }) + + it('treats absolute Windows drive-root children as inside the selected root', () => { + expect(isSameOrInsidePathForPlatform('D:\\', 'D:', 'win32')).toBe(true) + expect(isSameOrInsidePathForPlatform('D:\\child', 'D:', 'win32')).toBe(true) + expect(isSameOrInsidePathForPlatform('D:\\child', 'D:\\', 'win32')).toBe(true) + expect(isSameOrInsidePathForPlatform('D:\\project-extra', 'D:\\project', 'win32')).toBe(false) + expect(isSameOrInsidePathForPlatform('E:\\child', 'D:\\', 'win32')).toBe(false) + }) +}) diff --git a/src/server/api/filesystem.ts b/src/server/api/filesystem.ts index 61814227..ab8ef011 100644 --- a/src/server/api/filesystem.ts +++ b/src/server/api/filesystem.ts @@ -13,6 +13,10 @@ import { findGitRoot, gitExe } from '../../utils/git.js' import { ripGrep } from '../../utils/ripgrep.js' import { getInitialSettings } from '../../utils/settings/settings.js' import { isWithinRegisteredFilesystemRoot } from '../services/filesystemAccessRoots.js' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from '../services/windowsDrivePath.js' type FilesystemEntry = { name: string @@ -41,14 +45,7 @@ const IMAGE_MIME_TYPES: Record = { } function isWithinRoot(targetPath: string, rootPath: string): boolean { - const target = normalizeComparablePath(targetPath) - const root = normalizeComparablePath(rootPath) - return target === root || target.startsWith(`${root}${path.sep}`) -} - -function normalizeComparablePath(filePath: string): string { - const resolved = path.resolve(filePath) - return process.platform === 'win32' ? resolved.toLowerCase() : resolved + return isSameOrInsidePathForPlatform(targetPath, rootPath) } function isVcsMetadataDirectoryName(name: string): boolean { @@ -56,7 +53,7 @@ function isVcsMetadataDirectoryName(name: string): boolean { } function isAllowedFilesystemPath(targetPath: string): boolean { - const resolvedPath = path.resolve(targetPath) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath)) const homeDir = path.resolve(os.homedir()) if (isWithinRoot(resolvedPath, homeDir) || isWithinRoot(resolvedPath, '/tmp')) { @@ -93,7 +90,7 @@ async function handleServeFile(url: URL): Promise { return json({ error: 'Missing path parameter' }, 400) } - const resolvedPath = path.resolve(filePath) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(filePath)) if (!isAllowedFilesystemPath(resolvedPath)) { return json({ error: 'Access denied: path outside allowed directory' }, 403) @@ -132,7 +129,7 @@ async function handleServeFile(url: URL): Promise { async function handleBrowse(url: URL): Promise { const targetPath = url.searchParams.get('path') || os.homedir() || '/' - const resolvedPath = path.resolve(targetPath) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(targetPath)) if (!isAllowedFilesystemPath(resolvedPath)) { return json({ error: 'Access denied: path outside allowed directory' }, 403) diff --git a/src/server/services/filesystemAccessRoots.ts b/src/server/services/filesystemAccessRoots.ts index a77ace6b..0b3aeb5a 100644 --- a/src/server/services/filesystemAccessRoots.ts +++ b/src/server/services/filesystemAccessRoots.ts @@ -1,21 +1,18 @@ import * as path from 'node:path' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from './windowsDrivePath.js' const registeredRoots = new Set() -function normalizeComparablePath(filePath: string): string { - const resolved = path.resolve(filePath) - return process.platform === 'win32' ? resolved.toLowerCase() : resolved -} - function isWithinRoot(targetPath: string, rootPath: string): boolean { - const target = normalizeComparablePath(targetPath) - const root = normalizeComparablePath(rootPath) - return target === root || target.startsWith(`${root}${path.sep}`) + return isSameOrInsidePathForPlatform(targetPath, rootPath) } export function registerFilesystemAccessRoot(rootPath: string | null | undefined): void { if (!rootPath) return - registeredRoots.add(path.resolve(rootPath)) + registeredRoots.add(path.resolve(normalizeDriveRootPathForPlatform(rootPath))) } export function isWithinRegisteredFilesystemRoot(targetPath: string): boolean { diff --git a/src/server/services/repositoryLaunchService.ts b/src/server/services/repositoryLaunchService.ts index 03a546e8..b2487732 100644 --- a/src/server/services/repositoryLaunchService.ts +++ b/src/server/services/repositoryLaunchService.ts @@ -5,6 +5,7 @@ import { promisify } from 'node:util' import { ApiError } from '../middleware/errorHandler.js' import { findCanonicalGitRoot, findGitRoot } from '../../utils/git.js' import { registerFilesystemAccessRoot } from './filesystemAccessRoots.js' +import { normalizeDriveRootPathForPlatform } from './windowsDrivePath.js' import { ensureWorktreesDirExcluded, performPostCreationSetup, @@ -168,7 +169,7 @@ async function runGit( } async function resolveDirectory(workDir: string): Promise { - const resolved = path.resolve(workDir) + const resolved = path.resolve(normalizeDriveRootPathForPlatform(workDir)) let realPath: string try { realPath = await fs.realpath(resolved) @@ -332,7 +333,7 @@ export async function getRepositoryContext(workDir: string): Promise= 0; i--) { const entry = entries[i] if (entry.type === 'session-meta' && typeof (entry as Record).workDir === 'string') { - return (entry as Record).workDir as string + return normalizeDriveRootPathForPlatform((entry as Record).workDir as string) } } for (let i = entries.length - 1; i >= 0; i--) { const cwd = entries[i]?.cwd if (typeof cwd === 'string' && cwd.trim()) { - return cwd + return normalizeDriveRootPathForPlatform(cwd) } } @@ -915,7 +916,7 @@ export class SessionService { // Optionally filter to a specific project if (projectFilter) { - const sanitized = this.sanitizePath(projectFilter) + const sanitized = this.sanitizePath(normalizeDriveRootPathForPlatform(projectFilter)) projectDirs = projectDirs.filter((d) => d === sanitized) } @@ -966,6 +967,11 @@ export class SessionService { return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}` } + const windowsDriveRoot = sanitized.match(/^([a-zA-Z])--$/) + if (windowsDriveRoot) { + return `${windowsDriveRoot[1]}:${path.win32.sep}` + } + // On POSIX the original path starts with '/', so the sanitized form starts with '-'. // UNC-style Windows paths also recover to a leading double separator on Windows. return sanitized.replace(/-/g, path.sep) @@ -1644,7 +1650,7 @@ export class SessionService { async clearSessionTranscript(sessionId: string, fallbackWorkDir?: string): Promise { let found = await this.findSessionFile(sessionId) if (!found && fallbackWorkDir) { - const resolvedPath = path.resolve(fallbackWorkDir) + const resolvedPath = path.resolve(normalizeDriveRootPathForPlatform(fallbackWorkDir)) const absWorkDir = await fs.realpath(resolvedPath).catch(() => resolvedPath) const dirPath = path.join(this.getProjectsDir(), this.sanitizePath(absWorkDir)) await fs.mkdir(dirPath, { recursive: true }) @@ -1710,14 +1716,15 @@ export class SessionService { } } - const targetProjectDir = this.sanitizePath(metadata.workDir) + const normalizedWorkDir = normalizeDriveRootPathForPlatform(metadata.workDir) + const targetProjectDir = this.sanitizePath(normalizedWorkDir) const targetFilePath = path.join(this.getProjectsDir(), targetProjectDir, `${sessionId}.jsonl`) await fs.mkdir(path.dirname(targetFilePath), { recursive: true }) await this.appendJsonlEntry(targetFilePath, { type: 'session-meta', isMeta: true, - workDir: metadata.workDir, + workDir: normalizedWorkDir, repository, timestamp: new Date().toISOString(), }) @@ -1746,7 +1753,7 @@ export class SessionService { throw err } - const keepProjectDir = this.sanitizePath(keepWorkDir) + const keepProjectDir = this.sanitizePath(normalizeDriveRootPathForPlatform(keepWorkDir)) let removed = 0 for (const projectDir of projectDirs) { if (!projectDir.isDirectory()) continue diff --git a/src/server/services/windowsDrivePath.ts b/src/server/services/windowsDrivePath.ts new file mode 100644 index 00000000..82574ce3 --- /dev/null +++ b/src/server/services/windowsDrivePath.ts @@ -0,0 +1,30 @@ +import * as path from 'node:path' + +export function normalizeDriveRootPathForPlatform( + filePath: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform !== 'win32') return filePath + + const driveRootMatch = filePath.match(/^([a-zA-Z]):$/) + if (!driveRootMatch) return filePath + + return `${driveRootMatch[1]}:\\` +} + +export function isSameOrInsidePathForPlatform( + targetPath: string, + rootPath: string, + platform: NodeJS.Platform = process.platform, +): boolean { + const pathApi = platform === 'win32' ? path.win32 : path + const normalize = (filePath: string) => { + const resolved = pathApi.resolve(normalizeDriveRootPathForPlatform(filePath, platform)) + return platform === 'win32' ? resolved.toLowerCase() : resolved + } + const target = normalize(targetPath) + const root = normalize(rootPath) + const relative = pathApi.relative(root, target) + + return relative === '' || (!!relative && !relative.startsWith('..') && !pathApi.isAbsolute(relative)) +} diff --git a/src/server/services/workspaceService.ts b/src/server/services/workspaceService.ts index 4df47b9b..b9ba02cb 100644 --- a/src/server/services/workspaceService.ts +++ b/src/server/services/workspaceService.ts @@ -6,6 +6,10 @@ import { diffLines } from 'diff' import type { MessageEntry } from './sessionService.js' import type { FileHistorySnapshot } from '../../utils/fileHistory.js' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' +import { + isSameOrInsidePathForPlatform, + normalizeDriveRootPathForPlatform, +} from './windowsDrivePath.js' const MAX_PREVIEW_BYTES = 1024 * 1024 const MAX_UNTRACKED_STAT_BYTES = 256 * 1024 @@ -1004,7 +1008,7 @@ export class WorkspaceService { if (!workDir) { throw new Error(`Session not found: ${sessionId}`) } - return path.resolve(workDir) + return path.resolve(normalizeDriveRootPathForPlatform(workDir)) } private async getWorkspaceRoot( @@ -1149,14 +1153,7 @@ export class WorkspaceService { } private isWithinRoot(targetPath: string, rootPath: string): boolean { - const target = this.normalizeComparableAbsolutePath(targetPath) - const root = this.normalizeComparableAbsolutePath(rootPath) - return target === root || target.startsWith(`${root}${path.sep}`) - } - - private normalizeComparableAbsolutePath(filePath: string): string { - const resolved = path.resolve(filePath) - return process.platform === 'win32' ? resolved.toLowerCase() : resolved + return isSameOrInsidePathForPlatform(targetPath, rootPath) } private normalizeRelativePath(filePath: string): string {