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 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 => {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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