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:
2026-10-14 14:17:39 +07:00
parent b441f32949
commit e94038481e
38 changed files with 5961 additions and 27 deletions
+77
View File
@@ -0,0 +1,77 @@
/**
* @file FilesPage.tsx
* @description `/files` — the file manager (`FileBrowser`) as a page of its
* own, reached from the sidebar's **Files** entry. The location is kept in the
* URL (`?home=<me|user>&path=` or `?lane=<id>&path=`), so Back and a shared
* link land in the same folder. With neither it opens the user's own HOME
* (their Linux home under S3), or — where they have none — the last
* workspace shown (`agenthub.workspace.selected`), else the first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, isHomePlace, type FsPlace } from "../lib/api";
import { FileBrowser } from "../components/lanes/FileExplorer";
function lastWorkspace(): number | null {
try {
const v = Number(localStorage.getItem("agenthub.workspace.selected"));
return Number.isInteger(v) && v > 0 ? v : null;
} catch {
return null;
}
}
type Start = { lane: number } | { home: string };
export function FilesPage() {
const { t } = useTranslation(["lanes"]);
const [params, setParams] = useSearchParams();
const askedLane = Number(params.get("lane"));
const askedHome = params.get("home");
const [start, setStart] = useState<Start | null>(
askedHome
? { home: askedHome }
: Number.isInteger(askedLane) && askedLane > 0
? { lane: askedLane }
: null
);
const [none, setNone] = useState(false);
useEffect(() => {
if (start) return;
void (async () => {
const homes = await api.files.homes().catch(() => ({ homes: [] }));
if (homes.homes.some((h) => h.id === "me")) return setStart({ home: "me" });
const { lanes } = await api.lanes.list().catch(() => ({ lanes: [] }));
const last = lastWorkspace();
const pick = lanes.find((l) => l.id === last) ?? lanes[0];
if (pick) setStart({ lane: pick.id });
else setNone(true);
})();
}, [start]);
if (none) return <p className="p-8 text-sm text-fg-muted">{t("fx.noWorkspace")}</p>;
if (!start) return null;
return (
// Cancels Layout's page padding, like the Workspace session view.
<div className="-m-5 flex h-[calc(100vh-1.5rem)] max-md:h-[calc(100vh-4.25rem)] lg:-m-6">
<FileBrowser
laneId={"lane" in start ? start.lane : null}
initialHome={"home" in start ? start.home : null}
initialPath={params.get("path") ?? ""}
onNavigate={(place: FsPlace, path) => {
const next = new URLSearchParams(
isHomePlace(place)
? { home: place.home }
: { lane: String(typeof place === "number" ? place : place.lane) }
);
if (path) next.set("path", path);
setParams(next, { replace: false });
}}
/>
</div>
);
}
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
<span
class="text-status-warning/90"
>
UI build v2.7.6 (rebuild client to match)
UI build v2.7.7 (rebuild client to match)
</span>
</p>
</div>