返回 DeepSeek-Reasonix
filePaths.ts
根目录 / desktop / frontend / src / lib / filePaths.ts
1 // filePaths — the one cross-platform path parser shared by chat file
2 // recognition, the presented-file link map, and the file resource menu.
3 //
4 // This module owns the platform-independent half of path identity: which text
5 // is a path, which separator a flavor uses, and when two spellings name the
6 // same file. It never decides whether a file exists or may be opened — the
7 // desktop host resolves every candidate on the source host, where the real
8 // filesystem (and its case rules) lives. Keeping this layer conservative
9 // matters: over-merging two spellings only costs a display group, while a
10 // wrong merge would point a click at the wrong file.
11
12 import { localPathFromHref } from "./localFileUrl";
13
14 /** Separator/root conventions of the filesystem that owns a path. */
15 export type PathFlavor = "windows" | "posix";
16
17 const WINDOWS_DRIVE_RE = /^[A-Za-z]:[\\/]/;
18
19 export function isWindowsDrivePath(path: string): boolean {
20 return WINDOWS_DRIVE_RE.test(path);
21 }
22
23 /** A drive root still counts when it carries no trailing separator (`C:`). */
24 export function isWindowsDriveRoot(path: string): boolean {
25 return path.length === 2 && /^[A-Za-z]:$/.test(path);
26 }
27
28 /** `\\server\share\...` and its forward-slash twin `//server/share/...`. */
29 export function isUncPath(path: string): boolean {
30 return /^[\\/]{2}[^\\/]/.test(path);
31 }
32
33 /**
34 * The flavor a path declares by its own shape. Relative paths return
35 * `undefined` because their separator rule belongs to the session's host, not
36 * to the text — `src\a.svg` is two segments on Windows and one odd filename on
37 * Linux. Callers that know the host pass its flavor explicitly.
38 */
39 export function declaredPathFlavor(path: string): PathFlavor | undefined {
40 if (isWindowsDrivePath(path) || isUncPath(path)) return "windows";
41 if (path.startsWith("/")) return "posix";
42 return undefined;
43 }
44
45 /**
46 * The local path a `file://` URL names, decoded exactly once, or `undefined`
47 * for anything else. It delegates to the canonical Markdown link parser so this
48 * module cannot drift from the allowlist that already rejects a query, a
49 * fragment, a device authority, and an alternate data stream. An ordinary path
50 * is never decoded: a literal `%20` in a filename is not an escape.
51 */
52 export function fileURLToPath(raw: string): string | undefined {
53 return localPathFromHref(raw.trim()) ?? undefined;
54 }
55
56 /** Separator set for a flavor; POSIX filenames may legally contain `\`. */
57 function separatorsFor(flavor: PathFlavor): string[] {
58 return flavor === "windows" ? ["/", "\\"] : ["/"];
59 }
60
61 function splitSegments(path: string, flavor: PathFlavor): string[] {
62 const separators = separatorsFor(flavor);
63 const parts: string[] = [];
64 let current = "";
65 for (const character of path) {
66 if (separators.includes(character)) {
67 parts.push(current);
68 current = "";
69 continue;
70 }
71 current += character;
72 }
73 parts.push(current);
74 return parts;
75 }
76
77 /**
78 * Segment count that `..` may never fold away: a UNC share root is two
79 * segments, a drive letter is one, and a POSIX root is none.
80 */
81 function rootFloor(resolved: PathFlavor, unc: boolean, drive: boolean): number {
82 if (unc) return 2;
83 if (resolved === "windows" && drive) return 1;
84 return 0;
85 }
86
87 /**
88 * Canonical grouping key for a path: separators unified, `.`/`..` folded, and
89 * the UNC or drive root preserved. Case is deliberately preserved — only the
90 * source host's filesystem may decide whether two casings are one file.
91 */
92 export function fileIdentity(path: string, flavor?: PathFlavor): string {
93 const raw = path.trim();
94 if (!raw) return "";
95 const fromURL = fileURLToPath(raw);
96 // A malformed `file:` URL has no identity; it must not fall back to being
97 // treated as an ordinary path.
98 if (fromURL === undefined && /^file:/i.test(raw)) return "";
99 const value = fromURL ?? raw;
100 // Keep an ambiguous relative path literal until the source host supplies its
101 // separator semantics, matching Harness' host-owned file identity boundary.
102 const resolved = flavor ?? declaredPathFlavor(value) ?? "posix";
103 const unc = resolved === "windows" && isUncPath(value);
104 const absolute = unc || (resolved === "windows" ? isWindowsDrivePath(value) : value.startsWith("/"));
105
106 const segments: string[] = [];
107 const floor = rootFloor(resolved, unc, absolute);
108 for (const part of splitSegments(value, resolved)) {
109 if (!part || part === ".") continue;
110 if (part === "..") {
111 if (absolute && segments.length <= floor) continue;
112 if (!absolute && segments.length === 0) { segments.push(part); continue; }
113 if (segments[segments.length - 1] === "..") { segments.push(part); continue; }
114 segments.pop();
115 continue;
116 }
117 segments.push(part);
118 }
119
120 // A drive letter is case-insensitive at the OS level, unlike the rest of the
121 // path, so it is the one segment that may be folded. It also replaces the
122 // leading separator: the identity of `C:\a` is `C:/a`, not `/C:/a`.
123 const drive = resolved === "windows" && segments.length > 0 && /^[A-Za-z]:$/.test(segments[0]);
124 if (drive) segments[0] = segments[0].toUpperCase();
125 const identity = segments.join("/");
126 if (unc) return `//${identity}`;
127 // `C:\` and `C:\..` are the drive root; a bare `C:` is drive-relative and
128 // stays distinct from it.
129 if (drive) return absolute && segments.length === 1 ? `${segments[0]}/` : identity;
130 return absolute ? `/${identity}` : identity;
131 }
132
133 export function pathBasename(path: string, flavor?: PathFlavor): string {
134 const value = fileURLToPath(path) ?? path.trim();
135 if (!value) return "";
136 const resolved = flavor ?? declaredPathFlavor(value) ?? "posix";
137 const parts = splitSegments(value, resolved).filter(Boolean);
138 return parts[parts.length - 1] ?? "";
139 }
140
141 export function pathExtension(path: string): string {
142 const name = pathBasename(path);
143 const dot = name.lastIndexOf(".");
144 if (dot <= 0 || dot === name.length - 1) return "";
145 return name.slice(dot + 1).toLowerCase();
146 }
147
148 export function isAbsolutePath(path: string, flavor?: PathFlavor): boolean {
149 const value = fileURLToPath(path) ?? path.trim();
150 if (!value) return false;
151 if (isWindowsDrivePath(value) || isWindowsDriveRoot(value) || isUncPath(value)) return true;
152 const resolved = flavor ?? declaredPathFlavor(value);
153 if (resolved === "windows") return isWindowsDrivePath(value);
154 return value.startsWith("/");
155 }
156
156 lines TYPESCRIPT