fix: support Windows file paths in editor opens

This commit is contained in:
Bohdan Triapitsyn
2026-06-04 18:47:33 +03:00
parent 3214b79854
commit 6db08251ff
9 changed files with 237 additions and 174 deletions
@@ -29,6 +29,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import type { EditorAPI } from '@/lib/api/types';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getDirectoryForFilePath, isAbsoluteFilePath, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
const useCurrentMermaidTheme = () => {
const themeSystem = useOptionalThemeSystem();
@@ -1067,8 +1068,6 @@ type ParsedFileReference = {
column?: number;
};
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
const KNOWN_FILE_BASENAMES = new Set([
'dockerfile',
'makefile',
@@ -1083,74 +1082,15 @@ const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
.join('|');
const normalizePath = (value: string): string => {
const source = (value || '').trim();
if (!source) {
return '';
}
const withSlashes = source.replace(/\\/g, '/');
const hadUncPrefix = withSlashes.startsWith('//');
let normalized = withSlashes.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
const isUnixRoot = normalized === '/';
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
if (!isUnixRoot && !isWindowsDriveRoot) {
normalized = normalized.replace(/\/+$/, '');
}
return normalized;
return normalizeFilePath(value);
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/')
|| WINDOWS_DRIVE_PATH_PATTERN.test(value)
|| WINDOWS_UNC_PATH_PATTERN.test(value)
|| value.startsWith('//');
return isAbsoluteFilePath(value);
};
const toAbsolutePath = (basePath: string, targetPath: string): string => {
const normalizedTarget = normalizePath(targetPath);
if (!normalizedTarget) {
return normalizePath(basePath);
}
if (isAbsolutePath(normalizedTarget)) {
return normalizedTarget;
}
const normalizedBase = normalizePath(basePath);
if (!normalizedBase) {
return normalizedTarget;
}
const isWindowsDriveBase = /^[A-Za-z]:/.test(normalizedBase);
const prefix = isWindowsDriveBase ? normalizedBase.slice(0, 2) : '';
const baseRemainder = isWindowsDriveBase ? normalizedBase.slice(2) : normalizedBase;
const stack = baseRemainder.split('/').filter(Boolean);
const parts = normalizedTarget.split('/').filter(Boolean);
for (const part of parts) {
if (part === '.') {
continue;
}
if (part === '..') {
if (stack.length > 0) {
stack.pop();
}
continue;
}
stack.push(part);
}
if (isWindowsDriveBase) {
return `${prefix}/${stack.join('/')}`;
}
return `/${stack.join('/')}`;
return toAbsoluteFilePath(basePath, targetPath);
};
const trimPathCandidate = (value: string): string => {
@@ -1375,14 +1315,7 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
const normalizedDirectory = normalizePath(effectiveDirectory);
if (normalizedDirectory) {
return normalizedDirectory;
}
const normalizedPath = normalizePath(resolvedPath);
const parent = normalizedPath.replace(/\/[^/]*$/, '');
return parent || normalizedPath;
return getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};
const useFileReferenceInteractions = ({
@@ -88,9 +88,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
if (!currentDirectory) return;
if (!isGitFile(file)) return;
const absolutePath = file.path.startsWith('/')
? file.path
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
const absolutePath = file.path;
// Dedicated mobile root: open the per-file diff inside the mobile Changes surface.
if (mobileActions) {
@@ -18,6 +18,7 @@ import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './change
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { TurnActivityRecord } from './lib/turns/types';
import { toAbsoluteFilePath } from '@/lib/path-utils';
interface TurnChangedFilesDropdownProps {
activityParts: TurnActivityRecord[] | undefined;
@@ -57,9 +58,7 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
if (!currentDirectory) return;
if (isGitFile(file)) return;
const absolutePath = file.path.startsWith('/')
? file.path
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
const absolutePath = toAbsoluteFilePath(currentDirectory, file.path);
const editor = runtime?.editor;
if (editor) {
@@ -1,4 +1,5 @@
import type { ToolPart } from '@opencode-ai/sdk/v2';
import { getRelativeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
export interface ChangedFile {
path: string;
@@ -163,7 +164,7 @@ export const extractGitChangedFiles = (
if (!code || code === '!') continue;
const stats = diffStats?.[file.path];
result.push({
path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path,
path: toAbsoluteFilePath(directory, file.path),
relativePath: file.path,
insertions: stats?.insertions ?? 0,
deletions: stats?.deletions ?? 0,
@@ -176,16 +177,7 @@ export const extractGitChangedFiles = (
};
export const toRelativePath = (absolutePath: string, baseDirectory: string): string => {
const norm = (p: string) => p.split('\\').join('/').replace(/\/+$/, '');
const base = norm(baseDirectory);
const absPath = norm(absolutePath);
if (absPath.startsWith(base + '/')) {
return absPath.slice(base.length + 1);
}
if (absPath.startsWith(base)) {
return absPath.slice(base.length) || absPath;
}
return absPath;
return getRelativeFilePath(absolutePath, baseDirectory);
};
export const getDisplayPath = (file: ChangedFileEntry, currentDirectory: string): { fileName: string; dirPart: string } => {
@@ -23,6 +23,7 @@ import ReasoningPart from './ReasoningPart';
import JustificationBlock from './JustificationBlock';
import { areRenderRelevantPartsEqual } from '../renderCompare';
import { getExternalFaviconUrl } from '@/lib/url';
import { getDirectoryForFilePath, getRelativeFilePath, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-4 sm:!leading-6 tracking-normal';
const TOOL_ROW_TITLE_CLASS = cn('typography-meta font-medium', TOOL_ROW_TEXT_CLASS);
@@ -240,45 +241,6 @@ const getToolReadOffset = (activity: TurnActivityPart): number | undefined => {
return Math.floor(rawOffset);
};
const normalizePathValue = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
return trimmed.replace(/\\/g, '/').replace(/\/{2,}/g, '/');
};
const trimTrailingSlashes = (value: string): string => {
if (value === '/') {
return value;
}
return value.replace(/\/+$/, '');
};
const getRelativePathFromDirectory = (filePath: string, currentDirectory: string): string => {
const normalizedPath = trimTrailingSlashes(normalizePathValue(filePath));
const normalizedDirectory = trimTrailingSlashes(normalizePathValue(currentDirectory));
if (!normalizedPath) {
return '';
}
if (!normalizedDirectory) {
return normalizedPath;
}
if (normalizedPath === normalizedDirectory) {
return '.';
}
const prefix = `${normalizedDirectory}/`;
if (normalizedPath.startsWith(prefix)) {
return normalizedPath.slice(prefix.length);
}
return normalizedPath;
};
const renderReadFilePath = (displayPath: string, animate = true) => {
const lastSlash = displayPath.lastIndexOf('/');
@@ -326,23 +288,8 @@ const renderReadFilePath = (displayPath: string, animate = true) => {
);
};
const resolveAbsolutePath = (currentDirectory: string, filePath: string): string => {
const normalizedPath = normalizePathValue(filePath);
if (!normalizedPath) {
return '';
}
if (normalizedPath.startsWith('/')) {
return normalizedPath;
}
const normalizedDirectory = normalizePathValue(currentDirectory);
if (!normalizedDirectory) {
return normalizedPath;
}
return normalizedDirectory.endsWith('/') ? `${normalizedDirectory}${normalizedPath}` : `${normalizedDirectory}/${normalizedPath}`;
};
const resolveSkillFilePath = (skillPathOrDir: string): string => {
const normalizedPath = trimTrailingSlashes(normalizePathValue(skillPathOrDir));
const normalizedPath = normalizeFilePath(skillPathOrDir);
if (!normalizedPath) {
return '';
}
@@ -350,20 +297,6 @@ const resolveSkillFilePath = (skillPathOrDir: string): string => {
return normalizedPath.toLowerCase().endsWith('/skill.md') ? normalizedPath : `${normalizedPath}/SKILL.md`;
};
const getContextDirectoryForPath = (currentDirectory: string, absolutePath: string): string => {
const normalizedDirectory = normalizePathValue(currentDirectory);
if (normalizedDirectory) {
return normalizedDirectory;
}
const normalizedPath = normalizePathValue(absolutePath);
if (!normalizedPath) {
return '';
}
const parent = normalizedPath.replace(/\/[^/]*$/, '');
return parent || normalizedPath;
};
/**
* Get a short description for a static tool (for aggregation display).
*/
@@ -687,7 +620,7 @@ const StaticToolRowInner: React.FC<{
const offset = getToolReadOffset(activity);
if (!filePath) continue;
if (entries.some((entry) => entry.path === filePath)) continue;
const displayPath = getRelativePathFromDirectory(filePath, currentDirectory);
const displayPath = getRelativeFilePath(filePath, currentDirectory);
if (!displayPath) continue;
entries.push({ path: filePath, displayPath, offset });
}
@@ -695,7 +628,7 @@ const StaticToolRowInner: React.FC<{
}, [activities, currentDirectory, isReadGroup]);
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
const absolutePath = resolveAbsolutePath(currentDirectory, filePath);
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
if (!absolutePath) {
return;
}
@@ -706,7 +639,7 @@ const StaticToolRowInner: React.FC<{
}
const uiStore = useUIStore.getState();
const contextDirectory = getContextDirectoryForPath(currentDirectory, absolutePath);
const contextDirectory = getDirectoryForFilePath(currentDirectory, absolutePath);
if (offset && Number.isFinite(offset)) {
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
return;
@@ -719,7 +652,7 @@ const StaticToolRowInner: React.FC<{
return;
}
const uiStore = useUIStore.getState();
uiStore.openContextFile(currentDirectory || getContextDirectoryForPath('', skillPath), skillPath);
uiStore.openContextFile(currentDirectory || getDirectoryForFilePath('', skillPath), skillPath);
}, [currentDirectory]);
const normalizedToolName = toolName.toLowerCase();
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { Icon } from "@/components/icon/Icon";
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getDirectoryForFilePath } from '@/lib/path-utils';
type PartWithText = Part & { text?: string; content?: string; value?: string };
@@ -77,7 +78,7 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
const openSkill = React.useCallback((name: string) => {
const skill = skillByName.get(name);
if (!skill?.path) return;
openContextFile(effectiveDirectory || skill.path.replace(/\/[^/]*$/, '') || '/', skill.path);
openContextFile(effectiveDirectory || getDirectoryForFilePath('', skill.path) || '/', skill.path);
}, [effectiveDirectory, openContextFile, skillByName]);
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
+2 -11
View File
@@ -27,6 +27,7 @@ import { useDeviceInfo } from '@/lib/device';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { Icon } from "@/components/icon/Icon";
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
import { toAbsoluteFilePath } from '@/lib/path-utils';
import { sessionEvents } from '@/lib/sessionEvents';
import { useI18n } from '@/lib/i18n';
import type { I18nKey } from '@/lib/i18n/store';
@@ -129,18 +130,8 @@ const isWorkingStatusFile = (file: GitStatus['files'][number]): boolean => {
return Boolean(workingCode) || file.index === '?';
};
const isAbsolutePath = (value: string): boolean => {
return value.startsWith('/') || value.startsWith('//') || /^[A-Za-z]:\//.test(value);
};
const toAbsolutePath = (directory: string, filePath: string): string => {
const normalizedDirectory = directory.replace(/\\/g, '/').replace(/\/+$/g, '');
const normalizedFilePath = filePath.replace(/\\/g, '/');
if (isAbsolutePath(normalizedFilePath)) {
return normalizedFilePath;
}
const trimmedFilePath = normalizedFilePath.replace(/^\/+/, '');
return normalizedDirectory ? `${normalizedDirectory}/${trimmedFilePath}` : trimmedFilePath;
return toAbsoluteFilePath(directory, filePath);
};
const normalizePath = (value?: string | null): string =>
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test';
import {
getDirectoryForFilePath,
getRelativeFilePath,
isAbsoluteFilePath,
isFilePathWithinDirectory,
normalizeFilePath,
toAbsoluteFilePath,
} from './path-utils';
describe('path-utils', () => {
test('normalizes Windows paths without losing drive roots', () => {
expect(normalizeFilePath('C:\\Users\\Bohdan Triapitsyn\\projects\\openchamber\\')).toBe('C:/Users/Bohdan Triapitsyn/projects/openchamber');
expect(normalizeFilePath('C:/')).toBe('C:/');
expect(normalizeFilePath('\\\\server\\share\\project')).toBe('//server/share/project');
});
test('recognizes Windows absolute paths', () => {
expect(isAbsoluteFilePath('C:/Users/file.ts')).toBe(true);
expect(isAbsoluteFilePath('C:\\Users\\file.ts')).toBe(true);
expect(isAbsoluteFilePath('C:relative/file.ts')).toBe(false);
expect(isAbsoluteFilePath('src/file.ts')).toBe(false);
});
test('does not prefix Windows absolute targets with the workspace directory', () => {
expect(toAbsoluteFilePath('C:/Users/Bohdan Triapitsyn/projects/openchamber', 'C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/ui/Button.tsx')).toBe(
'C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/ui/Button.tsx',
);
});
test('joins relative targets under Windows workspaces', () => {
expect(toAbsoluteFilePath('C:/Users/Bohdan Triapitsyn/projects/openchamber', 'packages/ui/Button.tsx')).toBe(
'C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/ui/Button.tsx',
);
expect(toAbsoluteFilePath('C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/ui', '../web/package.json')).toBe(
'C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/web/package.json',
);
});
test('compares Windows workspace containment case-insensitively', () => {
expect(isFilePathWithinDirectory(
'c:/users/bohdan triapitsyn/projects/openchamber/packages/ui/button.tsx',
'C:/Users/Bohdan Triapitsyn/projects/openchamber',
)).toBe(true);
expect(getRelativeFilePath(
'C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/ui/Button.tsx',
'c:/users/bohdan triapitsyn/projects/openchamber',
)).toBe('packages/ui/Button.tsx');
});
test('falls back to file parent when current directory does not contain the path', () => {
expect(getDirectoryForFilePath(
'C:/Users/Bohdan Triapitsyn/projects/other',
'C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/ui/Button.tsx',
)).toBe('C:/Users/Bohdan Triapitsyn/projects/openchamber/packages/ui');
expect(getDirectoryForFilePath('', '/tmp/file.txt')).toBe('/tmp');
});
});
+157
View File
@@ -0,0 +1,157 @@
const WINDOWS_DRIVE_ROOT_PATTERN = /^[A-Za-z]:\/$/;
const WINDOWS_DRIVE_ABSOLUTE_PATTERN = /^[A-Za-z]:\//;
export const normalizeFilePath = (value: string | null | undefined): string => {
if (typeof value !== 'string') {
return '';
}
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const withSlashes = trimmed.replace(/\\/g, '/');
const hadUncPrefix = withSlashes.startsWith('//');
let normalized = withSlashes.replace(/\/+/g, '/');
if (hadUncPrefix && !normalized.startsWith('//')) {
normalized = `/${normalized}`;
}
const isUnixRoot = normalized === '/';
const isWindowsDriveRoot = WINDOWS_DRIVE_ROOT_PATTERN.test(normalized);
if (!isUnixRoot && !isWindowsDriveRoot) {
normalized = normalized.replace(/\/+$/, '');
}
return normalized;
};
export const isAbsoluteFilePath = (value: string | null | undefined): boolean => {
const normalized = normalizeFilePath(value);
return normalized.startsWith('/') || WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(normalized);
};
export const toComparableFilePath = (value: string | null | undefined): string => {
const normalized = normalizeFilePath(value);
return WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(normalized) || normalized.startsWith('//')
? normalized.toLowerCase()
: normalized;
};
const splitPathParts = (value: string): string[] => value.split('/').filter(Boolean);
const applyRelativeParts = (baseParts: string[], relativePath: string): string[] => {
const stack = [...baseParts];
for (const part of splitPathParts(relativePath)) {
if (part === '.') {
continue;
}
if (part === '..') {
if (stack.length > 0) {
stack.pop();
}
continue;
}
stack.push(part);
}
return stack;
};
export const toAbsoluteFilePath = (basePath: string | null | undefined, targetPath: string | null | undefined): string => {
const normalizedTarget = normalizeFilePath(targetPath);
if (!normalizedTarget) {
return normalizeFilePath(basePath);
}
if (isAbsoluteFilePath(normalizedTarget)) {
return normalizedTarget;
}
const normalizedBase = normalizeFilePath(basePath);
if (!normalizedBase) {
return normalizedTarget;
}
const drivePrefix = WINDOWS_DRIVE_ABSOLUTE_PATTERN.test(normalizedBase) ? normalizedBase.slice(0, 2) : '';
const isUncBase = normalizedBase.startsWith('//');
const isUnixBase = normalizedBase.startsWith('/') && !isUncBase;
const baseRemainder = drivePrefix ? normalizedBase.slice(2) : normalizedBase;
const parts = applyRelativeParts(splitPathParts(baseRemainder), normalizedTarget);
const joined = parts.join('/');
if (drivePrefix) {
return joined ? `${drivePrefix}/${joined}` : `${drivePrefix}/`;
}
if (isUncBase) {
return `//${joined}`;
}
if (isUnixBase) {
return `/${joined}`;
}
return joined;
};
export const isFilePathWithinDirectory = (filePath: string | null | undefined, directory: string | null | undefined): boolean => {
const normalizedFilePath = normalizeFilePath(filePath);
const normalizedDirectory = normalizeFilePath(directory);
if (!normalizedFilePath || !normalizedDirectory) {
return false;
}
const comparablePath = toComparableFilePath(normalizedFilePath);
const comparableDirectory = toComparableFilePath(normalizedDirectory);
return comparablePath === comparableDirectory || comparablePath.startsWith(`${comparableDirectory}/`);
};
export const getRelativeFilePath = (filePath: string | null | undefined, directory: string | null | undefined): string => {
const normalizedFilePath = normalizeFilePath(filePath);
const normalizedDirectory = normalizeFilePath(directory);
if (!normalizedFilePath) {
return '';
}
if (!normalizedDirectory) {
return normalizedFilePath;
}
if (toComparableFilePath(normalizedFilePath) === toComparableFilePath(normalizedDirectory)) {
return '.';
}
if (!isFilePathWithinDirectory(normalizedFilePath, normalizedDirectory)) {
return normalizedFilePath;
}
return normalizedFilePath.slice(normalizedDirectory.length + 1);
};
export const getDirectoryForFilePath = (currentDirectory: string | null | undefined, filePath: string | null | undefined): string => {
const normalizedDirectory = normalizeFilePath(currentDirectory);
const normalizedPath = normalizeFilePath(filePath);
if (normalizedDirectory && (!normalizedPath || isFilePathWithinDirectory(normalizedPath, normalizedDirectory))) {
return normalizedDirectory;
}
if (!normalizedPath) {
return normalizedDirectory;
}
const lastSlash = normalizedPath.lastIndexOf('/');
if (lastSlash === 0) {
return '/';
}
if (lastSlash < 0) {
return normalizedDirectory || normalizedPath;
}
if (/^[A-Za-z]:\//.test(normalizedPath) && lastSlash === 2) {
return normalizedPath.slice(0, 3);
}
return normalizedPath.slice(0, lastSlash);
};