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:
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -85,5 +85,6 @@
|
||||
"closeMenu": "Close menu",
|
||||
"sidebar": "Sidebar",
|
||||
"now": "now",
|
||||
"users": "Users"
|
||||
"users": "Users",
|
||||
"files": "Files"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user