diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index ecceea09ca1..bfa7a7fbed7 100644 --- a/apps/web/src/filePathDisplay.test.ts +++ b/apps/web/src/filePathDisplay.test.ts @@ -12,6 +12,27 @@ describe("formatWorkspaceRelativePath", () => { ).toBe("t3code/apps/web/src/session-logic.ts:501"); }); + it("preserves windows drive roots in display paths", () => { + expect(formatWorkspaceRelativePath("C:/Users/mike/file.ts", "C:/")).toBe( + "C:/Users/mike/file.ts", + ); + expect(formatWorkspaceRelativePath("C:\\Users\\mike\\file.ts", "C:\\")).toBe( + "C:/Users/mike/file.ts", + ); + }); + + it("preserves filesystem roots when formatting the root itself", () => { + expect(formatWorkspaceRelativePath("C:/", "C:/")).toBe("C:/"); + expect(formatWorkspaceRelativePath("/", "/")).toBe("/"); + expect(formatWorkspaceRelativePath("/usr/bin/tool", "/")).toBe("/usr/bin/tool"); + }); + + it("formats files from a UNC share root", () => { + expect(formatWorkspaceRelativePath("\\\\SERVER\\Share\\file.ts", "\\\\server\\share\\")).toBe( + "share/file.ts", + ); + }); + it("prefixes relative paths with the workspace root label", () => { expect( formatWorkspaceRelativePath( @@ -38,4 +59,31 @@ describe("formatWorkspaceRelativePath", () => { ), ).toBe("t3code/apps/web/src/session-logic.ts:501:9"); }); + + it("does not format a case-distinct posix sibling as workspace-relative", () => { + expect( + formatWorkspaceRelativePath( + "/tmp/t3code-case-test/project/probe.txt", + "/tmp/t3code-case-test/Project", + ), + ).toBe("/tmp/t3code-case-test/project/probe.txt"); + }); + + it("formats exact-case posix workspace paths as workspace-relative", () => { + expect( + formatWorkspaceRelativePath( + "/Users/mike/Project/src/session-logic.ts", + "/Users/mike/Project", + ), + ).toBe("Project/src/session-logic.ts"); + }); + + it("keeps windows workspace formatting case-insensitive", () => { + expect( + formatWorkspaceRelativePath( + "c:/users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts", + "C:/Users/Mike/Dev-Stuff/T3Code", + ), + ).toBe("T3Code/apps/web/src/session-logic.ts"); + }); }); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 5a6e2a02e10..c9c36a89761 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,3 +1,9 @@ +import { + isWindowsAbsolutePath, + normalizePathCaseForComparison, + normalizeProjectPathForDispatch, +} from "@t3tools/shared/path"; + import { splitPathAndPosition } from "./terminal-links"; function normalizePathSeparators(path: string): string { @@ -5,14 +11,24 @@ function normalizePathSeparators(path: string): string { } function canonicalizeWindowsDrivePath(path: string): string { - return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; + return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path; +} + +function isForwardSlashUncPath(path: string): boolean { + return /^\/\/[^/]/.test(path); } -function trimTrailingPathSeparators(path: string): string { - return path.replace(/[\\/]+$/, ""); +function isCaseInsensitiveWorkspacePath(path: string): boolean { + return isWindowsAbsolutePath(path) || isForwardSlashUncPath(path); +} + +function normalizeWorkspacePathForComparison(path: string): string { + const canonicalPath = isForwardSlashUncPath(path) ? path.replaceAll("/", "\\") : path; + return normalizePathCaseForComparison(canonicalPath); } function basenameOfPath(path: string): string { + if (/^[A-Za-z]:[\\/]?$/.test(path)) return path.slice(0, 2); const separatorIndex = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; } @@ -21,29 +37,59 @@ function stripRelativePrefixes(path: string): string { return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); } +export function resolveWorkspaceRelativePath(path: string, workspaceRoot: string): string | null { + const normalizedPath = canonicalizeWindowsDrivePath(path); + const normalizedRoot = canonicalizeWindowsDrivePath( + normalizeProjectPathForDispatch(workspaceRoot), + ); + const pathForCompare = normalizeWorkspacePathForComparison(normalizedPath); + const rootForCompare = normalizeWorkspacePathForComparison(normalizedRoot); + + if (pathForCompare === rootForCompare) return ""; + + const separator = isCaseInsensitiveWorkspacePath(normalizedRoot) ? "\\" : "/"; + const rootPrefix = rootForCompare.endsWith(separator) + ? rootForCompare + : `${rootForCompare}${separator}`; + if (!pathForCompare.startsWith(rootPrefix)) return null; + + const relativeStart = + normalizedRoot.endsWith("/") || normalizedRoot.endsWith("\\") + ? normalizedRoot.length + : normalizedRoot.length + 1; + return normalizePathSeparators(normalizedPath.slice(relativeStart)); +} + export function formatWorkspaceRelativePath( pathWithPosition: string, workspaceRoot: string | undefined, ): string { const { path, line, column } = splitPathAndPosition(pathWithPosition); - const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + const canonicalPath = canonicalizeWindowsDrivePath(path); + const normalizedPath = normalizePathSeparators(canonicalPath); let displayPath = normalizedPath; if (workspaceRoot) { - const normalizedWorkspaceRoot = canonicalizeWindowsDrivePath( - normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), + const canonicalWorkspaceRoot = canonicalizeWindowsDrivePath( + normalizeProjectPathForDispatch(workspaceRoot), ); + const normalizedWorkspaceRoot = normalizePathSeparators(canonicalWorkspaceRoot); const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); - const pathForCompare = normalizedPath.toLowerCase(); - const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); - const workspaceWithSeparator = `${workspaceForCompare}/`; - const workspaceLabelWithSeparator = `${workspaceLabel.toLowerCase()}/`; - - if (pathForCompare === workspaceForCompare) { - displayPath = workspaceLabel; - } else if (pathForCompare.startsWith(workspaceWithSeparator)) { - const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1); - displayPath = `${workspaceLabel}/${relativeSuffix}`; + const workspaceRelativePath = resolveWorkspaceRelativePath(path, workspaceRoot); + const caseInsensitive = isCaseInsensitiveWorkspacePath(canonicalWorkspaceRoot); + const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const workspaceLabelForCompare = caseInsensitive + ? workspaceLabel.toLowerCase() + : workspaceLabel; + const workspaceLabelWithSeparator = `${workspaceLabelForCompare}/`; + + if (workspaceRelativePath === "") { + displayPath = + normalizedWorkspaceRoot === "/" || /^[A-Za-z]:\/$/.test(normalizedWorkspaceRoot) + ? normalizedWorkspaceRoot + : workspaceLabel; + } else if (workspaceRelativePath !== null) { + displayPath = `${workspaceLabel}/${workspaceRelativePath}`; } else if (!normalizedPath.startsWith("/")) { const relativePath = stripRelativePrefixes(normalizedPath); displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator) diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 9fc29613867..73876393c60 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -108,6 +108,74 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("keeps posix workspace containment case-sensitive", () => { + expect( + resolveMarkdownFileLinkMeta( + "/tmp/t3code-case-test/project/probe.txt", + "/tmp/t3code-case-test/Project", + ), + ).toMatchObject({ + displayPath: "/tmp/t3code-case-test/project/probe.txt", + workspaceRelativePath: null, + }); + }); + + it("creates preview paths for exact-case posix workspace files", () => { + expect( + resolveMarkdownFileLinkMeta( + "/Users/mike/Project/src/session-logic.ts", + "/Users/mike/Project", + ), + ).toMatchObject({ + displayPath: "Project/src/session-logic.ts", + workspaceRelativePath: "src/session-logic.ts", + }); + }); + + it("preserves trailing whitespace in encoded workspace file paths", () => { + expect(resolveMarkdownFileLinkMeta("/tmp/repo/file.ts%20", "/tmp/repo")).toMatchObject({ + targetPath: "/tmp/repo/file.ts ", + displayPath: "repo/file.ts ", + workspaceRelativePath: "file.ts ", + }); + }); + + it("keeps windows workspace containment case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta( + "c:/users/mike/dev-stuff/t3code/apps/web/src/session-logic.ts", + "C:/Users/Mike/Dev-Stuff/T3Code", + ), + ).toMatchObject({ + displayPath: "T3Code/apps/web/src/session-logic.ts", + workspaceRelativePath: "apps/web/src/session-logic.ts", + }); + }); + + it("keeps unc workspace containment case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta( + "\\\\SERVER\\Share\\Project\\src\\session-logic.ts", + "\\\\server\\share\\project", + ), + ).toMatchObject({ + displayPath: "project/src/session-logic.ts", + workspaceRelativePath: "src/session-logic.ts", + }); + }); + + it("keeps forward-slash UNC workspace containment case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta( + "//SERVER/Share/Project/src/session-logic.ts", + "//server/share/project", + ), + ).toMatchObject({ + displayPath: "project/src/session-logic.ts", + workspaceRelativePath: "src/session-logic.ts", + }); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index a6dba941b8a..8ef7f04ffae 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,4 +1,4 @@ -import { formatWorkspaceRelativePath } from "./filePathDisplay"; +import { formatWorkspaceRelativePath, resolveWorkspaceRelativePath } from "./filePathDisplay"; import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; @@ -363,19 +363,6 @@ function basenameOfPath(path: string): string { return separatorIndex >= 0 ? path.slice(separatorIndex + 1) : path; } -function workspaceRelativePath(path: string, workspaceRoot: string | undefined): string | null { - if (!workspaceRoot) return null; - const normalizedPath = normalizeWindowsDrivePath(path.replaceAll("\\", "/")); - const normalizedRoot = normalizeWindowsDrivePath(workspaceRoot.replaceAll("\\", "/")).replace( - /\/+$/, - "", - ); - const pathForCompare = normalizedPath.toLowerCase(); - const rootForCompare = normalizedRoot.toLowerCase(); - if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; - return normalizedPath.slice(normalizedRoot.length + 1); -} - export function resolveMarkdownFileLinkMeta( href: string | undefined, cwd?: string, @@ -396,7 +383,7 @@ function buildFileLinkMetaFromTarget(targetPath: string, cwd?: string): Markdown filePath: path, targetPath, displayPath: formatWorkspaceRelativePath(targetPath, cwd), - workspaceRelativePath: workspaceRelativePath(path, cwd), + workspaceRelativePath: cwd ? resolveWorkspaceRelativePath(path, cwd) : null, basename: basenameOfPath(path), ...(lineNumber !== undefined ? { line: lineNumber } : {}), ...(columnNumber !== undefined ? { column: columnNumber } : {}), diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 52a07aae7be..c1007818c59 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,6 +4,7 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, + normalizePathCaseForComparison, } from "./path.ts"; describe("path helpers", () => { @@ -31,4 +32,9 @@ describe("path helpers", () => { expect(isExplicitRelativePath("..\\repo")).toBe(true); expect(isExplicitRelativePath("~/repo")).toBe(false); }); + + it("normalizes windows path casing without trimming filenames", () => { + expect(normalizePathCaseForComparison("C:/Repo/file.ts ")).toBe("c:\\repo\\file.ts "); + expect(normalizePathCaseForComparison("/repo/file.ts ")).toBe("/repo/file.ts "); + }); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index 66887d3f2ec..103ebb7558d 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -42,10 +42,13 @@ export function normalizeProjectPathForDispatch(value: string): string { return trimTrailingPathSeparators(value.trim()); } -export function normalizeProjectPathForComparison(value: string): string { - const normalized = normalizeProjectPathForDispatch(value); - if (isWindowsDrivePath(normalized) || isUncPath(normalized)) { - return normalized.replaceAll("/", "\\").toLowerCase(); +export function normalizePathCaseForComparison(value: string): string { + if (isWindowsDrivePath(value) || isUncPath(value)) { + return value.replaceAll("/", "\\").toLowerCase(); } - return normalized; + return value; +} + +export function normalizeProjectPathForComparison(value: string): string { + return normalizePathCaseForComparison(normalizeProjectPathForDispatch(value)); }