From f695ccec453d4a71c60520b63e28a633bc7033ae Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Tue, 28 Jul 2026 18:09:06 -0400 Subject: [PATCH 1/4] fix(web): preserve POSIX path casing in file links --- apps/web/src/filePathDisplay.test.ts | 27 ++++++++++++ apps/web/src/filePathDisplay.ts | 61 ++++++++++++++++++++-------- apps/web/src/markdown-links.test.ts | 48 ++++++++++++++++++++++ apps/web/src/markdown-links.ts | 17 +------- 4 files changed, 122 insertions(+), 31 deletions(-) diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index ecceea09ca1..cace1e1aac4 100644 --- a/apps/web/src/filePathDisplay.test.ts +++ b/apps/web/src/filePathDisplay.test.ts @@ -38,4 +38,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..61ba7cd332b 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,3 +1,9 @@ +import { + isWindowsAbsolutePath, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, +} from "@t3tools/shared/path"; + import { splitPathAndPosition } from "./terminal-links"; function normalizePathSeparators(path: string): string { @@ -5,11 +11,7 @@ function normalizePathSeparators(path: string): string { } function canonicalizeWindowsDrivePath(path: string): string { - return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; -} - -function trimTrailingPathSeparators(path: string): string { - return path.replace(/[\\/]+$/, ""); + return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path; } function basenameOfPath(path: string): string { @@ -21,29 +23,56 @@ function stripRelativePrefixes(path: string): string { return path.replace(/^\.\/+/, "").replace(/^\/+/, ""); } +export function resolveWorkspaceRelativePath(path: string, workspaceRoot: string): string | null { + const normalizedPath = canonicalizeWindowsDrivePath(normalizeProjectPathForDispatch(path)); + const normalizedRoot = canonicalizeWindowsDrivePath( + normalizeProjectPathForDispatch(workspaceRoot), + ); + const pathForCompare = normalizeProjectPathForComparison(normalizedPath); + const rootForCompare = normalizeProjectPathForComparison(normalizedRoot); + + if (pathForCompare === rootForCompare) return ""; + + const separator = isWindowsAbsolutePath(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()}/`; + const workspaceRelativePath = resolveWorkspaceRelativePath(path, workspaceRoot); + const caseInsensitive = isWindowsAbsolutePath(canonicalWorkspaceRoot); + const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const workspaceLabelForCompare = caseInsensitive + ? workspaceLabel.toLowerCase() + : workspaceLabel; + const workspaceLabelWithSeparator = `${workspaceLabelForCompare}/`; - if (pathForCompare === workspaceForCompare) { + if (workspaceRelativePath === "") { displayPath = workspaceLabel; - } else if (pathForCompare.startsWith(workspaceWithSeparator)) { - const relativeSuffix = normalizedPath.slice(normalizedWorkspaceRoot.length + 1); - displayPath = `${workspaceLabel}/${relativeSuffix}`; + } 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..ad3e9806400 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -108,6 +108,54 @@ 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("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("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 } : {}), From 22c08d68983c1690f73a41dae589922f11b6dfb8 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Tue, 28 Jul 2026 20:58:27 -0400 Subject: [PATCH 2/4] fix(web): preserve trailing spaces in file links --- apps/web/src/filePathDisplay.ts | 5 +++-- apps/web/src/markdown-links.test.ts | 8 ++++++++ packages/shared/src/path.test.ts | 6 ++++++ packages/shared/src/path.ts | 13 ++++++++----- 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 61ba7cd332b..912de6845ce 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,5 +1,6 @@ import { isWindowsAbsolutePath, + normalizePathCaseForComparison, normalizeProjectPathForComparison, normalizeProjectPathForDispatch, } from "@t3tools/shared/path"; @@ -24,11 +25,11 @@ function stripRelativePrefixes(path: string): string { } export function resolveWorkspaceRelativePath(path: string, workspaceRoot: string): string | null { - const normalizedPath = canonicalizeWindowsDrivePath(normalizeProjectPathForDispatch(path)); + const normalizedPath = canonicalizeWindowsDrivePath(path); const normalizedRoot = canonicalizeWindowsDrivePath( normalizeProjectPathForDispatch(workspaceRoot), ); - const pathForCompare = normalizeProjectPathForComparison(normalizedPath); + const pathForCompare = normalizePathCaseForComparison(normalizedPath); const rootForCompare = normalizeProjectPathForComparison(normalizedRoot); if (pathForCompare === rootForCompare) return ""; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index ad3e9806400..42307791262 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -132,6 +132,14 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + 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( 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)); } From cdb0c9317d7f6ea62a898c44118dc5eb7f535cfd Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Tue, 28 Jul 2026 21:40:10 -0400 Subject: [PATCH 3/4] fix(web): preserve filesystem root labels --- apps/web/src/filePathDisplay.test.ts | 21 +++++++++++++++++++++ apps/web/src/filePathDisplay.ts | 6 +++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index cace1e1aac4..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( diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 912de6845ce..b4be34aee6d 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -16,6 +16,7 @@ function canonicalizeWindowsDrivePath(path: string): string { } 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; } @@ -71,7 +72,10 @@ export function formatWorkspaceRelativePath( const workspaceLabelWithSeparator = `${workspaceLabelForCompare}/`; if (workspaceRelativePath === "") { - displayPath = workspaceLabel; + displayPath = + normalizedWorkspaceRoot === "/" || /^[A-Za-z]:\/$/.test(normalizedWorkspaceRoot) + ? normalizedWorkspaceRoot + : workspaceLabel; } else if (workspaceRelativePath !== null) { displayPath = `${workspaceLabel}/${workspaceRelativePath}`; } else if (!normalizedPath.startsWith("/")) { From 1165c05a45eb93b3e52cf4e175ff398b1922360b Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Tue, 28 Jul 2026 21:54:18 -0400 Subject: [PATCH 4/4] fix(web): preserve forward-slash UNC paths --- apps/web/src/filePathDisplay.ts | 22 +++++++++++++++++----- apps/web/src/markdown-links.test.ts | 12 ++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index b4be34aee6d..c9c36a89761 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,7 +1,6 @@ import { isWindowsAbsolutePath, normalizePathCaseForComparison, - normalizeProjectPathForComparison, normalizeProjectPathForDispatch, } from "@t3tools/shared/path"; @@ -15,6 +14,19 @@ function canonicalizeWindowsDrivePath(path: string): string { return /^\/[A-Za-z]:[\\/]/.test(path) ? path.slice(1) : path; } +function isForwardSlashUncPath(path: string): boolean { + return /^\/\/[^/]/.test(path); +} + +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("\\")); @@ -30,12 +42,12 @@ export function resolveWorkspaceRelativePath(path: string, workspaceRoot: string const normalizedRoot = canonicalizeWindowsDrivePath( normalizeProjectPathForDispatch(workspaceRoot), ); - const pathForCompare = normalizePathCaseForComparison(normalizedPath); - const rootForCompare = normalizeProjectPathForComparison(normalizedRoot); + const pathForCompare = normalizeWorkspacePathForComparison(normalizedPath); + const rootForCompare = normalizeWorkspacePathForComparison(normalizedRoot); if (pathForCompare === rootForCompare) return ""; - const separator = isWindowsAbsolutePath(normalizedRoot) ? "\\" : "/"; + const separator = isCaseInsensitiveWorkspacePath(normalizedRoot) ? "\\" : "/"; const rootPrefix = rootForCompare.endsWith(separator) ? rootForCompare : `${rootForCompare}${separator}`; @@ -64,7 +76,7 @@ export function formatWorkspaceRelativePath( const normalizedWorkspaceRoot = normalizePathSeparators(canonicalWorkspaceRoot); const workspaceLabel = basenameOfPath(normalizedWorkspaceRoot); const workspaceRelativePath = resolveWorkspaceRelativePath(path, workspaceRoot); - const caseInsensitive = isWindowsAbsolutePath(canonicalWorkspaceRoot); + const caseInsensitive = isCaseInsensitiveWorkspacePath(canonicalWorkspaceRoot); const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; const workspaceLabelForCompare = caseInsensitive ? workspaceLabel.toLowerCase() diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 42307791262..73876393c60 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -164,6 +164,18 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + 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(