From 9858ccc6ae1a58ffcafd201e89b655931245b9d2 Mon Sep 17 00:00:00 2001 From: Michel-Liao Date: Wed, 2 Sep 2026 22:00:59 -0400 Subject: [PATCH 1/4] fix(web): respect case in POSIX file links --- apps/web/src/filePathDisplay.ts | 18 ++++++++++++++--- apps/web/src/markdown-links.test.ts | 21 ++++++++++++++++++++ packages/client-runtime/src/markdownLinks.ts | 5 ++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index fc197a4092b5..a5686b2a8aa5 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -9,6 +9,15 @@ function normalizePathSeparators(path: string): string { return path.replaceAll("\\", "/"); } +function canonicalizeWindowsDrivePath(path: string): string { + return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; +} + +export function isWindowsFilesystemPath(path: string): boolean { + const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); + return /^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith("//"); +} + function trimTrailingPathSeparators(path: string): string { return path.replace(/[\\/]+$/, ""); } @@ -30,10 +39,13 @@ export function formatWorkspaceRelativePath( normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), ); const workspaceLabel = fileBasename(normalizedWorkspaceRoot); - const pathForCompare = normalizedPath.toLowerCase(); - const workspaceForCompare = normalizedWorkspaceRoot.toLowerCase(); + const caseInsensitive = isWindowsFilesystemPath(normalizedWorkspaceRoot); + const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const workspaceForCompare = caseInsensitive + ? normalizedWorkspaceRoot.toLowerCase() + : normalizedWorkspaceRoot; const workspaceWithSeparator = `${workspaceForCompare}/`; - const workspaceLabelWithSeparator = `${workspaceLabel.toLowerCase()}/`; + const workspaceLabelWithSeparator = `${caseInsensitive ? workspaceLabel.toLowerCase() : workspaceLabel}/`; if (pathForCompare === workspaceForCompare) { displayPath = workspaceLabel; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index e3b1cc5b2249..b8275b03f79c 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -254,6 +254,27 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("does not classify a case-distinct POSIX sibling as a workspace file", () => { + 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("keeps Windows workspace comparisons case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta("C:/Users/MIKE/Project/src/main.ts", "c:/users/mike/project"), + ).toMatchObject({ + displayPath: "project/src/main.ts", + workspaceRelativePath: "src/main.ts", + }); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 81cce1fd7eee..2e455655d005 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -342,6 +342,9 @@ export function workspaceRelativeFilePath( const normalizedRoot = stripSlashPrefixedWindowsDrive( workspaceRoot.replaceAll("\\", "/"), ).replace(/\/+$/, ""); - if (!normalizedPath.toLowerCase().startsWith(`${normalizedRoot.toLowerCase()}/`)) return null; + const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); + const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; + const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; + if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null; return normalizedPath.slice(normalizedRoot.length + 1); } From 1ef128cb7897d7757c6ae48d3fe3fdb9f7dbb389 Mon Sep 17 00:00:00 2001 From: Michel-Liao Date: Wed, 2 Sep 2026 22:09:00 -0400 Subject: [PATCH 2/4] fix(web): preserve Windows drive-root folding --- apps/web/src/filePathDisplay.ts | 2 +- apps/web/src/markdown-links.test.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index a5686b2a8aa5..4b0dbd661ef0 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -15,7 +15,7 @@ function canonicalizeWindowsDrivePath(path: string): string { export function isWindowsFilesystemPath(path: string): boolean { const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); - return /^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith("//"); + return /^[A-Za-z]:(?:\/|$)/.test(normalizedPath) || normalizedPath.startsWith("//"); } function trimTrailingPathSeparators(path: string): string { diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index b8275b03f79c..a110de9c0e77 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -275,6 +275,13 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("keeps drive-root workspace comparisons case-insensitive", () => { + expect(resolveMarkdownFileLinkMeta("C:/Users/MIKE/project.ts", "c:/")).toMatchObject({ + displayPath: "c:/Users/MIKE/project.ts", + workspaceRelativePath: "Users/MIKE/project.ts", + }); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( From 7a885a57fee9c1bb632c946272dde3f68d743f3a Mon Sep 17 00:00:00 2001 From: Michel-Liao Date: Wed, 2 Sep 2026 22:20:45 -0400 Subject: [PATCH 3/4] fix(web): distinguish UNC from POSIX paths --- apps/web/src/filePathDisplay.test.ts | 6 ++++++ apps/web/src/filePathDisplay.ts | 4 ++-- apps/web/src/markdown-links.test.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web/src/filePathDisplay.test.ts b/apps/web/src/filePathDisplay.test.ts index ecceea09ca1e..4c49133a3138 100644 --- a/apps/web/src/filePathDisplay.test.ts +++ b/apps/web/src/filePathDisplay.test.ts @@ -38,4 +38,10 @@ describe("formatWorkspaceRelativePath", () => { ), ).toBe("t3code/apps/web/src/session-logic.ts:501:9"); }); + + it("keeps double-slash POSIX paths case-sensitive", () => { + expect(formatWorkspaceRelativePath("//tmp/project/probe.txt", "//tmp/Project")).toBe( + "//tmp/project/probe.txt", + ); + }); }); diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 4b0dbd661ef0..5b26b48a5b30 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -15,7 +15,7 @@ function canonicalizeWindowsDrivePath(path: string): string { export function isWindowsFilesystemPath(path: string): boolean { const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); - return /^[A-Za-z]:(?:\/|$)/.test(normalizedPath) || normalizedPath.startsWith("//"); + return /^[A-Za-z]:(?:\/|$)/.test(normalizedPath) || path.startsWith("\\\\"); } function trimTrailingPathSeparators(path: string): string { @@ -39,7 +39,7 @@ export function formatWorkspaceRelativePath( normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), ); const workspaceLabel = fileBasename(normalizedWorkspaceRoot); - const caseInsensitive = isWindowsFilesystemPath(normalizedWorkspaceRoot); + const caseInsensitive = isWindowsFilesystemPath(workspaceRoot); const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; const workspaceForCompare = caseInsensitive ? normalizedWorkspaceRoot.toLowerCase() diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index a110de9c0e77..84620cf5810b 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -282,6 +282,18 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("keeps backslash UNC workspace comparisons case-insensitive", () => { + expect( + resolveMarkdownFileLinkMeta( + "\\\\server\\share\\PROJECT\\src\\main.ts", + "\\\\Server\\Share\\Project", + ), + ).toMatchObject({ + displayPath: "Project/src/main.ts", + workspaceRelativePath: "src/main.ts", + }); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( From b9228e12794bee58da92081214812c8d19a5559d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:38:54 -0700 Subject: [PATCH 4/4] fix(client-runtime): keep POSIX file link containment case-sensitive --- apps/web/src/filePathDisplay.ts | 12 ++---------- apps/web/src/markdown-links.test.ts | 15 +++++++++++++++ packages/client-runtime/src/markdownLinks.test.ts | 12 +++++++++++- 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 5b26b48a5b30..36f5873cf8e0 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -4,20 +4,12 @@ import { splitFilePathPosition, stripSlashPrefixedWindowsDrive, } from "@t3tools/client-runtime/markdown-links"; +import { isWindowsAbsolutePath } from "@t3tools/shared/path"; function normalizePathSeparators(path: string): string { return path.replaceAll("\\", "/"); } -function canonicalizeWindowsDrivePath(path: string): string { - return /^\/[A-Za-z]:\//.test(path) ? path.slice(1) : path; -} - -export function isWindowsFilesystemPath(path: string): boolean { - const normalizedPath = canonicalizeWindowsDrivePath(normalizePathSeparators(path)); - return /^[A-Za-z]:(?:\/|$)/.test(normalizedPath) || path.startsWith("\\\\"); -} - function trimTrailingPathSeparators(path: string): string { return path.replace(/[\\/]+$/, ""); } @@ -39,7 +31,7 @@ export function formatWorkspaceRelativePath( normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), ); const workspaceLabel = fileBasename(normalizedWorkspaceRoot); - const caseInsensitive = isWindowsFilesystemPath(workspaceRoot); + const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath; const workspaceForCompare = caseInsensitive ? normalizedWorkspaceRoot.toLowerCase() diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 84620cf5810b..5421c0d8781d 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -294,6 +294,21 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it.each([ + ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], + ["C:/Users/MIKE/file.ts", "c:/", "Users/MIKE/file.ts"], + ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], + ["/tmp/repo/file.ts%20", "/tmp/repo", "file.ts "], + ])("preserves the preview target for %s in workspace %s", (href, cwd, workspaceRelativePath) => { + expect(resolveMarkdownFileLinkMeta(href, cwd)).toMatchObject({ workspaceRelativePath }); + }); + + it("keeps an encoded final space in the absolute target", () => { + expect(resolveMarkdownFileLinkTarget("/tmp/repo/file.ts%20", "/tmp/repo")).toBe( + "/tmp/repo/file.ts ", + ); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index cf8f9ae2d16e..42cd7a35e473 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -160,7 +160,17 @@ describe("workspaceRelativeFilePath", () => { ["/repo/project/src/main.ts", "/repo/project/", "src/main.ts"], ["C:\\Users\\mike\\t3code\\apps\\web\\a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], ["/C:/Users/mike/t3code/apps/web/a.ts", "C:/Users/mike/t3code", "apps/web/a.ts"], - ["/Repo/Project/src/main.ts", "/repo/project", "src/main.ts"], + ["/Repo/Project/src/main.ts", "/repo/project", null], + ["/tmp/case/project/probe.txt", "/tmp/case/Project", null], + ["//tmp/case/project/probe.txt", "//tmp/case/Project", null], + ["/tmp/case/Project/probe.txt", "/tmp/case/Project", "probe.txt"], + ["C:/USERS/mike/t3code/main.ts", "c:/users/MIKE/t3code", "main.ts"], + ["/C:/USERS/mike/t3code/main.ts", "/c:/users/MIKE/t3code", "main.ts"], + ["\\\\server\\share\\PROJECT\\main.ts", "\\\\Server\\Share\\Project", "main.ts"], + ["/tmp/repo/file.ts", "/", "tmp/repo/file.ts"], + ["C:/Users/MIKE/main.ts", "c:/", "Users/MIKE/main.ts"], + ["\\\\server\\SHARE\\file.ts", "\\\\Server\\Share\\", "file.ts"], + ["/tmp/repo/file.ts ", "/tmp/repo", "file.ts "], ["/tmp/report.ts", "/repo/project", null], ["/repo/project-two/a.ts", "/repo/project", null], ["/repo/project/a.ts", undefined, null],