feat(server-mode): native-like folder browser, a developer kept to their own folder, no password minimum
- Folder browser (the Browse fallback for a remote browser): back/forward/up, a clickable breadcrumb that becomes a path field (Ctrl+L), Places (Home, the user's workspaces, recent picks), a filter, Name/Modified columns, Show hidden and New folder. A click selects; a double-click or Enter opens a folder or picks a file; Backspace goes up; typing jumps to a name. - GET /api/lanes/browse: `hidden=1` (never the dot entries at the top of a non-admin's own folder), each entry's `mtime`, the answer's `home`. - POST /api/lanes/browse/mkdir: one plain folder name inside an existing folder; a non-admin only in their own folder, none in server mode without one; under S3 a member's folder is made as the member. - scope.ownFolder falls back to server.conf's USERS_ROOT under S3, so a settings file without AGENTHUB_USERS_ROOT no longer opens the host's home to every developer. - Passwords: any non-empty password is accepted (no 8-character minimum). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
ensure: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
list: vi.fn(() => Promise.resolve({ lanes: [], counts: {} })),
|
||||
get: vi.fn(),
|
||||
profileInit: vi.fn(),
|
||||
mcpSync: vi.fn(),
|
||||
@@ -614,7 +616,7 @@ describe("AddLaneModal — folder browse", () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the browser, lists subdirectories, and selecting one fills the path field", async () => {
|
||||
it("opens the browser, lists subdirectories, and a double-click opens one", async () => {
|
||||
noNativePicker();
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
path: "/Users/tester",
|
||||
@@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
|
||||
await user.click(screen.getByTitle("Browse for a folder"));
|
||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
||||
await user.dblClick(screen.getByText("projects"));
|
||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false);
|
||||
});
|
||||
|
||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @file FolderBrowseModal.test.tsx
|
||||
* @description Unit tests for the in-app folder browser laid out like a
|
||||
* native picker: a click selects and Select picks that folder (else the
|
||||
* current one), a double-click opens, Backspace and the breadcrumb go up,
|
||||
* the filter narrows the list, Places open Home and a workspace, the path
|
||||
* field (Ctrl+L) opens a typed path, New folder makes one and selects it,
|
||||
* Show hidden re-lists with dot entries, and a Select is remembered as Recent.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { api } from "../../../lib/api";
|
||||
import { FolderBrowseModal } from "../FolderBrowseModal";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: { lanes: { browse: vi.fn(), mkdir: vi.fn(), list: vi.fn() } },
|
||||
}));
|
||||
|
||||
const TREE: Record<string, string[]> = {
|
||||
"/home/u": ["proj", "notes"],
|
||||
"/home/u/proj": ["api", "web"],
|
||||
"/home/u/proj/api": [],
|
||||
"/home/u/proj/web": [],
|
||||
"/home/u/notes": [],
|
||||
"/srv/ws": [],
|
||||
};
|
||||
|
||||
function listing(p: string, hidden = false) {
|
||||
const names = [...(TREE[p] ?? []), ...(hidden ? [".cfg"] : [])];
|
||||
return {
|
||||
path: p,
|
||||
parent: p === "/home/u" ? null : p.slice(0, p.lastIndexOf("/")) || "/",
|
||||
home: "/home/u",
|
||||
entries: names.map((n, i) => ({
|
||||
name: n,
|
||||
path: `${p}/${n}`,
|
||||
isGitRepo: n === "api",
|
||||
mtime: 1_700_000_000_000 + i,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.mocked(api.lanes.browse)
|
||||
.mockReset()
|
||||
.mockImplementation(async (p?: string, _f?: boolean, hidden?: boolean) => {
|
||||
const at = p || "/home/u";
|
||||
if (!(at in TREE)) throw new Error("path does not exist");
|
||||
return listing(at, hidden);
|
||||
});
|
||||
vi.mocked(api.lanes.mkdir).mockReset();
|
||||
vi.mocked(api.lanes.list)
|
||||
.mockReset()
|
||||
.mockResolvedValue({
|
||||
lanes: [{ id: 1, title: "Shop API", cwd: "/srv/ws" }],
|
||||
counts: {},
|
||||
} as never);
|
||||
});
|
||||
|
||||
const open = (onSelect = vi.fn(), initialPath?: string) => {
|
||||
render(
|
||||
<FolderBrowseModal open initialPath={initialPath} onSelect={onSelect} onClose={vi.fn()} />
|
||||
);
|
||||
return screen.findByRole("dialog", { name: "Browse for a folder" });
|
||||
};
|
||||
|
||||
const option = (dialog: HTMLElement, name: string) =>
|
||||
within(dialog).findByRole("option", { name: new RegExp(name) });
|
||||
|
||||
describe("FolderBrowseModal", () => {
|
||||
it("click selects, double-click opens, Select picks the selection else the folder", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const dialog = await open(onSelect);
|
||||
await userEvent.click(await option(dialog, "proj"));
|
||||
expect(await option(dialog, "proj")).toHaveAttribute("aria-selected", "true");
|
||||
await userEvent.dblClick(await option(dialog, "proj"));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
|
||||
});
|
||||
|
||||
it("Enter opens the selected folder and Backspace goes back up to it", async () => {
|
||||
const dialog = await open();
|
||||
const list = within(dialog).getByRole("listbox");
|
||||
await userEvent.click(await option(dialog, "proj"));
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await option(dialog, "api");
|
||||
list.focus();
|
||||
await userEvent.keyboard("{Backspace}");
|
||||
await waitFor(() =>
|
||||
expect(within(dialog).getByRole("option", { name: /proj/ })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("the breadcrumb, Back and Forward move between folders", async () => {
|
||||
const dialog = await open(vi.fn(), "/home/u/proj/api");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "proj" }));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Back/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/api", false, false)
|
||||
);
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Forward/ }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj", false, false)
|
||||
);
|
||||
// Above the user's own folder (it answered no parent) nothing is a link.
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
await option(dialog, "notes");
|
||||
expect(within(dialog).getByRole("button", { name: "home" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("the filter narrows the list, and Enter on a single match opens it", async () => {
|
||||
const dialog = await open();
|
||||
await option(dialog, "notes");
|
||||
await userEvent.type(within(dialog).getByRole("textbox", { name: "Filter…" }), "pro");
|
||||
expect(within(dialog).queryByRole("option", { name: /notes/ })).toBeNull();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
await option(dialog, "web");
|
||||
});
|
||||
|
||||
it("Places open a workspace and Home; Ctrl+L opens a typed path", async () => {
|
||||
const dialog = await open(vi.fn(), "/home/u/proj");
|
||||
await userEvent.click(await within(dialog).findByRole("button", { name: /Shop API/ }));
|
||||
await waitFor(() => expect(api.lanes.browse).toHaveBeenLastCalledWith("/srv/ws", false, false));
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /^Home$/ }));
|
||||
await option(dialog, "notes");
|
||||
await userEvent.keyboard("{Control>}l{/Control}");
|
||||
const field = within(dialog).getByRole("textbox", { name: "Type a path (Ctrl+L)" });
|
||||
await userEvent.clear(field);
|
||||
await userEvent.type(field, "/home/u/proj/web{Enter}");
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.browse).toHaveBeenLastCalledWith("/home/u/proj/web", false, false)
|
||||
);
|
||||
});
|
||||
|
||||
it("New folder makes one in the current folder and selects it", async () => {
|
||||
vi.mocked(api.lanes.mkdir).mockImplementation(async (parent, name) => {
|
||||
TREE[parent] = [...(TREE[parent] ?? []), name];
|
||||
return { path: `${parent}/${name}` };
|
||||
});
|
||||
const dialog = await open();
|
||||
await option(dialog, "proj");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: /New folder/ }));
|
||||
await userEvent.type(
|
||||
within(dialog).getByRole("textbox", { name: "Folder name" }),
|
||||
"fresh{Enter}"
|
||||
);
|
||||
expect(api.lanes.mkdir).toHaveBeenCalledWith("/home/u", "fresh");
|
||||
await waitFor(async () =>
|
||||
expect(await option(dialog, "fresh")).toHaveAttribute("aria-selected", "true")
|
||||
);
|
||||
TREE["/home/u"] = ["proj", "notes"];
|
||||
});
|
||||
|
||||
it("Show hidden re-lists with dot entries, and a pick is kept as Recent", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const dialog = await open(onSelect);
|
||||
await option(dialog, "proj");
|
||||
expect(within(dialog).queryByRole("option", { name: /\.cfg/ })).toBeNull();
|
||||
await userEvent.click(within(dialog).getByRole("checkbox", { name: /Show hidden/ }));
|
||||
await option(dialog, "\\.cfg");
|
||||
await userEvent.dblClick(await option(dialog, "proj"));
|
||||
await option(dialog, "web");
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Select this folder" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("/home/u/proj");
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.browse.recent") || "[]")).toEqual([
|
||||
"/home/u/proj",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user