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:
@@ -3,7 +3,7 @@
|
|||||||
* @description Unit tests for the Browse button beside path fields: a chosen
|
* @description Unit tests for the Browse button beside path fields: a chosen
|
||||||
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
||||||
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
||||||
* EREMOTE included).
|
* EREMOTE included), where a double-click on a file picks it.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ import { toast } from "../../lib/toast";
|
|||||||
import { PathPickButton } from "../PathPickButton";
|
import { PathPickButton } from "../PathPickButton";
|
||||||
|
|
||||||
vi.mock("../../lib/api", () => ({
|
vi.mock("../../lib/api", () => ({
|
||||||
api: { lanes: { pickPath: vi.fn(), browse: vi.fn() } },
|
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
|
||||||
}));
|
}));
|
||||||
vi.mock("../../lib/toast", () => ({
|
vi.mock("../../lib/toast", () => ({
|
||||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||||
@@ -55,7 +55,7 @@ describe("PathPickButton", () => {
|
|||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens the in-app browser for a file too, and a click on a file picks it", async () => {
|
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
|
||||||
const onPick = vi.fn();
|
const onPick = vi.fn();
|
||||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
@@ -68,9 +68,9 @@ describe("PathPickButton", () => {
|
|||||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||||
await userEvent.click(screen.getByRole("button"));
|
await userEvent.click(screen.getByRole("button"));
|
||||||
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
||||||
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true);
|
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false);
|
||||||
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
||||||
await userEvent.click(await within(dialog).findByText("id_ed25519"));
|
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
||||||
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
expect(toast.info).not.toHaveBeenCalled();
|
expect(toast.info).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -1,20 +1,97 @@
|
|||||||
/**
|
/**
|
||||||
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||||||
* button. Browsers refuse to expose an absolute filesystem path from a native
|
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
* absolute filesystem path from a native picker, so path selection here is
|
||||||
* (immediate subdirectories of a path, plus its files for `kind="file"`)
|
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||||||
* instead — breadcrumb-free, just an up-one-level button and a
|
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||||||
* click-to-descend list; in file mode a click on a file picks it.
|
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
|
||||||
|
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
||||||
|
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||||||
|
* Backspace goes up and typing jumps to a name.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FolderOpen, FolderGit2, ArrowUp, File } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ArrowUp,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
File,
|
||||||
|
FolderGit2,
|
||||||
|
FolderOpen,
|
||||||
|
FolderPlus,
|
||||||
|
Home,
|
||||||
|
Pencil,
|
||||||
|
Star,
|
||||||
|
} from "lucide-react";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
import { formatDateTime } from "../../lib/format";
|
||||||
|
import { isSubmitEnter } from "../../lib/keys";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
|
import { useWorkLanes } from "../../hooks/useLanes";
|
||||||
|
|
||||||
export function FolderBrowseModal({
|
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
|
||||||
|
type Entry = Listing["entries"][number];
|
||||||
|
|
||||||
|
const RECENT_KEY = "agenthub.browse.recent";
|
||||||
|
const HIDDEN_KEY = "agenthub.browse.hidden";
|
||||||
|
const RECENT_MAX = 8;
|
||||||
|
|
||||||
|
function readStored<T>(key: string, fallback: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw ? (JSON.parse(raw) as T) : fallback;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStored(key: string, value: unknown) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
// A private window or blocked storage: the preference just isn't kept.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `[label, path]` for each level of `p`, root first. */
|
||||||
|
function crumbs(p: string): [string, string][] {
|
||||||
|
const win = /^[A-Za-z]:\\/.test(p);
|
||||||
|
const sep = win ? "\\" : "/";
|
||||||
|
const parts = p.split(sep).filter(Boolean);
|
||||||
|
const out: [string, string][] = win ? [] : [["/", "/"]];
|
||||||
|
let acc = "";
|
||||||
|
for (const part of parts) {
|
||||||
|
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
|
||||||
|
out.push([part, acc]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
/** `file` lists files too, and picking one is the only way to select. */
|
||||||
|
kind?: "dir" | "file";
|
||||||
|
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||||||
|
initialPath?: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounted only while open, so a closed picker (one behind every Browse
|
||||||
|
* button) asks the server for nothing, and each opening starts fresh.
|
||||||
|
*/
|
||||||
|
export function FolderBrowseModal(props: Props) {
|
||||||
|
return props.open ? <BrowseDialog {...props} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BrowseDialog({
|
||||||
open,
|
open,
|
||||||
kind = "dir",
|
kind = "dir",
|
||||||
initialPath,
|
initialPath,
|
||||||
@@ -30,20 +107,62 @@ export function FolderBrowseModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation(["lanes"]);
|
const { t } = useTranslation(["lanes"]);
|
||||||
const [listing, setListing] = useState<Awaited<ReturnType<typeof api.lanes.browse>> | null>(null);
|
const { lanes } = useWorkLanes();
|
||||||
|
const [listing, setListing] = useState<Listing | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [pathText, setPathText] = useState("");
|
||||||
|
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
|
||||||
|
key: "name",
|
||||||
|
dir: 1,
|
||||||
|
});
|
||||||
|
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
|
||||||
|
// The highest folder this user may open (a member's own folder answers no
|
||||||
|
// parent) — breadcrumbs above it are not links.
|
||||||
|
const [floor, setFloor] = useState<string | null>(null);
|
||||||
|
const [home, setHome] = useState<string | null>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const crumbRef = useRef<HTMLDivElement>(null);
|
||||||
|
const typeAhead = useRef({ text: "", at: 0 });
|
||||||
|
|
||||||
const load = async (path?: string) => {
|
const load = async (
|
||||||
|
target?: string,
|
||||||
|
{ push = true, hidden = showHidden, select = null as string | null } = {}
|
||||||
|
) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setListing(await (kind === "file" ? api.lanes.browse(path, true) : api.lanes.browse(path)));
|
const next = await api.lanes.browse(target, kind === "file", hidden);
|
||||||
|
setListing(next);
|
||||||
|
setSelected(select);
|
||||||
|
setFilter("");
|
||||||
|
setEditing(false);
|
||||||
|
if (next.home) setHome(next.home);
|
||||||
|
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||||||
|
if (push) {
|
||||||
|
setHistory((h) => {
|
||||||
|
if (h.stack[h.at] === next.path) return h;
|
||||||
|
const stack = [...h.stack.slice(0, h.at + 1), next.path];
|
||||||
|
return { stack, at: stack.length - 1 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) void load(initialPath);
|
if (!open) return;
|
||||||
|
setHistory({ stack: [], at: -1 });
|
||||||
|
setCreating(false);
|
||||||
|
void load(initialPath);
|
||||||
// Only re-run when the modal actually opens - not on every initialPath
|
// Only re-run when the modal actually opens - not on every initialPath
|
||||||
// keystroke in the field behind it.
|
// keystroke in the field behind it.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@@ -54,8 +173,164 @@ export function FolderBrowseModal({
|
|||||||
const panel = useRef<HTMLDivElement>(null);
|
const panel = useRef<HTMLDivElement>(null);
|
||||||
useDialog(open, panel, onClose);
|
useDialog(open, panel, onClose);
|
||||||
|
|
||||||
|
const entries = useMemo(() => {
|
||||||
|
const needle = filter.trim().toLowerCase();
|
||||||
|
const list = (listing?.entries ?? []).filter(
|
||||||
|
(e) => !needle || e.name.toLowerCase().includes(needle)
|
||||||
|
);
|
||||||
|
const by = (a: Entry, b: Entry) =>
|
||||||
|
sort.key === "mtime"
|
||||||
|
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
|
||||||
|
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
|
||||||
|
sort.dir;
|
||||||
|
// Folders first, as every file picker lists them.
|
||||||
|
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
|
||||||
|
}, [listing, filter, sort]);
|
||||||
|
|
||||||
|
const places = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return lanes
|
||||||
|
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
|
||||||
|
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
|
||||||
|
}, [lanes]);
|
||||||
|
|
||||||
|
// A deep path keeps its LAST levels in view, like a native path bar.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = crumbRef.current;
|
||||||
|
if (el) el.scrollLeft = el.scrollWidth;
|
||||||
|
}, [listing?.path, editing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selected) return;
|
||||||
|
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
|
||||||
|
(x) => x.dataset.path === selected
|
||||||
|
);
|
||||||
|
el?.scrollIntoView?.({ block: "nearest" });
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const finish = (picked: string) => {
|
||||||
|
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
|
||||||
|
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
|
||||||
|
setRecent(next);
|
||||||
|
writeStored(RECENT_KEY, next);
|
||||||
|
onSelect(picked);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const activate = (entry: Entry) => {
|
||||||
|
if (entry.file) finish(entry.path);
|
||||||
|
else void load(entry.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const go = (delta: -1 | 1) => {
|
||||||
|
const at = history.at + delta;
|
||||||
|
const target = history.stack[at];
|
||||||
|
if (target === undefined) return;
|
||||||
|
void load(target, { push: false }).then((ok) => {
|
||||||
|
if (ok) setHistory((h) => ({ ...h, at }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const goUp = () => {
|
||||||
|
if (!listing?.parent) return;
|
||||||
|
const from = listing.path;
|
||||||
|
void load(listing.parent, { select: from });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
|
||||||
|
const target =
|
||||||
|
kind === "file"
|
||||||
|
? selectedEntry?.file
|
||||||
|
? selectedEntry.path
|
||||||
|
: null
|
||||||
|
: selectedEntry && !selectedEntry.file
|
||||||
|
? selectedEntry.path
|
||||||
|
: (listing?.path ?? null);
|
||||||
|
|
||||||
|
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const idx = entries.findIndex((x) => x.path === selected);
|
||||||
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.altKey && e.key === "ArrowUp") return goUp();
|
||||||
|
const next =
|
||||||
|
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
|
||||||
|
if (entries[next]) setSelected(entries[next].path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Home" || e.key === "End") {
|
||||||
|
e.preventDefault();
|
||||||
|
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
|
||||||
|
if (pick) setSelected(pick.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (selectedEntry) activate(selectedEntry);
|
||||||
|
else if (kind === "dir" && target) finish(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Backspace") {
|
||||||
|
e.preventDefault();
|
||||||
|
goUp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||||
|
// Typing jumps to the first name that starts with what was typed.
|
||||||
|
const now = Date.now();
|
||||||
|
const ta = typeAhead.current;
|
||||||
|
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
|
||||||
|
ta.at = now;
|
||||||
|
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
|
||||||
|
if (hit) setSelected(hit.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
|
||||||
|
e.preventDefault();
|
||||||
|
setPathText(listing?.path ?? "");
|
||||||
|
setEditing(true);
|
||||||
|
} else if (e.altKey && e.key === "ArrowLeft") {
|
||||||
|
e.preventDefault();
|
||||||
|
go(-1);
|
||||||
|
} else if (e.altKey && e.key === "ArrowRight") {
|
||||||
|
e.preventDefault();
|
||||||
|
go(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createFolder = async () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name || !listing) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const made = await api.lanes.mkdir(listing.path, name);
|
||||||
|
setCreating(false);
|
||||||
|
setNewName("");
|
||||||
|
await load(listing.path, { push: false, select: made.path });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHidden = () => {
|
||||||
|
const next = !showHidden;
|
||||||
|
setShowHidden(next);
|
||||||
|
writeStored(HIDDEN_KEY, next);
|
||||||
|
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconBtn =
|
||||||
|
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
|
||||||
|
const placeBtn = (active: boolean) =>
|
||||||
|
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
|
||||||
|
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||||||
|
}`;
|
||||||
|
const path = listing?.path ?? "";
|
||||||
|
const floorDepth = floor ? crumbs(floor).length : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
@@ -64,82 +339,347 @@ export function FolderBrowseModal({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={panel}
|
ref={panel}
|
||||||
className="relative flex max-h-[70vh] w-full max-w-md flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={onPanelKey}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||||
data-tour="folder-browse"
|
data-tour="folder-browse"
|
||||||
>
|
>
|
||||||
<div className="border-b border-border p-3">
|
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||||
{listing?.path || "…"}
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
onClick={() => go(-1)}
|
||||||
|
disabled={history.at <= 0}
|
||||||
|
title={t("browse.back")}
|
||||||
|
aria-label={t("browse.back")}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
onClick={() => go(1)}
|
||||||
|
disabled={history.at >= history.stack.length - 1}
|
||||||
|
title={t("browse.forward")}
|
||||||
|
aria-label={t("browse.forward")}
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
onClick={goUp}
|
||||||
|
disabled={!listing?.parent}
|
||||||
|
title={t("browse.up")}
|
||||||
|
aria-label={t("browse.up")}
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={pathText}
|
||||||
|
onChange={(e) => setPathText(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (pathText.trim()) void load(pathText.trim());
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
aria-label={t("browse.editPath")}
|
||||||
|
spellCheck={false}
|
||||||
|
className="input min-w-0 flex-1 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
ref={crumbRef}
|
||||||
|
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
|
||||||
|
onDoubleClick={() => {
|
||||||
|
setPathText(path);
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{crumbs(path).map(([label, p], i, all) => {
|
||||||
|
const last = i === all.length - 1;
|
||||||
|
const locked = i + 1 < floorDepth;
|
||||||
|
return (
|
||||||
|
<span key={p} className="flex shrink-0 items-center">
|
||||||
|
{i > 0 && all[i - 1]?.[0] !== "/" && (
|
||||||
|
<span className="px-0.5 text-fg-muted">›</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={last || locked}
|
||||||
|
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
|
||||||
|
className={`rounded px-1 py-0.5 font-mono text-xs ${
|
||||||
|
last
|
||||||
|
? "font-medium text-fg-primary"
|
||||||
|
: locked
|
||||||
|
? "text-fg-muted"
|
||||||
|
: "text-fg-secondary hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={iconBtn}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
setPathText(path);
|
||||||
|
setEditing((v) => !v);
|
||||||
|
}}
|
||||||
|
title={t("browse.editPath")}
|
||||||
|
aria-label={t("browse.editPath")}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{/* Places */}
|
||||||
|
<nav
|
||||||
|
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
|
||||||
|
aria-label={t("browse.places")}
|
||||||
|
>
|
||||||
|
{home && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={placeBtn(path === home)}
|
||||||
|
onClick={() => void load(home)}
|
||||||
|
title={home}
|
||||||
|
>
|
||||||
|
<Home className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{t("browse.home")}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{places.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
<Star className="h-3 w-3" />
|
||||||
|
{t("browse.workspaces")}
|
||||||
|
</div>
|
||||||
|
{places.map((pl) => (
|
||||||
|
<button
|
||||||
|
key={pl.path}
|
||||||
|
type="button"
|
||||||
|
className={placeBtn(path === pl.path)}
|
||||||
|
onClick={() => void load(pl.path)}
|
||||||
|
title={pl.path}
|
||||||
|
>
|
||||||
|
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||||||
|
<span className="truncate">{pl.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{t("browse.recent")}
|
||||||
|
</div>
|
||||||
|
{recent.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
className={placeBtn(path === r)}
|
||||||
|
onClick={() => void load(r)}
|
||||||
|
title={r}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{baseName(r)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Listing */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border p-2">
|
||||||
|
<input
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (entries[0]) setSelected(entries[0].path);
|
||||||
|
listRef.current?.focus();
|
||||||
|
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
|
||||||
|
e.preventDefault();
|
||||||
|
activate(entries[0]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("browse.filter")}
|
||||||
|
aria-label={t("browse.filter")}
|
||||||
|
className="input min-w-0 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||||||
|
{(["name", "mtime"] as const).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
|
||||||
|
}
|
||||||
|
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
|
||||||
|
>
|
||||||
|
{key === "name" ? t("browse.name") : t("browse.modified")}
|
||||||
|
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
tabIndex={0}
|
||||||
|
role="listbox"
|
||||||
|
aria-label={path}
|
||||||
|
onKeyDown={onListKey}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{creating && (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
|
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isSubmitEnter(e)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void createFolder();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("browse.newFolderName")}
|
||||||
|
aria-label={t("browse.newFolderName")}
|
||||||
|
className="input min-w-0 flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void createFolder()}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t("browse.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const isSel = entry.path === selected;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={entry.path}
|
||||||
|
data-path={entry.path}
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSel}
|
||||||
|
onClick={() => setSelected(entry.path)}
|
||||||
|
onDoubleClick={() => activate(entry)}
|
||||||
|
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
|
||||||
|
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.file ? (
|
||||||
|
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
|
||||||
|
) : entry.isGitRepo ? (
|
||||||
|
<FolderGit2
|
||||||
|
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FolderOpen
|
||||||
|
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||||||
|
<span
|
||||||
|
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
|
||||||
|
>
|
||||||
|
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{listing && entries.length === 0 && (
|
||||||
|
<p className="p-2 text-xs text-fg-muted">
|
||||||
|
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-1">
|
{/* Footer: what will be picked, and the actions. */}
|
||||||
{error && (
|
<div className="border-t border-border p-2">
|
||||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
<div
|
||||||
{error}
|
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||||||
</p>
|
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||||||
)}
|
title={target ?? undefined}
|
||||||
{listing?.parent && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => load(listing.parent!)}
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-secondary hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<ArrowUp className="h-3.5 w-3.5" />
|
|
||||||
..
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{listing?.entries.map((entry) => (
|
|
||||||
<button
|
|
||||||
key={entry.path}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!entry.file) return void load(entry.path);
|
|
||||||
onSelect(entry.path);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
{entry.file ? (
|
|
||||||
<File className="h-3.5 w-3.5 text-fg-muted" />
|
|
||||||
) : entry.isGitRepo ? (
|
|
||||||
<FolderGit2 className="h-3.5 w-3.5 text-accent" />
|
|
||||||
) : (
|
|
||||||
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{entry.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{listing && listing.entries.length === 0 && !listing.parent && (
|
|
||||||
<p className="p-2 text-xs text-fg-muted">{t("browse.empty")}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 border-t border-border p-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="btn-ghost border border-border text-xs"
|
|
||||||
>
|
>
|
||||||
{t("destructive.cancel")}
|
<bdi>{target ?? "…"}</bdi>
|
||||||
</button>
|
</div>
|
||||||
{kind === "dir" && (
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!listing}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (listing) onSelect(listing.path);
|
setNewName("");
|
||||||
onClose();
|
setCreating(true);
|
||||||
}}
|
}}
|
||||||
className="btn-primary text-xs disabled:opacity-50"
|
disabled={!listing}
|
||||||
|
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t("browse.select")}
|
<FolderPlus className="h-3.5 w-3.5" />
|
||||||
|
{t("browse.newFolder")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
|
||||||
|
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
{t("browse.showHidden")}
|
||||||
|
</label>
|
||||||
|
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||||||
|
{t("browse.hint")}
|
||||||
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-ghost border border-border text-xs"
|
||||||
|
>
|
||||||
|
{t("destructive.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!target}
|
||||||
|
onClick={() => target && finish(target)}
|
||||||
|
className="btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
ensure: vi.fn(),
|
ensure: vi.fn(),
|
||||||
browse: vi.fn(),
|
browse: vi.fn(),
|
||||||
pickPath: 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(),
|
get: vi.fn(),
|
||||||
profileInit: vi.fn(),
|
profileInit: vi.fn(),
|
||||||
mcpSync: vi.fn(),
|
mcpSync: vi.fn(),
|
||||||
@@ -614,7 +616,7 @@ describe("AddLaneModal — folder browse", () => {
|
|||||||
expect(onClose).not.toHaveBeenCalled();
|
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();
|
noNativePicker();
|
||||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||||
path: "/Users/tester",
|
path: "/Users/tester",
|
||||||
@@ -627,8 +629,8 @@ describe("AddLaneModal — folder browse", () => {
|
|||||||
await user.click(screen.getByTitle("Browse for a folder"));
|
await user.click(screen.getByTitle("Browse for a folder"));
|
||||||
expect(await screen.findByText("projects")).toBeInTheDocument();
|
expect(await screen.findByText("projects")).toBeInTheDocument();
|
||||||
|
|
||||||
await user.click(screen.getByText("projects"));
|
await user.dblClick(screen.getByText("projects"));
|
||||||
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects");
|
expect(api.lanes.browse).toHaveBeenCalledWith("/Users/tester/projects", false, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Escape closes only the folder browser, not the whole modal", async () => {
|
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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,6 +42,24 @@
|
|||||||
"browse.empty": "This folder is empty.",
|
"browse.empty": "This folder is empty.",
|
||||||
"browse.fileTitle": "Browse for a file",
|
"browse.fileTitle": "Browse for a file",
|
||||||
"browse.select": "Select this folder",
|
"browse.select": "Select this folder",
|
||||||
|
"browse.back": "Back (Alt+←)",
|
||||||
|
"browse.create": "Create",
|
||||||
|
"browse.editPath": "Type a path (Ctrl+L)",
|
||||||
|
"browse.filter": "Filter…",
|
||||||
|
"browse.forward": "Forward (Alt+→)",
|
||||||
|
"browse.hint": "Double-click or Enter opens · Backspace goes up · type to jump",
|
||||||
|
"browse.home": "Home",
|
||||||
|
"browse.modified": "Modified",
|
||||||
|
"browse.name": "Name",
|
||||||
|
"browse.newFolder": "New folder",
|
||||||
|
"browse.newFolderName": "Folder name",
|
||||||
|
"browse.noMatch": "Nothing matches the filter.",
|
||||||
|
"browse.places": "Places",
|
||||||
|
"browse.recent": "Recent",
|
||||||
|
"browse.selectFile": "Select file",
|
||||||
|
"browse.showHidden": "Show hidden",
|
||||||
|
"browse.up": "Up one level (Backspace)",
|
||||||
|
"browse.workspaces": "Workspaces",
|
||||||
"browse.title": "Browse for a folder",
|
"browse.title": "Browse for a folder",
|
||||||
"changes.back": "All files",
|
"changes.back": "All files",
|
||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"passwordHint": "At least 8 characters",
|
"passwordHint": "No minimum length",
|
||||||
"newPassword": "New password",
|
"newPassword": "New password",
|
||||||
"changeRole": "Change role",
|
"changeRole": "Change role",
|
||||||
"disable": "Disable",
|
"disable": "Disable",
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"ESELF": "You cannot change your own role or disable yourself.",
|
"ESELF": "You cannot change your own role or disable yourself.",
|
||||||
"EEXISTS": "That username is taken.",
|
"EEXISTS": "That username is taken.",
|
||||||
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
"EBADUSER": "Username: letters, digits, '.', '_' or '-' (up to 64).",
|
||||||
"EBADPASSWORD": "Password: at least 8 characters."
|
"EBADPASSWORD": "Password cannot be empty."
|
||||||
},
|
},
|
||||||
"readOnly": "Analysts can view accounts but not change them.",
|
"readOnly": "Analysts can view accounts but not change them.",
|
||||||
"auditTitle": "Audit log",
|
"auditTitle": "Audit log",
|
||||||
|
|||||||
@@ -42,6 +42,24 @@
|
|||||||
"browse.empty": "Thư mục này trống.",
|
"browse.empty": "Thư mục này trống.",
|
||||||
"browse.fileTitle": "Duyệt chọn tệp",
|
"browse.fileTitle": "Duyệt chọn tệp",
|
||||||
"browse.select": "Chọn thư mục này",
|
"browse.select": "Chọn thư mục này",
|
||||||
|
"browse.back": "Quay lại (Alt+←)",
|
||||||
|
"browse.create": "Tạo",
|
||||||
|
"browse.editPath": "Gõ đường dẫn (Ctrl+L)",
|
||||||
|
"browse.filter": "Lọc…",
|
||||||
|
"browse.forward": "Tiến (Alt+→)",
|
||||||
|
"browse.hint": "Nháy đúp hoặc Enter để mở · Backspace để lên · gõ chữ để nhảy tới",
|
||||||
|
"browse.home": "Thư mục chính",
|
||||||
|
"browse.modified": "Ngày sửa",
|
||||||
|
"browse.name": "Tên",
|
||||||
|
"browse.newFolder": "Thư mục mới",
|
||||||
|
"browse.newFolderName": "Tên thư mục",
|
||||||
|
"browse.noMatch": "Không có mục nào khớp bộ lọc.",
|
||||||
|
"browse.places": "Vị trí",
|
||||||
|
"browse.recent": "Gần đây",
|
||||||
|
"browse.selectFile": "Chọn tệp",
|
||||||
|
"browse.showHidden": "Hiện mục ẩn",
|
||||||
|
"browse.up": "Lên một cấp (Backspace)",
|
||||||
|
"browse.workspaces": "Workspace",
|
||||||
"browse.title": "Duyệt chọn thư mục",
|
"browse.title": "Duyệt chọn thư mục",
|
||||||
"changes.back": "Tất cả file",
|
"changes.back": "Tất cả file",
|
||||||
"changes.bin": "bin",
|
"changes.bin": "bin",
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"username": "Tên đăng nhập",
|
"username": "Tên đăng nhập",
|
||||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||||
"password": "Mật khẩu",
|
"password": "Mật khẩu",
|
||||||
"passwordHint": "Ít nhất 8 ký tự",
|
"passwordHint": "Không giới hạn độ dài tối thiểu",
|
||||||
"newPassword": "Mật khẩu mới",
|
"newPassword": "Mật khẩu mới",
|
||||||
"changeRole": "Đổi vai trò",
|
"changeRole": "Đổi vai trò",
|
||||||
"disable": "Khoá",
|
"disable": "Khoá",
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
"ESELF": "Bạn không thể tự đổi vai trò hay tự khoá chính mình.",
|
||||||
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
"EEXISTS": "Tên đăng nhập này đã có người dùng.",
|
||||||
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
"EBADUSER": "Tên đăng nhập: chữ, số, '.', '_' hoặc '-' (tối đa 64 ký tự).",
|
||||||
"EBADPASSWORD": "Mật khẩu: ít nhất 8 ký tự."
|
"EBADPASSWORD": "Mật khẩu không được để trống."
|
||||||
},
|
},
|
||||||
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
"readOnly": "Analyst chỉ xem được tài khoản, không thay đổi được.",
|
||||||
"auditTitle": "Nhật ký audit",
|
"auditTitle": "Nhật ký audit",
|
||||||
|
|||||||
+23
-3
@@ -2971,19 +2971,39 @@ export const api = {
|
|||||||
* filesystem paths from a native picker, so this drives a server-backed one.
|
* filesystem paths from a native picker, so this drives a server-backed one.
|
||||||
* @param path Absolute path to list; defaults server-side to the home dir.
|
* @param path Absolute path to list; defaults server-side to the home dir.
|
||||||
* @param files List files too (`file: true`) — a file field's picker.
|
* @param files List files too (`file: true`) — a file field's picker.
|
||||||
* @returns `{ path, parent, entries }` — `parent` is null at the root.
|
* @param hidden List dot entries too (never at the top of a member's home).
|
||||||
|
* @returns `{ path, parent, entries, home }` — `parent` is null at the
|
||||||
|
* root, `home` is the folder the browser's Home place opens.
|
||||||
*/
|
*/
|
||||||
browse: (path?: string, files?: boolean) => {
|
browse: (path?: string, files?: boolean, hidden?: boolean) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (path) q.set("path", path);
|
if (path) q.set("path", path);
|
||||||
if (files) q.set("files", "1");
|
if (files) q.set("files", "1");
|
||||||
|
if (hidden) q.set("hidden", "1");
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return request<{
|
return request<{
|
||||||
path: string;
|
path: string;
|
||||||
parent: string | null;
|
parent: string | null;
|
||||||
entries: { name: string; path: string; isGitRepo: boolean; file?: boolean }[];
|
home?: string;
|
||||||
|
entries: {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isGitRepo: boolean;
|
||||||
|
file?: boolean;
|
||||||
|
mtime?: number | null;
|
||||||
|
}[];
|
||||||
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
}>(`/lanes/browse${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* POST /api/lanes/browse/mkdir — the folder browser's New folder: one
|
||||||
|
* folder `name` inside `parent`. Throws `.code` `EBADNAME`, `EEXISTS`,
|
||||||
|
* `EFOLDER` (outside the caller's own folder).
|
||||||
|
*/
|
||||||
|
mkdir: (parent: string, name: string) =>
|
||||||
|
request<{ path: string }>("/lanes/browse/mkdir", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ parent, name }),
|
||||||
|
}),
|
||||||
/**
|
/**
|
||||||
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
|
* POST /api/lanes/pick-path — open the OS's own folder/file picker on the
|
||||||
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
|
* server's desktop and wait for the user. Throws `.code` `EREMOTE` (not a
|
||||||
|
|||||||
+11
-3
@@ -235,9 +235,17 @@ back on ANY of those to the in-app browser `GET /api/lanes/browse?path=<abs>`.
|
|||||||
A file field asks it with `files=1`: entries then include the folder's files
|
A file field asks it with `files=1`: entries then include the folder's files
|
||||||
(`file: true`, listed after the folders), and `path` opens the way the native
|
(`file: true`, listed after the folders), and `path` opens the way the native
|
||||||
picker's `start` does (`~` expanded, a file's folder, home when missing) —
|
picker's `start` does (`~` expanded, a file's folder, home when missing) —
|
||||||
a click on a file picks it. In server mode with
|
a double-click on a file picks it. `hidden=1` lists dot entries too (never
|
||||||
`AGENTHUB_USERS_ROOT` set, a developer's browser opens on their own folder
|
those at the top of a non-admin's own folder), every entry carries its `mtime`
|
||||||
(`parent: null` there) and any path whose real path leaves it is `403 EFOLDER`.
|
(ms) and the answer its `home` (the browser's Home place). In server mode with
|
||||||
|
`AGENTHUB_USERS_ROOT` set (under S3, else server.conf's `USERS_ROOT`), a
|
||||||
|
developer's browser opens on their own folder (`parent: null` there) and any
|
||||||
|
path whose real path leaves it is `403 EFOLDER`. The browser's **New folder**
|
||||||
|
is `POST /api/lanes/browse/mkdir` (`{parent, name}` → `201 {path}`): one plain
|
||||||
|
folder name inside an existing folder, `400 EBADNAME`/`ENOTFOUND`,
|
||||||
|
`409 EEXISTS`; a non-admin only inside their own folder, and in server mode
|
||||||
|
not at all without one (`403 EFOLDER`); a member's folder is made as the
|
||||||
|
member (S3).
|
||||||
|
|
||||||
#### Update a workspace
|
#### Update a workspace
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ than behind a disclosure — a disclosure around a single field only hid it.
|
|||||||
|
|
||||||
A workspace has no AI Proxy of its own: each NEW session picks one. The **AI Proxy** select in the workspace home's prompt box lists the profiles saved in Settings › AI Proxy (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a proxy (only Claude and Codex can), and it remembers the last pick (`agenthub.session.proxy`, falling back to the pre-rename `agenthub.session.router`). The start sends `proxy: "<name>"` (`router` is still accepted); a Claude pane then runs with `--settings=<data dir>/proxy-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two proxies, a worktree session gets its proxy too, and switching never touches a running session. A resume goes back to the proxy its session last ran on (`dashboard_runs.proxy`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's proxy into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Proxy profiles.
|
A workspace has no AI Proxy of its own: each NEW session picks one. The **AI Proxy** select in the workspace home's prompt box lists the profiles saved in Settings › AI Proxy (9Router, OpenRouter, a self-hosted gateway) plus **Native** (the vendor's own API); it shows Native alone, disabled, when the session's AI tool cannot use a proxy (only Claude and Codex can), and it remembers the last pick (`agenthub.session.proxy`, falling back to the pre-rename `agenthub.session.router`). The start sends `proxy: "<name>"` (`router` is still accepted); a Claude pane then runs with `--settings=<data dir>/proxy-settings/<name>.json`, which outranks the workspace's own settings, and a Codex pane with `-p <name>` plus its key through tmux `-e`. So two sessions of one workspace can run on two proxies, a worktree session gets its proxy too, and switching never touches a running session. A resume goes back to the proxy its session last ran on (`dashboard_runs.proxy`). Editing a profile in Settings rewrites those files (a rotated key reaches live Claude sessions); deleting it removes them. Older builds wrote a workspace's proxy into its `.claude/settings.local.json`; every server start takes those eight keys back out. See `docs/API.md` § AI Proxy profiles.
|
||||||
|
|
||||||
Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the small in-app folder browser instead (`GET /api/lanes/browse?path=<abs>`): click a subfolder to descend, ".." to go up, "Select this folder" to fill the path field; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker: its in-app fallback lists the folder's files too, and a click on a file picks it).
|
Both modes' path field has a **Browse** button next to it that opens the operating system's own folder picker (`POST /api/lanes/pick-path`). A browser cannot hand a web page an absolute path from its own dialog, so the SERVER opens the dialog on its desktop (zenity/kdialog on Linux, `osascript` on macOS, PowerShell on Windows) — which is your screen only when the browser runs on the same machine. A browser on another machine, or a server with no desktop or no zenity/kdialog, gets the in-app folder browser instead (`GET /api/lanes/browse?path=<abs>`), laid out like a native picker: Back/Forward/Up, a clickable breadcrumb that becomes a path field (✎ or Ctrl+L), Places (Home, your workspaces, recent picks), a filter, Name/Modified columns (click to sort), **Show hidden**, and **New folder**. A click selects, a double-click or Enter opens (Backspace goes up, typing jumps to a name), and "Select this folder" fills the path field with the selected folder, else the one shown; git repos are marked. The same button sits beside the workspace home's **Folder** field, Import history's folder, the project-memory folder, CLAUDE_HOME/CODEX_HOME and a remote source's SSH identity file (a file picker: its in-app fallback lists the folder's files too, and a double-click on a file picks it).
|
||||||
|
|
||||||
## Workspace tool: Claude or Codex
|
## Workspace tool: Claude or Codex
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -196,7 +196,9 @@ that already belongs to another user's workspace gets `409 EOWNED`. Admins are
|
|||||||
not limited. Without [one Linux user per account](#one-linux-user-per-account)
|
not limited. Without [one Linux user per account](#one-linux-user-per-account)
|
||||||
you create each user's folder yourself, and this keeps workspaces apart, not
|
you create each user's folder yourself, and this keeps workspaces apart, not
|
||||||
agents. `install-system` sets this value to `/srv/agenthub/users`, and adding a
|
agents. `install-system` sets this value to `/srv/agenthub/users`, and adding a
|
||||||
user makes the folder (it is the Linux account's home).
|
user makes the folder (it is the Linux account's home). With S3 installed, a
|
||||||
|
settings file that lacks the variable falls back to `USERS_ROOT` in
|
||||||
|
`/etc/agenthub/server.conf`, so a developer is never shown the host's home.
|
||||||
|
|
||||||
## What the gate does
|
## What the gate does
|
||||||
|
|
||||||
@@ -222,7 +224,7 @@ user makes the folder (it is the Linux account's home).
|
|||||||
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
|
- A login lasts 30 days in the httpOnly, `SameSite=Strict` cookie
|
||||||
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
|
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
|
||||||
change and disabling the user each end it.
|
change and disabling the user each end it.
|
||||||
- Passwords are stored as scrypt hashes and must be at least 8 characters.
|
- Passwords are stored as scrypt hashes; any non-empty password is accepted (no minimum length).
|
||||||
Every login attempt counts before its password is checked, so attempts sent
|
Every login attempt counts before its password is checked, so attempts sent
|
||||||
in parallel count too. After 10 failed logins from one address, or 30 for one
|
in parallel count too. After 10 failed logins from one address, or 30 for one
|
||||||
username, in 15 minutes, further logins answer `429 ETOOMANY` until the
|
username, in 15 minutes, further logins answer `429 ETOOMANY` until the
|
||||||
@@ -539,7 +541,8 @@ that are not theirs. Each case below is refused, with a test in
|
|||||||
line AgentHub adds for a new worktree or `.mcp.json` is written only when the
|
line AgentHub adds for a new worktree or `.mcp.json` is written only when the
|
||||||
repo's real git dir is the same account's; a FIFO there cannot hang the
|
repo's real git dir is the same account's; a FIFO there cannot hang the
|
||||||
dashboard.
|
dashboard.
|
||||||
- **Browsing and branch lists.** A developer's `GET /api/lanes/browse` and
|
- **Browsing and branch lists.** A developer's `GET /api/lanes/browse`, the
|
||||||
|
browser's New folder (`POST /api/lanes/browse/mkdir`, made as the member) and
|
||||||
`GET /api/lanes/branches` stay inside their own folder (`403 EFOLDER`), and
|
`GET /api/lanes/branches` stay inside their own folder (`403 EFOLDER`), and
|
||||||
so does a workspace's extra folder (`POST /api/lanes/:id/sources/folder`,
|
so does a workspace's extra folder (`POST /api/lanes/:id/sources/folder`,
|
||||||
refused before the path is looked at, so the answer says nothing about
|
refused before the path is looked at, so the answer says nothing about
|
||||||
|
|||||||
@@ -598,6 +598,41 @@ describe("GET /api/lanes/browse", () => {
|
|||||||
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||||
assert.ok(!dirsOnly.body.entries.some((e) => e.name === "b-file.txt"));
|
assert.ok(!dirsOnly.body.entries.some((e) => e.name === "b-file.txt"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("answers the home folder and each entry's mtime; hidden=1 lists dot entries", async () => {
|
||||||
|
fs.mkdirSync(path.join(ROOT, ".hidden-dir"), { recursive: true });
|
||||||
|
const plain = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||||
|
assert.equal(plain.body.home, os.homedir());
|
||||||
|
assert.ok(!plain.body.entries.some((e) => e.name === ".hidden-dir"));
|
||||||
|
const repo = plain.body.entries.find((e) => e.name === "src-repo");
|
||||||
|
assert.equal(typeof repo.mtime, "number");
|
||||||
|
const all = await request("GET", `/api/lanes/browse?hidden=1&path=${encodeURIComponent(ROOT)}`);
|
||||||
|
assert.ok(all.body.entries.some((e) => e.name === ".hidden-dir"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/lanes/browse/mkdir", () => {
|
||||||
|
it("makes one new folder in an existing one and answers its path", async () => {
|
||||||
|
const r = await request("POST", "/api/lanes/browse/mkdir", { parent: ROOT, name: "made" });
|
||||||
|
assert.equal(r.status, 201, JSON.stringify(r.body));
|
||||||
|
assert.equal(r.body.path, path.join(ROOT, "made"));
|
||||||
|
assert.ok(fs.statSync(path.join(ROOT, "made")).isDirectory());
|
||||||
|
const again = await request("POST", "/api/lanes/browse/mkdir", { parent: ROOT, name: "made" });
|
||||||
|
assert.equal(again.status, 409);
|
||||||
|
assert.equal(again.body.error.code, "EEXISTS");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a name that is not one plain folder name, and a missing parent", async () => {
|
||||||
|
for (const name of ["", ".", "..", "a/b", "a\0b", "x".repeat(256), 5]) {
|
||||||
|
const r = await request("POST", "/api/lanes/browse/mkdir", { parent: ROOT, name });
|
||||||
|
assert.equal(r.status, 400, `${JSON.stringify(name)}: ${JSON.stringify(r.body)}`);
|
||||||
|
assert.equal(r.body.error.code, "EBADNAME");
|
||||||
|
}
|
||||||
|
for (const parent of ["relative/dir", path.join(ROOT, "nope")]) {
|
||||||
|
const r = await request("POST", "/api/lanes/browse/mkdir", { parent, name: "x" });
|
||||||
|
assert.equal(r.status, 400, JSON.stringify(r.body));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("destructive lane lifecycle actions", () => {
|
describe("destructive lane lifecycle actions", () => {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* @file scope-users-root.test.js
|
||||||
|
* @description A non-admin's own folder comes from `AGENTHUB_USERS_ROOT`, and
|
||||||
|
* under S3 from server.conf's `USERS_ROOT` when that variable is missing — a
|
||||||
|
* server with S3 installed but no variable in its settings file let every
|
||||||
|
* developer browse the host account's whole home.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { describe, it, before, after, afterEach } = require("node:test");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
|
||||||
|
const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "scope-users-root-")));
|
||||||
|
const CONF = path.join(TMP, "server.conf");
|
||||||
|
process.env.AGENTHUB_SERVER_CONF = CONF;
|
||||||
|
process.env.AGENTHUB_MODE = "server";
|
||||||
|
delete process.env.AGENTHUB_USERS_ROOT;
|
||||||
|
|
||||||
|
const scope = require("../lib/scope");
|
||||||
|
const linux = require("../lib/linux-accounts");
|
||||||
|
|
||||||
|
const dev = { user: { id: 2, username: "vinnt", role: "developer" } };
|
||||||
|
const admin = { user: { id: 1, username: "boss", role: "admin" } };
|
||||||
|
|
||||||
|
describe("scope.ownFolder", () => {
|
||||||
|
before(() => {
|
||||||
|
fs.writeFileSync(CONF, "HOST_USER=host\nHOST_UID=1000\nUSERS_ROOT=/srv/agenthub/users\n");
|
||||||
|
linux.__resetConfCache();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.AGENTHUB_USERS_ROOT;
|
||||||
|
});
|
||||||
|
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
it("falls back to server.conf's USERS_ROOT under S3", () => {
|
||||||
|
assert.equal(scope.ownFolder(dev), "/srv/agenthub/users/vinnt");
|
||||||
|
assert.equal(scope.folderAllowed(dev, os.homedir()), false);
|
||||||
|
assert.equal(scope.folderAllowed(dev, "/srv/agenthub/users/vinnt/proj"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers AGENTHUB_USERS_ROOT when it is set", () => {
|
||||||
|
process.env.AGENTHUB_USERS_ROOT = "/data/users";
|
||||||
|
assert.equal(scope.ownFolder(dev), "/data/users/vinnt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never limits an admin", () => {
|
||||||
|
assert.equal(scope.ownFolder(admin), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no folder without S3 or the variable", () => {
|
||||||
|
fs.rmSync(CONF);
|
||||||
|
linux.__resetConfCache();
|
||||||
|
assert.equal(scope.ownFolder(dev), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,12 +94,18 @@ describe("managing accounts from the dashboard", () => {
|
|||||||
});
|
});
|
||||||
assert.equal(dup.status, 409);
|
assert.equal(dup.status, 409);
|
||||||
assert.equal(dup.body.error.code, "EEXISTS");
|
assert.equal(dup.body.error.code, "EEXISTS");
|
||||||
|
// No minimum length — only an empty password is refused.
|
||||||
|
const empty = await call("POST", "/api/users", {
|
||||||
|
cookie: boss,
|
||||||
|
body: { username: "zed", password: "", currentPassword: BOSS_PASS },
|
||||||
|
});
|
||||||
|
assert.equal(empty.status, 400);
|
||||||
|
assert.equal(empty.body.error.code, "EBADPASSWORD");
|
||||||
const short = await call("POST", "/api/users", {
|
const short = await call("POST", "/api/users", {
|
||||||
cookie: boss,
|
cookie: boss,
|
||||||
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
|
body: { username: "zed", password: "1", currentPassword: BOSS_PASS },
|
||||||
});
|
});
|
||||||
assert.equal(short.status, 400);
|
assert.equal(short.status, 201);
|
||||||
assert.equal(short.body.error.code, "EBADPASSWORD");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses without the admin's password, or with a wrong one", async () => {
|
it("refuses without the admin's password, or with a wrong one", async () => {
|
||||||
|
|||||||
@@ -155,6 +155,23 @@ describe("server mode", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a member with no folder of their own may not make one anywhere (browse/mkdir)", async () => {
|
||||||
|
for (const as of ["alice", "ana"]) {
|
||||||
|
const r = await call("POST", "/api/lanes/browse/mkdir", {
|
||||||
|
as,
|
||||||
|
body: { parent: ROOT, name: `by-${as}` },
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 403, JSON.stringify(r.body));
|
||||||
|
assert.equal(r.body.error.code, "EFOLDER");
|
||||||
|
}
|
||||||
|
assert.ok(!fs.existsSync(path.join(ROOT, "by-alice")));
|
||||||
|
const admin = await call("POST", "/api/lanes/browse/mkdir", {
|
||||||
|
as: "boss",
|
||||||
|
body: { parent: ROOT, name: "by-boss" },
|
||||||
|
});
|
||||||
|
assert.equal(admin.status, 201, JSON.stringify(admin.body));
|
||||||
|
});
|
||||||
|
|
||||||
it("pipeline stats cover only a member's own workspaces", async () => {
|
it("pipeline stats cover only a member's own workspaces", async () => {
|
||||||
const mine = await get("/api/lanes/pipeline-stats", "alice");
|
const mine = await get("/api/lanes/pipeline-stats", "alice");
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
|||||||
@@ -226,6 +226,37 @@ describe("a developer browses and lists branches only in their own folder", () =
|
|||||||
assert.equal(admin.status, 200);
|
assert.equal(admin.status, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hidden=1 shows dot entries, never the logins at the top of their home", async () => {
|
||||||
|
fs.mkdirSync(path.join(MIA, ".claude"), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(MIA, "proj", ".cfg"), { recursive: true });
|
||||||
|
const top = await call("mia", "GET", "/api/lanes/browse?hidden=1");
|
||||||
|
assert.equal(top.status, 200, JSON.stringify(top.body));
|
||||||
|
assert.equal(top.body.home, MIA);
|
||||||
|
assert.ok(!top.body.entries.some((e) => e.name.startsWith(".")));
|
||||||
|
const inner = await call(
|
||||||
|
"mia",
|
||||||
|
"GET",
|
||||||
|
`/api/lanes/browse?hidden=1&path=${encodeURIComponent(path.join(MIA, "proj"))}`
|
||||||
|
);
|
||||||
|
assert.ok(inner.body.entries.some((e) => e.name === ".cfg"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mkdir: only in their own folder, made as them", async () => {
|
||||||
|
const mine = await call("mia", "POST", "/api/lanes/browse/mkdir", {
|
||||||
|
parent: MIA,
|
||||||
|
name: "fresh",
|
||||||
|
});
|
||||||
|
assert.equal(mine.status, 201, JSON.stringify(mine.body));
|
||||||
|
assert.ok(fs.statSync(path.join(MIA, "fresh")).isDirectory());
|
||||||
|
for (const parent of [BOB, path.join(MIA, "tohost")]) {
|
||||||
|
const r = await call("mia", "POST", "/api/lanes/browse/mkdir", { parent, name: "x" });
|
||||||
|
assert.equal(r.status, 403, `${parent}: ${JSON.stringify(r.body)}`);
|
||||||
|
assert.equal(r.body.error.code, "EFOLDER");
|
||||||
|
}
|
||||||
|
assert.ok(!fs.existsSync(path.join(BOB, "x")));
|
||||||
|
assert.ok(!fs.existsSync(path.join(HOSTDIR, "x")));
|
||||||
|
});
|
||||||
|
|
||||||
it("branches", async () => {
|
it("branches", async () => {
|
||||||
const other = await call(
|
const other = await call(
|
||||||
"mia",
|
"mia",
|
||||||
|
|||||||
+3
-3
@@ -39,7 +39,6 @@ const COOKIE = "agenthub_sid";
|
|||||||
const SESSION_DAYS = 30;
|
const SESSION_DAYS = 30;
|
||||||
const ROLES = ["admin", "analyst", "developer"];
|
const ROLES = ["admin", "analyst", "developer"];
|
||||||
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
||||||
const MIN_PASSWORD = 8;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
* API prefixes whose writes change the host account's own setup. (AI Proxy
|
||||||
@@ -120,8 +119,9 @@ function validateUsername(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validatePassword(password) {
|
function validatePassword(password) {
|
||||||
if (typeof password !== "string" || password.length < MIN_PASSWORD) {
|
// No minimum length (the operator's choice) — only an empty one is refused.
|
||||||
throw err("EBADPASSWORD", `password: at least ${MIN_PASSWORD} characters`);
|
if (typeof password !== "string" || password.length === 0) {
|
||||||
|
throw err("EBADPASSWORD", "password: required");
|
||||||
}
|
}
|
||||||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-2
@@ -289,12 +289,18 @@ function audienceIncludes(audience, user) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A non-admin's own folder, `<AGENTHUB_USERS_ROOT>/<username>`, or null (no
|
* A non-admin's own folder, `<AGENTHUB_USERS_ROOT>/<username>`, or null (no
|
||||||
* root set, or an admin). Whatever the method: an analyst reads every user's
|
* root set, or an admin). Under S3 a missing variable falls back to
|
||||||
|
* server.conf's `USERS_ROOT` — the folder the member's Linux account lives in —
|
||||||
|
* so a settings file without it can never open the host's whole disk to them.
|
||||||
|
* Whatever the method: an analyst reads every user's
|
||||||
* ROWS, but a path named in a request is read with the dashboard's own rights
|
* ROWS, but a path named in a request is read with the dashboard's own rights
|
||||||
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
|
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
|
||||||
*/
|
*/
|
||||||
function ownFolder(req) {
|
function ownFolder(req) {
|
||||||
const root = (process.env.AGENTHUB_USERS_ROOT || "").trim();
|
const linux = require("./linux-accounts");
|
||||||
|
const root =
|
||||||
|
(process.env.AGENTHUB_USERS_ROOT || "").trim() ||
|
||||||
|
(linux.s3Enabled() ? linux.serverConf().usersRoot : "");
|
||||||
if (!req?.user || req.user.role === "admin" || !root) return null;
|
if (!req?.user || req.user.role === "admin" || !root) return null;
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
return path.join(path.resolve(root), req.user.username);
|
return path.join(path.resolve(root), req.user.username);
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ const paths = {
|
|||||||
required: ["username", "password", "currentPassword"],
|
required: ["username", "password", "currentPassword"],
|
||||||
properties: {
|
properties: {
|
||||||
username: { type: "string" },
|
username: { type: "string" },
|
||||||
password: { type: "string", minLength: 8 },
|
password: { type: "string", minLength: 1 },
|
||||||
role: {
|
role: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["admin", "analyst", "developer"],
|
enum: ["admin", "analyst", "developer"],
|
||||||
@@ -282,7 +282,7 @@ const paths = {
|
|||||||
operationId: "resetServerUserPassword",
|
operationId: "resetServerUserPassword",
|
||||||
summary: "Admin: a new password for that user; every login of theirs ends",
|
summary: "Admin: a new password for that user; every login of theirs ends",
|
||||||
parameters: [idParam],
|
parameters: [idParam],
|
||||||
requestBody: changeBody({ password: { type: "string", minLength: 8 } }, ["password"]),
|
requestBody: changeBody({ password: { type: "string", minLength: 1 } }, ["password"]),
|
||||||
responses: changeResponses,
|
responses: changeResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -151,6 +151,42 @@ const paths = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/lanes/browse/mkdir": {
|
||||||
|
post: {
|
||||||
|
tags: ["Lanes"],
|
||||||
|
summary: "New folder, from the in-app folder browser",
|
||||||
|
description:
|
||||||
|
"Makes ONE folder `name` (no `/`, `\\`, NUL, `.` or `..`; ≤ 255 bytes) inside the existing folder `parent` — never `-p`, never over an existing entry. A non-admin makes one only inside their own folder, by real path (`EFOLDER`), and in server mode a non-admin with no folder of their own makes none. Under S3 a folder in a member's home is made as that member.",
|
||||||
|
operationId: "makeBrowseFolder",
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["parent", "name"],
|
||||||
|
properties: {
|
||||||
|
parent: { type: "string", description: "Absolute path of an existing folder." },
|
||||||
|
name: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description: "`{path}` — the folder made.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { type: "object", properties: { path: { type: "string" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { description: "EBADNAME, EBADPATH, or ENOTFOUND (no such parent folder)." },
|
||||||
|
403: { description: "EBADORIGIN, or EFOLDER (outside the caller's own folder)." },
|
||||||
|
409: { description: "EEXISTS (the name is taken) or EMEMBERGONE." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/lanes/ensure": {
|
"/api/lanes/ensure": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["Lanes"],
|
tags: ["Lanes"],
|
||||||
|
|||||||
+94
-6
@@ -566,6 +566,9 @@ router.post("/gc", sameOriginGuard, (req, res) => {
|
|||||||
* "/:id" so the literal "browse" segment is never captured as a lane id.
|
* "/:id" so the literal "browse" segment is never captured as a lane id.
|
||||||
* `files=1` (a file field's fallback) lists files too, and opens `path` the
|
* `files=1` (a file field's fallback) lists files too, and opens `path` the
|
||||||
* way the native picker would (`startDir`: `~`, a file's folder, else home).
|
* way the native picker would (`startDir`: `~`, a file's folder, else home).
|
||||||
|
* `hidden=1` lists dot entries too — never those at the top of a non-admin's
|
||||||
|
* own folder, where a member's logins live. Each entry carries its `mtime`
|
||||||
|
* (ms), and `home` is the folder the browser's Home place opens.
|
||||||
*/
|
*/
|
||||||
router.get("/browse", (req, res) => {
|
router.get("/browse", (req, res) => {
|
||||||
// A developer browses only their own folder (by real path: a link in it
|
// A developer browses only their own folder (by real path: a link in it
|
||||||
@@ -573,6 +576,7 @@ router.get("/browse", (req, res) => {
|
|||||||
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
|
// rights, CAP_DAC_READ_SEARCH under S3, not theirs.
|
||||||
const own = scope.ownFolder(req);
|
const own = scope.ownFolder(req);
|
||||||
const files = req.query.files === "1";
|
const files = req.query.files === "1";
|
||||||
|
const hidden = req.query.hidden === "1";
|
||||||
const raw =
|
const raw =
|
||||||
typeof req.query.path === "string" && req.query.path.trim()
|
typeof req.query.path === "string" && req.query.path.trim()
|
||||||
? files
|
? files
|
||||||
@@ -594,15 +598,33 @@ router.get("/browse", (req, res) => {
|
|||||||
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
|
.json({ error: { code: "ENOTADIR", message: "path is not a directory" } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const atOwnTop = Boolean(own) && resolved === lanesLib.canonicalCwd(own);
|
||||||
|
const mtimeOf = (full) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(full).mtimeMs;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
let entries = [];
|
let entries = [];
|
||||||
try {
|
try {
|
||||||
entries = fs
|
entries = fs
|
||||||
.readdirSync(resolved, { withFileTypes: true })
|
.readdirSync(resolved, { withFileTypes: true })
|
||||||
.filter((e) => (e.isDirectory() || (files && e.isFile())) && !e.name.startsWith("."))
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
(e.isDirectory() || (files && e.isFile())) &&
|
||||||
|
(!e.name.startsWith(".") || (hidden && !atOwnTop))
|
||||||
|
)
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const full = path.join(resolved, e.name);
|
const full = path.join(resolved, e.name);
|
||||||
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true };
|
const mtime = mtimeOf(full);
|
||||||
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
|
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true, mtime };
|
||||||
|
return {
|
||||||
|
name: e.name,
|
||||||
|
path: full,
|
||||||
|
isGitRepo: fs.existsSync(path.join(full, ".git")),
|
||||||
|
mtime,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
// Folders first, as every file picker lists them.
|
// Folders first, as every file picker lists them.
|
||||||
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
|
.sort((a, b) => !!a.file - !!b.file || a.name.localeCompare(b.name));
|
||||||
@@ -610,12 +632,78 @@ router.get("/browse", (req, res) => {
|
|||||||
// An unreadable entry mid-listing is skipped, not a request failure.
|
// An unreadable entry mid-listing is skipped, not a request failure.
|
||||||
}
|
}
|
||||||
|
|
||||||
const top =
|
const top = path.dirname(resolved) === resolved || atOwnTop;
|
||||||
path.dirname(resolved) === resolved || (own && resolved === lanesLib.canonicalCwd(own));
|
|
||||||
const parent = top ? null : path.dirname(resolved);
|
const parent = top ? null : path.dirname(resolved);
|
||||||
res.json({ path: resolved, parent, entries });
|
res.json({ path: resolved, parent, entries, home: own || os.homedir() });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folder browser's New folder: makes ONE folder `name` (a plain name —
|
||||||
|
* no separator, not `.`/`..`) in the existing folder `parent`, never with
|
||||||
|
* `-p` and never over an existing entry (`EEXISTS`). A non-admin makes one
|
||||||
|
* only inside their own folder, judged by real path like `GET /browse`, and
|
||||||
|
* in server mode a non-admin with no folder of their own makes none
|
||||||
|
* (`EFOLDER`) — a read of the host's disk is not a write to it. A folder in a
|
||||||
|
* member's home (S3) is made AS the member, never by the host.
|
||||||
|
*/
|
||||||
|
router.post("/browse/mkdir", sameOriginGuard, (req, res) => {
|
||||||
|
const { parent, name } = req.body || {};
|
||||||
|
if (
|
||||||
|
typeof name !== "string" ||
|
||||||
|
!name ||
|
||||||
|
name === "." ||
|
||||||
|
name === ".." ||
|
||||||
|
/[/\\\0]/.test(name) ||
|
||||||
|
Buffer.byteLength(name) > 255
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: { code: "EBADNAME", message: "a folder name without '/' or '\\'" } });
|
||||||
|
}
|
||||||
|
if (typeof parent !== "string" || !path.isAbsolute(parent)) {
|
||||||
|
return res.status(400).json({ error: { code: "EBADPATH", message: "parent: absolute path" } });
|
||||||
|
}
|
||||||
|
const own = scope.ownFolder(req);
|
||||||
|
const limited = Boolean(req.user) && req.user.role !== "admin";
|
||||||
|
if (limited && !own) return refuseFolder(res);
|
||||||
|
const dir = own ? lanesLib.canonicalCwd(path.resolve(parent)) : path.resolve(parent);
|
||||||
|
if (own && !scope.folderAllowed(req, dir, { allowHome: true })) return refuseFolder(res);
|
||||||
|
try {
|
||||||
|
if (!fs.statSync(dir).isDirectory()) throw new Error("not a directory");
|
||||||
|
} catch {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: { code: "ENOTFOUND", message: "parent folder does not exist" } });
|
||||||
|
}
|
||||||
|
const target = path.join(dir, name);
|
||||||
|
if (fs.existsSync(target) || isSymlink(target)) {
|
||||||
|
return res.status(409).json({ error: { code: "EEXISTS", message: "that name is taken" } });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const runAs = require("../lib/run-as");
|
||||||
|
const acct = runAs.accountForPath(target);
|
||||||
|
if (acct) runAs.mkdirAs(acct, target);
|
||||||
|
else fs.mkdirSync(target);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.code === "EMEMBERGONE" ? 409 : err.code === "EEXIST" ? 409 : 500;
|
||||||
|
return res.status(status).json({
|
||||||
|
error: {
|
||||||
|
code: err.code === "EEXIST" ? "EEXISTS" : err.code || "EMKDIR",
|
||||||
|
message: err.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.status(201).json({ path: target });
|
||||||
|
});
|
||||||
|
|
||||||
|
function isSymlink(p) {
|
||||||
|
try {
|
||||||
|
return fs.lstatSync(p).isSymbolicLink();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The OS's own folder/file picker, shown on the SERVER's desktop
|
* The OS's own folder/file picker, shown on the SERVER's desktop
|
||||||
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
|
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
|
||||||
|
|||||||
Reference in New Issue
Block a user