fix: support Windows file paths in editor opens
This commit is contained in:
@@ -29,6 +29,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
|||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
import type { EditorAPI } from '@/lib/api/types';
|
import type { EditorAPI } from '@/lib/api/types';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { getDirectoryForFilePath, isAbsoluteFilePath, normalizeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
|
|
||||||
const useCurrentMermaidTheme = () => {
|
const useCurrentMermaidTheme = () => {
|
||||||
const themeSystem = useOptionalThemeSystem();
|
const themeSystem = useOptionalThemeSystem();
|
||||||
@@ -1067,8 +1068,6 @@ type ParsedFileReference = {
|
|||||||
column?: number;
|
column?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
|
||||||
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
|
||||||
const KNOWN_FILE_BASENAMES = new Set([
|
const KNOWN_FILE_BASENAMES = new Set([
|
||||||
'dockerfile',
|
'dockerfile',
|
||||||
'makefile',
|
'makefile',
|
||||||
@@ -1083,74 +1082,15 @@ const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
|
|||||||
.join('|');
|
.join('|');
|
||||||
|
|
||||||
const normalizePath = (value: string): string => {
|
const normalizePath = (value: string): string => {
|
||||||
const source = (value || '').trim();
|
return normalizeFilePath(value);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAbsolutePath = (value: string): boolean => {
|
const isAbsolutePath = (value: string): boolean => {
|
||||||
return value.startsWith('/')
|
return isAbsoluteFilePath(value);
|
||||||
|| WINDOWS_DRIVE_PATH_PATTERN.test(value)
|
|
||||||
|| WINDOWS_UNC_PATH_PATTERN.test(value)
|
|
||||||
|| value.startsWith('//');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const toAbsolutePath = (basePath: string, targetPath: string): string => {
|
const toAbsolutePath = (basePath: string, targetPath: string): string => {
|
||||||
const normalizedTarget = normalizePath(targetPath);
|
return toAbsoluteFilePath(basePath, 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('/')}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const trimPathCandidate = (value: string): string => {
|
const trimPathCandidate = (value: string): string => {
|
||||||
@@ -1375,14 +1315,7 @@ const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
||||||
const normalizedDirectory = normalizePath(effectiveDirectory);
|
return getDirectoryForFilePath(effectiveDirectory, resolvedPath);
|
||||||
if (normalizedDirectory) {
|
|
||||||
return normalizedDirectory;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedPath = normalizePath(resolvedPath);
|
|
||||||
const parent = normalizedPath.replace(/\/[^/]*$/, '');
|
|
||||||
return parent || normalizedPath;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const useFileReferenceInteractions = ({
|
const useFileReferenceInteractions = ({
|
||||||
|
|||||||
@@ -88,9 +88,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
|
|||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
if (!isGitFile(file)) return;
|
if (!isGitFile(file)) return;
|
||||||
|
|
||||||
const absolutePath = file.path.startsWith('/')
|
const absolutePath = file.path;
|
||||||
? file.path
|
|
||||||
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
|
|
||||||
|
|
||||||
// Dedicated mobile root: open the per-file diff inside the mobile Changes surface.
|
// Dedicated mobile root: open the per-file diff inside the mobile Changes surface.
|
||||||
if (mobileActions) {
|
if (mobileActions) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './change
|
|||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import type { TurnActivityRecord } from './lib/turns/types';
|
import type { TurnActivityRecord } from './lib/turns/types';
|
||||||
|
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
|
|
||||||
interface TurnChangedFilesDropdownProps {
|
interface TurnChangedFilesDropdownProps {
|
||||||
activityParts: TurnActivityRecord[] | undefined;
|
activityParts: TurnActivityRecord[] | undefined;
|
||||||
@@ -57,9 +58,7 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
|||||||
if (!currentDirectory) return;
|
if (!currentDirectory) return;
|
||||||
if (isGitFile(file)) return;
|
if (isGitFile(file)) return;
|
||||||
|
|
||||||
const absolutePath = file.path.startsWith('/')
|
const absolutePath = toAbsoluteFilePath(currentDirectory, file.path);
|
||||||
? file.path
|
|
||||||
: (currentDirectory.endsWith('/') ? currentDirectory : currentDirectory + '/') + file.path;
|
|
||||||
|
|
||||||
const editor = runtime?.editor;
|
const editor = runtime?.editor;
|
||||||
if (editor) {
|
if (editor) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ToolPart } from '@opencode-ai/sdk/v2';
|
import type { ToolPart } from '@opencode-ai/sdk/v2';
|
||||||
|
import { getRelativeFilePath, toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
|
|
||||||
export interface ChangedFile {
|
export interface ChangedFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -163,7 +164,7 @@ export const extractGitChangedFiles = (
|
|||||||
if (!code || code === '!') continue;
|
if (!code || code === '!') continue;
|
||||||
const stats = diffStats?.[file.path];
|
const stats = diffStats?.[file.path];
|
||||||
result.push({
|
result.push({
|
||||||
path: file.path.startsWith('/') ? file.path : (directory.endsWith('/') ? directory : directory + '/') + file.path,
|
path: toAbsoluteFilePath(directory, file.path),
|
||||||
relativePath: file.path,
|
relativePath: file.path,
|
||||||
insertions: stats?.insertions ?? 0,
|
insertions: stats?.insertions ?? 0,
|
||||||
deletions: stats?.deletions ?? 0,
|
deletions: stats?.deletions ?? 0,
|
||||||
@@ -176,16 +177,7 @@ export const extractGitChangedFiles = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const toRelativePath = (absolutePath: string, baseDirectory: string): string => {
|
export const toRelativePath = (absolutePath: string, baseDirectory: string): string => {
|
||||||
const norm = (p: string) => p.split('\\').join('/').replace(/\/+$/, '');
|
return getRelativeFilePath(absolutePath, baseDirectory);
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDisplayPath = (file: ChangedFileEntry, currentDirectory: string): { fileName: string; dirPart: string } => {
|
export const getDisplayPath = (file: ChangedFileEntry, currentDirectory: string): { fileName: string; dirPart: string } => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import ReasoningPart from './ReasoningPart';
|
|||||||
import JustificationBlock from './JustificationBlock';
|
import JustificationBlock from './JustificationBlock';
|
||||||
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
import { areRenderRelevantPartsEqual } from '../renderCompare';
|
||||||
import { getExternalFaviconUrl } from '@/lib/url';
|
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_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);
|
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);
|
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 renderReadFilePath = (displayPath: string, animate = true) => {
|
||||||
const lastSlash = displayPath.lastIndexOf('/');
|
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 resolveSkillFilePath = (skillPathOrDir: string): string => {
|
||||||
const normalizedPath = trimTrailingSlashes(normalizePathValue(skillPathOrDir));
|
const normalizedPath = normalizeFilePath(skillPathOrDir);
|
||||||
if (!normalizedPath) {
|
if (!normalizedPath) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -350,20 +297,6 @@ const resolveSkillFilePath = (skillPathOrDir: string): string => {
|
|||||||
return normalizedPath.toLowerCase().endsWith('/skill.md') ? normalizedPath : `${normalizedPath}/SKILL.md`;
|
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).
|
* Get a short description for a static tool (for aggregation display).
|
||||||
*/
|
*/
|
||||||
@@ -687,7 +620,7 @@ const StaticToolRowInner: React.FC<{
|
|||||||
const offset = getToolReadOffset(activity);
|
const offset = getToolReadOffset(activity);
|
||||||
if (!filePath) continue;
|
if (!filePath) continue;
|
||||||
if (entries.some((entry) => entry.path === filePath)) continue;
|
if (entries.some((entry) => entry.path === filePath)) continue;
|
||||||
const displayPath = getRelativePathFromDirectory(filePath, currentDirectory);
|
const displayPath = getRelativeFilePath(filePath, currentDirectory);
|
||||||
if (!displayPath) continue;
|
if (!displayPath) continue;
|
||||||
entries.push({ path: filePath, displayPath, offset });
|
entries.push({ path: filePath, displayPath, offset });
|
||||||
}
|
}
|
||||||
@@ -695,7 +628,7 @@ const StaticToolRowInner: React.FC<{
|
|||||||
}, [activities, currentDirectory, isReadGroup]);
|
}, [activities, currentDirectory, isReadGroup]);
|
||||||
|
|
||||||
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
|
const handleReadFileClick = React.useCallback((filePath: string, offset?: number) => {
|
||||||
const absolutePath = resolveAbsolutePath(currentDirectory, filePath);
|
const absolutePath = toAbsoluteFilePath(currentDirectory, filePath);
|
||||||
if (!absolutePath) {
|
if (!absolutePath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -706,7 +639,7 @@ const StaticToolRowInner: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uiStore = useUIStore.getState();
|
const uiStore = useUIStore.getState();
|
||||||
const contextDirectory = getContextDirectoryForPath(currentDirectory, absolutePath);
|
const contextDirectory = getDirectoryForFilePath(currentDirectory, absolutePath);
|
||||||
if (offset && Number.isFinite(offset)) {
|
if (offset && Number.isFinite(offset)) {
|
||||||
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
|
uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1);
|
||||||
return;
|
return;
|
||||||
@@ -719,7 +652,7 @@ const StaticToolRowInner: React.FC<{
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const uiStore = useUIStore.getState();
|
const uiStore = useUIStore.getState();
|
||||||
uiStore.openContextFile(currentDirectory || getContextDirectoryForPath('', skillPath), skillPath);
|
uiStore.openContextFile(currentDirectory || getDirectoryForFilePath('', skillPath), skillPath);
|
||||||
}, [currentDirectory]);
|
}, [currentDirectory]);
|
||||||
|
|
||||||
const normalizedToolName = toolName.toLowerCase();
|
const normalizedToolName = toolName.toLowerCase();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
|
import { getDirectoryForFilePath } from '@/lib/path-utils';
|
||||||
|
|
||||||
type PartWithText = Part & { text?: string; content?: string; value?: string };
|
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 openSkill = React.useCallback((name: string) => {
|
||||||
const skill = skillByName.get(name);
|
const skill = skillByName.get(name);
|
||||||
if (!skill?.path) return;
|
if (!skill?.path) return;
|
||||||
openContextFile(effectiveDirectory || skill.path.replace(/\/[^/]*$/, '') || '/', skill.path);
|
openContextFile(effectiveDirectory || getDirectoryForFilePath('', skill.path) || '/', skill.path);
|
||||||
}, [effectiveDirectory, openContextFile, skillByName]);
|
}, [effectiveDirectory, openContextFile, skillByName]);
|
||||||
|
|
||||||
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
|
const hasActiveSelectionInElement = React.useCallback((element: HTMLElement): boolean => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
import { getContextFileOpenFailureMessage, validateContextFileOpen } from '@/lib/contextFileOpenGuard';
|
||||||
|
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { I18nKey } from '@/lib/i18n/store';
|
import type { I18nKey } from '@/lib/i18n/store';
|
||||||
@@ -129,18 +130,8 @@ const isWorkingStatusFile = (file: GitStatus['files'][number]): boolean => {
|
|||||||
return Boolean(workingCode) || file.index === '?';
|
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 toAbsolutePath = (directory: string, filePath: string): string => {
|
||||||
const normalizedDirectory = directory.replace(/\\/g, '/').replace(/\/+$/g, '');
|
return toAbsoluteFilePath(directory, filePath);
|
||||||
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
|
||||||
if (isAbsolutePath(normalizedFilePath)) {
|
|
||||||
return normalizedFilePath;
|
|
||||||
}
|
|
||||||
const trimmedFilePath = normalizedFilePath.replace(/^\/+/, '');
|
|
||||||
return normalizedDirectory ? `${normalizedDirectory}/${trimmedFilePath}` : trimmedFilePath;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizePath = (value?: string | null): string =>
|
const normalizePath = (value?: string | null): string =>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user