Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions apps/web/src/filePathDisplay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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");
});
});
78 changes: 62 additions & 16 deletions apps/web/src/filePathDisplay.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,34 @@
import {
isWindowsAbsolutePath,
normalizePathCaseForComparison,
normalizeProjectPathForDispatch,
} from "@t3tools/shared/path";

import { splitPathAndPosition } from "./terminal-links";

function normalizePathSeparators(path: string): string {
return path.replaceAll("\\", "/");
}

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;
}
Expand All @@ -21,29 +37,59 @@ function stripRelativePrefixes(path: string): string {
return path.replace(/^\.\/+/, "").replace(/^\/+/, "");
}

export function resolveWorkspaceRelativePath(path: string, workspaceRoot: string): string | null {
Comment thread
AksharP5 marked this conversation as resolved.
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}`;
Comment thread
AksharP5 marked this conversation as resolved.
} else if (!normalizedPath.startsWith("/")) {
const relativePath = stripRelativePrefixes(normalizedPath);
displayPath = pathForCompare.startsWith(workspaceLabelWithSeparator)
Expand Down
68 changes: 68 additions & 0 deletions apps/web/src/markdown-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 2 additions & 15 deletions apps/web/src/markdown-links.ts
Original file line number Diff line number Diff line change
@@ -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]:[\\/]/;
Expand Down Expand Up @@ -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,
Expand All @@ -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 } : {}),
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isUncPath,
isWindowsAbsolutePath,
isWindowsDrivePath,
normalizePathCaseForComparison,
} from "./path.ts";

describe("path helpers", () => {
Expand Down Expand Up @@ -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 ");
});
});
13 changes: 8 additions & 5 deletions packages/shared/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Loading