1import {
2 isExplicitRelativePath,
3 isUncPath,
4 isWindowsAbsolutePath,
5 isWindowsDrivePath,
6 normalizeProjectPathForComparison,
7 normalizeProjectPathForDispatch,
8} from "@t3tools/shared/path";
9
10export { normalizeProjectPathForComparison, normalizeProjectPathForDispatch };
11
12export const isWindowsPlatform = (platform: string): boolean => {
13 return /^win(dows)?/i.test(platform);
14};
15
16function getAbsolutePathKind(value: string): "unix" | "windows" | null {
17 if (isWindowsDrivePath(value) || isUncPath(value)) {
18 return "windows";
19 }
20 if (value.startsWith("/")) {
21 return "unix";
22 }
23 return null;
24}
25
26function preferredPathSeparator(value: string): "/" | "\\" {
27 const absolutePathKind = getAbsolutePathKind(value);
28 if (absolutePathKind === "windows") return "\\";
29 if (absolutePathKind === "unix") return "/";
30 return value.includes("\\") ? "\\" : "/";
31}
32
33export function hasTrailingPathSeparator(value: string): boolean {
34 return (getAbsolutePathKind(value) === "unix" ? /\/$/ : /[\\/]$/).test(value);
35}
36
37export { isExplicitRelativePath as isExplicitRelativeProjectPath };
38
39function splitPathSegments(value: string, separator: "/" | "\\"): string[] {
40 return value.split(separator === "/" ? /\/+/ : /[\\/]+/).filter(Boolean);
41}
42
43function getLastPathSeparatorIndex(value: string): number {
44 if (getAbsolutePathKind(value) === "unix") {
45 return value.lastIndexOf("/");
46 }
47 return Math.max(value.lastIndexOf("/"), value.lastIndexOf("\\"));
48}
49
50function splitAbsolutePath(value: string): {
51 root: string;
52 separator: "/" | "\\";
53 segments: string[];
54} | null {
55 if (isWindowsDrivePath(value)) {
56 const root = `${value.slice(0, 2)}\\`;
57 const segments = splitPathSegments(value.slice(root.length), "\\");
58 return { root, separator: "\\", segments };
59 }
60 if (isUncPath(value)) {
61 const segments = splitPathSegments(value, "\\");
62 const [server, share, ...rest] = segments;
63 if (!server || !share) return null;
64 return {
65 root: `\\\\${server}\\${share}\\`,
66 separator: "\\",
67 segments: rest,
68 };
69 }
70 if (value.startsWith("/")) {
71 return {
72 root: "/",
73 separator: "/",
74 segments: splitPathSegments(value.slice(1), "/"),
75 };
76 }
77 return null;
78}
79
80export function isFilesystemBrowseQuery(value: string, platform = ""): boolean {
81 const allowWindowsPaths = isWindowsPlatform(platform);
82 return (
83 value.startsWith("./") ||
84 value.startsWith("../") ||
85 value.startsWith(".\\") ||
86 value.startsWith("..\\") ||
87 value.startsWith("/") ||
88 value.startsWith("~/") ||
89 (allowWindowsPaths && isWindowsAbsolutePath(value))
90 );
91}
92
93export function isUnsupportedWindowsProjectPath(value: string, platform: string): boolean {
94 return isWindowsAbsolutePath(value) && !isWindowsPlatform(platform);
95}
96
97export function resolveProjectPathForDispatch(value: string, cwd?: string | null): string {
98 const trimmedValue = value.trim();
99 if (!isExplicitRelativePath(trimmedValue) || !cwd) {
100 return normalizeProjectPathForDispatch(trimmedValue);
101 }
102
103 const absoluteBase = splitAbsolutePath(normalizeProjectPathForDispatch(cwd));
104 if (!absoluteBase) {
105 return normalizeProjectPathForDispatch(trimmedValue);
106 }
107
108 const nextSegments = [...absoluteBase.segments];
109 for (const segment of trimmedValue.split(/[\\/]+/)) {
110 if (segment.length === 0 || segment === ".") continue;
111 if (segment === "..") {
112 nextSegments.pop();
113 continue;
114 }
115 nextSegments.push(segment);
116 }
117
118 const joinedPath = nextSegments.join(absoluteBase.separator);
119 return normalizeProjectPathForDispatch(
120 joinedPath.length === 0 ? absoluteBase.root : `${absoluteBase.root}${joinedPath}`,
121 );
122}
123
124export function findProjectByPath<T extends { workspaceRoot?: string; cwd?: string }>(
125 projects: ReadonlyArray<T>,
126 candidatePath: string,
127): T | undefined {
128 const normalizedCandidate = normalizeProjectPathForComparison(candidatePath);
129 if (normalizedCandidate.length === 0) {
130 return undefined;
131 }
132 return projects.find((project) => {
133 const cwd = project.workspaceRoot ?? project.cwd;
134 return cwd ? normalizeProjectPathForComparison(cwd) === normalizedCandidate : false;
135 });
136}
137
138/** Whether a project is its environment's Scratch project (`ServerConfig.scratchWorkspaceRoot`). */
139export function isScratchProject(
140 project: { readonly workspaceRoot: string },
141 scratchWorkspaceRoot: string | null | undefined,
142): boolean {
143 return (
144 scratchWorkspaceRoot != null && findProjectByPath([project], scratchWorkspaceRoot) !== undefined
145 );
146}
147
148export function inferProjectTitleFromPath(value: string): string {
149 const normalized = normalizeProjectPathForDispatch(value);
150 const absolutePath = splitAbsolutePath(normalized);
151 if (absolutePath) {
152 return absolutePath.segments.findLast(Boolean) ?? normalized;
153 }
154 const segments = normalized.split(/[/\\]/);
155 return segments.findLast(Boolean) ?? normalized;
156}
157
158export function appendBrowsePathSegment(currentPath: string, segment: string): string {
159 const separator = preferredPathSeparator(currentPath);
160 return `${getBrowseDirectoryPath(currentPath)}${segment}${separator}`;
161}
162
163export function getBrowseLeafPathSegment(currentPath: string): string {
164 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
165 return currentPath.slice(lastSeparatorIndex + 1);
166}
167
168export function getBrowseDirectoryPath(currentPath: string): string {
169 if (hasTrailingPathSeparator(currentPath)) {
170 return currentPath;
171 }
172 const lastSeparatorIndex = getLastPathSeparatorIndex(currentPath);
173 return lastSeparatorIndex < 0 ? currentPath : currentPath.slice(0, lastSeparatorIndex + 1);
174}
175
176export function ensureBrowseDirectoryPath(currentPath: string): string {
177 const trimmed = currentPath.trim();
178 if (trimmed.length === 0 || hasTrailingPathSeparator(trimmed)) {
179 return trimmed;
180 }
181 return `${trimmed}${preferredPathSeparator(trimmed)}`;
182}
183
184export function getBrowseParentPath(currentPath: string): string | null {
185 const trimmed = normalizeProjectPathForDispatch(currentPath);
186 const absolutePath = splitAbsolutePath(trimmed);
187 if (absolutePath) {
188 if (absolutePath.segments.length === 0) return null;
189 if (absolutePath.segments.length === 1) return absolutePath.root;
190 const parentSegments = absolutePath.segments.slice(0, -1).join(absolutePath.separator);
191 return `${absolutePath.root}${parentSegments}${absolutePath.separator}`;
192 }
193
194 const separator = preferredPathSeparator(currentPath);
195 const lastSeparatorIndex = getLastPathSeparatorIndex(trimmed);
196 if (lastSeparatorIndex < 0) return null;
197 if (lastSeparatorIndex === 2 && /^[a-zA-Z]:/.test(trimmed)) {
198 return `${trimmed.slice(0, 2)}${separator}`;
199 }
200 return trimmed.slice(0, lastSeparatorIndex + 1);
201}
202
203export function canNavigateUp(currentPath: string): boolean {
204 return hasTrailingPathSeparator(currentPath) && getBrowseParentPath(currentPath) !== null;
205}
206
207export * from "./projectCommands.ts";
208export * from "./projectEntities.ts";