From 6db08251ff365ec18c2ecc73408c15661172f4c2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 4 Jun 2026 18:47:33 +0300 Subject: [PATCH] fix: support Windows file paths in editor opens --- .../components/chat/MarkdownRendererImpl.tsx | 77 +-------- .../src/components/chat/PendingChangesBar.tsx | 4 +- .../chat/TurnChangedFilesDropdown.tsx | 5 +- .../ui/src/components/chat/changedFiles.ts | 14 +- .../chat/message/parts/ProgressiveGroup.tsx | 79 +-------- .../chat/message/parts/UserTextPart.tsx | 3 +- packages/ui/src/components/views/DiffView.tsx | 13 +- packages/ui/src/lib/path-utils.test.ts | 59 +++++++ packages/ui/src/lib/path-utils.ts | 157 ++++++++++++++++++ 9 files changed, 237 insertions(+), 174 deletions(-) create mode 100644 packages/ui/src/lib/path-utils.test.ts create mode 100644 packages/ui/src/lib/path-utils.ts diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 577995b0..31e41034 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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 => { }; 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 = ({ diff --git a/packages/ui/src/components/chat/PendingChangesBar.tsx b/packages/ui/src/components/chat/PendingChangesBar.tsx index 23d81614..55c60c8e 100644 --- a/packages/ui/src/components/chat/PendingChangesBar.tsx +++ b/packages/ui/src/components/chat/PendingChangesBar.tsx @@ -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) { diff --git a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx index 1b2cdf88..e1b4a72f 100644 --- a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx +++ b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx @@ -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 = 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) { diff --git a/packages/ui/src/components/chat/changedFiles.ts b/packages/ui/src/components/chat/changedFiles.ts index fc8731ce..e0ee7e80 100644 --- a/packages/ui/src/components/chat/changedFiles.ts +++ b/packages/ui/src/components/chat/changedFiles.ts @@ -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 } => { diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index 01949045..d688d606 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -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(); diff --git a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx index 811cbd43..205c2780 100644 --- a/packages/ui/src/components/chat/message/parts/UserTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/UserTextPart.tsx @@ -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 = ({ 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 => { diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 699d56a1..927cd5ff 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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 => diff --git a/packages/ui/src/lib/path-utils.test.ts b/packages/ui/src/lib/path-utils.test.ts new file mode 100644 index 00000000..d6745a4e --- /dev/null +++ b/packages/ui/src/lib/path-utils.test.ts @@ -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'); + }); +}); diff --git a/packages/ui/src/lib/path-utils.ts b/packages/ui/src/lib/path-utils.ts new file mode 100644 index 00000000..eb205760 --- /dev/null +++ b/packages/ui/src/lib/path-utils.ts @@ -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); +};