Skip to content
Source
Sign inSign up

path.ts

168 lines6.3 KB

1export function isWindowsDrivePath(value: string): boolean {
2 return /^[a-zA-Z]:([/\\]|$)/.test(value);
3}
4​
5export function isUncPath(value: string): boolean {
6 return value.startsWith("\\\\");
7}
8​
9/**
10 * Windows treats `/` and `\` alike, so any two leading separators name a UNC
11 * or device path. Touching one makes Windows sign in to that host with the
12 * user's NTLM credentials, so agent-authored paths must not reach one.
13 */
14export function isNetworkPath(value: string): boolean {
15 return /^[\\/]{2}/.test(value);
17​
18export function isWindowsAbsolutePath(value: string): boolean {
19 return isUncPath(value) || isWindowsDrivePath(value);
21​
22export function isExplicitRelativePath(value: string): boolean {
23 return (
24 value === "." ||
25 value === ".." ||
26 value.startsWith("./") ||
27 value.startsWith("../") ||
28 value.startsWith(".\\") ||
29 value.startsWith("..\\")
30 );
32​
33function isRootPath(value: string): boolean {
34 // Bare C: means the current directory on C, not its root; require the drive separator.
35 return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]$/.test(value);
37​
38function trimTrailingPathSeparators(value: string): string {
39 if (value.length === 0 || isRootPath(value)) {
40 return value;
41 }
42 const trimmed = value.startsWith("/")
43 ? value.replace(/\/+$/g, "")
44 : value.replace(/[\\/]+$/g, "");
45 if (trimmed.length === 0) {
46 return value;
47 }
48 return /^[a-zA-Z]:$/.test(trimmed) ? `${trimmed}\\` : trimmed;
50​
51export function normalizeProjectPathForDispatch(value: string): string {
52 return trimTrailingPathSeparators(value.trim());
54​
55export function normalizeProjectPathForComparison(value: string): string {
56 const normalized = normalizeProjectPathForDispatch(value);
57 if (isWindowsDrivePath(normalized) || isUncPath(normalized)) {
58 return normalized.replaceAll("/", "\\").toLowerCase();
59 }
60 return normalized;
62​
63// Windows refuses these as file names, with or without an extension.
64const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/;
65​
66export function newProjectFolderName(name: string): string {
67 const slug = name
68 .normalize("NFKD")
69 .replace(/[̀-ͯ]/g, "")
70 .toLowerCase()
71 .replace(/[^a-z0-9]+/g, "-")
72 .replace(/^-+/, "")
73 .slice(0, 64)
74 .replace(/-+$/, "");
75 if (slug.length === 0) return "project";
76 return WINDOWS_RESERVED_NAME.test(slug) ? `${slug}-project` : slug;
78​
79const SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN = /^\/[A-Za-z]:[\\/]/;
80​
81/** Browser URL parsers write `C:/foo` as `/C:/foo` for file URLs. */
82export function stripSlashPrefixedWindowsDrive(path: string): string {
83 return SLASH_PREFIXED_WINDOWS_DRIVE_PATTERN.test(path) ? path.slice(1) : path;
85​
86export function fileBasename(path: string): string {
87 const trimmed = path.replace(/[/\\]+$/, "");
88 if (trimmed.length === 0) return path;
89 const separatorIndex = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
90 return separatorIndex >= 0 ? trimmed.slice(separatorIndex + 1) : trimmed;
92​
93export function workspaceRelativeFilePath(
94 path: string,
95 workspaceRoot: string | null | undefined,
96): string | null {
97 if (!workspaceRoot) return null;
98 const caseInsensitive =
99 isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)) ||
100 (isNetworkPath(workspaceRoot) && isUncPath(path));
101 const normalizedPath = caseInsensitive
102 ? stripSlashPrefixedWindowsDrive(path.replaceAll("\\", "/"))
103 : path;
104 const normalizedRoot = (
105 caseInsensitive
106 ? stripSlashPrefixedWindowsDrive(workspaceRoot.replaceAll("\\", "/"))
107 : workspaceRoot
108 ).replace(/\/+$/, "");
109 const pathForCompare = caseInsensitive ? normalizedPath.toLowerCase() : normalizedPath;
110 const rootForCompare = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
111 if (pathForCompare.replace(/\/+$/, "") === rootForCompare) return ".";
112 if (!pathForCompare.startsWith(`${rootForCompare}/`)) return null;
113 const relativePath = normalizedPath.slice(normalizedRoot.length + 1);
114 // `/repo/../x` starts with the root but escapes it; the server rejects that as a workspace path.
115 let depth = 0;
116 for (const segment of relativePath.split("/")) {
117 if (segment === "..") depth -= 1;
118 else if (segment.length > 0 && segment !== ".") depth += 1;
119 if (depth < 0) return null;
120 }
121 return relativePath;
123​
124export function isAbsolutePath(value: string): boolean {
125 return value.startsWith("/") || isWindowsAbsolutePath(value);
127​
128/** Home-relative host paths are expanded by the destination server. */
129export function isHostPath(value: string): boolean {
130 return isAbsolutePath(value) || value.startsWith("~/");
132​
133// Finder and Explorer copy a path ready for a shell: `'/a b'`, `/a\'s\ b`, or `"C:\a b"`.
134const QUOTED_PATH_AT_END = /(?:'((?:[^']|'\\'')+)'|"([^"]+)")\s*$/;
135const SHELL_ESCAPE = /\\([\s'"\\()[\]{}<>|&;$`!*?#~^=])/g;
136​
137/**
138 * What a folder box holds after a change. A full path pasted into it replaces the box, even after
139 * the text already there, because `~/'/a b'` would create that nested folder.
140 */
141export function resolvePastedPath(previous: string, next: string): string {
142 const isTyping = next.startsWith(previous)
143 ? next.length === previous.length + 1
144 : previous.startsWith(next);
145 if (isTyping) return next;
146​
147 const match = QUOTED_PATH_AT_END.exec(next);
148 // Text before the quote that wasn't in the box is a search, not a folder to paste after.
149 if (match && previous.startsWith(next.slice(0, match.index).trimEnd())) {
150 const quoted = match[1]?.replaceAll("'\\''", "'") ?? match[2] ?? "";
151 if (isHostPath(quoted)) return quoted;
152 }
153​
154 // Without a trailing separator, `/a` then `/a/b` is a longer path, not one pasted after a folder.
155 const afterFolder = /[/\\]$/.test(previous) && next.startsWith(previous);
156 const path = afterFolder ? next.slice(previous.length) : next;
157 if (!isHostPath(path)) return next;
158 // `\` separates folders in Windows paths, so it only escapes in `/` and `~/` paths.
159 return isWindowsAbsolutePath(path) ? path : path.replace(SHELL_ESCAPE, "$1");
161​
162export function resolveWorkspaceFilePath(path: string, workspaceRoot: string): string {
163 if (isAbsolutePath(path) || path.startsWith("~/") || path.startsWith("~\\")) return path;
164 const base = workspaceRoot.replace(/[/\\]+$/, "");
165 return isWindowsAbsolutePath(workspaceRoot)
166 ? `${base}\\${path.replaceAll("/", "\\")}`
167 : `${base}/${path}`;