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
+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);
};