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
|
||||
* 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
|
||||
* EREMOTE included).
|
||||
* EREMOTE included), where a double-click on a file picks it.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import { toast } from "../../lib/toast";
|
||||
import { PathPickButton } from "../PathPickButton";
|
||||
|
||||
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", () => ({
|
||||
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||
@@ -55,7 +55,7 @@ describe("PathPickButton", () => {
|
||||
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();
|
||||
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
||||
vi.mocked(api.lanes.browse).mockResolvedValue({
|
||||
@@ -68,9 +68,9 @@ describe("PathPickButton", () => {
|
||||
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
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();
|
||||
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(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,20 +1,97 @@
|
||||
/**
|
||||
* @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
|
||||
* picker, so path selection here is done by browsing `GET /api/lanes/browse`
|
||||
* (immediate subdirectories of a path, plus its files for `kind="file"`)
|
||||
* instead — breadcrumb-free, just an up-one-level button and a
|
||||
* click-to-descend list; in file mode a click on a file picks it.
|
||||
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||||
* absolute filesystem path from a native picker, so path selection here is
|
||||
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||||
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||||
* (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>
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
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 { formatDateTime } from "../../lib/format";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
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,
|
||||
kind = "dir",
|
||||
initialPath,
|
||||
@@ -30,20 +107,62 @@ export function FolderBrowseModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
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 [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);
|
||||
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) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
// keystroke in the field behind it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -54,8 +173,164 @@ export function FolderBrowseModal({
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
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;
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
@@ -64,82 +339,347 @@ export function FolderBrowseModal({
|
||||
>
|
||||
<div
|
||||
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()}
|
||||
onKeyDown={onPanelKey}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||||
data-tour="folder-browse"
|
||||
>
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="truncate font-mono text-xs text-fg-secondary" title={listing?.path}>
|
||||
{listing?.path || "…"}
|
||||
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||||
<div className="flex items-center gap-1 border-b border-border p-2">
|
||||
<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 className="flex-1 overflow-y-auto p-1">
|
||||
{error && (
|
||||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{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"
|
||||
{/* Footer: what will be picked, and the actions. */}
|
||||
<div className="border-t border-border p-2">
|
||||
<div
|
||||
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||||
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||||
title={target ?? undefined}
|
||||
>
|
||||
{t("destructive.cancel")}
|
||||
</button>
|
||||
{kind === "dir" && (
|
||||
<bdi>{target ?? "…"}</bdi>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!listing}
|
||||
onClick={() => {
|
||||
if (listing) onSelect(listing.path);
|
||||
onClose();
|
||||
setNewName("");
|
||||
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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,24 @@
|
||||
"browse.empty": "This folder is empty.",
|
||||
"browse.fileTitle": "Browse for a file",
|
||||
"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",
|
||||
"changes.back": "All files",
|
||||
"changes.bin": "bin",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"username": "Username",
|
||||
"usernameHint": "Letters, digits, '.', '_' or '-'",
|
||||
"password": "Password",
|
||||
"passwordHint": "At least 8 characters",
|
||||
"passwordHint": "No minimum length",
|
||||
"newPassword": "New password",
|
||||
"changeRole": "Change role",
|
||||
"disable": "Disable",
|
||||
@@ -64,7 +64,7 @@
|
||||
"ESELF": "You cannot change your own role or disable yourself.",
|
||||
"EEXISTS": "That username is taken.",
|
||||
"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.",
|
||||
"auditTitle": "Audit log",
|
||||
|
||||
@@ -42,6 +42,24 @@
|
||||
"browse.empty": "Thư mục này trống.",
|
||||
"browse.fileTitle": "Duyệt chọn tệp",
|
||||
"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",
|
||||
"changes.back": "Tất cả file",
|
||||
"changes.bin": "bin",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"usernameHint": "Chữ, số, '.', '_' hoặc '-'",
|
||||
"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",
|
||||
"changeRole": "Đổi vai trò",
|
||||
"disable": "Khoá",
|
||||
@@ -64,7 +64,7 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"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.
|
||||
* @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.
|
||||
* @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();
|
||||
if (path) q.set("path", path);
|
||||
if (files) q.set("files", "1");
|
||||
if (hidden) q.set("hidden", "1");
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
path: string;
|
||||
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}` : ""}`);
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
(`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) —
|
||||
a click on a file picks it. In server mode with
|
||||
`AGENTHUB_USERS_ROOT` set, a developer's browser opens on their own folder
|
||||
(`parent: null` there) and any path whose real path leaves it is `403 EFOLDER`.
|
||||
a double-click on a file picks it. `hidden=1` lists dot entries too (never
|
||||
those at the top of a non-admin's own folder), every entry carries its `mtime`
|
||||
(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
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+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)
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`agenthub_sid`. Only the token's SHA-256 is stored. Logging out, a password
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
repo's real git dir is the same account's; a FIFO there cannot hang the
|
||||
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
|
||||
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
|
||||
|
||||
@@ -598,6 +598,41 @@ describe("GET /api/lanes/browse", () => {
|
||||
const dirsOnly = await request("GET", `/api/lanes/browse?path=${encodeURIComponent(ROOT)}`);
|
||||
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", () => {
|
||||
|
||||
@@ -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.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", {
|
||||
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.body.error.code, "EBADPASSWORD");
|
||||
assert.equal(short.status, 201);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const mine = await get("/api/lanes/pipeline-stats", "alice");
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -226,6 +226,37 @@ describe("a developer browses and lists branches only in their own folder", () =
|
||||
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 () => {
|
||||
const other = await call(
|
||||
"mia",
|
||||
|
||||
+3
-3
@@ -39,7 +39,6 @@ const COOKIE = "agenthub_sid";
|
||||
const SESSION_DAYS = 30;
|
||||
const ROLES = ["admin", "analyst", "developer"];
|
||||
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
|
||||
@@ -120,8 +119,9 @@ function validateUsername(name) {
|
||||
}
|
||||
|
||||
function validatePassword(password) {
|
||||
if (typeof password !== "string" || password.length < MIN_PASSWORD) {
|
||||
throw err("EBADPASSWORD", `password: at least ${MIN_PASSWORD} characters`);
|
||||
// No minimum length (the operator's choice) — only an empty one is refused.
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
throw err("EBADPASSWORD", "password: required");
|
||||
}
|
||||
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
|
||||
* 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
|
||||
* (CAP_DAC_READ_SEARCH under S3), so an analyst's GET is held to it too.
|
||||
*/
|
||||
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;
|
||||
const path = require("node:path");
|
||||
return path.join(path.resolve(root), req.user.username);
|
||||
|
||||
@@ -241,7 +241,7 @@ const paths = {
|
||||
required: ["username", "password", "currentPassword"],
|
||||
properties: {
|
||||
username: { type: "string" },
|
||||
password: { type: "string", minLength: 8 },
|
||||
password: { type: "string", minLength: 1 },
|
||||
role: {
|
||||
type: "string",
|
||||
enum: ["admin", "analyst", "developer"],
|
||||
@@ -282,7 +282,7 @@ const paths = {
|
||||
operationId: "resetServerUserPassword",
|
||||
summary: "Admin: a new password for that user; every login of theirs ends",
|
||||
parameters: [idParam],
|
||||
requestBody: changeBody({ password: { type: "string", minLength: 8 } }, ["password"]),
|
||||
requestBody: changeBody({ password: { type: "string", minLength: 1 } }, ["password"]),
|
||||
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": {
|
||||
post: {
|
||||
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.
|
||||
* `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).
|
||||
* `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) => {
|
||||
// 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.
|
||||
const own = scope.ownFolder(req);
|
||||
const files = req.query.files === "1";
|
||||
const hidden = req.query.hidden === "1";
|
||||
const raw =
|
||||
typeof req.query.path === "string" && req.query.path.trim()
|
||||
? files
|
||||
@@ -594,15 +598,33 @@ router.get("/browse", (req, res) => {
|
||||
.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 = [];
|
||||
try {
|
||||
entries = fs
|
||||
.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) => {
|
||||
const full = path.join(resolved, e.name);
|
||||
if (e.isFile()) return { name: e.name, path: full, isGitRepo: false, file: true };
|
||||
return { name: e.name, path: full, isGitRepo: fs.existsSync(path.join(full, ".git")) };
|
||||
const mtime = mtimeOf(full);
|
||||
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.
|
||||
.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.
|
||||
}
|
||||
|
||||
const top =
|
||||
path.dirname(resolved) === resolved || (own && resolved === lanesLib.canonicalCwd(own));
|
||||
const top = path.dirname(resolved) === resolved || atOwnTop;
|
||||
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
|
||||
* (`server/lib/native-dialog.js`), answering `{path}` or `{cancelled: true}`.
|
||||
|
||||
Reference in New Issue
Block a user