1import {
2 isWorkspaceAudioPreviewPath,
3 isWorkspaceVideoPreviewPath,
4} from "@t3tools/shared/filePreview";
5
6export interface FileBreadcrumb {
7 readonly label: string;
8 readonly path: string;
9 readonly kind: "project" | "directory" | "file";
10}
11
12function isWindowsAbsolutePath(value: string): boolean {
13 return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
14}
15
16/** A file route holding an absolute path shows a host file outside the workspace. */
17export function isAbsolutePath(value: string): boolean {
18 return value.startsWith("/") || isWindowsAbsolutePath(value);
19}
20
21/** Route segments that `normalizeRoutePath` joins back into the same path, root included. */
22export function fileRoutePathSegments(path: string): string[] {
23 const segments = path.split("/").filter((segment) => segment.length > 0);
24 return path.startsWith("/") ? ["", ...segments] : segments;
25}
26
27function isWindowsPathStyle(value: string): boolean {
28 return isWindowsAbsolutePath(value) || /^[A-Za-z]:\\/.test(value);
29}
30
31function joinPath(base: string, next: string, separator: "/" | "\\"): string {
32 const cleanBase = base.replace(/[\\/]+$/, "");
33 if (separator === "\\") {
34 return `${cleanBase}\\${next.replaceAll("/", "\\")}`;
35 }
36 return `${cleanBase}/${next.replace(/^\/+/, "")}`;
37}
38
39export function basename(path: string): string {
40 const parts = path.split(/[\\/]/).filter(Boolean);
41 return parts.at(-1) ?? path;
42}
43
44export function resolveWorkspaceFilePath(cwd: string, relativePath: string): string {
45 if (isAbsolutePath(relativePath)) {
46 return relativePath;
47 }
48
49 const separator: "/" | "\\" = isWindowsPathStyle(cwd) ? "\\" : "/";
50 return joinPath(cwd, relativePath, separator);
51}
52
53function normalizeRelativePath(value: string): string | null {
54 const segments: string[] = [];
55 for (const segment of value.replaceAll("\\", "/").split("/")) {
56 if (segment.length === 0 || segment === ".") {
57 continue;
58 }
59 if (segment === "..") {
60 if (segments.length === 0) {
61 return null;
62 }
63 segments.pop();
64 continue;
65 }
66 segments.push(segment);
67 }
68 return segments.length > 0 ? segments.join("/") : null;
69}
70
71export function resolveWorkspaceRelativeFilePath(
72 workspaceRoot: string | null | undefined,
73 targetPath: string,
74): string | null {
75 if (!isAbsolutePath(targetPath)) {
76 if (targetPath.startsWith("~/") || targetPath.startsWith("~\\")) {
77 return null;
78 }
79 return normalizeRelativePath(targetPath);
80 }
81 if (!workspaceRoot) {
82 return null;
83 }
84
85 const normalizedTarget = targetPath.replaceAll("\\", "/");
86 const normalizedRoot = workspaceRoot.replaceAll("\\", "/").replace(/\/+$/, "");
87 const caseInsensitive = isWindowsAbsolutePath(targetPath) || isWindowsAbsolutePath(workspaceRoot);
88 const comparableTarget = caseInsensitive ? normalizedTarget.toLowerCase() : normalizedTarget;
89 const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot;
90 if (!comparableTarget.startsWith(`${comparableRoot}/`)) {
91 return null;
92 }
93
94 const relativePath = normalizedTarget.slice(normalizedRoot.length + 1);
95 // `/repo/../x` starts with the root but escapes it.
96 if (relativePath.split("/").includes("..")) {
97 return null;
98 }
99 return normalizeRelativePath(relativePath);
100}
101
102export function isVideoPreviewFile(path: string): boolean {
103 return isWorkspaceVideoPreviewPath(path);
104}
105
106export function isAudioPreviewFile(path: string): boolean {
107 return isWorkspaceAudioPreviewPath(path.split(/[?#]/, 1)[0] ?? "");
108}
109
110export function isSvgImagePreviewFile(path: string): boolean {
111 return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? "");
112}
113
114export function isMarkdownPreviewFile(path: string): boolean {
115 return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
116}
117
118export function fileBreadcrumbs(projectName: string, relativePath: string): FileBreadcrumb[] {
119 const parts = relativePath.split("/").filter(Boolean);
120 return [
121 { label: projectName, path: "", kind: "project" },
122 ...parts.map((part, index) => ({
123 label: part,
124 path: parts.slice(0, index + 1).join("/"),
125 kind: index === parts.length - 1 ? ("file" as const) : ("directory" as const),
126 })),
127 ];
128}
129
130/**
131 * The location line under a file's name: `project · parent/dir`. A host file outside the
132 * workspace is not under the project, so it shows its directory alone.
133 */
134export function fileHeaderSubtitle(projectName: string, relativePath: string): string {
135 const parentDir = relativePath.slice(
136 0,
137 Math.max(relativePath.lastIndexOf("/"), relativePath.lastIndexOf("\\"), 0),
138 );
139 return isAbsolutePath(relativePath)
140 ? parentDir
141 : [projectName, parentDir].filter(Boolean).join(" · ");
142}