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
+9
View File
@@ -87,6 +87,7 @@ const SessionDetail = lazy(() =>
const SnapshotView = lazy(() =>
import("./pages/SnapshotView").then((m) => ({ default: m.SnapshotView }))
);
const FilesPage = lazy(() => import("./pages/FilesPage").then((m) => ({ default: m.FilesPage })));
const Users = lazy(() => import("./pages/Users").then((m) => ({ default: m.Users })));
function PageFallback() {
@@ -210,6 +211,14 @@ export default function App() {
</Page>
}
/>
<Route
path="files"
element={
<Page>
<FilesPage />
</Page>
}
/>
<Route
path="users"
element={
+3
View File
@@ -56,6 +56,7 @@ import { useDialog } from "../hooks/useDialog";
import {
LayoutDashboard,
FolderOpen,
HardDrive,
BarChart3,
Settings,
Wifi,
@@ -88,6 +89,8 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
// The file manager (FilesPage): browse, upload and download workspace files.
{ to: "/files", icon: HardDrive, key: "nav:files" },
] as const;
const STORAGE_KEY = "sidebar-collapsed";
File diff suppressed because it is too large Load Diff
@@ -102,7 +102,7 @@ import {
Sparkles,
Trash2,
UnfoldVertical,
Upload,
FolderOpen,
} from "lucide-react";
import { api, gitTarget } from "../../lib/api";
import { pasteToLane } from "../../lib/panePaste";
@@ -123,6 +123,7 @@ import {
type LaneDiffChange,
} from "./LaneDiffView";
import { LaneConflictView } from "./LaneConflictView";
import { FileExplorer } from "./FileExplorer";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { MarkdownPreview, isMarkdown } from "../MarkdownPreview";
import { LaneGitHeader, useLaneGitFacts } from "./LaneGitHeader";
@@ -1639,6 +1640,14 @@ export function LaneChangesPanel({
{t("changes.uploadHere")}
</MenuItem>
)}
{treeCtx.menu.item.dir && (
<MenuItem
testId="lane-changes-menu-explorer"
onSelect={pick(treeCtx.close, () => setExplorer({ path: treeCtx.menu!.item.path }))}
>
{t("changes.openInExplorer")}
</MenuItem>
)}
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
{t("changes.copyPath")}
</MenuItem>
@@ -1888,7 +1897,11 @@ export function LaneChangesPanel({
// upload only creates, into the folder it was asked for ("" = the root).
const uploadInput = useRef<HTMLInputElement>(null);
const uploadDir = useRef("");
const [uploading, setUploading] = useState(false);
const [, setUploading] = useState(false);
// The File Explorer (the toolbar button where Upload was); the tree is
// re-read when it closes after changing anything.
const [explorer, setExplorer] = useState<{ path: string } | null>(null);
const explorerChanged = useRef(false);
const download = (path: string) => {
const a = document.createElement("a");
a.href = api.lanes.gitDownloadUrl(target, path);
@@ -2121,9 +2134,24 @@ export function LaneChangesPanel({
aria-label={t("changes.quickOpen")}
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
/>
<IconButton label={t("changes.upload")} disabled={uploading} onClick={() => pickUpload("")}>
<Upload className="h-3.5 w-3.5" />
<IconButton label={t("changes.openExplorer")} onClick={() => setExplorer({ path: "" })}>
<FolderOpen className="h-3.5 w-3.5" />
</IconButton>
<FileExplorer
open={!!explorer}
laneId={laneId}
session={session}
source={source}
initialPath={explorer?.path ?? ""}
onChanged={() => (explorerChanged.current = true)}
onClose={() => {
setExplorer(null);
if (explorerChanged.current) {
explorerChanged.current = false;
void rereadAll();
}
}}
/>
<input
ref={uploadInput}
type="file"
@@ -0,0 +1,257 @@
/**
* @file Tests for the workspace File Explorer modal: it lists a folder and
* opens sub-folders, makes a folder inline, deletes into the Trash without a
* confirm, copies in one workspace and pastes into another (`fromLane`),
* asks Replace / Keep both / Skip when an upload's name is taken, refuses a
* batch over 500 MB before sending anything, and restores from the Trash.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileExplorer } from "../FileExplorer";
import { api, type FsListing } from "../../../lib/api";
import { toast } from "../../../lib/toast";
vi.mock("../../../lib/api", () => ({
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
api: {
lanes: {
list: vi.fn(),
gitDownloadUrl: (t: number, p: string) => `/api/lanes/${t}/git/download?path=${p}`,
},
files: {
homes: vi.fn(),
downloadUrl: (t: unknown, p: string) => `/dl/${JSON.stringify(t)}/${p}`,
list: vi.fn(),
mkdir: vi.fn(),
newFile: vi.fn(),
rename: vi.fn(),
move: vi.fn(),
copy: vi.fn(),
remove: vi.fn(),
trash: vi.fn(),
restore: vi.fn(),
purge: vi.fn(),
upload: vi.fn(),
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
},
},
}));
vi.mock("../../../lib/toast", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const root: FsListing = {
path: "",
parent: null,
truncated: false,
entries: [
{ name: "src", path: "src", type: "dir", size: null, mtime: 0 },
{ name: "a.txt", path: "a.txt", type: "file", size: 12, mtime: 0 },
],
};
const src: FsListing = {
path: "src",
parent: "",
truncated: false,
entries: [{ name: "x.js", path: "src/x.js", type: "file", size: 2, mtime: 0 }],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(api.lanes.list).mockResolvedValue({
lanes: [
{ id: 1, title: "one", cwd: "/w/one" },
{ id: 2, title: "two", cwd: "/w/two" },
],
counts: {},
} as never);
vi.mocked(api.files.list).mockImplementation(async (_t, p = "") => (p === "src" ? src : root));
vi.mocked(api.files.trash).mockResolvedValue({ items: [] });
vi.mocked(api.files.homes).mockResolvedValue({
homes: [
{ id: "me", label: "An", readOnly: false },
{ id: "bob", label: "Bob", readOnly: true },
],
});
});
const open = () => render(<FileExplorer open laneId={1} onClose={vi.fn()} onChanged={vi.fn()} />);
const row = (name: string) =>
screen.getAllByTestId("fx-row").find((r) => r.textContent?.includes(name))!;
describe("FileExplorer", () => {
it("hides dot files until Ctrl+H, like the Linux file manager", async () => {
vi.mocked(api.files.list).mockResolvedValue({
...root,
entries: [
...root.entries,
{ name: ".env.example", path: ".env.example", type: "file", size: 1, mtime: 0 },
],
});
open();
await screen.findByText("a.txt");
expect(screen.queryByText(".env.example")).toBeNull();
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "h", ctrlKey: true });
expect(await screen.findByText(".env.example")).toBeInTheDocument();
});
it("shows a labelled action bar for a selection, and goes Back after opening a folder", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(row("a.txt"));
const bar = screen.getByTestId("fx-actionbar");
expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
expect(within(bar).getByRole("button", { name: /Move to Trash/ })).toBeInTheDocument();
fireEvent.doubleClick(row("src"));
await screen.findByText("x.js");
await user.click(screen.getByRole("button", { name: "Back" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
});
it("lists a folder and opens a sub-folder on double-click", async () => {
open();
expect(await screen.findByText("a.txt")).toBeInTheDocument();
fireEvent.doubleClick(row("src"));
expect(await screen.findByText("x.js")).toBeInTheDocument();
expect(api.files.list).toHaveBeenLastCalledWith(1, "src");
});
it("makes a folder inline", async () => {
vi.mocked(api.files.mkdir).mockResolvedValue({ path: "docs" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "New" }));
await user.click(screen.getByRole("menuitem", { name: "New folder" }));
const name = screen.getByLabelText("Name");
await user.clear(name);
await user.type(name, "docs{Enter}");
await waitFor(() => expect(api.files.mkdir).toHaveBeenCalledWith(1, "docs"));
});
it("deletes into the Trash without asking", async () => {
vi.mocked(api.files.remove).mockResolvedValue({ trashed: ["id1"] });
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
await waitFor(() => expect(api.files.remove).toHaveBeenCalledWith(1, ["a.txt"]));
expect(screen.queryByRole("alertdialog")).toBeNull();
});
it("copies here and pastes into another workspace", async () => {
vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
fireEvent.click(row("a.txt"));
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
await user.click(await screen.findByRole("button", { name: "two" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(2, ""));
await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
await waitFor(() =>
expect(api.files.copy).toHaveBeenCalledWith(2, ["a.txt"], "", {
fromLane: 1,
conflict: "fail",
})
);
});
it("asks about a taken name on upload, and sends the choice", async () => {
vi.mocked(api.files.upload)
.mockRejectedValueOnce(
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
)
.mockResolvedValueOnce({ path: "a (1).txt" });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
const file = new File(["hi"], "a.txt");
await user.upload(screen.getByTestId("fx-upload-files"), file);
const ask = await screen.findByTestId("fx-conflict");
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
await waitFor(() =>
expect(api.files.upload).toHaveBeenLastCalledWith(
1,
"a.txt",
file,
"keep",
expect.any(Function)
)
);
});
it("refuses a batch over 500 MB before sending anything", async () => {
open();
await screen.findByText("a.txt");
const big = new File(["x"], "big.bin");
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(api.files.upload).not.toHaveBeenCalled();
});
it("restores from the Trash", async () => {
vi.mocked(api.files.trash).mockResolvedValue({
items: [
{
id: "abc12345",
name: "a.txt",
laneId: 1,
path: "a.txt",
isDir: false,
deletedAt: "2026-10-14T00:00:00Z",
},
],
});
vi.mocked(api.files.restore).mockResolvedValue({ path: "a.txt", laneId: null });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(screen.getByRole("button", { name: "Trash" }));
const trashRow = await screen.findByTestId("fx-trash-row");
await user.click(within(trashRow).getByRole("button", { name: /Restore/ }));
await waitFor(() => expect(api.files.restore).toHaveBeenCalledWith(1, "abc12345"));
});
it("opens the user's home from the sidebar, and pastes from it into a workspace", async () => {
vi.mocked(api.files.copy).mockResolvedValue({ copied: ["a.txt"] });
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(await screen.findByRole("button", { name: "Home" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith({ home: "me" }, ""));
fireEvent.click(row("a.txt"));
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "c", ctrlKey: true });
await user.click(screen.getByRole("button", { name: "one" }));
await waitFor(() => expect(api.files.list).toHaveBeenLastCalledWith(1, ""));
await user.click(screen.getByRole("button", { name: /Paste 1 item/ }));
await waitFor(() =>
expect(api.files.copy).toHaveBeenCalledWith(1, ["a.txt"], "", {
fromHome: "me",
conflict: "fail",
})
);
});
it("shows another user's home read-only: no New, Upload, Cut, Rename or Delete", async () => {
const user = userEvent.setup();
open();
await screen.findByText("a.txt");
await user.click(await screen.findByRole("button", { name: /Bob/ }));
expect(await screen.findByTestId("fx-readonly")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "New" })).toBeNull();
expect(screen.queryByRole("button", { name: "Upload" })).toBeNull();
await user.click(row("a.txt"));
const bar = screen.getByTestId("fx-actionbar");
expect(within(bar).getByRole("button", { name: /Download/ })).toBeInTheDocument();
expect(within(bar).getByRole("button", { name: /Copy/ })).toBeInTheDocument();
expect(within(bar).queryByRole("button", { name: /Cut|Rename|Move to Trash/ })).toBeNull();
fireEvent.keyDown(screen.getByTestId("fx-list"), { key: "Delete" });
expect(api.files.remove).not.toHaveBeenCalled();
});
});
+85 -1
View File
@@ -522,5 +522,89 @@
"changes.historyWholeBranch": "Whole branch",
"changes.annotate": "Annotate",
"changes.noBlame": "Nothing to annotate: this file has no committed history yet",
"changes.notCommitted": "Not committed yet"
"changes.notCommitted": "Not committed yet",
"fx.title": "Files",
"fx.workspace": "Workspace",
"fx.files": "Files",
"fx.trash": "Trash",
"fx.close": "Close",
"fx.up": "Parent folder",
"fx.refresh": "Refresh",
"fx.newFolder": "New folder",
"fx.newFile": "New file",
"fx.uploadFiles": "Upload files",
"fx.uploadFolder": "Upload a folder",
"fx.download": "Download",
"fx.downloadZip": "Download as zip",
"fx.rename": "Rename",
"fx.copy": "Copy",
"fx.cut": "Cut",
"fx.paste": "Paste",
"fx.pasteInto": "Paste into this folder",
"fx.pasteFrom": "Paste {{count}} item(s) from {{workspace}}",
"fx.delete": "Move to Trash",
"fx.open": "Open",
"fx.location": "Location",
"fx.name": "Name",
"fx.size": "Size",
"fx.modified": "Modified",
"fx.empty": "This folder is empty — drop files here to upload.",
"fx.itemsCount": "{{count}} item(s)",
"fx.selectedCount": "{{count}} selected",
"fx.dropHint": "Drag files or folders here to upload · drag onto a folder to move (Ctrl: copy)",
"fx.uploading": "Uploading {{done}}/{{total}}…",
"fx.uploaded": "Uploaded {{count}} file(s).",
"fx.uploadFailed": "Could not upload {{name}}: {{message}}",
"fx.tooBig": "{{size}} is more than one upload may carry ({{max}}). Upload fewer files at a time.",
"fx.trashed": "Moved {{count}} item(s) to the Trash.",
"fx.copiedDone": "Copied {{count}} item(s) — open a folder (or another workspace) and Paste.",
"fx.cutDone": "Cut {{count}} item(s) — open a folder (or another workspace) and Paste.",
"fx.pasted": "Pasted.",
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `sudo agenthub server install-system` once.",
"fx.conflictTitle": "Already there",
"fx.conflictBody": "This name is taken in the destination: {{names}}",
"fx.conflictAll": "Do this for every remaining file",
"fx.cancel": "Cancel",
"fx.skip": "Skip",
"fx.keepBoth": "Keep both",
"fx.replace": "Replace",
"fx.trashHint": "Deleted files and folders, restorable for 7 days.",
"fx.emptyTrash": "Empty Trash",
"fx.restore": "Restore",
"fx.purge": "Delete forever",
"fx.restored": "Restored.",
"fx.trashEmpty": "The Trash is empty.",
"changes.openExplorer": "Open File Explorer — manage, upload and download files",
"changes.openInExplorer": "Open in File Explorer",
"fx.places": "Workspaces",
"fx.back": "Back",
"fx.forward": "Forward",
"fx.search": "Search (Ctrl+F)",
"fx.searchHere": "Search this folder…",
"fx.viewAs": "View as",
"fx.gridView": "Icons",
"fx.listView": "List",
"fx.new": "New",
"fx.upload": "Upload",
"fx.more": "More",
"fx.selectAll": "Select all",
"fx.showHidden": "Show hidden files (Ctrl+H)",
"fx.untitledFolder": "New folder",
"fx.untitledFile": "untitled.txt",
"fx.copyPath": "Copy path",
"fx.clearSelection": "Clear selection",
"fx.noMatch": "Nothing here matches.",
"fx.loading": "Loading…",
"fx.dropHere": "Drop to upload into {{folder}}",
"fx.nameField": "Name",
"fx.from": "From",
"fx.deletedAt": "Deleted",
"fx.unavailable": "The file manager is not available on this dashboard yet — it needs a restart after the update.",
"fx.noWorkspace": "There is no workspace yet. Add one first, then its files show here.",
"fx.myHome": "Home",
"fx.userHomes": "Users' homes",
"fx.readOnlyHome": "Another user's home — view and download only",
"fx.readOnly": "Read-only",
"fx.readOnlyHint": "This is another user's home: you can view, copy and download, not change it."
}
+2 -1
View File
@@ -85,5 +85,6 @@
"closeMenu": "Close menu",
"sidebar": "Sidebar",
"now": "now",
"users": "Users"
"users": "Users",
"files": "Files"
}
+85 -1
View File
@@ -522,5 +522,89 @@
"changes.historyWholeBranch": "Cả nhánh",
"changes.annotate": "Annotate",
"changes.noBlame": "Không có gì để annotate: file này chưa có lịch sử commit",
"changes.notCommitted": "Chưa commit"
"changes.notCommitted": "Chưa commit",
"fx.title": "Quản lý file",
"fx.workspace": "Workspace",
"fx.files": "File",
"fx.trash": "Thùng rác",
"fx.close": "Đóng",
"fx.up": "Thư mục cha",
"fx.refresh": "Làm mới",
"fx.newFolder": "Thư mục mới",
"fx.newFile": "File mới",
"fx.uploadFiles": "Tải file lên",
"fx.uploadFolder": "Tải cả thư mục lên",
"fx.download": "Tải về",
"fx.downloadZip": "Tải về dạng zip",
"fx.rename": "Đổi tên",
"fx.copy": "Sao chép",
"fx.cut": "Cắt",
"fx.paste": "Dán",
"fx.pasteInto": "Dán vào thư mục này",
"fx.pasteFrom": "Dán {{count}} mục từ {{workspace}}",
"fx.delete": "Chuyển vào Thùng rác",
"fx.open": "Mở",
"fx.location": "Vị trí",
"fx.name": "Tên",
"fx.size": "Dung lượng",
"fx.modified": "Sửa lần cuối",
"fx.empty": "Thư mục trống — thả file vào đây để tải lên.",
"fx.itemsCount": "{{count}} mục",
"fx.selectedCount": "Đã chọn {{count}}",
"fx.dropHint": "Kéo file hoặc thư mục vào đây để tải lên · kéo vào một thư mục để di chuyển (Ctrl: sao chép)",
"fx.uploading": "Đang tải lên {{done}}/{{total}}…",
"fx.uploaded": "Đã tải lên {{count}} file.",
"fx.uploadFailed": "Không tải lên được {{name}}: {{message}}",
"fx.tooBig": "{{size}} vượt quá giới hạn một lần tải lên ({{max}}). Hãy tải ít file hơn mỗi lần.",
"fx.trashed": "Đã chuyển {{count}} mục vào Thùng rác.",
"fx.copiedDone": "Đã sao chép {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
"fx.cutDone": "Đã cắt {{count}} mục — mở thư mục (hoặc workspace khác) rồi Dán.",
"fx.pasted": "Đã dán.",
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `sudo agenthub server install-system` một lần.",
"fx.conflictTitle": "Đã có sẵn",
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
"fx.cancel": "Huỷ",
"fx.skip": "Bỏ qua",
"fx.keepBoth": "Giữ cả hai",
"fx.replace": "Thay thế",
"fx.trashHint": "File và thư mục đã xoá, khôi phục được trong 7 ngày.",
"fx.emptyTrash": "Dọn sạch Thùng rác",
"fx.restore": "Khôi phục",
"fx.purge": "Xoá vĩnh viễn",
"fx.restored": "Đã khôi phục.",
"fx.trashEmpty": "Thùng rác trống.",
"changes.openExplorer": "Mở Quản lý file — quản lý, tải lên và tải về file",
"changes.openInExplorer": "Mở trong Quản lý file",
"fx.places": "Workspace",
"fx.back": "Quay lại",
"fx.forward": "Tiến tới",
"fx.search": "Tìm (Ctrl+F)",
"fx.searchHere": "Tìm trong thư mục này…",
"fx.viewAs": "Kiểu xem",
"fx.gridView": "Biểu tượng",
"fx.listView": "Danh sách",
"fx.new": "Mới",
"fx.upload": "Tải lên",
"fx.more": "Thêm",
"fx.selectAll": "Chọn tất cả",
"fx.showHidden": "Hiện file ẩn (Ctrl+H)",
"fx.untitledFolder": "Thư mục mới",
"fx.untitledFile": "chua-dat-ten.txt",
"fx.copyPath": "Sao chép đường dẫn",
"fx.clearSelection": "Bỏ chọn",
"fx.noMatch": "Không có mục nào khớp.",
"fx.loading": "Đang tải…",
"fx.dropHere": "Thả để tải lên vào {{folder}}",
"fx.nameField": "Tên",
"fx.from": "Từ",
"fx.deletedAt": "Ngày xoá",
"fx.unavailable": "Dashboard này chưa có trình quản lý file — cần khởi động lại sau khi cập nhật.",
"fx.noWorkspace": "Chưa có workspace nào. Hãy thêm một workspace, file của nó sẽ hiện ở đây.",
"fx.myHome": "Thư mục home",
"fx.userHomes": "Home của các user",
"fx.readOnlyHome": "Home của user khác — chỉ xem và tải về",
"fx.readOnly": "Chỉ đọc",
"fx.readOnlyHint": "Đây là home của user khác: bạn chỉ xem, sao chép và tải về được, không sửa được."
}
+2 -1
View File
@@ -85,5 +85,6 @@
"closeMenu": "Đóng menu",
"sidebar": "Thanh bên",
"now": "bây giờ",
"users": "Người dùng"
"users": "Người dùng",
"files": "Quản lý file"
}
+167
View File
@@ -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"),
+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>