Skip to content

Commit 3dae9e6

Browse files
tyulyukovclaude
andcommitted
chore(web): port path normalization helpers (prereq for project grouping)
Extracts normalizeProjectPathForComparison and path regex helpers from upstream pingdotgg#2024 in isolation, so they're available as a dependency for the configurable project grouping port (pingdotgg#2055). Adds @marcode/shared/path subpath export. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 84b6b29 commit 3dae9e6

5 files changed

Lines changed: 436 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
appendBrowsePathSegment,
5+
canNavigateUp,
6+
getBrowseDirectoryPath,
7+
findProjectByPath,
8+
getBrowseLeafPathSegment,
9+
getBrowseParentPath,
10+
hasTrailingPathSeparator,
11+
inferProjectTitleFromPath,
12+
isExplicitRelativeProjectPath,
13+
isFilesystemBrowseQuery,
14+
normalizeProjectPathForComparison,
15+
normalizeProjectPathForDispatch,
16+
isUnsupportedWindowsProjectPath,
17+
resolveProjectPathForDispatch,
18+
} from "./projectPaths";
19+
20+
describe("projectPaths", () => {
21+
it("normalizes trailing separators for dispatch and comparison", () => {
22+
expect(normalizeProjectPathForDispatch(" /repo/app/ ")).toBe("/repo/app");
23+
expect(normalizeProjectPathForComparison("/repo/app/")).toBe("/repo/app");
24+
});
25+
26+
it("normalizes windows-style paths for comparison", () => {
27+
expect(normalizeProjectPathForComparison("C:/Work/Repo/")).toBe("c:\\work\\repo");
28+
expect(normalizeProjectPathForComparison("C:\\Work\\Repo\\")).toBe("c:\\work\\repo");
29+
});
30+
31+
it("finds existing projects even when the input formatting differs", () => {
32+
const existing = findProjectByPath(
33+
[
34+
{ id: "project-1", cwd: "/repo/app" },
35+
{ id: "project-2", cwd: "C:\\Work\\Repo" },
36+
],
37+
"C:/Work/Repo/",
38+
);
39+
40+
expect(existing?.id).toBe("project-2");
41+
});
42+
43+
it("infers project titles from normalized paths", () => {
44+
expect(inferProjectTitleFromPath("/repo/app/")).toBe("app");
45+
expect(inferProjectTitleFromPath("C:\\Work\\Repo\\")).toBe("Repo");
46+
expect(inferProjectTitleFromPath("/home/user\\project/")).toBe("user\\project");
47+
});
48+
49+
it("detects browse queries across supported path styles", () => {
50+
expect(isFilesystemBrowseQuery(".")).toBe(false);
51+
expect(isFilesystemBrowseQuery("..")).toBe(false);
52+
expect(isFilesystemBrowseQuery("./")).toBe(true);
53+
expect(isFilesystemBrowseQuery("../")).toBe(true);
54+
expect(isFilesystemBrowseQuery("~/projects")).toBe(true);
55+
expect(isFilesystemBrowseQuery("..\\docs")).toBe(true);
56+
expect(isFilesystemBrowseQuery("notes")).toBe(false);
57+
});
58+
59+
it("only treats windows-style paths as browse queries on windows", () => {
60+
expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "MacIntel")).toBe(false);
61+
expect(isFilesystemBrowseQuery("C:\\Work\\Repo\\", "Win32")).toBe(true);
62+
expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "MacIntel")).toBe(true);
63+
expect(isUnsupportedWindowsProjectPath("C:\\Work\\Repo\\", "Win32")).toBe(false);
64+
});
65+
66+
it("detects explicit relative project paths", () => {
67+
expect(isExplicitRelativeProjectPath(".")).toBe(true);
68+
expect(isExplicitRelativeProjectPath("..")).toBe(true);
69+
expect(isExplicitRelativeProjectPath("./docs")).toBe(true);
70+
expect(isExplicitRelativeProjectPath("..\\docs")).toBe(true);
71+
expect(isExplicitRelativeProjectPath("/repo/docs")).toBe(false);
72+
});
73+
74+
it("resolves explicit relative paths against the current project", () => {
75+
expect(resolveProjectPathForDispatch(".", "/repo/app")).toBe("/repo/app");
76+
expect(resolveProjectPathForDispatch("..", "/repo/app")).toBe("/repo");
77+
expect(resolveProjectPathForDispatch("./docs", "/repo/app")).toBe("/repo/app/docs");
78+
expect(resolveProjectPathForDispatch("../docs", "/repo/app")).toBe("/repo/docs");
79+
expect(resolveProjectPathForDispatch("./Repo", "C:\\Work")).toBe("C:\\Work\\Repo");
80+
expect(resolveProjectPathForDispatch("./docs", "/home/user\\project")).toBe(
81+
"/home/user\\project/docs",
82+
);
83+
});
84+
85+
it("navigates browse paths with matching separators", () => {
86+
expect(appendBrowsePathSegment("/repo/", "src")).toBe("/repo/src/");
87+
expect(appendBrowsePathSegment("C:\\Work\\", "Repo")).toBe("C:\\Work\\Repo\\");
88+
expect(appendBrowsePathSegment("/home/user\\project/", "docs")).toBe(
89+
"/home/user\\project/docs/",
90+
);
91+
expect(getBrowseParentPath("/repo/src/")).toBe("/repo/");
92+
expect(getBrowseParentPath("C:\\Work\\Repo\\")).toBe("C:\\Work\\");
93+
expect(getBrowseParentPath("\\\\server\\share\\")).toBeNull();
94+
expect(getBrowseParentPath("\\\\server\\share\\repo\\")).toBe("\\\\server\\share\\");
95+
expect(getBrowseParentPath("C:\\")).toBeNull();
96+
expect(getBrowseParentPath("/home/user\\project/docs/")).toBe("/home/user\\project/");
97+
});
98+
99+
it("detects browse path boundaries", () => {
100+
expect(hasTrailingPathSeparator("/repo/src/")).toBe(true);
101+
expect(hasTrailingPathSeparator("/repo/src")).toBe(false);
102+
expect(getBrowseDirectoryPath("/repo/src")).toBe("/repo/");
103+
expect(getBrowseDirectoryPath("/repo/src/")).toBe("/repo/src/");
104+
expect(getBrowseLeafPathSegment("/repo/src")).toBe("src");
105+
expect(getBrowseLeafPathSegment("C:\\Work\\Repo\\Docs")).toBe("Docs");
106+
expect(getBrowseDirectoryPath("/home/user\\project/docs")).toBe("/home/user\\project/");
107+
expect(getBrowseLeafPathSegment("/home/user\\project/docs")).toBe("docs");
108+
});
109+
110+
it("only allows browse-up after entering a directory", () => {
111+
expect(canNavigateUp("~/repo")).toBe(false);
112+
expect(canNavigateUp("~/a")).toBe(false);
113+
expect(canNavigateUp("~/repo/")).toBe(true);
114+
expect(canNavigateUp("\\\\server\\share\\")).toBe(false);
115+
expect(canNavigateUp("\\\\server\\share\\repo\\")).toBe(true);
116+
});
117+
});

apps/web/src/lib/projectPaths.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import {
2+
isExplicitRelativePath,
3+
isUncPath,
4+
isWindowsAbsolutePath,
5+
isWindowsDrivePath,
6+
} from "@marcode/shared/path";
7+
import { isWindowsPlatform } from "./utils";
8+
9+
function isRootPath(value: string): boolean {
10+
return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value);
11+
}
12+
13+
function getAbsolutePathKind(value: string): "unix" | "windows" | null {
14+
if (isWindowsDrivePath(value) || isUncPath(value)) {
15+
return "windows";
16+
}
17+
18+
if (value.startsWith("/")) {
19+
return "unix";
20+
}
21+
22+
return null;
23+
}
24+
25+
function trimTrailingPathSeparators(value: string): string {
26+
if (value.length === 0 || isRootPath(value)) {
27+
return value;
28+
}
29+
30+
const trimmed =
31+
getAbsolutePathKind(value) === "unix"
32+
? value.replace(/\/+$/g, "")
33+
: value.replace(/[\\/]+$/g, "");
34+
if (trimmed.length === 0) {
35+
return value;
36+
}
37+
38+
return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
39+
}
40+
41+
function preferredPathSeparator(value: string): "/" | "\\" {
42+
const absolutePathKind = getAbsolutePathKind(value);
43+
if (absolutePathKind === "windows") {
44+
return "\\";
45+
}
46+
if (absolutePathKind === "unix") {
47+
return "/";
48+
}
49+
50+
return value.includes("\\") ? "\\" : "/";
51+
}
52+
53+
export function hasTrailingPathSeparator(value: string): boolean {
54+
return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value);
55+
}
56+
57+
export { isExplicitRelativePath as isExplicitRelativeProjectPath };
58+
59+
function splitPathSegments(value: string, separator: "/" | "\\"): string[] {
60+
return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean);
61+
}
62+
63+
function getLastPathSeparatorIndex(value: string): number {
64+
if (getAbsolutePathKind(value) === "unix") {
65+
return value.lastIndexOf("/");
66+
}
67+
68+
return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
69+
}
70+
71+
function splitAbsolutePath(value: string): {
72+
root: string;
73+
separator: "/" | "\\";
74+
segments: string[];
75+
} | null {
76+
if (isWindowsDrivePath(value)) {
77+
const root = `${value.slice(0, 2)}\\`;
78+
const segments = splitPathSegments(value.slice(root.length), "\\");
79+
return { root, separator: "\\", segments };
80+
}
81+
if (isUncPath(value)) {
82+
const segments = splitPathSegments(value, "\\");
83+
const [server, share, ...rest] = segments;
84+
if (!server || !share) {
85+
return null;
86+
}
87+
return {
88+
root: `\\\\${server}\\${share}\\`,
89+
separator: "\\",
90+
segments: rest,
91+
};
92+
}
93+
if (value.startsWith("/")) {
94+
return {
95+
root: "/",
96+
separator: "/",
97+
segments: splitPathSegments(value.slice(1), "/"),
98+
};
99+
}
100+
return null;
101+
}
102+
103+
export function isFilesystemBrowseQuery(
104+
value: string,
105+
platform = typeof navigator === "undefined" ? "" : navigator.platform,
106+
): boolean {
107+
const allowWindowsPaths = isWindowsPlatform(platform);
108+
return (
109+
value.startsWith("./") ||
110+
value.startsWith("../") ||
111+
value.startsWith(".\\") ||
112+
value.startsWith("..\\") ||
113+
value.startsWith("/") ||
114+
value.startsWith("~/") ||
115+
(allowWindowsPaths && isWindowsAbsolutePath(value))
116+
);
117+
}
118+
119+
export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean {
120+
return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
121+
}
122+
123+
export function normalizeProjectPathForDispatch(value: string): string {
124+
return trimTrailingPathSeparators(value.trim());
125+
}
126+
127+
export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
128+
const trimmedValue = value.trim();
129+
if (!isExplicitRelativePath(trimmedValue) || !cwd) {
130+
return normalizeProjectPathForDispatch(trimmedValue);
131+
}
132+
133+
const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd));
134+
if (!absoluteBase) {
135+
return normalizeProjectPathForDispatch(trimmedValue);
136+
}
137+
138+
const nextSegments = [...absoluteBase.segments];
139+
for (const segment of trimmedValue.split(/[\\/]+/)) {
140+
if (segment.length === 0 || segment === ".") {
141+
continue;
142+
}
143+
if (segment === "..") {
144+
nextSegments.pop();
145+
continue;
146+
}
147+
nextSegments.push(segment);
148+
}
149+
150+
const joinedPath = nextSegments.join(absoluteBase.separator);
151+
if (joinedPath.length === 0) {
152+
return normalizeProjectPathForDispatch(absoluteBase.root);
153+
}
154+
155+
return normalizeProjectPathForDispatch(`${absoluteBase.root}${joinedPath}`);
156+
}
157+
158+
export function normalizeProjectPathForComparison(value: string): string {
159+
const normalized = normalizeProjectPathForDispatch(value);
160+
if (isWindowsDrivePath(normalized) || normalized.startsWith("\\\\")) {
161+
return normalized.replaceAll("/", "\\").toLowerCase();
162+
}
163+
return normalized;
164+
}
165+
166+
export function findProjectByPath<T extends { cwd: string }>(
167+
projects: ReadonlyArray<T>,
168+
candidatePath: string,
169+
): T | undefined {
170+
const normalizedCandidate = normalizeProjectPathForComparison(candidatePath);
171+
if (normalizedCandidate.length === 0) {
172+
return undefined;
173+
}
174+
175+
return projects.find(
176+
(project) => normalizeProjectPathForComparison(project.cwd) === normalizedCandidate,
177+
);
178+
}
179+
180+
export function inferProjectTitleFromPath(value: string): string {
181+
const normalized = normalizeProjectPathForDispatch(value);
182+
const absolutePath = splitAbsolutePath(normalized);
183+
if (absolutePath) {
184+
return absolutePath.segments.findLast(Boolean) ?? normalized;
185+
}
186+
187+
const segments = normalized.split(/[/\\]/);
188+
return segments.findLast(Boolean) ?? normalized;
189+
}
190+
191+
export function appendBrowsePathSegment(currentPath: string, segment: string): string {
192+
const separator = preferredPathSeparator(currentPath);
193+
return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;
194+
}
195+
196+
export function getBrowseLeafPathSegment(currentPath: string): string {
197+
const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
198+
return currentPath.slice(lastSeparatorIndex + 1);
199+
}
200+
201+
export function getBrowseDirectoryPath(currentPath: string): string {
202+
if (hasTrailingPathSeparator(currentPath)) {
203+
return currentPath;
204+
}
205+
206+
const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
207+
if (lastSeparatorIndex < 0) {
208+
return currentPath;
209+
}
210+
211+
return currentPath.slice(0, lastSeparatorIndex + 1);
212+
}
213+
214+
export function ensureBrowseDirectoryPath(currentPath: string): string {
215+
const trimmed = currentPath.trim();
216+
if (trimmed.length === 0) {
217+
return trimmed;
218+
}
219+
220+
if (hasTrailingPathSeparator(trimmed)) {
221+
return trimmed;
222+
}
223+
224+
return `${trimmed}${preferredPathSeparator(trimmed)}`;
225+
}
226+
227+
export function getBrowseParentPath(currentPath: string): string | null {
228+
const trimmed = trimTrailingPathSeparators(currentPath);
229+
const absolutePath = splitAbsolutePath(trimmed);
230+
if (absolutePath) {
231+
if (absolutePath.segments.length === 0) {
232+
return null;
233+
}
234+
235+
if (absolutePath.segments.length === 1) {
236+
return absolutePath.root;
237+
}
238+
239+
const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator);
240+
return `${absolutePath.root}${parentSegments}${absolutePath.separator}`;
241+
}
242+
243+
const separator = preferredPathSeparator(currentPath);
244+
const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed);
245+
246+
if (lastSeparatorIndex < 0) {
247+
return null;
248+
}
249+
250+
if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) {
251+
return `${trimmed.slice(0, 2)}${separator}`;
252+
}
253+
254+
return trimmed.slice(0, lastSeparatorIndex + 1);
255+
}
256+
257+
export function canNavigateUp(currentPath: string): boolean {
258+
return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null;
259+
}

packages/shared/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
"./qrCode": {
6060
"types": "./src/qrCode.ts",
6161
"import": "./src/qrCode.ts"
62+
},
63+
"./path": {
64+
"types": "./src/path.ts",
65+
"import": "./src/path.ts"
6266
}
6367
},
6468
"scripts": {

0 commit comments

Comments
 (0)