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:
+1
-1
@@ -52,7 +52,7 @@ The client is a single-page application (SPA) built with modern web technologies
|
||||
- **React 18.3** - Component-based UI with hooks and concurrent features
|
||||
- **TypeScript 5.7** - Full type safety across components, utilities, and API contracts
|
||||
- **Vite 6.1** - Lightning-fast HMR during development, optimized production builds
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of per-browser preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`), picked in Settings → Appearance and read when a terminal opens
|
||||
- **Tailwind CSS 3.4** - Utility-first CSS framework; colors are CSS-variable-backed tokens (`darkMode: "class"`) so a Dark/Light/System switch (Settings → Appearance; `useTheme` hook — the sidebar has no theme or language controls, the language lives in Settings → Appearance) re-themes the whole app by flipping one class. A **named theme** per mode is one more attribute: `data-theme` on `<html>` (absent for `default`) selects a CSS-variable block in `index.css` — `.dark[data-theme="darcula|apple-dark|orca-dark"]` and `[data-mode="light"][data-theme="apple-light|intellij-light|canvas|orca-light"]` — that overrides only the tokens it changes. `--tint-base` is `var(--surface-3)` and light `--syn-plain` is `var(--fg-primary)`, so a theme never restates them. A new theme is a block there plus an id in `THEMES` (`hooks/useTheme.ts`) and a label in `components/settings/AppearanceSettings.tsx`; its Settings preview needs no hex, because the swatch re-scopes the same variables on its own element. Storage: `theme` = `dark|light|system`, `agenthub.theme.dark` / `agenthub.theme.light` = theme ids (unknown ⇒ `default`); state is read through `useSyncExternalStore`, never cached in the module, so every consumer and every tab agree. An inline `<script>` at the top of `client/index.html` applies the same keys before first paint (no flash of the stock dark palette); it does not validate ids — the hook does, on mount. The workspace terminal's font is the same kind of preference: `agenthub.terminal.font` = `jetbrains|maple|system` (`lib/terminalFont.ts`, default `jetbrains`) and `agenthub.terminal.fontSize` = 11|12|13|14|15|16|18|20 px (`lib/fontSize.ts`, default 13), picked in Settings → Appearance; a change fires `agenthub-font-size` on `window`, so an open terminal re-fits at once. Settings → Appearance → **Text size** (`#text-size`) sets `agenthub.ui.fontSize` = 14|16|17|18 (Small/Default/Large/Larger), the ROOT font size in px (`lib/fontSize.ts`; the index.html script applies it before first paint too); Tailwind's sizes are rem and `index.css` re-states every `text-[Npx]` class in rem, so all text scales with it. All of these keys are synced to the account like the theme (`PREF_KEYS` in `lib/userPrefs.ts` and `server/routes/me.js`)
|
||||
|
||||
**The surface ramp means elevation, and it runs the same direction in both themes: a higher index is LIGHTER in dark and LIGHTER in light.** So in light mode the page is the *darkest* step (`surface-0`, `#eceef2`) and a `.card` is brighter than the page it sits on (`surface-3`, `#fdfdfe`), which is also what makes every `hover:bg-surface-N` lift rather than dim. Light mode originally used Radix slate in its native direction — index up meant darker — which inverted the whole language: a near-white page (the glare complaint) under grey cards that read as recessed. Never "fix" a light-mode colour by swapping which step a component uses; fix the value of the step.
|
||||
|
||||
|
||||
+5
-1
@@ -2,7 +2,8 @@
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<!-- Apply the saved appearance before first paint (same keys as
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount). -->
|
||||
src/hooks/useTheme.ts, which re-applies and validates on mount, and
|
||||
src/lib/fontSize.ts). -->
|
||||
<script>
|
||||
try {
|
||||
var m = localStorage.getItem("theme");
|
||||
@@ -17,6 +18,9 @@
|
||||
r.classList.toggle("dark", d);
|
||||
r.setAttribute("data-mode", d ? "dark" : "light");
|
||||
if (t && t !== "default") r.setAttribute("data-theme", t);
|
||||
// Text size (src/lib/fontSize.ts, UI_FONT_SIZES): the root font size.
|
||||
var f = Number(localStorage.getItem("agenthub.ui.fontSize"));
|
||||
if (f === 14 || f === 17 || f === 18) r.style.fontSize = f + "px";
|
||||
} catch (e) {}
|
||||
</script>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
Generated
-42
@@ -1204,9 +1204,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1362,9 +1359,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1379,9 +1373,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1396,9 +1387,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1413,9 +1401,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1430,9 +1415,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1447,9 +1429,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1464,9 +1443,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1481,9 +1457,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1498,9 +1471,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1515,9 +1485,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1532,9 +1499,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1549,9 +1513,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1566,9 +1527,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@
|
||||
"addLaneToolDefaultBadge": "Default",
|
||||
"addLaneNoTools": "No AI tool is installed and enabled.",
|
||||
"addLaneNoToolsLink": "Open Settings → AI Tools",
|
||||
"addLaneToolHint": "Which CLI drives this workspace. Cannot be changed later.",
|
||||
"addLaneToolHint": "Default AI tool",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} more",
|
||||
"addLaneWorktreesTitle": "Worktrees of this repo ({{count}})",
|
||||
@@ -84,6 +84,12 @@
|
||||
"changes.expand": "Show the changes panel",
|
||||
"changes.expandAll": "Expand every file",
|
||||
"changes.expandAllFolders": "Expand all folders",
|
||||
"changes.download": "Download",
|
||||
"changes.upload": "Upload files to the workspace root",
|
||||
"changes.uploadHere": "Upload files here…",
|
||||
"changes.uploaded_one": "Uploaded {{count}} file to {{dir}}",
|
||||
"changes.uploaded_other": "Uploaded {{count}} files to {{dir}}",
|
||||
"changes.uploadFailed": "Could not upload {{name}}: {{message}}",
|
||||
"changes.expandContext": "Show more surrounding lines",
|
||||
"changes.expandFile": "Expand {{path}}",
|
||||
"changes.fileGone": "That file no longer has changes in this range.",
|
||||
@@ -186,6 +192,7 @@
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — refuses instead of merging when the branch has diverged",
|
||||
"gitBar.push": "Push",
|
||||
"gitBar.nothingToPush": "Nothing to push — {{upstream}} already has every commit.",
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Branch",
|
||||
"gitBar.switchApply": "Switch branch",
|
||||
@@ -200,6 +207,21 @@
|
||||
"mode.repoHint": "Adopt this directory as a workspace, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
||||
"mode.clone": "Clone",
|
||||
"mode.cloneHint": "Clone a repository over HTTPS or SSH into one of your folders, as a new workspace.",
|
||||
"clone.urlLabel": "Repository URL",
|
||||
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git or ssh://… — SSH uses your own key (Settings → Git).",
|
||||
"clone.parentLabel": "Clone into folder",
|
||||
"clone.parentHint": "The repository is cloned to {{path}}",
|
||||
"clone.nameLabel": "Folder name",
|
||||
"clone.cloning": "Cloning…",
|
||||
"clone.loginNeeded": "{{host}} needs a login: your username and a password or access token.",
|
||||
"clone.loginRejected": "{{host}} rejected that username or token.",
|
||||
"clone.username": "Username",
|
||||
"clone.token": "Password or access token",
|
||||
"clone.remember": "Remember this login for my account",
|
||||
"clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.",
|
||||
"clone.sshSettings": "Open Settings → Git",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
@@ -268,6 +290,7 @@
|
||||
"changes.mergeAbortConfirm": "Click again to abort the {{op}}",
|
||||
"changes.discardShortConfirm": "Discard?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendPushed": "The last commit is already pushed; amending it would rewrite shared history. Revert it from History instead.",
|
||||
"changes.amendHint": "Fold the ticked files into the last commit (only before it is pushed). Nothing ticked rewords it; leave the message empty to keep the old one.",
|
||||
"changes.amendPlaceholder": "New message (empty = keep the old one)",
|
||||
"changes.historyOf": "History of branch",
|
||||
@@ -344,6 +367,9 @@
|
||||
"tree.splitHint": "Live pane — Ctrl/Cmd+click opens it in a split beside the focused pane",
|
||||
"tree.renameSession": "Rename session",
|
||||
"tree.archiveSession": "Archive — hide from this workspace (deletes nothing)",
|
||||
"tree.pinSession": "Pin to the top of this workspace",
|
||||
"tree.unpinSession": "Unpin",
|
||||
"tree.pinned": "Pinned",
|
||||
"tree.worktreeSession": "In its own worktree: .claude/worktrees/{{name}}",
|
||||
"tree.archiveWorktreeTitle": "Archive and remove its worktree?",
|
||||
"tree.archiveWorktreeBody": "Like Claude Desktop, archiving removes the session's worktree .claude/worktrees/{{name}} and its branch. Git refuses if it holds uncommitted changes, and a branch with commits merged nowhere is kept — nothing unsaved is lost.",
|
||||
@@ -423,6 +449,8 @@
|
||||
"sessionMenu.details": "Session details",
|
||||
"sessionMenu.copyId": "Copy session ID",
|
||||
"sessionMenu.archive": "Archive",
|
||||
"sessionMenu.pin": "Pin",
|
||||
"sessionMenu.unpin": "Unpin",
|
||||
"panes.show": "Show {{name}} here",
|
||||
"panes.splitNew.right": "Split right with a new terminal",
|
||||
"panes.splitNew.bottom": "Split down with a new terminal",
|
||||
|
||||
@@ -893,6 +893,30 @@
|
||||
"pet": "Pet",
|
||||
"petDescription": "The corner mascot: show it, its size, speech bubbles, and which pet it wears."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"authorTitle": "Commit author",
|
||||
"authorName": "Name",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||
"authorSaved": "Commit author saved.",
|
||||
"title": "Your git identity",
|
||||
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
||||
"descriptionHost": "The SSH key of the account this AgentHub runs as. Add workspace → Clone uses it, and the clone keeps it for its later fetch, pull and push. HTTPS logins for this account are the Git credentials above.",
|
||||
"sshTitle": "SSH key",
|
||||
"generate": "Generate SSH key",
|
||||
"replace": "Replace key",
|
||||
"replaceConfirm": "Replace the SSH key? Clones that use the old key stop working until the new public key is added to their host.",
|
||||
"keyMade": "SSH key ready — add its public key to your git host.",
|
||||
"remove": "Delete",
|
||||
"removeKeyConfirm": "Delete this SSH key? Clones that use it can no longer fetch or push.",
|
||||
"publicKey": "Public key",
|
||||
"copy": "Copy public key",
|
||||
"addKeyHint": "Add this public key to your account on the git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). The private key never leaves the server.",
|
||||
"noKey": "No SSH key yet. Without one, an SSH clone uses the account's default ~/.ssh keys.",
|
||||
"httpsTitle": "HTTPS logins",
|
||||
"noLogins": "No HTTPS login saved. A clone asks for one when a host needs it.",
|
||||
"removeLoginConfirm": "Delete the login for {{host}}? Clones from that host will ask for a login again."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "One login per host, shared by everyone using this AgentHub. A token is never shown again after it is saved.",
|
||||
@@ -1061,6 +1085,14 @@
|
||||
"agenthub": "AgentHub-managed worktrees",
|
||||
"agenthubDesc": "Worktrees AgentHub created under its workspaces root."
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Text & fonts",
|
||||
"description": "Language, text size and the terminal's font. Saved to your account."
|
||||
},
|
||||
"sessionsView": {
|
||||
"title": "Sessions",
|
||||
"description": "How a new session opens and which sessions the session view's tab bar holds. Saved to your account."
|
||||
},
|
||||
"language": {
|
||||
"title": "Language",
|
||||
"description": "The language of the whole dashboard, saved to your account.",
|
||||
@@ -1076,9 +1108,20 @@
|
||||
"label": "Tabs from every workspace",
|
||||
"labelDesc": "Keep every workspace's live sessions as tabs, and mix them in one split. Focusing a tab selects its workspace. Off: only the selected workspace's sessions."
|
||||
},
|
||||
"textSize": {
|
||||
"title": "Text size",
|
||||
"description": "How large the dashboard's text is — menus, lists, chat and settings. Saved to your account. Ctrl + / Ctrl − in the browser still zooms everything on top of this.",
|
||||
"label": "Interface text",
|
||||
"names": {
|
||||
"14": "Small",
|
||||
"16": "Default",
|
||||
"17": "Large",
|
||||
"18": "Larger"
|
||||
}
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Terminal font",
|
||||
"description": "The font of the workspace terminals. Takes effect the next time a terminal opens. Saved to your account.",
|
||||
"description": "The font and size of the workspace terminals. Open terminals change at once. Saved to your account.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1086,7 +1129,8 @@
|
||||
"system": "System font"
|
||||
},
|
||||
"systemNote": "Your browser's fixed-width font. It may lack some Vietnamese letters (ấ, ế, ố…), which then draw out of line.",
|
||||
"preview": "Preview"
|
||||
"preview": "Preview",
|
||||
"sizeLabel": "Size"
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Copy host",
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
"addLaneToolDefaultBadge": "Mặc định",
|
||||
"addLaneNoTools": "Chưa có AI tool nào được cài và bật.",
|
||||
"addLaneNoToolsLink": "Mở Cài đặt → AI Tools",
|
||||
"addLaneToolHint": "CLI nào điều khiển workspace này. Không thể thay đổi sau này.",
|
||||
"addLaneToolHint": "AI tool mặc định",
|
||||
"addLaneWorktreeDetached": "(detached)",
|
||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||
"addLaneWorktreesTitle": "Worktree của repo này ({{count}})",
|
||||
@@ -84,6 +84,12 @@
|
||||
"changes.expand": "Mở bảng thay đổi",
|
||||
"changes.expandAll": "Mở tất cả file",
|
||||
"changes.expandAllFolders": "Mở tất cả thư mục",
|
||||
"changes.download": "Tải xuống",
|
||||
"changes.upload": "Tải file lên thư mục gốc của workspace",
|
||||
"changes.uploadHere": "Tải file lên đây…",
|
||||
"changes.uploaded_one": "Đã tải {{count}} file lên {{dir}}",
|
||||
"changes.uploaded_other": "Đã tải {{count}} file lên {{dir}}",
|
||||
"changes.uploadFailed": "Không tải lên được {{name}}: {{message}}",
|
||||
"changes.expandContext": "Hiện thêm dòng xung quanh",
|
||||
"changes.expandFile": "Mở {{path}}",
|
||||
"changes.fileGone": "File này không còn thay đổi nào trong phạm vi đang xem.",
|
||||
@@ -186,6 +192,7 @@
|
||||
"gitBar.pull": "Pull",
|
||||
"gitBar.pullHint": "git pull --ff-only — nhánh đã rẽ nhánh thì báo lỗi chứ không merge",
|
||||
"gitBar.push": "Push",
|
||||
"gitBar.nothingToPush": "Không có gì để push — {{upstream}} đã có mọi commit.",
|
||||
"gitBar.pushHint": "git push -u origin HEAD",
|
||||
"gitBar.switch": "Nhánh",
|
||||
"gitBar.switchApply": "Chuyển nhánh",
|
||||
@@ -200,6 +207,21 @@
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm workspace, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
||||
"mode.clone": "Clone",
|
||||
"mode.cloneHint": "Clone một repository qua HTTPS hoặc SSH vào thư mục của bạn, thành workspace mới.",
|
||||
"clone.urlLabel": "URL repository",
|
||||
"clone.urlHint": "https://host/owner/repo.git, git@host:owner/repo.git hoặc ssh://… — SSH dùng key riêng của bạn (Cài đặt → Git).",
|
||||
"clone.parentLabel": "Clone vào thư mục",
|
||||
"clone.parentHint": "Repository sẽ được clone vào {{path}}",
|
||||
"clone.nameLabel": "Tên thư mục",
|
||||
"clone.cloning": "Đang clone…",
|
||||
"clone.loginNeeded": "{{host}} cần đăng nhập: username và mật khẩu hoặc access token.",
|
||||
"clone.loginRejected": "{{host}} từ chối username hoặc token này.",
|
||||
"clone.username": "Username",
|
||||
"clone.token": "Mật khẩu hoặc access token",
|
||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
||||
"clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.",
|
||||
"clone.sshSettings": "Mở Cài đặt → Git",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
@@ -268,6 +290,7 @@
|
||||
"changes.mergeAbortConfirm": "Bấm lần nữa để huỷ {{op}}",
|
||||
"changes.discardShortConfirm": "Xoá?",
|
||||
"changes.amend": "Amend",
|
||||
"changes.amendPushed": "Commit cuối đã được push; amend sẽ viết lại lịch sử đã chia sẻ. Hãy revert nó trong History.",
|
||||
"changes.amendHint": "Gộp các file đã tick vào commit cuối (chỉ khi chưa push). Không tick file nào thì chỉ sửa message; để trống message để giữ message cũ.",
|
||||
"changes.amendPlaceholder": "Message mới (trống = giữ message cũ)",
|
||||
"changes.historyOf": "Lịch sử của nhánh",
|
||||
@@ -344,6 +367,9 @@
|
||||
"tree.splitHint": "Pane đang chạy — Ctrl/Cmd+click để mở ở split cạnh pane đang focus",
|
||||
"tree.renameSession": "Đổi tên session",
|
||||
"tree.archiveSession": "Lưu trữ — ẩn khỏi workspace này (không xoá gì)",
|
||||
"tree.pinSession": "Ghim lên đầu workspace này",
|
||||
"tree.unpinSession": "Bỏ ghim",
|
||||
"tree.pinned": "Đã ghim",
|
||||
"tree.worktreeSession": "Trong worktree riêng: .claude/worktrees/{{name}}",
|
||||
"tree.archiveWorktreeTitle": "Lưu trữ và gỡ worktree của session?",
|
||||
"tree.archiveWorktreeBody": "Giống Claude Desktop, lưu trữ sẽ gỡ worktree .claude/worktrees/{{name}} của session cùng branch của nó. Git sẽ từ chối nếu còn thay đổi chưa commit, và branch có commit chưa merge vào đâu sẽ được giữ lại — không mất gì chưa lưu.",
|
||||
@@ -423,6 +449,8 @@
|
||||
"sessionMenu.details": "Chi tiết session",
|
||||
"sessionMenu.copyId": "Sao chép ID session",
|
||||
"sessionMenu.archive": "Lưu trữ",
|
||||
"sessionMenu.pin": "Ghim",
|
||||
"sessionMenu.unpin": "Bỏ ghim",
|
||||
"panes.show": "Hiện {{name}} ở đây",
|
||||
"panes.splitNew.right": "Chia sang phải với terminal mới",
|
||||
"panes.splitNew.bottom": "Chia xuống dưới với terminal mới",
|
||||
|
||||
@@ -893,6 +893,30 @@
|
||||
"pet": "Pet",
|
||||
"petDescription": "Linh vật ở góc màn hình: hiện/ẩn, kích thước, bong bóng lời thoại và pet đang dùng."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"authorTitle": "Tác giả commit",
|
||||
"authorName": "Tên",
|
||||
"authorEmail": "Email",
|
||||
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
||||
"authorSaved": "Đã lưu tác giả commit.",
|
||||
"title": "Danh tính git của bạn",
|
||||
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
||||
"descriptionHost": "SSH key của tài khoản mà AgentHub đang chạy. Add workspace → Clone dùng key này, và bản clone giữ lại cho các lần fetch, pull, push sau. Đăng nhập HTTPS của tài khoản này là mục Git credentials ở trên.",
|
||||
"sshTitle": "SSH key",
|
||||
"generate": "Tạo SSH key",
|
||||
"replace": "Thay key",
|
||||
"replaceConfirm": "Thay SSH key? Các bản clone dùng key cũ sẽ không hoạt động cho tới khi public key mới được thêm vào host.",
|
||||
"keyMade": "SSH key đã sẵn sàng — hãy thêm public key vào git host của bạn.",
|
||||
"remove": "Xoá",
|
||||
"removeKeyConfirm": "Xoá SSH key này? Các bản clone dùng nó sẽ không fetch/push được nữa.",
|
||||
"publicKey": "Public key",
|
||||
"copy": "Copy public key",
|
||||
"addKeyHint": "Thêm public key này vào tài khoản trên git host (GitHub: Settings → SSH and GPG keys; GitLab: Preferences → SSH Keys). Private key không bao giờ rời khỏi server.",
|
||||
"noKey": "Chưa có SSH key. Khi chưa có, clone qua SSH dùng key mặc định trong ~/.ssh của tài khoản.",
|
||||
"httpsTitle": "Đăng nhập HTTPS",
|
||||
"noLogins": "Chưa lưu đăng nhập HTTPS nào. Khi clone, host nào cần sẽ hỏi.",
|
||||
"removeLoginConfirm": "Xoá đăng nhập của {{host}}? Clone từ host đó sẽ hỏi đăng nhập lại."
|
||||
},
|
||||
"gitCreds": {
|
||||
"title": "Git credentials",
|
||||
"description": "Mỗi host một tài khoản, dùng chung cho mọi người dùng AgentHub này. Token đã lưu sẽ không hiển thị lại.",
|
||||
@@ -1061,6 +1085,14 @@
|
||||
"agenthub": "Worktree do AgentHub quản lý",
|
||||
"agenthubDesc": "Worktree AgentHub tạo dưới thư mục gốc workspace của nó."
|
||||
},
|
||||
"fonts": {
|
||||
"title": "Chữ & font",
|
||||
"description": "Ngôn ngữ, cỡ chữ và font của terminal. Lưu theo tài khoản của bạn."
|
||||
},
|
||||
"sessionsView": {
|
||||
"title": "Phiên làm việc",
|
||||
"description": "Cách mở một session mới và những session nào nằm trên thanh tab. Lưu theo tài khoản của bạn."
|
||||
},
|
||||
"language": {
|
||||
"title": "Ngôn ngữ",
|
||||
"description": "Ngôn ngữ của toàn bộ dashboard, được lưu vào tài khoản của bạn.",
|
||||
@@ -1076,9 +1108,20 @@
|
||||
"label": "Tab của mọi workspace",
|
||||
"labelDesc": "Giữ session đang chạy của mọi workspace thành tab, và trộn chúng trong một tab chia đôi. Chọn một tab sẽ chuyển sang workspace của nó. Tắt: chỉ session của workspace đang chọn."
|
||||
},
|
||||
"textSize": {
|
||||
"title": "Cỡ chữ",
|
||||
"description": "Độ lớn chữ của dashboard — menu, danh sách, chat và cài đặt. Lưu vào tài khoản của bạn. Ctrl + / Ctrl − của trình duyệt vẫn phóng to toàn bộ thêm trên mức này.",
|
||||
"label": "Chữ giao diện",
|
||||
"names": {
|
||||
"14": "Nhỏ",
|
||||
"16": "Mặc định",
|
||||
"17": "Lớn",
|
||||
"18": "Rất lớn"
|
||||
}
|
||||
},
|
||||
"terminalFont": {
|
||||
"title": "Font terminal",
|
||||
"description": "Font của các terminal trong workspace. Áp dụng từ lần mở terminal tiếp theo. Lưu vào tài khoản của bạn.",
|
||||
"description": "Font và cỡ chữ của các terminal trong workspace. Terminal đang mở đổi ngay. Lưu vào tài khoản của bạn.",
|
||||
"label": "Font",
|
||||
"names": {
|
||||
"jetbrains": "JetBrains Mono",
|
||||
@@ -1086,7 +1129,8 @@
|
||||
"system": "Font hệ thống"
|
||||
},
|
||||
"systemNote": "Font độ rộng cố định của trình duyệt. Font này có thể thiếu một số chữ tiếng Việt (ấ, ế, ố…), khi đó chữ sẽ bị lệch ô.",
|
||||
"preview": "Xem trước"
|
||||
"preview": "Xem trước",
|
||||
"sizeLabel": "Cỡ chữ"
|
||||
},
|
||||
"rowMenu": {
|
||||
"copyHost": "Sao chép host",
|
||||
|
||||
+38
-2
@@ -563,7 +563,7 @@
|
||||
reads it; no typography plugin. */
|
||||
.md-preview {
|
||||
color: rgb(var(--fg-secondary));
|
||||
font-size: 13px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -650,7 +650,7 @@
|
||||
.md-preview pre,
|
||||
.md-preview .md-frontmatter {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
padding: 0.75em 1em;
|
||||
border-radius: 6px;
|
||||
@@ -711,3 +711,39 @@
|
||||
.xterm-viewport::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Settings → Appearance → Text size (lib/fontSize.ts) sets the ROOT font size.
|
||||
Tailwind's own sizes are rem and follow it; these are the `text-[Npx]`
|
||||
classes the app uses, re-stated in rem (identical at the default 16px) so
|
||||
the small labels grow with everything else. After `@tailwind utilities`,
|
||||
same specificity, so these win. A new `text-[Npx]` value belongs here too. */
|
||||
.text-\[9px\] {
|
||||
font-size: 0.5625rem;
|
||||
}
|
||||
.text-\[10px\] {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
.text-\[10\.5px\] {
|
||||
font-size: 0.65625rem;
|
||||
}
|
||||
.text-\[11px\] {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.text-\[12px\] {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.text-\[12\.5px\] {
|
||||
font-size: 0.78125rem;
|
||||
}
|
||||
.text-\[13px\] {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.text-\[15px\] {
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.text-\[16px\] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.text-\[18px\] {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
+77
-1
@@ -987,6 +987,8 @@ export const api = {
|
||||
lane?: number;
|
||||
/** `false` = not archived, `true` = archived only; omitted = both. */
|
||||
archived?: boolean;
|
||||
/** Pinned sessions first (by pin time), before the limit. */
|
||||
pinnedFirst?: boolean;
|
||||
/** Only active sessions waiting on the user (`awaiting=1`). */
|
||||
awaiting?: boolean;
|
||||
/** ISO bounds on `started_at`. */
|
||||
@@ -1005,6 +1007,7 @@ export const api = {
|
||||
if (params?.tool) qs.set("tool", params.tool);
|
||||
if (params?.lane !== undefined) qs.set("lane", String(params.lane));
|
||||
if (params?.archived !== undefined) qs.set("archived", params.archived ? "1" : "0");
|
||||
if (params?.pinnedFirst) qs.set("pinned_first", "1");
|
||||
if (params?.awaiting) qs.set("awaiting", "1");
|
||||
if (params?.from) qs.set("from", params.from);
|
||||
if (params?.to) qs.set("to", params.to);
|
||||
@@ -1066,7 +1069,7 @@ export const api = {
|
||||
),
|
||||
/** PATCH /api/sessions/:id — rename, or archive (hides it from its
|
||||
* workspace, deletes nothing). */
|
||||
patch: (id: string, body: { name?: string; archived?: boolean }) =>
|
||||
patch: (id: string, body: { name?: string; archived?: boolean; pinned?: boolean }) =>
|
||||
request<{ session: Session }>(`/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
@@ -2440,6 +2443,24 @@ export const api = {
|
||||
{ method: "DELETE" }
|
||||
),
|
||||
},
|
||||
/** The signed-in user's own git identity (`/api/me/git`, `server/routes/git-identity.js`). */
|
||||
gitIdentity: {
|
||||
get: () => request<GitIdentity>("/me/git"),
|
||||
/** Make the SSH key; `replace` swaps an existing one for a new pair. */
|
||||
generateKey: (replace = false) =>
|
||||
request<GitIdentity>("/me/git/ssh-key", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ replace }),
|
||||
}),
|
||||
removeKey: () => request<GitIdentity>("/me/git/ssh-key", { method: "DELETE" }),
|
||||
saveHttps: (body: { host: string; username: string; password: string }) =>
|
||||
request<GitIdentity>("/me/git/https", { method: "POST", body: JSON.stringify(body) }),
|
||||
removeHttps: (host: string) =>
|
||||
request<GitIdentity>(`/me/git/https/${encodeURIComponent(host)}`, { method: "DELETE" }),
|
||||
/** PUT /api/me/git/author — `user.name` / `user.email` for the account's commits. */
|
||||
saveAuthor: (body: { name: string; email: string }) =>
|
||||
request<GitIdentity>("/me/git/author", { method: "PUT", body: JSON.stringify(body) }),
|
||||
},
|
||||
/** Frozen session copies (`/api/snapshots`, `server/routes/snapshots.js`). */
|
||||
snapshots: {
|
||||
list: () => request<{ snapshots: SessionSnapshotInfo[] }>("/snapshots"),
|
||||
@@ -3046,6 +3067,25 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* POST /api/lanes/clone — `git clone <url>` into `<parent>/<name>` as the
|
||||
* folder's account, with that account's own SSH key / HTTPS login, then a
|
||||
* workspace on it. 409 `ECREDENTIALS` (the error's `auth` names the host)
|
||||
* asks for a login, sent again as `credentials`; 409 `ESSHAUTH` means the
|
||||
* SSH key was refused.
|
||||
*/
|
||||
clone: (body: {
|
||||
url: string;
|
||||
parent: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
tool?: LaneTool;
|
||||
credentials?: { username: string; password: string; remember?: boolean };
|
||||
}) =>
|
||||
request<{ lane: Lane; created: boolean; path: string }>("/lanes/clone", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
/**
|
||||
* GET /api/lanes/pipelines — every pipeline template the server can render
|
||||
* a lane against, built-in plus any `DASHBOARD_PIPELINES_DIR` override.
|
||||
@@ -3221,6 +3261,24 @@ export const api = {
|
||||
/** URL of GET /api/lanes/:id/git/raw — an image's bytes, for an `<img>`. */
|
||||
gitRawUrl: (id: GitTarget, path: string, rev = "work") =>
|
||||
`${BASE}${gitUrl(id, "/raw", new URLSearchParams({ path, rev }))}`,
|
||||
/** GET /api/lanes/:id/git/download — one working file as an attachment
|
||||
* (any type; a plain, non-git folder too). For an `<a download>`. */
|
||||
gitDownloadUrl: (id: GitTarget, path: string) =>
|
||||
`${BASE}${gitUrl(id, "/download", new URLSearchParams({ path }))}`,
|
||||
/**
|
||||
* POST /api/lanes/:id/git/upload — create `file` in the folder `dir`
|
||||
* ("" = the workspace root). Only creates: a taken name answers 409
|
||||
* EEXISTS; at most 25 MB (413 ETOOBIG).
|
||||
*/
|
||||
gitUpload: (id: GitTarget, dir: string, file: File) =>
|
||||
request<{ path: string }>(gitUrl(id, "/upload", new URLSearchParams({ dir })), {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"X-File-Name": encodeURIComponent(file.name),
|
||||
},
|
||||
}),
|
||||
/**
|
||||
* PUT /api/lanes/:id/git/file — save the editor's text over the working
|
||||
* file. `baseSha` is the `sha` it was opened at; a file that changed on
|
||||
@@ -3719,6 +3777,24 @@ export interface GitCredential {
|
||||
hasToken: true;
|
||||
}
|
||||
|
||||
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
||||
export interface GitIdentity {
|
||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||
account: "member" | "host";
|
||||
/** False for the host's identity seen by a non-admin. */
|
||||
writable: boolean;
|
||||
ssh: { publicKey: string; fingerprint: string } | null;
|
||||
/** The account's commit author (`git config --global`); null fields = unset. */
|
||||
author: { name: string | null; email: string | null };
|
||||
https: {
|
||||
protocol: "http" | "https";
|
||||
host: string;
|
||||
username: string;
|
||||
savedAt?: string;
|
||||
hasToken: true;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface GitCredentialInput {
|
||||
protocol: string;
|
||||
host: string;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* @file fontSize — the two text sizes Settings → Appearance offers (issue #79),
|
||||
* viewer preferences kept in `localStorage` and synced to the account like the
|
||||
* theme (`lib/userPrefs.ts` PREF_KEYS):
|
||||
* - `agenthub.ui.fontSize`: the interface's size, the ROOT font size in px.
|
||||
* Tailwind's sizes and spacing are rem, and `index.css` re-states every
|
||||
* `text-[Npx]` class the app uses in rem, so the whole UI follows it.
|
||||
* `index.html` applies it before first paint (same key, same range).
|
||||
* Capped at 18px: the diff view pins its rows at `ROW_PX` (16px).
|
||||
* - `agenthub.terminal.fontSize`: xterm's cell font size in px (default 13).
|
||||
* A write fires `FONT_SIZE_EVENT`, which open terminals listen for to re-fit.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const UI_FONT_SIZE_KEY = "agenthub.ui.fontSize";
|
||||
export const TERMINAL_FONT_SIZE_KEY = "agenthub.terminal.fontSize";
|
||||
/** Fired on `window` after either size changes (this tab or a hydrate). */
|
||||
export const FONT_SIZE_EVENT = "agenthub-font-size";
|
||||
|
||||
/** The interface sizes, root px; 16 is the browser's own default. */
|
||||
export const UI_FONT_SIZES = [14, 16, 17, 18] as const;
|
||||
export type UiFontSize = (typeof UI_FONT_SIZES)[number];
|
||||
export const DEFAULT_UI_FONT_SIZE: UiFontSize = 16;
|
||||
|
||||
export const TERMINAL_FONT_SIZES = [11, 12, 13, 14, 15, 16, 18, 20] as const;
|
||||
export const DEFAULT_TERMINAL_FONT_SIZE = 13;
|
||||
|
||||
function read(key: string): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: number) {
|
||||
try {
|
||||
window.localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
/* storage blocked — this page still applies it */
|
||||
}
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
}
|
||||
|
||||
export function readUiFontSize(): UiFontSize {
|
||||
const n = Number(read(UI_FONT_SIZE_KEY));
|
||||
return (UI_FONT_SIZES as readonly number[]).includes(n)
|
||||
? (n as UiFontSize)
|
||||
: DEFAULT_UI_FONT_SIZE;
|
||||
}
|
||||
|
||||
/** Paint the stored interface size on `<html>` (the default leaves it unset). */
|
||||
export function applyUiFontSize(size: UiFontSize = readUiFontSize()) {
|
||||
const root = document.documentElement;
|
||||
if (size === DEFAULT_UI_FONT_SIZE) root.style.removeProperty("font-size");
|
||||
else root.style.fontSize = `${size}px`;
|
||||
}
|
||||
|
||||
export function writeUiFontSize(size: UiFontSize) {
|
||||
applyUiFontSize(size);
|
||||
write(UI_FONT_SIZE_KEY, size);
|
||||
}
|
||||
|
||||
export function readTerminalFontSize(): number {
|
||||
const n = Number(read(TERMINAL_FONT_SIZE_KEY));
|
||||
return (TERMINAL_FONT_SIZES as readonly number[]).includes(n) ? n : DEFAULT_TERMINAL_FONT_SIZE;
|
||||
}
|
||||
|
||||
export function writeTerminalFontSize(size: number) {
|
||||
write(TERMINAL_FONT_SIZE_KEY, size);
|
||||
}
|
||||
@@ -72,9 +72,26 @@ export const SETTINGS_INDEX: {
|
||||
section: "appearance",
|
||||
titleKey: "terminalFont.title",
|
||||
descKey: "terminalFont.description",
|
||||
keywords: ["font", "phông", "maple", "jetbrains", "monospace", "rounded", "bo tròn"],
|
||||
keywords: [
|
||||
"font",
|
||||
"phông",
|
||||
"maple",
|
||||
"jetbrains",
|
||||
"monospace",
|
||||
"rounded",
|
||||
"bo tròn",
|
||||
"size",
|
||||
"cỡ chữ",
|
||||
],
|
||||
to: "#terminal-font",
|
||||
},
|
||||
{
|
||||
section: "appearance",
|
||||
titleKey: "textSize.title",
|
||||
descKey: "textSize.description",
|
||||
keywords: ["font size", "text size", "zoom", "larger", "cỡ chữ", "chữ to", "phóng to"],
|
||||
to: "#text-size",
|
||||
},
|
||||
{
|
||||
section: "data",
|
||||
titleKey: "pushIngest.title",
|
||||
@@ -314,6 +331,12 @@ export const SETTINGS_INDEX: {
|
||||
{ section: "ai-proxy", titleKey: "aiProxy.title", keywords: ["router", "endpoint", "key"] },
|
||||
{ section: "pricing", titleKey: "pricing.title", keywords: ["cost", "price", "tokens"] },
|
||||
{ section: "git", titleKey: "gitCreds.title", adminOnly: true, keywords: ["login", "token"] },
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "gitIdentity.title",
|
||||
keywords: ["ssh", "key", "clone", "https"],
|
||||
to: "#git-identity",
|
||||
},
|
||||
{
|
||||
section: "git",
|
||||
titleKey: "worktrees.showUntracked",
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
* terminal opens. `jetbrains` (the default) and the rounded `maple` are
|
||||
* bundled webfonts that cover every Vietnamese letter; `system` is the browser's own
|
||||
* fixed-width font, which may not (DejaVu Sans Mono on Linux lacks ấ, ế, ố…,
|
||||
* and those glyphs then fall back to a face of another width).
|
||||
* and those glyphs then fall back to a face of another width). A change fires
|
||||
* `FONT_SIZE_EVENT` (`lib/fontSize.ts`), so an open terminal takes it too.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { FONT_SIZE_EVENT } from "./fontSize";
|
||||
|
||||
export const TERMINAL_FONT_KEY = "agenthub.terminal.font";
|
||||
|
||||
export const TERMINAL_FONTS = {
|
||||
@@ -34,4 +37,5 @@ export function writeTerminalFont(font: TerminalFont) {
|
||||
} catch {
|
||||
/* storage blocked — terminals keep the default */
|
||||
}
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
}
|
||||
|
||||
@@ -741,6 +741,9 @@ export interface Session {
|
||||
/** ISO time the user archived the session out of its workspace's lists;
|
||||
* null when not archived. Hides only. Maps to `sessions.archived_at`. */
|
||||
archived_at?: string | null;
|
||||
/** ISO time the user pinned the session to the top of its workspace's
|
||||
* sidebar list; null when not pinned. Maps to `sessions.pinned_at`. */
|
||||
pinned_at?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useSyncExternalStore } from "react";
|
||||
import { api } from "./api";
|
||||
import i18n from "../i18n";
|
||||
import { applyStoredTheme } from "../hooks/useTheme";
|
||||
import { FONT_SIZE_EVENT, applyUiFontSize } from "./fontSize";
|
||||
|
||||
/** The localStorage keys an account's preferences are made of (server/routes/me.js). */
|
||||
export const PREF_KEYS = [
|
||||
@@ -27,6 +28,8 @@ export const PREF_KEYS = [
|
||||
"agenthub.theme.light",
|
||||
"i18nextLng",
|
||||
"agenthub.terminal.font",
|
||||
"agenthub.terminal.fontSize",
|
||||
"agenthub.ui.fontSize",
|
||||
"agenthub.pet",
|
||||
"agenthub.pet.size",
|
||||
"agenthub.pet.names",
|
||||
@@ -107,6 +110,8 @@ function reapply(prefs: Record<string, string>) {
|
||||
applyStoredTheme();
|
||||
window.dispatchEvent(new Event("agenthub-theme"));
|
||||
window.dispatchEvent(new Event("sagi:prefs"));
|
||||
applyUiFontSize();
|
||||
window.dispatchEvent(new Event(FONT_SIZE_EVENT));
|
||||
const lng = prefs.i18nextLng;
|
||||
if (lng && lng !== i18n.language) void i18n.changeLanguage(lng);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import { CodexConfig } from "./CodexConfig";
|
||||
import { AiToolConfig } from "./AiToolConfig";
|
||||
import { AiProxyConfig } from "./AiProxyConfig";
|
||||
import { GitCredentialsConfig } from "./GitCredentialsConfig";
|
||||
import { GitIdentitySettings } from "../components/settings/GitIdentitySettings";
|
||||
import { AppearanceSettings } from "../components/settings/AppearanceSettings";
|
||||
import { AccountSettings } from "../components/settings/AccountSettings";
|
||||
import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
@@ -400,6 +401,15 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<GitCredentialsConfig />
|
||||
</section>
|
||||
)}
|
||||
{/* The user's own SSH key / HTTPS logins (S3: their own home). */}
|
||||
{(isAdmin || signedIn) && (
|
||||
<section id="git-identity" className="scroll-mt-24">
|
||||
<h2 className="mb-2 text-sm font-semibold text-fg-primary">
|
||||
{t("gitIdentity.title")}
|
||||
</h2>
|
||||
<GitIdentitySettings />
|
||||
</section>
|
||||
)}
|
||||
<div id="worktrees" className="scroll-mt-24">
|
||||
<WorktreeVisibilitySettings />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user