diff --git a/packages/ui/src/components/session/sidebar/utils.tsx b/packages/ui/src/components/session/sidebar/utils.tsx index 7fe94a9a..6156b294 100644 --- a/packages/ui/src/components/session/sidebar/utils.tsx +++ b/packages/ui/src/components/session/sidebar/utils.tsx @@ -3,6 +3,9 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { getCurrentIntlLocale } from '@/lib/i18n'; import { formatMessage, useI18nStore } from '@/lib/i18n/store'; +import { normalizePath } from '@/lib/pathNormalization'; +export { normalizePath }; + const t = (key: Parameters[1], params?: Parameters[2]) => formatMessage(useI18nStore.getState().dictionary, key, params); @@ -77,14 +80,6 @@ export const formatSessionCompactDateLabel = (updatedMs: number): string => { return t('common.relative.yearsAgoCompact', { count: Math.floor(diff / year) }); }; -export const normalizePath = (value?: string | null) => { - if (!value) { - return null; - } - const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); - return normalized.length === 0 ? '/' : normalized; -}; - export const isPathWithinProject = (directory?: string | null, projectPath?: string | null): boolean => { const normalizedDirectory = normalizePath(directory); const normalizedProjectPath = normalizePath(projectPath); diff --git a/packages/ui/src/lib/pathNormalization.test.ts b/packages/ui/src/lib/pathNormalization.test.ts new file mode 100644 index 00000000..846ced20 --- /dev/null +++ b/packages/ui/src/lib/pathNormalization.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test'; + +import { normalizePath } from './pathNormalization'; + +describe('normalizePath', () => { + describe('non-string inputs', () => { + test('returns null for null', () => { + expect(normalizePath(null)).toBeNull(); + }); + + test('returns null for undefined', () => { + expect(normalizePath(undefined)).toBeNull(); + }); + + test('returns null for empty string', () => { + expect(normalizePath('')).toBeNull(); + }); + + test('returns null for whitespace-only', () => { + expect(normalizePath(' ')).toBeNull(); + }); + }); + + describe('backslashes', () => { + test('converts backslashes to forward slashes', () => { + expect(normalizePath('C:\\Users\\me\\project')).toBe('C:/Users/me/project'); + }); + }); + + describe('drive letter casing', () => { + test('uppercases lowercase Windows drive letter', () => { + expect(normalizePath('c:\\Users\\me\\project')).toBe('C:/Users/me/project'); + }); + + test('preserves already-uppercase drive letter', () => { + expect(normalizePath('C:\\Users\\me\\project')).toBe('C:/Users/me/project'); + }); + + test('does not match multi-character tokens before colon', () => { + expect(normalizePath('abc:def')).toBe('abc:def'); + }); + + test('does not touch drive letter in middle of path', () => { + // Only the leading drive letter is touched; a "c:" later in the + // path is left alone (no upper-casing, no backslash conversion of + // the surrounding characters beyond the backslash-to-slash step). + expect(normalizePath('/foo/c:\\bar')).toBe('/foo/c:/bar'); + }); + }); + + describe('trailing slashes', () => { + test('strips a single trailing slash', () => { + expect(normalizePath('C:/Users/me/')).toBe('C:/Users/me'); + }); + + test('strips multiple trailing slashes', () => { + expect(normalizePath('C:/Users/me///')).toBe('C:/Users/me'); + }); + + test('preserves root /', () => { + expect(normalizePath('/')).toBe('/'); + }); + + test('preserves single-char after slash strip', () => { + expect(normalizePath('C:/')).toBe('C:'); + }); + }); + + describe('degenerate slash-only inputs', () => { + // '///' → stays '///' after backslash replace → trailing-slash strip + // yields '' → null. This is the new defensive behavior. + test('returns null for multiple forward slashes', () => { + expect(normalizePath('///')).toBeNull(); + }); + + // '\\\\' in source = 2 backslash chars → replace to '//' → strip → '' → null. + test('returns null for multiple backslashes', () => { + expect(normalizePath('\\\\')).toBeNull(); + }); + + // A single backslash '\\' is normalized to a single forward slash + // and treated as the filesystem root, returned as '/'. (This is the + // pre-existing behavior; the defensive fix only adds null for + // slash-only inputs that strip down to ''.) + test('normalizes a single backslash to the root "/"', () => { + expect(normalizePath('\\')).toBe('/'); + }); + }); + + describe('Unix paths', () => { + test('passes through a Unix path unchanged', () => { + expect(normalizePath('/home/user/project')).toBe('/home/user/project'); + }); + + test('strips trailing slashes from Unix paths', () => { + expect(normalizePath('/home/user/project/')).toBe('/home/user/project'); + }); + }); +}); diff --git a/packages/ui/src/lib/pathNormalization.ts b/packages/ui/src/lib/pathNormalization.ts new file mode 100644 index 00000000..987f6b7c --- /dev/null +++ b/packages/ui/src/lib/pathNormalization.ts @@ -0,0 +1,29 @@ +/** + * Normalize a directory path for consistent comparison. + * + * Handles Windows-specific path quirks: + * - Converts backslashes to forward slashes + * - Uppercases lowercase Windows drive letters (e.g., "c:\\" → "C:\\") + * - Trims trailing slashes (except for the root "/") + * + * Returns null for non-string inputs, null/undefined, empty strings, + * whitespace-only strings, and paths that consist only of slashes + * (e.g. "\\", "\\\\", "///"). + * + * The drive letter regex is anchored (^([a-z]):) and matches only a + * single lowercase letter, so it never affects multi-character tokens + * (e.g., "abc:def"), URLs, or Windows `\\?\` device paths. + */ +export const normalizePath = (value?: string | null): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const replaced = trimmed + .replace(/\\/g, "/") + .replace(/^([a-z]):/, (_, letter: string) => letter.toUpperCase() + ":"); + + if (replaced === "/") return "/"; + const stripped = replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; + return stripped || null; +}; diff --git a/packages/ui/src/lib/projectResolution.ts b/packages/ui/src/lib/projectResolution.ts index 834231e9..64129ab3 100644 --- a/packages/ui/src/lib/projectResolution.ts +++ b/packages/ui/src/lib/projectResolution.ts @@ -1,14 +1,8 @@ import type { ProjectEntry } from "@/lib/api/types"; import type { WorktreeMetadata } from "@/types/worktree"; -export const normalizeProjectPath = (value?: string | null): string | null => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const replaced = trimmed.replace(/\\/g, "/"); - if (replaced === "/") return "/"; - return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced; -}; +import { normalizePath } from "@/lib/pathNormalization"; +export const normalizeProjectPath = normalizePath; export const resolveProjectForDirectory = ( projects: ProjectEntry[], diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index f60426e6..8e468bbb 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -19,6 +19,7 @@ import { streamDebugEnabled } from "@/stores/utils/streamDebug"; import { parseModelIdentifier } from "@/lib/modelIdentifier"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace"; +import { normalizePath } from "@/lib/pathNormalization"; import { getSyncConfig, subscribeToSyncConfigChanges } from "@/sync/sync-refs"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; @@ -742,9 +743,9 @@ const rememberWorktreeProject = (worktree: string, project: string): void => { }; const normalizeConfigPath = (value: string | null | undefined): string | null => { - const trimmed = typeof value === 'string' ? value.trim() : ''; - if (!trimmed) return null; - return trimmed.replace(/\\/g, '/').replace(/\/+$/, '') || '/'; + const result = normalizePath(value); + if (result === null) return null; + return result || '/'; }; const getKnownProjectDirectories = (): string[] => { diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 7acfd763..c7dacd56 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -4,6 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { listGlobalSessionPages } from '@/stores/globalSessions'; import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow'; import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; +import { normalizePath } from '@/lib/pathNormalization'; type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -37,21 +38,6 @@ let inflightLoad: Promise | null = null; // not apply its (stale) snapshot after the reset. let loadGeneration = 0; -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== 'string') { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - const replaced = trimmed.replace(/\\/g, '/'); - if (replaced === '/') { - return '/'; - } - return replaced.length > 1 ? replaced.replace(/\/+$/, '') : replaced; -}; - export const resolveGlobalSessionDirectory = (session: Session): string | null => { const record = session as Session & { directory?: string | null; diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 556f32bb..1a2ad75e 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -27,6 +27,7 @@ import { useCommandsStore } from "@/stores/useCommandsStore" import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" +import { normalizePath } from "@/lib/pathNormalization" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" @@ -315,14 +316,6 @@ export type SessionUIState = { // Helpers // --------------------------------------------------------------------------- -const normalizePath = (value?: string | null): string | null => { - if (typeof value !== "string") return null - const trimmed = value.trim() - if (!trimmed) return null - const replaced = trimmed.replace(/\\/g, "/") - if (replaced === "/") return "/" - return replaced.length > 1 ? replaced.replace(/\/+$/, "") : replaced -} const resolveDirectoryKey = (session: Session): string | null => { const sessionRecord = session as Session & { diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.js b/packages/web/server/lib/opencode/settings-normalization-runtime.js index b07cb3d9..90c1d3b0 100644 --- a/packages/web/server/lib/opencode/settings-normalization-runtime.js +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.js @@ -68,13 +68,28 @@ export const createSettingsNormalizationRuntime = (dependencies) => { return trimmed; } - const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed); + // Normalize Windows drive letter to uppercase to ensure consistent + // case across all path representations on Windows. NTFS is case-insensitive + // but case-preserving, so a path like "c:\\Users\\..." and "C:\\Users\\..." + // would be stored differently in settings.json across sessions. + const uppercaseDriveLetter = (p) => + p.replace(/^([a-z]):/, (_, letter) => letter.toUpperCase() + ':'); - if (processLike.platform !== 'win32') { - return resolved; + const isWindows = processLike.platform === 'win32'; + const caseNormalized = isWindows ? uppercaseDriveLetter(trimmed) : trimmed; + const resolved = options.resolveRealpath === false ? caseNormalized : safeRealpathSync(caseNormalized); + + // Re-normalize after realpath — safeRealpathSync may return a + // lowercase drive letter on some Windows environments. + const finalResolved = isWindows && typeof resolved === 'string' + ? uppercaseDriveLetter(resolved) + : resolved; + + if (!isWindows) { + return finalResolved; } - return resolved.replace(/\//g, '\\'); + return finalResolved.replace(/\//g, '\\'); }; const areStringArraysEqual = (a, b) => { diff --git a/packages/web/server/lib/opencode/settings-normalization-runtime.test.js b/packages/web/server/lib/opencode/settings-normalization-runtime.test.js index 3fd6f377..1874ab37 100644 --- a/packages/web/server/lib/opencode/settings-normalization-runtime.test.js +++ b/packages/web/server/lib/opencode/settings-normalization-runtime.test.js @@ -52,6 +52,27 @@ describe('settings normalization runtime - symlink resolution', () => { const result = runtime.normalizePathForPersistence('/some/path'); expect(result).toBe('/some/path'); }); + + it('preserves lowercase colon-prefixed paths on non-Windows platforms', () => { + const runtime = createTestRuntime({ realpathSync: undefined }); + + expect(runtime.normalizePathForPersistence('c:project')).toBe('c:project'); + }); + + it('uppercases Windows drive letter before and after realpath resolution', () => { + const runtime = createTestRuntime({ + processLike: { platform: 'win32', env: {} }, + realpathSync: (p) => { + // Simulate safeRealpathSync returning a lowercase drive letter + if (p === 'C:\\Users\\me\\project') return 'c:\\real\\project'; + return p; + }, + }); + + const result = runtime.normalizePathForPersistence('c:\\Users\\me\\project'); + // Drive letter uppercased on input AND after realpath + expect(result).toBe('C:\\real\\project'); + }); }); describe('sanitizeProjects', () => {