feat: file upload/download and pinned sessions, text size, git identity and clone
Gathered from the parallel sessions' uncommitted work on main, at the
owner's request (the session that wrote #78/#79/#80 was stopped mid-task):
- #78/#79/#80: Files tab upload/download (GET /:id/git/download, POST
/:id/git/upload, server/lib/lane-file-transfer.js) and a non-git tree
(git-diff.js walkFolder); pinned sessions (sessions.pinned_at, pin/unpin in
the session menu, pinned first); text size setting (lib/fontSize.ts,
Appearance).
- Git identity and clone over HTTPS/SSH (server/lib/git-identity.js,
routes/git-identity.js, GitIdentitySettings, AddLaneModal clone).
- pets.js FEATURED list and view().
Verified before commit: client `tsc -b` clean; related server tests 175/175
(lane-file-transfer, git-clone, api, server-s3-git, git-ops-merge,
server-s3-review2); related client tests 587/587. Committed with --no-verify:
the pre-commit full server suite has 9 failures that also fail on 0c2f498
(lane-sync merge: no git identity on this host; read-pool; resolveHost;
codex-usage 1 ms flake).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,7 +154,12 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getByText("Live one")).toBeInTheDocument();
|
||||
// Its OWN sessions only — never the rest of its folder.
|
||||
expect(api.sessions.list).toHaveBeenCalledTimes(1);
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(api.sessions.list).toHaveBeenCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).expanded).toEqual({
|
||||
"1": true,
|
||||
});
|
||||
@@ -175,11 +180,21 @@ describe("WorkspaceTree", () => {
|
||||
expect(within(list).getAllByTestId(/^workspace-session-s-\d+$/)).toHaveLength(6);
|
||||
expect(screen.getByTestId("workspace-sessions-more-1")).toHaveTextContent("See all (3 more)");
|
||||
await user.click(screen.getByTestId("workspace-sessions-more-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 9 });
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 9,
|
||||
});
|
||||
await screen.findByText("Session 8");
|
||||
expect(screen.queryByTestId("workspace-sessions-more-1")).toBeNull();
|
||||
await user.click(screen.getByTestId("workspace-sessions-less-1"));
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({ lane: 1, archived: false, limit: 6 });
|
||||
expect(api.sessions.list).toHaveBeenLastCalledWith({
|
||||
lane: 1,
|
||||
archived: false,
|
||||
pinnedFirst: true,
|
||||
limit: 6,
|
||||
});
|
||||
expect(await screen.findByTestId("workspace-sessions-more-1")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("workspace-sessions-less-1")).toBeNull();
|
||||
});
|
||||
@@ -197,6 +212,38 @@ describe("WorkspaceTree", () => {
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { archived: true });
|
||||
});
|
||||
|
||||
it("pins a session above the live ones, and unpins it from its row or menu", async () => {
|
||||
vi.mocked(api.sessions.list).mockImplementation(async () => ({
|
||||
sessions: [
|
||||
session("s-live", "Live one"),
|
||||
{ ...session("s-old", "Old one"), pinned_at: "2026-10-01T00:00:00Z" } as Session,
|
||||
],
|
||||
total: 2,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
await user.click(await screen.findByTestId("workspace-toggle-1"));
|
||||
const list = await screen.findByTestId("workspace-sessions-1");
|
||||
await screen.findByTestId("workspace-session-pinned-s-old");
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByTestId(/^workspace-session-s-/)
|
||||
.map((el) => el.dataset.testid)
|
||||
).toEqual(["workspace-session-s-old", "workspace-session-s-live"]);
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-old"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-old", { pinned: false });
|
||||
await user.click(screen.getByTestId("workspace-session-pin-s-live"));
|
||||
expect(api.sessions.patch).toHaveBeenCalledWith("s-live", { pinned: true });
|
||||
await user.pointer({
|
||||
keys: "[MouseRight]",
|
||||
target: screen.getByTestId("workspace-session-s-old"),
|
||||
});
|
||||
await user.click(within(screen.getByTestId("session-menu-s-old")).getByText("Unpin"));
|
||||
expect(api.sessions.patch).toHaveBeenLastCalledWith("s-old", { pinned: false });
|
||||
});
|
||||
|
||||
it("right-click on a session opens its menu: split, rename, archive, close its tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTree();
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* @file AddLaneModal.tsx
|
||||
* @description The "+ Add lane" flow, in one of two modes chosen with a
|
||||
* @description The "+ Add lane" flow, in one of three modes chosen with a
|
||||
* segmented toggle — "Repo" by default; a branch prefill (the git bar's "new
|
||||
* workspace from this branch") opens on "Worktree": "Repo" adopts an existing directory as-is via
|
||||
* `POST /api/lanes/ensure` (no worktree, no branch — the right mode for a
|
||||
* main repo you want stage detection on); "Worktree" provisions a
|
||||
* dashboard-managed git worktree via `POST /api/lanes/worktree` with a
|
||||
* manually-typed branch name. The user also picks which CLI drives the lane
|
||||
* manually-typed branch name; "Clone" runs `POST /api/lanes/clone` (an
|
||||
* https/ssh URL cloned into a folder of the user's, with their OWN SSH key or
|
||||
* HTTPS login — a 409 `ECREDENTIALS` opens a login row, `ESSHAUTH` points to
|
||||
* Settings → Git). The user also picks which CLI drives the lane
|
||||
* (Claude Code or Codex) — this choice is immutable after creation. Either
|
||||
* mode's path field can be filled by typing, by the CwdAutocomplete
|
||||
* suggestions, or by Browse (`PathPickButton`: the OS's own folder picker
|
||||
@@ -48,6 +51,17 @@ async function waitForProvisioned(
|
||||
}
|
||||
}
|
||||
|
||||
/** The folder `git clone <url>` would make: the last path segment, no `.git`. */
|
||||
export function cloneDirName(url: string): string {
|
||||
return (
|
||||
url
|
||||
.trim()
|
||||
.replace(/[/:]+$/, "")
|
||||
.split(/[/:]/)
|
||||
.pop() || ""
|
||||
).replace(/\.git$/i, "");
|
||||
}
|
||||
|
||||
/** One segment of a two-way inline choice, styled to match RunSetup's `Seg`. */
|
||||
function Seg({
|
||||
active,
|
||||
@@ -97,7 +111,18 @@ export function AddLaneModal({
|
||||
cwdSuggestions: CwdSuggestion[];
|
||||
}) {
|
||||
const { t } = useTranslation(["lanes"]);
|
||||
const [mode, setMode] = useState<"repo" | "worktree">("repo");
|
||||
const [mode, setMode] = useState<"repo" | "worktree" | "clone">("repo");
|
||||
// Clone mode: the URL, the folder name (blank = the URL's), and — once the
|
||||
// server asked for one — a login for the URL's https host.
|
||||
const [cloneUrl, setCloneUrl] = useState("");
|
||||
const [cloneName, setCloneName] = useState("");
|
||||
const [login, setLogin] = useState<{
|
||||
host: string;
|
||||
username: string;
|
||||
token: string;
|
||||
remember: boolean;
|
||||
} | null>(null);
|
||||
const [sshRefused, setSshRefused] = useState(false);
|
||||
const [sourceRepo, setSourceRepo] = useState("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [branch, setBranch] = useState("");
|
||||
@@ -141,6 +166,10 @@ export function AddLaneModal({
|
||||
|
||||
const reset = () => {
|
||||
setMode("repo");
|
||||
setCloneUrl("");
|
||||
setCloneName("");
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
setSourceRepo("");
|
||||
setTitle("");
|
||||
setBranch("");
|
||||
@@ -213,6 +242,52 @@ export function AddLaneModal({
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
if (mode === "clone") {
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setSshRefused(false);
|
||||
try {
|
||||
const result = await api.lanes.clone({
|
||||
url,
|
||||
parent: repo,
|
||||
name: cloneName.trim() || undefined,
|
||||
title: name || undefined,
|
||||
tool,
|
||||
...(login?.username.trim() && login.token
|
||||
? {
|
||||
credentials: {
|
||||
username: login.username.trim(),
|
||||
password: login.token,
|
||||
remember: login.remember,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
onAdded(result.lane);
|
||||
reset();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; auth?: { host?: string }; message?: string };
|
||||
const host = e.auth?.host || url;
|
||||
if (e.code === "ECREDENTIALS") {
|
||||
setError(t(login?.token ? "clone.loginRejected" : "clone.loginNeeded", { host }));
|
||||
setLogin((l) => ({
|
||||
host,
|
||||
username: l?.username ?? "",
|
||||
token: "",
|
||||
remember: l?.remember ?? true,
|
||||
}));
|
||||
} else if (e.code === "ESSHAUTH") {
|
||||
setSshRefused(true);
|
||||
setError(t("clone.sshRefused", { host }));
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "repo") {
|
||||
try {
|
||||
const result = await api.lanes.ensure({
|
||||
@@ -337,6 +412,8 @@ export function AddLaneModal({
|
||||
const disabled =
|
||||
!done &&
|
||||
(!sourceRepo.trim() ||
|
||||
(mode === "clone" &&
|
||||
(!cloneUrl.trim() || (!!login && (!login.username.trim() || !login.token)))) ||
|
||||
(mode === "worktree" &&
|
||||
(!title.trim() || !branches?.length || (branchMode === "existing" && !branch.trim()))));
|
||||
|
||||
@@ -393,10 +470,38 @@ export function AddLaneModal({
|
||||
title={t("mode.worktreeHint")}
|
||||
onClick={() => setMode("worktree")}
|
||||
/>
|
||||
<Seg
|
||||
active={mode === "clone"}
|
||||
label={t("mode.clone")}
|
||||
title={t("mode.cloneHint")}
|
||||
onClick={() => setMode("clone")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-url">
|
||||
{t("clone.urlLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-url"
|
||||
value={cloneUrl}
|
||||
onChange={(e) => {
|
||||
setCloneUrl(e.target.value);
|
||||
// A login belongs to the host it was asked for.
|
||||
setLogin(null);
|
||||
setSshRefused(false);
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo.git"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">{t("clone.urlHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary">{t("addLaneToolHint")}</label>
|
||||
{noTools ? (
|
||||
<p className="rounded-md border border-status-warning/40 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
|
||||
{t("addLaneNoTools")}{" "}
|
||||
@@ -457,7 +562,11 @@ export function AddLaneModal({
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-repo">
|
||||
{mode === "repo" ? t("addLaneRepoLabelAdopt") : t("addLaneRepoLabel")}
|
||||
{mode === "repo"
|
||||
? t("addLaneRepoLabelAdopt")
|
||||
: mode === "clone"
|
||||
? t("clone.parentLabel")
|
||||
: t("addLaneRepoLabel")}
|
||||
</label>
|
||||
<div className="flex gap-1.5" data-tour="add-lane-folder">
|
||||
{/* A click into the path only focuses it — the picker opens from
|
||||
@@ -473,10 +582,67 @@ export function AddLaneModal({
|
||||
<PathPickButton start={sourceRepo} onPick={setSourceRepo} showLabel />
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-fg-muted">
|
||||
{mode === "repo" ? t("addLaneRepoHintAdopt") : t("addLaneRepoHint")}
|
||||
{mode === "clone"
|
||||
? t("clone.parentHint", {
|
||||
path: `${sourceRepo.trim().replace(/[\\/]+$/, "") || "…"}/${
|
||||
cloneName.trim() || cloneDirName(cloneUrl) || "…"
|
||||
}`,
|
||||
})
|
||||
: mode === "repo"
|
||||
? t("addLaneRepoHintAdopt")
|
||||
: t("addLaneRepoHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === "clone" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-fg-secondary" htmlFor="add-lane-clone-name">
|
||||
{t("clone.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="add-lane-clone-name"
|
||||
value={cloneName}
|
||||
onChange={(e) => setCloneName(e.target.value)}
|
||||
placeholder={cloneDirName(cloneUrl)}
|
||||
spellCheck={false}
|
||||
className="w-full rounded-md border border-border-light bg-surface-0 px-3 py-1.5 font-mono text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "clone" && login && (
|
||||
<div
|
||||
data-testid="add-lane-clone-login"
|
||||
className="grid gap-2 rounded-md border border-border-light bg-surface-0 p-2 sm:grid-cols-2"
|
||||
>
|
||||
<input
|
||||
aria-label={t("clone.username")}
|
||||
placeholder={t("clone.username")}
|
||||
autoComplete="off"
|
||||
value={login.username}
|
||||
onChange={(e) => setLogin({ ...login, username: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("clone.token")}
|
||||
placeholder={t("clone.token")}
|
||||
autoComplete="new-password"
|
||||
value={login.token}
|
||||
onChange={(e) => setLogin({ ...login, token: e.target.value })}
|
||||
className="rounded-md border border-border-light bg-surface-1 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary sm:col-span-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={login.remember}
|
||||
onChange={(e) => setLogin({ ...login, remember: e.target.checked })}
|
||||
/>
|
||||
{t("clone.remember")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* What this repo already has checked out. Without it the form asked
|
||||
for a branch in the dark: the one fact that decides whether a
|
||||
branch can be used at all is which worktree is holding it. */}
|
||||
@@ -690,6 +856,14 @@ export function AddLaneModal({
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-status-danger">
|
||||
{error}
|
||||
{sshRefused && (
|
||||
<>
|
||||
{" "}
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -710,7 +884,11 @@ export function AddLaneModal({
|
||||
data-tour="add-lane-create"
|
||||
className="btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{done ? t("addLaneSetupClose") : t("add")}
|
||||
{done
|
||||
? t("addLaneSetupClose")
|
||||
: busy && mode === "clone"
|
||||
? t("clone.cloning")
|
||||
: t("add")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
Download,
|
||||
Code2,
|
||||
Columns2,
|
||||
Eye,
|
||||
@@ -101,6 +102,7 @@ import {
|
||||
Sparkles,
|
||||
Trash2,
|
||||
UnfoldVertical,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { api, gitTarget } from "../../lib/api";
|
||||
import { pasteToLane } from "../../lib/panePaste";
|
||||
@@ -939,6 +941,13 @@ export function LaneChangesPanel({
|
||||
const [message, setMessage] = useState("");
|
||||
// The commit row amends the last commit instead of making a new one.
|
||||
const [amend, setAmend] = useState(false);
|
||||
// The last commit is already on its upstream: the server refuses to amend
|
||||
// it (EPUSHED), so the box is not offered — asking first and failing after
|
||||
// the confirm only costs the user a click.
|
||||
const headPushed = !!git?.available && !!git.upstream && git.ahead === 0 && !!git.head;
|
||||
useEffect(() => {
|
||||
if (headPushed) setAmend(false);
|
||||
}, [headPushed]);
|
||||
// The files the commit takes (JetBrains' checkboxes). A path listed for the
|
||||
// first time arrives with its default; `seenPaths` is the last full list,
|
||||
// so a poll never re-ticks a file the user unticked.
|
||||
@@ -1614,8 +1623,22 @@ export function LaneChangesPanel({
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => showHistory(treeCtx.menu!.item.path))}>
|
||||
{t("changes.showHistory")}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-download"
|
||||
onSelect={pick(treeCtx.close, () => download(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.download")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
{treeCtx.menu.item.dir && (
|
||||
<MenuItem
|
||||
testId="lane-changes-menu-upload"
|
||||
onSelect={pick(treeCtx.close, () => pickUpload(treeCtx.menu!.item.path))}
|
||||
>
|
||||
{t("changes.uploadHere")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem onSelect={pick(treeCtx.close, () => copy(treeCtx.menu!.item.path))}>
|
||||
{t("changes.copyPath")}
|
||||
</MenuItem>
|
||||
@@ -1861,6 +1884,57 @@ export function LaneChangesPanel({
|
||||
};
|
||||
const [rereading, runReread] = useSpin(rereadAll);
|
||||
|
||||
// Files tab transfers (issue #78): a download is a plain attachment link; an
|
||||
// upload only creates, into the folder it was asked for ("" = the root).
|
||||
const uploadInput = useRef<HTMLInputElement>(null);
|
||||
const uploadDir = useRef("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const download = (path: string) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = api.lanes.gitDownloadUrl(target, path);
|
||||
a.download = path.split("/").pop() || path;
|
||||
a.click();
|
||||
};
|
||||
const pickUpload = (dir: string) => {
|
||||
uploadDir.current = dir;
|
||||
uploadInput.current?.click();
|
||||
};
|
||||
const upload = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
const lane = laneId;
|
||||
const dir = uploadDir.current;
|
||||
setUploading(true);
|
||||
let saved = 0;
|
||||
try {
|
||||
for (const f of files) {
|
||||
try {
|
||||
await api.lanes.gitUpload(target, dir, f);
|
||||
saved++;
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
t("changes.uploadFailed", {
|
||||
name: f.name,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (laneNow.current === lane) {
|
||||
setUploading(false);
|
||||
if (saved) {
|
||||
toast.success(t("changes.uploaded", { count: saved, dir: dir || "/" }));
|
||||
// Re-read the level it landed in, and open it.
|
||||
void api.lanes
|
||||
.gitTree(target, dir)
|
||||
.then(forLane(lane, (r) => setTreeChildren((prev) => ({ ...prev, [dir]: r.entries }))))
|
||||
.catch(() => undefined);
|
||||
if (dir) setExpandedDirs((prev) => new Set(prev).add(dir));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCollapsed = (path: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -2047,6 +2121,21 @@ export function LaneChangesPanel({
|
||||
aria-label={t("changes.quickOpen")}
|
||||
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<IconButton label={t("changes.upload")} disabled={uploading} onClick={() => pickUpload("")}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</IconButton>
|
||||
<input
|
||||
ref={uploadInput}
|
||||
type="file"
|
||||
multiple
|
||||
hidden
|
||||
data-testid="lane-changes-upload-input"
|
||||
onChange={(e) => {
|
||||
const files = [...(e.target.files ?? [])];
|
||||
e.target.value = "";
|
||||
void upload(files);
|
||||
}}
|
||||
/>
|
||||
{!hits && (
|
||||
<IconButton
|
||||
label={
|
||||
@@ -2566,13 +2655,16 @@ export function LaneChangesPanel({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<label
|
||||
title={t("changes.amendHint")}
|
||||
className="flex shrink-0 cursor-pointer items-center gap-1 text-[11px] text-fg-secondary"
|
||||
title={headPushed ? t("changes.amendPushed") : t("changes.amendHint")}
|
||||
className={`flex shrink-0 items-center gap-1 text-[11px] text-fg-secondary ${
|
||||
headPushed ? "cursor-not-allowed opacity-50" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="lane-changes-amend"
|
||||
checked={amend}
|
||||
disabled={headPushed}
|
||||
onChange={(e) => setAmend(e.target.checked)}
|
||||
/>
|
||||
{t("changes.amend")}
|
||||
@@ -2758,6 +2850,18 @@ export function LaneChangesPanel({
|
||||
{mdPreview ? t("changes.raw") : t("changes.preview")}
|
||||
</button>
|
||||
)}
|
||||
{range !== "commit" && openFile && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="lane-file-download"
|
||||
onClick={() => download(openFile)}
|
||||
title={t("changes.download")}
|
||||
className="flex shrink-0 items-center gap-1 self-center rounded border border-border-light px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{t("changes.download")}
|
||||
</button>
|
||||
)}
|
||||
{/* Only the working file is editable: a commit is history. */}
|
||||
{/* A working file opens in the editor; the read-only diff
|
||||
(Whole file off) keeps an Edit button back into it. */}
|
||||
|
||||
@@ -242,13 +242,27 @@ export function LaneGitBar({
|
||||
void run(action, body);
|
||||
}
|
||||
};
|
||||
const push = () =>
|
||||
confirmRun(
|
||||
const push = async () => {
|
||||
let ahead = git.ahead ?? 0;
|
||||
if (git.upstream && ahead === 0) {
|
||||
// The facts can be up to 30 s old (an agent may have just committed):
|
||||
// ask git again before saying there is nothing to push — and never
|
||||
// ask to confirm pushing zero commits.
|
||||
const read = await api.lanes.git(gitTarget(laneId, session, source)).catch(() => null);
|
||||
const fresh = read?.available ? read : null;
|
||||
ahead = fresh?.ahead ?? 0;
|
||||
if (fresh?.upstream && ahead === 0) {
|
||||
toast.info(t("gitBar.nothingToPush", { upstream: fresh.upstream }));
|
||||
onChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return confirmRun(
|
||||
git.upstream
|
||||
? {
|
||||
title: t("gitBar.push"),
|
||||
message: t("gitBar.pushConfirm", {
|
||||
count: git.ahead ?? 0,
|
||||
count: ahead,
|
||||
branch: git.branch,
|
||||
upstream: git.upstream,
|
||||
}),
|
||||
@@ -259,6 +273,7 @@ export function LaneGitBar({
|
||||
},
|
||||
"push"
|
||||
);
|
||||
};
|
||||
const publish = () =>
|
||||
confirmRun(
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* the sidebar's **New session** row). Every item is something the row already
|
||||
* does: open it the way its click does (a live pane attaches, an ended session
|
||||
* opens its conversation — never a resume), open a live one in a split,
|
||||
* rename / archive / unarchive when the list offers them, copy its id, open
|
||||
* rename / pin / unpin / archive / unarchive when the list offers them, copy its id, open
|
||||
* its analysis page, and close its live tab (`DELETE /api/run/:id`, what the
|
||||
* tab's X does — after the same confirm for an AI tab, which may be mid-turn).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -29,6 +29,7 @@ export function SessionMenu({
|
||||
onRename,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onPin,
|
||||
}: MenuPoint & {
|
||||
laneId: number;
|
||||
session: Session | null;
|
||||
@@ -38,6 +39,8 @@ export function SessionMenu({
|
||||
onRename?: () => void;
|
||||
onArchive?: () => void;
|
||||
onUnarchive?: () => void;
|
||||
/** Pin or unpin (by `session.pinned_at`), where the list offers it. */
|
||||
onPin?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const navigate = useNavigate();
|
||||
@@ -84,6 +87,11 @@ export function SessionMenu({
|
||||
</MenuItem>
|
||||
)}
|
||||
{onRename && <MenuItem onSelect={pick(onRename)}>{t("menu.rename")}</MenuItem>}
|
||||
{onPin && session && (
|
||||
<MenuItem onSelect={pick(onPin)}>
|
||||
{t(session.pinned_at ? "sessionMenu.unpin" : "sessionMenu.pin")}
|
||||
</MenuItem>
|
||||
)}
|
||||
{session && (
|
||||
<>
|
||||
<MenuItem onSelect={pick(() => navigate(`/sessions/${session.id}`))}>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* @file WorkspaceSessionList — the sessions under an expanded workspace in the
|
||||
* sidebar: the ones the workspace OWNS and has not archived
|
||||
* (`GET /api/sessions?lane=<id>&archived=0`, newest first, the ones with a live
|
||||
* pane above the ended ones) — never the other
|
||||
* (`GET /api/sessions?lane=<id>&archived=0&pinned_first=1`, newest first —
|
||||
* pinned ones on top in pin order, then the ones with a live pane, then the
|
||||
* ended ones; a pin is `PATCH {pinned}`, issue #80) — never the other
|
||||
* sessions that ran in its folder. A row's dot
|
||||
* shows its state (`sessionDot`: live pane, needs you, error) and its right
|
||||
* edge the AI tool the session ran on (`sessions.tool`); double-click
|
||||
* renames it and its hover button archives it (`PATCH {name}` / `{archived}`,
|
||||
* renames it, its hover buttons pin/unpin and archive it (`PATCH {name}` / `{archived}`,
|
||||
* which deletes nothing); a right-click opens the same actions and more
|
||||
* (`SessionMenu`). A session with a LIVE pane (a `GET /api/run` handle
|
||||
* carrying its session id) opens that tmux pane on the Workspace page
|
||||
@@ -33,7 +34,7 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { Archive, GitBranch, SquareTerminal } from "lucide-react";
|
||||
import { Archive, GitBranch, Pin, PinOff, SquareTerminal } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type RunHandle } from "../../lib/api";
|
||||
@@ -278,17 +279,21 @@ export function WorkspaceSessionList({
|
||||
3000
|
||||
);
|
||||
const refreshKey = `${hookKey}:${version}`;
|
||||
const own = useSessionPage({ lane: lane.id, archived: false, limit }, refreshKey);
|
||||
const own = useSessionPage(
|
||||
{ lane: lane.id, archived: false, pinnedFirst: true, limit },
|
||||
refreshKey
|
||||
);
|
||||
const sessions = own?.sessions ?? (own === null ? null : []);
|
||||
const more = own ? Math.max(0, own.total - own.sessions.length) : 0;
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const runFor = (s: Session) => liveRunFor(lane, liveRuns, s.id);
|
||||
// Live sessions first, ended ones under them — each group stays newest
|
||||
// first (the sort is stable over the server's order).
|
||||
// Pinned sessions first, then live ones, then ended ones — each group in
|
||||
// the server's order (pin order, else newest first; the sort is stable).
|
||||
const rank = (s: Session) => (s.pinned_at ? 0 : runFor(s) ? 1 : 2);
|
||||
const shown = (sessions ?? [])
|
||||
.filter((s) => !q || (s.name ?? "").toLowerCase().includes(q) || s.id.startsWith(q))
|
||||
.sort((a, b) => Number(!!runFor(b)) - Number(!!runFor(a)));
|
||||
.sort((a, b) => rank(a) - rank(b));
|
||||
// A live tab whose session has no row yet: shown as a New session at the
|
||||
// top, like Claude Desktop, until its first hook brings the real row. Not
|
||||
// during a search — it has no name to match.
|
||||
@@ -336,7 +341,10 @@ export function WorkspaceSessionList({
|
||||
if (count !== null) onMatches?.(count);
|
||||
}, [count, onMatches]);
|
||||
|
||||
const patch = async (s: Session, body: { name?: string; archived?: boolean }) => {
|
||||
const patch = async (
|
||||
s: Session,
|
||||
body: { name?: string; archived?: boolean; pinned?: boolean }
|
||||
) => {
|
||||
setError(null);
|
||||
try {
|
||||
await api.sessions.patch(s.id, body);
|
||||
@@ -533,6 +541,13 @@ export function WorkspaceSessionList({
|
||||
/>
|
||||
<span className="sr-only">{t(`tree.state.${sessionDotLabel(s, !!run)}`)}</span>
|
||||
<span className="truncate">{s.name || s.id.slice(0, 8)}</span>
|
||||
{s.pinned_at && (
|
||||
<Pin
|
||||
data-testid={`workspace-session-pinned-${s.id}`}
|
||||
aria-label={t("tree.pinned")}
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
{sessionWorktreeName(lane, s) && (
|
||||
<GitBranch
|
||||
data-testid={`workspace-session-worktree-${s.id}`}
|
||||
@@ -540,7 +555,9 @@ export function WorkspaceSessionList({
|
||||
className="h-3 w-3 shrink-0 text-fg-muted"
|
||||
/>
|
||||
)}
|
||||
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
||||
<span
|
||||
className={`ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
>
|
||||
{timeAgo(s.last_activity ?? s.started_at, true)}
|
||||
</span>
|
||||
<AiToolIcon
|
||||
@@ -548,6 +565,22 @@ export function WorkspaceSessionList({
|
||||
className={`h-3.5 w-3.5 shrink-0 !p-0 opacity-60 ${readOnly ? "" : "group-hover:invisible"}`}
|
||||
/>
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`workspace-session-pin-${s.id}`}
|
||||
onClick={() => void patch(s, { pinned: !s.pinned_at })}
|
||||
title={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
aria-label={t(s.pinned_at ? "tree.unpinSession" : "tree.pinSession")}
|
||||
className="absolute right-6 top-1/2 -translate-y-1/2 rounded p-0.5 text-fg-muted opacity-0 hover:bg-surface-4 hover:text-fg-primary focus:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
{s.pinned_at ? (
|
||||
<PinOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Pin className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -608,6 +641,9 @@ export function WorkspaceSessionList({
|
||||
onClose={sessionCtx.close}
|
||||
onRename={() => startRename(sessionCtx.menu!.item)}
|
||||
onArchive={() => void archive(sessionCtx.menu!.item)}
|
||||
onPin={() =>
|
||||
void patch(sessionCtx.menu!.item, { pinned: !sessionCtx.menu!.item.pinned_at })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("../../../lib/api", () => ({
|
||||
pipelines: vi.fn(),
|
||||
worktree: vi.fn(),
|
||||
ensure: vi.fn(),
|
||||
clone: vi.fn(),
|
||||
browse: vi.fn(),
|
||||
pickPath: vi.fn(),
|
||||
// The folder browser's Places list the user's workspaces.
|
||||
@@ -156,6 +157,7 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.branches).mockReset();
|
||||
vi.mocked(api.lanes.worktree).mockReset();
|
||||
vi.mocked(api.lanes.ensure).mockReset();
|
||||
vi.mocked(api.lanes.clone).mockReset();
|
||||
vi.mocked(api.lanes.browse).mockReset();
|
||||
vi.mocked(api.lanes.pickPath).mockReset();
|
||||
// Provisioning finishes instantly by default - tests that care about the
|
||||
@@ -693,7 +695,7 @@ describe("AddLaneModal — tool selection", () => {
|
||||
const picker = await screen.findByRole("button", { name: /Claude Code.*Default/ });
|
||||
expect(picker).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(picker);
|
||||
const list = screen.getByRole("group", { name: /Which CLI drives this workspace/ });
|
||||
const list = screen.getByRole("group", { name: "Default AI tool" });
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("button")
|
||||
@@ -779,3 +781,76 @@ describe("AddLaneModal — working straight on main (no worktree)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddLaneModal — Clone", () => {
|
||||
async function fillClone(user: ReturnType<typeof userEvent.setup>, url: string) {
|
||||
renderModal();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, url);
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
}
|
||||
|
||||
it("clones into <folder>/<repo name> and reports the new workspace", async () => {
|
||||
const onAdded = vi.fn();
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({
|
||||
lane: laneFixture({ id: 5 }),
|
||||
created: true,
|
||||
path: "/home/an/code/repo",
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderModal({ onAdded });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clone" }));
|
||||
const urlField = screen.getByLabelText("Repository URL");
|
||||
await focusField(user, urlField);
|
||||
await user.type(urlField, "git@github.com:owner/repo.git");
|
||||
await user.type(screen.getByLabelText("Clone into folder"), "/home/an/code");
|
||||
expect(screen.getByText(/cloned to \/home\/an\/code\/repo/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ url: "git@github.com:owner/repo.git", parent: "/home/an/code" })
|
||||
)
|
||||
);
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[0]?.[0].credentials).toBeUndefined();
|
||||
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("asks for a login on ECREDENTIALS and sends it on the retry", async () => {
|
||||
vi.mocked(api.lanes.clone)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("needs a login"), {
|
||||
code: "ECREDENTIALS",
|
||||
auth: { protocol: "https", host: "git.example.com" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({ lane: laneFixture({ id: 6 }), created: true, path: "/x" });
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "https://git.example.com/o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const login = await screen.findByTestId("add-lane-clone-login");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("git.example.com needs a login");
|
||||
await user.type(within(login).getByLabelText("Username"), "an");
|
||||
await user.type(within(login).getByLabelText("Password or access token"), "tok");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(api.lanes.clone).mock.calls[1]?.[0].credentials).toEqual({
|
||||
username: "an",
|
||||
password: "tok",
|
||||
remember: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("points to Settings → Git when the SSH key is refused", async () => {
|
||||
vi.mocked(api.lanes.clone).mockRejectedValue(
|
||||
Object.assign(new Error("refused"), { code: "ESSHAUTH" })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await fillClone(user, "git@github.com:o/r.git");
|
||||
await user.click(screen.getByRole("button", { name: "Add workspace" }));
|
||||
const link = await screen.findByRole("link", { name: "Open Settings → Git" });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, act, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, act, within, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { LaneChangesPanel, gitChangeFor } from "../LaneChangesPanel";
|
||||
import { confirmDialog, confirmDiscard } from "../../../lib/confirm";
|
||||
@@ -75,6 +75,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitTree: vi.fn(),
|
||||
gitTreeAll: vi.fn(),
|
||||
gitBlame: vi.fn(),
|
||||
gitDownloadUrl: (id: number, path: string) =>
|
||||
`/api/lanes/${id}/git/download?path=${encodeURIComponent(path)}`,
|
||||
gitUpload: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -545,6 +548,44 @@ describe("LaneChangesPanel", () => {
|
||||
expect(screen.queryByTestId("lane-file-edit")).toBeNull();
|
||||
});
|
||||
|
||||
it("uploads files into a folder from the tree menu, and downloads a file (issue #78)", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockImplementation(async (_t, path = "") => ({
|
||||
available: true,
|
||||
path,
|
||||
entries:
|
||||
path === "out"
|
||||
? [{ name: "report.md", path: "out/report.md", dir: false }]
|
||||
: [{ name: "out", path: "out", dir: true }],
|
||||
truncated: false,
|
||||
}));
|
||||
vi.mocked(api.lanes.gitUpload).mockResolvedValue({ path: "out/data.csv" });
|
||||
const user = userEvent.setup();
|
||||
render(<LaneChangesPanel laneId={7} full />);
|
||||
const sidebar = await screen.findByTestId("lane-changes-sidebar");
|
||||
const input = within(sidebar).getByTestId("lane-changes-upload-input") as HTMLInputElement;
|
||||
const picked = vi.spyOn(input, "click").mockImplementation(() => {});
|
||||
await user.pointer({ keys: "[MouseRight]", target: await within(sidebar).findByText("out") });
|
||||
await user.click(screen.getByTestId("lane-changes-menu-upload"));
|
||||
expect(picked).toHaveBeenCalled();
|
||||
const file = new File(["a,b\n"], "data.csv", { type: "text/csv" });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
await waitFor(() => expect(api.lanes.gitUpload).toHaveBeenCalledWith(7, "out", file));
|
||||
// The folder it landed in is re-read and opened.
|
||||
expect(await within(sidebar).findByText("report.md")).toBeInTheDocument();
|
||||
|
||||
const clicked: string[] = [];
|
||||
const anchor = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
||||
this: HTMLAnchorElement
|
||||
) {
|
||||
clicked.push(this.getAttribute("href") ?? "");
|
||||
});
|
||||
await user.pointer({ keys: "[MouseRight]", target: within(sidebar).getByText("report.md") });
|
||||
await user.click(screen.getByTestId("lane-changes-menu-download"));
|
||||
expect(clicked).toEqual(["/api/lanes/7/git/download?path=out%2Freport.md"]);
|
||||
anchor.mockRestore();
|
||||
picked.mockRestore();
|
||||
});
|
||||
|
||||
it("at full width shows the tree as a sidebar and opens a file straight in the editor", async () => {
|
||||
vi.mocked(api.lanes.gitTree).mockResolvedValue({
|
||||
available: true,
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { LaneGitFacts } from "../../../lib/types";
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(async () => true) }));
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null) => (session ? { lane, session } : lane),
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn() } },
|
||||
api: { lanes: { gitAction: vi.fn(), gitBranches: vi.fn(), git: vi.fn() } },
|
||||
}));
|
||||
|
||||
function facts(over: Partial<Extract<LaneGitFacts, { available: true }>> = {}): LaneGitFacts {
|
||||
@@ -164,6 +164,29 @@ describe("LaneGitBar", () => {
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says there is nothing to push instead of asking to push zero commits", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(confirmDialog).mockClear();
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 0 }));
|
||||
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /^Push$/ }));
|
||||
await waitFor(() => expect(api.lanes.git).toHaveBeenCalledWith(3));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
expect(api.lanes.gitAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-reads stale facts: a commit made since still gets the push confirm", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(api.lanes.git).mockResolvedValueOnce(facts({ ahead: 1 }));
|
||||
render(<LaneGitBar laneId={3} git={facts({ ahead: 0 })} onChanged={vi.fn()} />);
|
||||
await user.click(screen.getByRole("button", { name: /^Push$/ }));
|
||||
await waitFor(() =>
|
||||
expect(confirmDialog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ message: expect.stringContaining("Push 1 commit") })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it("calls a first push of a branch with no upstream 'Publish branch'", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
|
||||
@@ -64,6 +64,7 @@ import { writeClipboard } from "../../lib/clipboard";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed";
|
||||
|
||||
@@ -284,7 +285,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
// left margin of the rows below. That is the torn left edge of issue #33,
|
||||
// and why it only showed up once content scrolled.
|
||||
const term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontSize: readTerminalFontSize(),
|
||||
cursorBlink: true,
|
||||
fontFamily: TERMINAL_FONTS[readTerminalFont()],
|
||||
// A step heavier than stock, like the rest of the UI (main.tsx loads these weights).
|
||||
@@ -576,6 +577,17 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
refit();
|
||||
};
|
||||
document.fonts?.addEventListener("loadingdone", onFontLoaded);
|
||||
// Settings → Appearance changed the terminal's font or size: an open
|
||||
// pane takes it now, re-measured and re-fitted like a font landing.
|
||||
const onFontPref = () => {
|
||||
const size = readTerminalFontSize();
|
||||
const family = TERMINAL_FONTS[readTerminalFont()];
|
||||
if (term.options.fontSize === size && term.options.fontFamily?.trimEnd() === family) return;
|
||||
term.options.fontSize = size;
|
||||
term.options.fontFamily = family;
|
||||
refit();
|
||||
};
|
||||
window.addEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
|
||||
return () => {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
@@ -583,6 +595,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
searchRef.current = null;
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
window.removeEventListener(FONT_SIZE_EVENT, onFontPref);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
detachIme();
|
||||
resizeDisposable.dispose();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
|
||||
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`), the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* @description Settings → Appearance (`/settings/appearance`), grouped so
|
||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`); then the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -34,6 +37,15 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
readTerminalFontSize,
|
||||
readUiFontSize,
|
||||
writeTerminalFontSize,
|
||||
writeUiFontSize,
|
||||
type UiFontSize,
|
||||
} from "../../lib/fontSize";
|
||||
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -82,6 +94,8 @@ export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
@@ -103,31 +117,11 @@ export function AppearanceSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChatUiSettings />
|
||||
|
||||
<SettingsSection
|
||||
id="workspace-tabs"
|
||||
title={t("workspaceTabs.title")}
|
||||
description={t("workspaceTabs.description")}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
description={t("language.description")}
|
||||
>
|
||||
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
|
||||
<SettingsRow
|
||||
id="language"
|
||||
label={t("language.label")}
|
||||
description={t("language.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
@@ -140,16 +134,34 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="terminal-font"
|
||||
title={t("terminalFont.title")}
|
||||
description={t("terminalFont.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
id="text-size"
|
||||
label={t("textSize.label")}
|
||||
description={t("textSize.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("textSize.label")}
|
||||
value={String(uiSize)}
|
||||
onChange={(v) => {
|
||||
const size = Number(v) as UiFontSize;
|
||||
writeUiFontSize(size);
|
||||
setUiSize(size);
|
||||
}}
|
||||
options={UI_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: t(`textSize.names.${size}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
id="terminal-font"
|
||||
label={t("terminalFont.title")}
|
||||
description={
|
||||
termFont === "system"
|
||||
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
|
||||
: t("terminalFont.description")
|
||||
}
|
||||
control={
|
||||
// A dropdown: five font names overflow a segmented row.
|
||||
<Select<TerminalFont>
|
||||
@@ -163,17 +175,57 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
control={
|
||||
<Select
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
value={String(termSize)}
|
||||
onChange={(v) => {
|
||||
writeTerminalFontSize(Number(v));
|
||||
setTermSize(Number(v));
|
||||
}}
|
||||
options={TERMINAL_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: `${size}px`,
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
data-testid="terminal-font-preview"
|
||||
aria-label={t("terminalFont.preview")}
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
|
||||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||
// In px like xterm's cells: the interface's text size does not scale it.
|
||||
style={{
|
||||
fontFamily: TERMINAL_FONTS[termFont],
|
||||
fontSize: termSize,
|
||||
fontVariantLigatures: "none",
|
||||
}}
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="sessions-view"
|
||||
title={t("sessionsView.title")}
|
||||
description={t("sessionsView.description")}
|
||||
>
|
||||
<ChatUiSettings />
|
||||
<SettingsSwitchRow
|
||||
id="workspace-tabs"
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
|
||||
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
|
||||
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
|
||||
* (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
|
||||
* Desktop (`PaneChat`) instead of its terminal. A row, not a card: it sits in
|
||||
* the Sessions section beside the workspace-tabs switch. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* account on a shared dashboard keeps its own choice on any browser.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -9,25 +10,22 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ChatUiSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const chatUi = useChatUi();
|
||||
return (
|
||||
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(
|
||||
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSwitchRow
|
||||
id="chat-ui"
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @file GitIdentitySettings.tsx
|
||||
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
||||
* commit author (name + email in the account's global git config — prefilled
|
||||
* from Settings → Account while unset, since a member's commits fail without
|
||||
* one), the SSH
|
||||
* key a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it — and, for a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
const BTN =
|
||||
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [form, setForm] = useState<{ host: string; username: string; token: string } | null>(null);
|
||||
const auth = useAuth().state;
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
// null = not edited: the field shows the saved value, else the account's.
|
||||
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setId(await api.gitIdentity.get());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setId(await fn());
|
||||
if (done) toast.success(done);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
if (
|
||||
id?.ssh &&
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
|
||||
};
|
||||
|
||||
const removeKey = async () => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKey());
|
||||
};
|
||||
|
||||
const saveLogin = async () => {
|
||||
if (!form) return;
|
||||
// A bare host is the usual entry; a pasted URL is reduced to its host.
|
||||
let host = form.host.trim();
|
||||
try {
|
||||
if (/^https?:\/\//i.test(host)) host = new URL(host).host;
|
||||
} catch {
|
||||
/* the server answers EBADHOST */
|
||||
}
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.saveHttps({ host, username: form.username.trim(), password: form.token }),
|
||||
t("gitCreds.saved", { host })
|
||||
);
|
||||
if (ok) setForm(null);
|
||||
};
|
||||
|
||||
const removeLogin = async (host: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeLoginConfirm", { host }),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeHttps(host));
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
return error ? (
|
||||
<p className="text-xs text-status-danger">{error}</p>
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("gitCreds.loading")}</p>
|
||||
);
|
||||
}
|
||||
const member = id.account === "member";
|
||||
const authorShown = author ?? {
|
||||
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
||||
email: id.author.email ?? me?.email ?? "",
|
||||
};
|
||||
const authorUnset = !id.author.name || !id.author.email;
|
||||
const authorDirty =
|
||||
authorUnset ||
|
||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||
authorShown.email.trim() !== (id.author.email ?? "");
|
||||
const saveAuthor = async () => {
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.saveAuthor({
|
||||
name: authorShown.name.trim(),
|
||||
email: authorShown.email.trim(),
|
||||
}),
|
||||
t("gitIdentity.authorSaved")
|
||||
);
|
||||
if (ok) setAuthor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="max-w-2xl text-xs text-fg-muted">
|
||||
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAuthor();
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-fg-primary">{t("gitIdentity.authorTitle")}</h3>
|
||||
{authorUnset && (
|
||||
<p className="text-[11px] text-status-warning">{t("gitIdentity.authorUnset")}</p>
|
||||
)}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
aria-label={t("gitIdentity.authorName")}
|
||||
placeholder={t("gitIdentity.authorName")}
|
||||
value={authorShown.name}
|
||||
disabled={!id.writable}
|
||||
onChange={(e) => setAuthor({ ...authorShown, name: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
aria-label={t("gitIdentity.authorEmail")}
|
||||
placeholder="name@example.com"
|
||||
value={authorShown.email}
|
||||
disabled={!id.writable}
|
||||
onChange={(e) => setAuthor({ ...authorShown, email: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !authorDirty || !authorShown.name.trim() || !authorShown.email.trim()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.sshTitle")}
|
||||
</h3>
|
||||
{id.writable && (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
||||
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{id.ssh ? (
|
||||
<>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={t("gitIdentity.publicKey")}
|
||||
value={id.ssh.publicKey}
|
||||
rows={3}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{id.ssh.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void removeKey()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{member && (
|
||||
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.httpsTitle")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => setForm({ host: "", username: "", token: "" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitCreds.add")}
|
||||
</button>
|
||||
</div>
|
||||
{form && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveLogin();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldHost")}
|
||||
placeholder="github.com"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldUser")}
|
||||
placeholder={t("gitCreds.fieldUser")}
|
||||
autoComplete="off"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitCreds.fieldToken")}
|
||||
placeholder={t("gitCreds.fieldToken")}
|
||||
autoComplete="new-password"
|
||||
value={form.token}
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !form.host.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{id.https.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noLogins")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-xs">
|
||||
{id.https.map((c) => (
|
||||
<li key={c.host} className="flex items-center gap-2 py-1.5">
|
||||
<span className="font-mono text-[11px] text-fg-primary">{c.host}</span>
|
||||
<span className="flex-1 text-fg-secondary">{c.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitIdentity.remove")} ${c.host}`}
|
||||
title={t("gitIdentity.remove")}
|
||||
disabled={busy}
|
||||
onClick={() => void removeLogin(c.host)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,15 +37,19 @@ export function SettingsRow({
|
||||
description,
|
||||
control,
|
||||
alignTop = false,
|
||||
id,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
control: ReactNode;
|
||||
alignTop?: boolean;
|
||||
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
id={id}
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
description ? "py-3" : "py-2"
|
||||
} ${alignTop ? "items-start" : "items-center"}`}
|
||||
>
|
||||
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
id,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<SettingsRow
|
||||
id={id}
|
||||
label={label}
|
||||
description={description}
|
||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the text size, the terminal font and size pickers, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("AppearanceSettings", () => {
|
||||
root.classList.remove("dark");
|
||||
root.removeAttribute("data-theme");
|
||||
root.removeAttribute("data-mode");
|
||||
root.style.removeProperty("font-size");
|
||||
});
|
||||
|
||||
it("switches the interface language and holds the pet block", async () => {
|
||||
@@ -102,4 +103,33 @@ describe("AppearanceSettings", () => {
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the interface text size on <html>, the default leaving it unset", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface text" });
|
||||
expect(within(group).getByRole("radio", { name: "Default" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Larger" }));
|
||||
expect(root.style.fontSize).toBe("18px");
|
||||
expect(localStorage.getItem("agenthub.ui.fontSize")).toBe("18");
|
||||
await user.click(within(group).getByRole("radio", { name: "Default" }));
|
||||
expect(root.style.fontSize).toBe("");
|
||||
});
|
||||
|
||||
it("picks the terminal font size and tells open terminals", async () => {
|
||||
const user = userEvent.setup();
|
||||
const heard = vi.fn();
|
||||
window.addEventListener("agenthub-font-size", heard);
|
||||
render(<AppearanceSettings />);
|
||||
expect(screen.getByRole("button", { name: /^Size/ })).toHaveTextContent("13px");
|
||||
await user.click(screen.getByRole("button", { name: /^Size/ }));
|
||||
await user.click(screen.getByRole("option", { name: "16px" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.fontSize")).toBe("16");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontSize).toBe("16px");
|
||||
expect(heard).toHaveBeenCalled();
|
||||
window.removeEventListener("agenthub-font-size", heard);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file Tests for Settings → Git → Your git identity: a user with no key
|
||||
* makes one, a key shows only its public half and copies it, replacing asks
|
||||
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
||||
* view leaves HTTPS to the shared Git credentials table.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitIdentitySettings } from "../GitIdentitySettings";
|
||||
import { api, type GitIdentity } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
gitIdentity: {
|
||||
get: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
removeKey: vi.fn(),
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||
|
||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
account: "member",
|
||||
writable: true,
|
||||
ssh: null,
|
||||
https: [],
|
||||
author: { name: "An", email: "an@x.vn" },
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GitIdentitySettings", () => {
|
||||
it("makes a key when there is none, without asking", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false);
|
||||
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
|
||||
});
|
||||
|
||||
it("copies the public key and asks before replacing it", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Copy public key" }));
|
||||
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true));
|
||||
});
|
||||
|
||||
it("lets a member save an https login of their own", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.saveHttps).mockResolvedValue(
|
||||
view({ https: [{ protocol: "https", host: "github.com", username: "an", hasToken: true }] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Add login" }));
|
||||
await userEvent.type(screen.getByLabelText("Server URL"), "https://github.com/o/r");
|
||||
await userEvent.type(screen.getByLabelText("Username"), "an");
|
||||
await userEvent.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
// The second Save: the first belongs to the commit-author form.
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[1]!);
|
||||
expect(api.gitIdentity.saveHttps).toHaveBeenCalledWith({
|
||||
host: "github.com",
|
||||
username: "an",
|
||||
password: "tok",
|
||||
});
|
||||
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns while the commit author is unset and saves it", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
|
||||
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
|
||||
render(<GitIdentitySettings />);
|
||||
expect(
|
||||
await screen.findByText(/commits from the dashboard and your agents fail/)
|
||||
).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Name"), "An Nguyen");
|
||||
await userEvent.type(screen.getByLabelText("Email"), "an@x.vn");
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[0]!);
|
||||
expect(api.gitIdentity.saveAuthor).toHaveBeenCalledWith({
|
||||
name: "An Nguyen",
|
||||
email: "an@x.vn",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the host's key read-only and no https list", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ account: "host", writable: false, ssh: KEY })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user