|
| 1 | +// Owns the workspace archive feature (list + extract inside .zip/.tar archives). |
| 2 | +// `WorkspacePanels.tsx` mounts this for every path `isWorkspaceArchivePath` |
| 3 | +// accepts, and the runtime owns every hardening decision — this file renders |
| 4 | +// what `readWorkspaceArchive` is willing to name and never invents a path. |
| 5 | + |
| 6 | +import { useCallback, useEffect, useState, type ReactElement } from "react"; |
| 7 | +import type { |
| 8 | + WebuiArchiveEntry, |
| 9 | + WebuiWorkspaceArchiveExtractResult, |
| 10 | + WebuiWorkspaceArchiveListing, |
| 11 | +} from "../../server/port.js"; |
| 12 | + |
| 13 | +export type WorkspaceArchiveViewProps = { |
| 14 | + /** Absolute workspace root; every archive path below is relative to it. */ |
| 15 | + readonly workspaceDir: string; |
| 16 | + /** The archive's path inside the workspace, as the file tree reported it. */ |
| 17 | + readonly path: string; |
| 18 | + /** |
| 19 | + * Lists one level of the archive. The runtime owns every hardening |
| 20 | + * decision (entry ceiling, expansion ratio, path validation), so the view |
| 21 | + * renders only what this call is willing to name. `prefix` walks deeper |
| 22 | + * levels; absent, the listing is the archive root. |
| 23 | + */ |
| 24 | + readonly readWorkspaceArchive?: (request: { readonly workspaceDir: string; readonly path: string; readonly prefix?: string }) => Promise<WebuiWorkspaceArchiveListing>; |
| 25 | + /** Writes selected entries out of the archive into `destination`. */ |
| 26 | + readonly extractWorkspaceArchive?: (request: { readonly workspaceDir: string; readonly path: string; readonly destination: string; readonly prefix?: string }) => Promise<WebuiWorkspaceArchiveExtractResult>; |
| 27 | + /** |
| 28 | + * The directory extraction is allowed to write into. A browser cannot ask |
| 29 | + * for an arbitrary path, so the view picks one from the workspace and |
| 30 | + * hands it to the port rather than typing a raw destination. |
| 31 | + */ |
| 32 | + readonly destination?: string; |
| 33 | +}; |
| 34 | + |
| 35 | +type ArchiveLoadState = |
| 36 | + | { readonly status: "loading" } |
| 37 | + | { readonly status: "error"; readonly error: string } |
| 38 | + | { readonly status: "ready"; readonly listing: WebuiWorkspaceArchiveListing }; |
| 39 | + |
| 40 | +type ArchiveExtractState = |
| 41 | + | { readonly status: "idle" } |
| 42 | + | { readonly status: "running" } |
| 43 | + | { readonly status: "done"; readonly result: WebuiWorkspaceArchiveExtractResult } |
| 44 | + | { readonly status: "error"; readonly error: string }; |
| 45 | + |
| 46 | +/** |
| 47 | + * One directory level of an archive, with navigation and the extract control. |
| 48 | + * |
| 49 | + * Split out from the container so every state is reachable without a running |
| 50 | + * effect: `renderToStaticMarkup` does not run effects, and the parent would |
| 51 | + * otherwise leave loading, empty, error and truncated untestable. |
| 52 | + */ |
| 53 | +export function WorkspaceArchiveListing({ |
| 54 | + archivePath, |
| 55 | + prefix, |
| 56 | + destination, |
| 57 | + entries, |
| 58 | + totalEntries, |
| 59 | + truncated, |
| 60 | + extractState, |
| 61 | + onEnterDirectory, |
| 62 | + onGoUp, |
| 63 | + onExtract, |
| 64 | +}: { |
| 65 | + readonly archivePath: string; |
| 66 | + /** The directory being shown, relative to the archive root; "" is the root. */ |
| 67 | + readonly prefix: string; |
| 68 | + /** The workspace-relative directory an extraction would write into. */ |
| 69 | + readonly destination: string; |
| 70 | + readonly entries: readonly WebuiArchiveEntry[]; |
| 71 | + readonly totalEntries: number; |
| 72 | + readonly truncated: boolean; |
| 73 | + readonly extractState: ArchiveExtractState; |
| 74 | + readonly onEnterDirectory?: (name: string) => void; |
| 75 | + readonly onGoUp?: () => void; |
| 76 | + readonly onExtract?: () => void; |
| 77 | +}): ReactElement { |
| 78 | + return ( |
| 79 | + <div className="webui-workspace-archive-view" data-testid="workspace-archive-view"> |
| 80 | + <p className="webui-workspace-archive-path" data-testid="workspace-archive-path"> |
| 81 | + {`${archivePath}${prefix ? ` › ${prefix}` : ""}`} |
| 82 | + </p> |
| 83 | + {prefix |
| 84 | + ? <button type="button" data-testid="workspace-archive-up" onClick={onGoUp}>返回上级</button> |
| 85 | + : null} |
| 86 | + {truncated |
| 87 | + ? ( |
| 88 | + <p role="status" data-testid="workspace-archive-truncated"> |
| 89 | + {`压缩包共 ${totalEntries} 项,这里只显示了 ${entries.length} 项;其余条目未列出。`} |
| 90 | + </p> |
| 91 | + ) |
| 92 | + : null} |
| 93 | + {entries.length === 0 |
| 94 | + ? <p data-testid="workspace-archive-empty">该目录没有条目。</p> |
| 95 | + : ( |
| 96 | + <ul className="webui-workspace-archive-entries" data-testid="workspace-archive-entries"> |
| 97 | + {entries.map((entry) => ( |
| 98 | + <li |
| 99 | + key={entry.path} |
| 100 | + data-testid="workspace-archive-entry" |
| 101 | + data-archive-path={entry.path} |
| 102 | + data-directory={entry.isDirectory ? "true" : "false"} |
| 103 | + > |
| 104 | + {entry.isDirectory |
| 105 | + ? ( |
| 106 | + <button type="button" data-testid="workspace-archive-directory" onClick={() => onEnterDirectory?.(entry.name)}> |
| 107 | + {`${entry.name}(目录)`} |
| 108 | + </button> |
| 109 | + ) |
| 110 | + : ( |
| 111 | + <> |
| 112 | + <span data-testid="workspace-archive-file">{entry.name}</span> |
| 113 | + {entry.size === undefined |
| 114 | + ? null |
| 115 | + : <span data-testid="workspace-archive-size">{formatArchiveSize(entry.size)}</span>} |
| 116 | + </> |
| 117 | + )} |
| 118 | + </li> |
| 119 | + ))} |
| 120 | + </ul> |
| 121 | + )} |
| 122 | + <ArchiveExtractControls destination={destination} extractState={extractState} onExtract={onExtract} /> |
| 123 | + </div> |
| 124 | + ); |
| 125 | +} |
| 126 | + |
| 127 | +/** |
| 128 | + * The extract affordance, with its destination shown rather than typed. |
| 129 | + * |
| 130 | + * The destination is a workspace-relative path this component derived from the |
| 131 | + * archive (or a caller-supplied one); there is no free-text field anywhere in |
| 132 | + * this view, because a destination a browser can type is a path-traversal |
| 133 | + * endpoint rather than a feature. The server re-validates it regardless. |
| 134 | + */ |
| 135 | +function ArchiveExtractControls({ |
| 136 | + destination, |
| 137 | + extractState, |
| 138 | + onExtract, |
| 139 | +}: { |
| 140 | + readonly destination: string; |
| 141 | + readonly extractState: ArchiveExtractState; |
| 142 | + readonly onExtract?: () => void; |
| 143 | +}): ReactElement { |
| 144 | + return ( |
| 145 | + <div className="webui-workspace-archive-extract"> |
| 146 | + <p data-testid="workspace-archive-destination"> |
| 147 | + {destination ? `解压目标:${destination}` : "没有可用的解压目标。"} |
| 148 | + </p> |
| 149 | + <button |
| 150 | + type="button" |
| 151 | + data-testid="workspace-archive-extract" |
| 152 | + disabled={!destination || !onExtract || extractState.status === "running"} |
| 153 | + onClick={onExtract} |
| 154 | + > |
| 155 | + {extractState.status === "running" ? "正在解压…" : "解压当前目录"} |
| 156 | + </button> |
| 157 | + {extractState.status === "done" |
| 158 | + ? ( |
| 159 | + <p role="status" data-testid="workspace-archive-extract-result"> |
| 160 | + {`已解压 ${extractState.result.writtenFiles} 个文件到 ${extractState.result.destination}${extractState.result.truncated ? "(条目过多,仅解压了前一部分)" : ""}。`} |
| 161 | + </p> |
| 162 | + ) |
| 163 | + : null} |
| 164 | + {extractState.status === "error" |
| 165 | + ? <p role="alert" data-testid="workspace-archive-extract-error">{extractState.error}</p> |
| 166 | + : null} |
| 167 | + </div> |
| 168 | + ); |
| 169 | +} |
| 170 | + |
| 171 | +/** Shown for every state that is not a listing: unavailable, loading, failed. */ |
| 172 | +export function WorkspaceArchiveNotice({ |
| 173 | + reason, |
| 174 | + role = "status", |
| 175 | +}: { |
| 176 | + readonly reason: string; |
| 177 | + /** `alert` for a failure, `status` for a state that was never available. */ |
| 178 | + readonly role?: "status" | "alert"; |
| 179 | +}): ReactElement { |
| 180 | + return <p role={role} data-testid="workspace-archive-notice">{reason}</p>; |
| 181 | +} |
| 182 | + |
| 183 | +/** |
| 184 | + * The archive preview: one level at a time, with the extract control. |
| 185 | + * |
| 186 | + * Entering a directory re-lists with `prefix`; going up drops the last segment. |
| 187 | + * A `truncated` listing is passed straight through rather than smoothed over — |
| 188 | + * the runtime hit a ceiling and the user has to know that what they see is not |
| 189 | + * the whole archive. |
| 190 | + */ |
| 191 | +export function WorkspaceArchiveView(props: WorkspaceArchiveViewProps): ReactElement { |
| 192 | + const { workspaceDir, path, destination, readWorkspaceArchive, extractWorkspaceArchive } = props; |
| 193 | + const [prefix, setPrefix] = useState(""); |
| 194 | + const [load, setLoad] = useState<ArchiveLoadState>({ status: "loading" }); |
| 195 | + const [extract, setExtract] = useState<ArchiveExtractState>({ status: "idle" }); |
| 196 | + const target = destination ?? archiveDestinationFor(path); |
| 197 | + |
| 198 | + useEffect(() => { |
| 199 | + if (!readWorkspaceArchive) return; |
| 200 | + let stale = false; |
| 201 | + setLoad({ status: "loading" }); |
| 202 | + readWorkspaceArchive({ workspaceDir, path, ...(prefix ? { prefix } : {}) }) |
| 203 | + .then((listing) => { |
| 204 | + if (stale) return; |
| 205 | + setLoad({ status: "ready", listing }); |
| 206 | + }) |
| 207 | + .catch((error: unknown) => { |
| 208 | + if (stale) return; |
| 209 | + setLoad({ status: "error", error: describeError(error) }); |
| 210 | + }); |
| 211 | + return () => { |
| 212 | + stale = true; |
| 213 | + }; |
| 214 | + }, [workspaceDir, path, prefix, readWorkspaceArchive]); |
| 215 | + |
| 216 | + const enterDirectory = useCallback((name: string) => { |
| 217 | + setPrefix((current) => (current ? `${current}/${name}` : name)); |
| 218 | + }, []); |
| 219 | + const goUp = useCallback(() => { |
| 220 | + setPrefix((current) => { |
| 221 | + const slash = current.lastIndexOf("/"); |
| 222 | + return slash === -1 ? "" : current.slice(0, slash); |
| 223 | + }); |
| 224 | + }, []); |
| 225 | + const extractHere = useCallback(() => { |
| 226 | + if (!extractWorkspaceArchive || !target) return; |
| 227 | + setExtract({ status: "running" }); |
| 228 | + extractWorkspaceArchive({ workspaceDir, path, destination: target, ...(prefix ? { prefix } : {}) }) |
| 229 | + .then((result) => setExtract({ status: "done", result })) |
| 230 | + .catch((error: unknown) => setExtract({ status: "error", error: describeError(error) })); |
| 231 | + }, [extractWorkspaceArchive, workspaceDir, path, prefix, target]); |
| 232 | + |
| 233 | + if (!readWorkspaceArchive) { |
| 234 | + return <WorkspaceArchiveNotice reason="当前服务没有提供压缩包浏览能力。" />; |
| 235 | + } |
| 236 | + if (load.status === "loading") return <WorkspaceArchiveNotice reason="正在读取压缩包…" />; |
| 237 | + if (load.status === "error") return <WorkspaceArchiveNotice reason={load.error} role="alert" />; |
| 238 | + return ( |
| 239 | + <WorkspaceArchiveListing |
| 240 | + archivePath={load.listing.archivePath} |
| 241 | + prefix={prefix} |
| 242 | + destination={target} |
| 243 | + entries={load.listing.entries} |
| 244 | + totalEntries={load.listing.totalEntries} |
| 245 | + truncated={load.listing.truncated} |
| 246 | + extractState={extract} |
| 247 | + onEnterDirectory={enterDirectory} |
| 248 | + onGoUp={prefix ? goUp : undefined} |
| 249 | + onExtract={extractWorkspaceArchive && target ? extractHere : undefined} |
| 250 | + /> |
| 251 | + ); |
| 252 | +} |
| 253 | + |
| 254 | +/** |
| 255 | + * The destination an extraction uses when the caller supplies none: the |
| 256 | + * archive's own name without its extension, beside the archive. |
| 257 | + * |
| 258 | + * Derived rather than typed for the reason `destination`'s prop comment gives — |
| 259 | + * a browser cannot name an absolute path, and every path it could type would |
| 260 | + * need the server to distrust it. Returns "" for a name that is not a plain |
| 261 | + * relative path, which the view renders as "no usable destination" instead of |
| 262 | + * handing the server something it would have to reject. |
| 263 | + */ |
| 264 | +export function archiveDestinationFor(archivePath: string): string { |
| 265 | + const cleaned = archivePath.replace(/\\/gu, "/"); |
| 266 | + const slash = cleaned.lastIndexOf("/"); |
| 267 | + const directory = slash === -1 ? "" : cleaned.slice(0, slash); |
| 268 | + const name = cleaned.slice(slash + 1).replace(/\.(?:tar\.gz|tgz|zip|tar|gz|bz2|xz|7z|rar)$/iu, ""); |
| 269 | + const candidate = directory ? `${directory}/${name}` : name; |
| 270 | + if (!candidate) return ""; |
| 271 | + return candidate.split("/").some((segment) => segment === "" || segment === "." || segment === "..") ? "" : candidate; |
| 272 | +} |
| 273 | + |
| 274 | +/** Bytes as a short human string; absent stays absent rather than becoming 0. */ |
| 275 | +export function formatArchiveSize(size: number): string { |
| 276 | + if (!Number.isFinite(size) || size < 0) return ""; |
| 277 | + if (size < 1024) return `${size} B`; |
| 278 | + const units = ["KB", "MB", "GB", "TB"]; |
| 279 | + let value = size / 1024; |
| 280 | + let unit = 0; |
| 281 | + while (value >= 1024 && unit < units.length - 1) { |
| 282 | + value /= 1024; |
| 283 | + unit += 1; |
| 284 | + } |
| 285 | + return `${value.toFixed(1)} ${units[unit]}`; |
| 286 | +} |
| 287 | + |
| 288 | +function describeError(error: unknown): string { |
| 289 | + return error instanceof Error && error.message ? error.message : String(error); |
| 290 | +} |
0 commit comments