Skip to content

Commit 17747ad

Browse files
authored
Merge pull request #9 from mcode-webui/feat/webui-workspace-panel-f
feat(webui): module F — the workspace panel, six capabilities
2 parents 7622643 + 065fba7 commit 17747ad

26 files changed

Lines changed: 5027 additions & 91 deletions

‎packages/webui/src/client/components/WebuiClientFoundationApp.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1433,7 +1433,7 @@ export function WebuiClientFoundationApp(
14331433
</aside>
14341434
</div>
14351435
) : null}
1436-
{!homeMode && workspacePanel.open ? <WebuiWorkspacePanel state={workspacePanel} dispatch={dispatchWorkspacePanel} sessionId={selectedSessionId} workspaceDir={selectedSession?.workspaceDir} listWorkspaceFileTree={transport?.listWorkspaceFileTree} readWorkspaceFile={transport?.readWorkspaceFile} readCanvas={transport?.readCanvas} applyCanvas={transport?.applyCanvas} createTerminal={transport?.createTerminal} listTerminals={transport?.listTerminals} writeTerminal={transport?.writeTerminal} disposeTerminal={transport?.disposeTerminal} watchTerminal={transport?.watchTerminal} getWorkspaceReviewSummary={transport?.getWorkspaceReviewSummary} listWorkspaceReviewFileDiffs={transport?.listWorkspaceReviewFileDiffs} searchWorkspaceReviewDiffs={transport?.searchWorkspaceReviewDiffs} onClose={() => dispatchWorkspacePanel({ type: "close-panel" })} /> : null}
1436+
{!homeMode && workspacePanel.open ? <WebuiWorkspacePanel state={workspacePanel} dispatch={dispatchWorkspacePanel} sessionId={selectedSessionId} workspaceDir={selectedSession?.workspaceDir} listWorkspaceFileTree={transport?.listWorkspaceFileTree} readWorkspaceFile={transport?.readWorkspaceFile} workspaceFileUrl={transport?.workspaceFileUrl} readWorkspaceArchive={transport?.readWorkspaceArchive} extractWorkspaceArchive={transport?.extractWorkspaceArchive} readCanvas={transport?.readCanvas} applyCanvas={transport?.applyCanvas} createTerminal={transport?.createTerminal} listTerminals={transport?.listTerminals} writeTerminal={transport?.writeTerminal} disposeTerminal={transport?.disposeTerminal} watchTerminal={transport?.watchTerminal} getWorkspaceReviewSummary={transport?.getWorkspaceReviewSummary} listWorkspaceReviewFileDiffs={transport?.listWorkspaceReviewFileDiffs} searchWorkspaceReviewDiffs={transport?.searchWorkspaceReviewDiffs} onClose={() => dispatchWorkspacePanel({ type: "close-panel" })} /> : null}
14371437
</>}
14381438
</main>
14391439
</div>
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
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

Comments
 (0)