feat: v2.7.7 — File Explorer on workspaces and the user's home, clone with per-user git identity
- File Explorer (Files in the sidebar, /files; also from a workspace's Files tab, portalled so no panel clips it), laid out like GNOME Files: places (Home, workspaces, Trash), Back/Forward/Up, path bar, search, icon grid with image thumbnails or list, labelled New/Upload, selection action bar, hidden files behind Ctrl+H, right-click menus and the usual keys. Make, rename, move, copy (within a workspace, between workspaces, home ↔ workspace), delete into a per-account trash restorable for 7 days, streamed upload of files and whole folders (≤ 500 MB a batch, Replace / Keep both / Skip on a taken name), download a file or a zip of anything. server/lib/file-explorer.js, /api/lanes/:id/fs/*, /api/homes/:who/fs/*. - The user's home: every dot entry at its top stays hidden and refused (also via a symlink, download, zip or as a copy source); an admin may view other users' homes read-only, audited. - write-as learns move/copy (re-run `sudo agenthub server install-system`). - Hardening: a write to a workspace re-checks its owner by the live role; S3 keeps its server.conf if the file stops reading and a tmux call never falls back to the host's server; the AI Proxy gateway marks what it relays and the dashboard refuses it; a NUL in a pane's argv/env is refused. - OpenAPI for the new routes (server/openapi-extra/files.js); docs (API, SERVER-MODE, CLAUDE.md) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -702,6 +702,8 @@ async function send<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
auth: body?.error?.auth as GitAuthNeeded | undefined,
|
||||
// ESESSIONLIVE from a workspace start: the pane already holding it.
|
||||
runId: body?.error?.run_id as string | undefined,
|
||||
// EEXISTS from the File Explorer: the names already taken.
|
||||
names: body?.error?.names as string[] | undefined,
|
||||
});
|
||||
}
|
||||
// A 200 that is not JSON — the SPA's index.html from a proxy or a dev server
|
||||
@@ -753,6 +755,54 @@ function gitUrl(target: GitTarget, sub: string, q?: URLSearchParams): string {
|
||||
return `/lanes/${lane}/git${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
|
||||
/** Where the File Explorer works: a workspace (as {@link GitTarget}) or a user's home. */
|
||||
export type FsPlace = GitTarget | { home: string };
|
||||
export const isHomePlace = (p: FsPlace): p is { home: string } =>
|
||||
typeof p === "object" && "home" in p;
|
||||
|
||||
/** `/lanes/:id/fs<sub>` (same target as {@link gitUrl}) or `/homes/:who/fs<sub>`. */
|
||||
function fsUrl(place: FsPlace, sub: string, q?: URLSearchParams): string {
|
||||
if (isHomePlace(place)) {
|
||||
const qs = new URLSearchParams(q).toString();
|
||||
return `/homes/${encodeURIComponent(place.home)}/fs${sub}${qs ? `?${qs}` : ""}`;
|
||||
}
|
||||
return gitUrl(place, sub, q).replace(/^(\/lanes\/\d+)\/git/, "$1/fs");
|
||||
}
|
||||
|
||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
label: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
/** One entry of a File Explorer folder listing (`GET /api/lanes/:id/fs/list`). */
|
||||
export interface FsEntry {
|
||||
name: string;
|
||||
/** Workspace-relative path. */
|
||||
path: string;
|
||||
/** `link` is a symlink; `linkdir` one to a folder inside the workspace. */
|
||||
type: "dir" | "file" | "link" | "linkdir" | "other";
|
||||
size: number | null;
|
||||
mtime: number;
|
||||
}
|
||||
export interface FsListing {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
entries: FsEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
export interface FsTrashItem {
|
||||
id: string;
|
||||
name: string;
|
||||
laneId: number | null;
|
||||
path: string;
|
||||
isDir: boolean;
|
||||
deletedAt: string;
|
||||
}
|
||||
/** How a taken name is settled: ask (409 EEXISTS with `names`), skip, keep both, replace. */
|
||||
export type FsConflict = "fail" | "skip" | "keep" | "replace";
|
||||
|
||||
/** The target for `laneId`: an extra folder's repo, else `session`'s worktree, else the workspace. */
|
||||
export function gitTarget(
|
||||
laneId: number,
|
||||
@@ -2443,6 +2493,123 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/**
|
||||
* The workspace File Explorer (`/api/lanes/:id/fs/*`, server/lib/file-explorer.js).
|
||||
* Every write runs as the folder's account; a taken name answers 409
|
||||
* EEXISTS with `names` unless `conflict` settles it.
|
||||
*/
|
||||
files: {
|
||||
/** GET /api/homes — the homes this user may open (their own; others' read-only for an admin). */
|
||||
homes: () => request<{ homes: FsHome[] }>("/homes"),
|
||||
/** One file's download URL, on a workspace or a home. */
|
||||
downloadUrl: (t: FsPlace, path: string) =>
|
||||
isHomePlace(t)
|
||||
? `${BASE}${fsUrl(t, "/download", new URLSearchParams({ path }))}`
|
||||
: `${BASE}${gitUrl(t, "/download", new URLSearchParams({ path }))}`,
|
||||
list: (t: FsPlace, path = "") =>
|
||||
request<FsListing>(fsUrl(t, "/list", new URLSearchParams({ path }))),
|
||||
mkdir: (t: FsPlace, path: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/mkdir"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
newFile: (t: FsPlace, path: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/newfile"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
rename: (t: FsPlace, path: string, name: string) =>
|
||||
request<{ path: string }>(fsUrl(t, "/rename"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path, name }),
|
||||
}),
|
||||
move: (t: FsPlace, paths: string[], dest: string, conflict?: FsConflict) =>
|
||||
request<{ moved: string[] }>(fsUrl(t, "/move"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths, dest, conflict }),
|
||||
}),
|
||||
/** Copy into `t`'s `dest` — from `t` itself, or from workspace `fromLane`. */
|
||||
copy: (
|
||||
t: FsPlace,
|
||||
paths: string[],
|
||||
dest: string,
|
||||
opts: { fromLane?: number; fromHome?: string; conflict?: FsConflict } = {}
|
||||
) =>
|
||||
request<{ copied: string[] }>(fsUrl(t, "/copy"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths, dest, ...opts }),
|
||||
}),
|
||||
/** Into the account's trash (restorable). */
|
||||
remove: (t: FsPlace, paths: string[]) =>
|
||||
request<{ trashed: string[] }>(fsUrl(t, "/delete"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ paths }),
|
||||
}),
|
||||
trash: (t: FsPlace) =>
|
||||
request<{ items: FsTrashItem[]; keepDays?: number }>(fsUrl(t, "/trash")),
|
||||
restore: (t: FsPlace, id: string) =>
|
||||
request<{ path: string; laneId: number | null }>(fsUrl(t, "/trash/restore"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
purge: (t: FsPlace, id: string | "all") =>
|
||||
request<{ purged: number }>(fsUrl(t, "/trash/purge"), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
}),
|
||||
/** A zip of `paths` (files and folders), for a plain link/anchor download. */
|
||||
zipUrl: (t: FsPlace, paths: string[]) => {
|
||||
const q = new URLSearchParams();
|
||||
for (const p of paths) q.append("p", p);
|
||||
return `${BASE}${fsUrl(t, "/zip", q)}`;
|
||||
},
|
||||
/**
|
||||
* Stream `file` to `path` (sub-folders made as needed), reporting upload
|
||||
* progress. Rejects like {@link request}: `code`, and `names` on EEXISTS.
|
||||
*/
|
||||
upload: (
|
||||
t: FsPlace,
|
||||
path: string,
|
||||
file: Blob,
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void
|
||||
) =>
|
||||
new Promise<{ path: string; skipped?: boolean }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", `${BASE}${fsUrl(t, "/upload", new URLSearchParams({ path, conflict }))}`);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
const token = dashboardToken();
|
||||
if (token) xhr.setRequestHeader("x-dashboard-token", token);
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } & {
|
||||
path?: string;
|
||||
skipped?: boolean;
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(body as { path: string; skipped?: boolean });
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401 && body.error?.code === "EAUTH") {
|
||||
window.dispatchEvent(new Event(AUTH_REQUIRED_EVENT));
|
||||
}
|
||||
reject(
|
||||
Object.assign(new Error(body.error?.message || `HTTP ${xhr.status}`), {
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
})
|
||||
);
|
||||
};
|
||||
xhr.send(file);
|
||||
}),
|
||||
},
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
gitIdentity: {
|
||||
get: () => request<GitIdentity>("/me/git"),
|
||||
|
||||
Reference in New Issue
Block a user