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,10 +1,13 @@
|
||||
/**
|
||||
* @file AppearanceSettings.tsx
|
||||
* @description Settings → Appearance (`/settings/appearance`): the per-user
|
||||
* Chat UI default (`ChatUiSettings`, `#chat-ui`), whether the session view's
|
||||
* tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`), the interface language, the
|
||||
* terminal font (`lib/terminalFont.ts`, with a preview line), the
|
||||
* @description Settings → Appearance (`/settings/appearance`), grouped so
|
||||
* one card holds one subject: **Text & fonts** (`#fonts`) — the interface
|
||||
* language (`#language`), the text size (`lib/fontSize.ts`, `#text-size`,
|
||||
* issue #79), the terminal font and its size (`lib/terminalFont.ts`,
|
||||
* `#terminal-font`, with a preview line); **Sessions** (`#sessions-view`) —
|
||||
* the per-user Chat UI default (`ChatUiSettings`, `#chat-ui`) and whether the
|
||||
* session view's tabs hold every workspace's sessions (`lib/workspaceTabs.ts`,
|
||||
* `#workspace-tabs`); then the
|
||||
* Dark / Light / System mode switch and one named-theme picker per mode, and the
|
||||
* corner pet (`PetSettings`, deep-linked as `#pet`) — the old General and Pet
|
||||
* pages redirect here. Theme state is in `hooks/useTheme.ts`, palettes in
|
||||
@@ -34,6 +37,15 @@ import {
|
||||
type TerminalFont,
|
||||
} from "../../lib/terminalFont";
|
||||
import { readAllTabs, writeAllTabs } from "../../lib/workspaceTabs";
|
||||
import {
|
||||
TERMINAL_FONT_SIZES,
|
||||
UI_FONT_SIZES,
|
||||
readTerminalFontSize,
|
||||
readUiFontSize,
|
||||
writeTerminalFontSize,
|
||||
writeUiFontSize,
|
||||
type UiFontSize,
|
||||
} from "../../lib/fontSize";
|
||||
|
||||
export const THEME_LABELS: Record<string, string> = {
|
||||
darcula: "Darcula",
|
||||
@@ -82,6 +94,8 @@ export function AppearanceSettings() {
|
||||
const { t, i18n } = useTranslation("settings");
|
||||
const { mode, theme, themeFor, setMode, setThemeFor } = useTheme();
|
||||
const [termFont, setTermFont] = useState(readTerminalFont);
|
||||
const [termSize, setTermSize] = useState(readTerminalFontSize);
|
||||
const [uiSize, setUiSize] = useState(readUiFontSize);
|
||||
const [allTabs, setAllTabs] = useState(readAllTabs);
|
||||
const pickTermFont = (font: TerminalFont) => {
|
||||
writeTerminalFont(font);
|
||||
@@ -103,31 +117,11 @@ export function AppearanceSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<ChatUiSettings />
|
||||
|
||||
<SettingsSection
|
||||
id="workspace-tabs"
|
||||
title={t("workspaceTabs.title")}
|
||||
description={t("workspaceTabs.description")}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="language"
|
||||
title={t("language.title")}
|
||||
description={t("language.description")}
|
||||
>
|
||||
<SettingsSection id="fonts" title={t("fonts.title")} description={t("fonts.description")}>
|
||||
<SettingsRow
|
||||
id="language"
|
||||
label={t("language.label")}
|
||||
description={t("language.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("language.label")}
|
||||
@@ -140,16 +134,34 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="terminal-font"
|
||||
title={t("terminalFont.title")}
|
||||
description={t("terminalFont.description")}
|
||||
>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.label")}
|
||||
description={termFont === "system" ? t("terminalFont.systemNote") : undefined}
|
||||
id="text-size"
|
||||
label={t("textSize.label")}
|
||||
description={t("textSize.description")}
|
||||
control={
|
||||
<SettingsSegmented
|
||||
ariaLabel={t("textSize.label")}
|
||||
value={String(uiSize)}
|
||||
onChange={(v) => {
|
||||
const size = Number(v) as UiFontSize;
|
||||
writeUiFontSize(size);
|
||||
setUiSize(size);
|
||||
}}
|
||||
options={UI_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: t(`textSize.names.${size}`),
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
id="terminal-font"
|
||||
label={t("terminalFont.title")}
|
||||
description={
|
||||
termFont === "system"
|
||||
? `${t("terminalFont.description")} ${t("terminalFont.systemNote")}`
|
||||
: t("terminalFont.description")
|
||||
}
|
||||
control={
|
||||
// A dropdown: five font names overflow a segmented row.
|
||||
<Select<TerminalFont>
|
||||
@@ -163,17 +175,57 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingsRow
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
control={
|
||||
<Select
|
||||
label={t("terminalFont.sizeLabel")}
|
||||
value={String(termSize)}
|
||||
onChange={(v) => {
|
||||
writeTerminalFontSize(Number(v));
|
||||
setTermSize(Number(v));
|
||||
}}
|
||||
options={TERMINAL_FONT_SIZES.map((size) => ({
|
||||
value: String(size),
|
||||
label: `${size}px`,
|
||||
}))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<p
|
||||
data-testid="terminal-font-preview"
|
||||
aria-label={t("terminalFont.preview")}
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-[13px] text-fg-primary"
|
||||
className="mt-2 rounded-md border border-border bg-surface-0 px-3 py-2 text-fg-primary"
|
||||
// No ligatures: xterm draws one glyph per cell, so the terminal has none.
|
||||
style={{ fontFamily: TERMINAL_FONTS[termFont], fontVariantLigatures: "none" }}
|
||||
// In px like xterm's cells: the interface's text size does not scale it.
|
||||
style={{
|
||||
fontFamily: TERMINAL_FONTS[termFont],
|
||||
fontSize: termSize,
|
||||
fontVariantLigatures: "none",
|
||||
}}
|
||||
>
|
||||
❯ Tiếng Việt: ấ ế ố ồ ủ ữ — const x = {"{ ok: true }"}; 0O il1|
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
id="sessions-view"
|
||||
title={t("sessionsView.title")}
|
||||
description={t("sessionsView.description")}
|
||||
>
|
||||
<ChatUiSettings />
|
||||
<SettingsSwitchRow
|
||||
id="workspace-tabs"
|
||||
label={t("workspaceTabs.label")}
|
||||
description={t("workspaceTabs.labelDesc")}
|
||||
checked={allTabs}
|
||||
onChange={(on) => {
|
||||
writeAllTabs(on);
|
||||
setAllTabs(on);
|
||||
}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<section id="theme" className="card p-5 space-y-5 scroll-mt-24">
|
||||
<h3 className="text-sm font-semibold text-fg-primary">{t("appearance.theme")}</h3>
|
||||
<SettingsRow
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file ChatUiSettings — Settings → Appearance → Chat UI: whether a Claude or
|
||||
* Codex session opens as a chat like Claude Desktop (`PaneChat`) instead of
|
||||
* its terminal. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* @file ChatUiSettings — Settings → Appearance → Sessions, the Chat UI row
|
||||
* (`#chat-ui`): whether a Claude or Codex session opens as a chat like Claude
|
||||
* Desktop (`PaneChat`) instead of its terminal. A row, not a card: it sits in
|
||||
* the Sessions section beside the workspace-tabs switch. Saved per user on the server (`lib/chatUi.ts`), so each
|
||||
* account on a shared dashboard keeps its own choice on any browser.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
@@ -9,25 +10,22 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { saveChatUi, useChatUi } from "../../lib/chatUi";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
|
||||
import { SettingsSwitchRow } from "./SettingsRow";
|
||||
|
||||
export function ChatUiSettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const chatUi = useChatUi();
|
||||
return (
|
||||
<SettingsSection id="chat-ui" title={t("chatUi.title")} description={t("chatUi.description")}>
|
||||
<SettingsSwitchRow
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(
|
||||
t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" })
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSwitchRow
|
||||
id="chat-ui"
|
||||
label={t("chatUi.label")}
|
||||
description={t("chatUi.labelDesc")}
|
||||
checked={chatUi}
|
||||
onChange={(on) =>
|
||||
saveChatUi(on).catch((err: unknown) =>
|
||||
toast.error(t("chatUi.saveFailed", { message: err instanceof Error ? err.message : "" }))
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* @file GitIdentitySettings.tsx
|
||||
* @description Settings → Git → **Your git identity** (`/api/me/git`): the
|
||||
* commit author (name + email in the account's global git config — prefilled
|
||||
* from Settings → Account while unset, since a member's commits fail without
|
||||
* one), the SSH
|
||||
* key a clone (Add workspace → Clone) and the pushes from that clone use —
|
||||
* make one, copy its public half to add to GitHub/GitLab, replace or delete
|
||||
* it — and, for a user with their own Linux account (S3), their own HTTPS
|
||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||
* table above it, so this lists them only for a member. The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, KeyRound, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { api, type GitIdentity } from "../../lib/api";
|
||||
import { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { toast } from "../../lib/toast";
|
||||
import { useAuth } from "../AuthGate";
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded border border-border-light bg-surface-0 px-2 py-1 text-xs text-fg-primary placeholder:text-fg-muted focus:border-accent focus:outline-none";
|
||||
const BTN =
|
||||
"inline-flex items-center gap-1.5 rounded border border-border-light px-2.5 py-1 text-xs text-fg-secondary hover:bg-surface-3 disabled:cursor-not-allowed disabled:opacity-40";
|
||||
|
||||
export function GitIdentitySettings() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [form, setForm] = useState<{ host: string; username: string; token: string } | null>(null);
|
||||
const auth = useAuth().state;
|
||||
const me = auth.mode === "server" ? auth.user : null;
|
||||
// null = not edited: the field shows the saved value, else the account's.
|
||||
const [author, setAuthor] = useState<{ name: string; email: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setId(await api.gitIdentity.get());
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<GitIdentity>, done?: string) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setId(await fn());
|
||||
if (done) toast.success(done);
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
if (
|
||||
id?.ssh &&
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.replaceConfirm"),
|
||||
confirmLabel: t("gitIdentity.replace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.generateKey(Boolean(id?.ssh)), t("gitIdentity.keyMade"));
|
||||
};
|
||||
|
||||
const removeKey = async () => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeKeyConfirm"),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeKey());
|
||||
};
|
||||
|
||||
const saveLogin = async () => {
|
||||
if (!form) return;
|
||||
// A bare host is the usual entry; a pasted URL is reduced to its host.
|
||||
let host = form.host.trim();
|
||||
try {
|
||||
if (/^https?:\/\//i.test(host)) host = new URL(host).host;
|
||||
} catch {
|
||||
/* the server answers EBADHOST */
|
||||
}
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.saveHttps({ host, username: form.username.trim(), password: form.token }),
|
||||
t("gitCreds.saved", { host })
|
||||
);
|
||||
if (ok) setForm(null);
|
||||
};
|
||||
|
||||
const removeLogin = async (host: string) => {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.removeLoginConfirm", { host }),
|
||||
confirmLabel: t("gitIdentity.remove"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
await run(() => api.gitIdentity.removeHttps(host));
|
||||
};
|
||||
|
||||
if (!id) {
|
||||
return error ? (
|
||||
<p className="text-xs text-status-danger">{error}</p>
|
||||
) : (
|
||||
<p className="text-xs text-fg-muted">{t("gitCreds.loading")}</p>
|
||||
);
|
||||
}
|
||||
const member = id.account === "member";
|
||||
const authorShown = author ?? {
|
||||
name: id.author.name ?? me?.displayName ?? me?.username ?? "",
|
||||
email: id.author.email ?? me?.email ?? "",
|
||||
};
|
||||
const authorUnset = !id.author.name || !id.author.email;
|
||||
const authorDirty =
|
||||
authorUnset ||
|
||||
authorShown.name.trim() !== (id.author.name ?? "") ||
|
||||
authorShown.email.trim() !== (id.author.email ?? "");
|
||||
const saveAuthor = async () => {
|
||||
const ok = await run(
|
||||
() =>
|
||||
api.gitIdentity.saveAuthor({
|
||||
name: authorShown.name.trim(),
|
||||
email: authorShown.email.trim(),
|
||||
}),
|
||||
t("gitIdentity.authorSaved")
|
||||
);
|
||||
if (ok) setAuthor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="max-w-2xl text-xs text-fg-muted">
|
||||
{member ? t("gitIdentity.descriptionMember") : t("gitIdentity.descriptionHost")}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-xs text-status-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="max-w-2xl space-y-2 rounded-lg border border-border p-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveAuthor();
|
||||
}}
|
||||
>
|
||||
<h3 className="text-xs font-semibold text-fg-primary">{t("gitIdentity.authorTitle")}</h3>
|
||||
{authorUnset && (
|
||||
<p className="text-[11px] text-status-warning">{t("gitIdentity.authorUnset")}</p>
|
||||
)}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
aria-label={t("gitIdentity.authorName")}
|
||||
placeholder={t("gitIdentity.authorName")}
|
||||
value={authorShown.name}
|
||||
disabled={!id.writable}
|
||||
onChange={(e) => setAuthor({ ...authorShown, name: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
aria-label={t("gitIdentity.authorEmail")}
|
||||
placeholder="name@example.com"
|
||||
value={authorShown.email}
|
||||
disabled={!id.writable}
|
||||
onChange={(e) => setAuthor({ ...authorShown, email: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !authorDirty || !authorShown.name.trim() || !authorShown.email.trim()}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.sshTitle")}
|
||||
</h3>
|
||||
{id.writable && (
|
||||
<button type="button" className={BTN} disabled={busy} onClick={() => void generate()}>
|
||||
{id.ssh ? <RefreshCw className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{id.ssh ? t("gitIdentity.replace") : t("gitIdentity.generate")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{id.ssh ? (
|
||||
<>
|
||||
<textarea
|
||||
readOnly
|
||||
aria-label={t("gitIdentity.publicKey")}
|
||||
value={id.ssh.publicKey}
|
||||
rows={3}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className={`${INPUT} resize-none break-all font-mono text-[11px]`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[10px] text-fg-muted">
|
||||
{id.ssh.fingerprint}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => void copyText(id.ssh?.publicKey ?? "")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
{id.writable && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${BTN} hover:text-status-danger`}
|
||||
disabled={busy}
|
||||
onClick={() => void removeKey()}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted">{t("gitIdentity.addKeyHint")}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noKey")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{member && (
|
||||
<div className="max-w-2xl space-y-2 rounded-lg border border-border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||
{t("gitIdentity.httpsTitle")}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className={BTN}
|
||||
onClick={() => setForm({ host: "", username: "", token: "" })}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("gitCreds.add")}
|
||||
</button>
|
||||
</div>
|
||||
{form && (
|
||||
<form
|
||||
className="grid gap-2 sm:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveLogin();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldHost")}
|
||||
placeholder="github.com"
|
||||
value={form.host}
|
||||
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
<input
|
||||
aria-label={t("gitCreds.fieldUser")}
|
||||
placeholder={t("gitCreds.fieldUser")}
|
||||
autoComplete="off"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={t("gitCreds.fieldToken")}
|
||||
placeholder={t("gitCreds.fieldToken")}
|
||||
autoComplete="new-password"
|
||||
value={form.token}
|
||||
onChange={(e) => setForm({ ...form, token: e.target.value })}
|
||||
className={INPUT}
|
||||
/>
|
||||
<div className="flex gap-2 sm:col-span-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !form.host.trim() || !form.username.trim() || !form.token}
|
||||
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{t("gitCreds.save")}
|
||||
</button>
|
||||
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||
{t("gitCreds.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{id.https.length === 0 ? (
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.noLogins")}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-xs">
|
||||
{id.https.map((c) => (
|
||||
<li key={c.host} className="flex items-center gap-2 py-1.5">
|
||||
<span className="font-mono text-[11px] text-fg-primary">{c.host}</span>
|
||||
<span className="flex-1 text-fg-secondary">{c.username}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t("gitIdentity.remove")} ${c.host}`}
|
||||
title={t("gitIdentity.remove")}
|
||||
disabled={busy}
|
||||
onClick={() => void removeLogin(c.host)}
|
||||
className="rounded p-1 text-fg-muted hover:bg-surface-3 hover:text-status-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,15 +37,19 @@ export function SettingsRow({
|
||||
description,
|
||||
control,
|
||||
alignTop = false,
|
||||
id,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
control: ReactNode;
|
||||
alignTop?: boolean;
|
||||
/** A deep-link anchor (`#id`) for one row inside a grouped section. */
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
id={id}
|
||||
className={`${id ? "scroll-mt-24 " : ""}flex justify-between gap-4 border-b border-border/60 last:border-b-0 ${
|
||||
description ? "py-3" : "py-2"
|
||||
} ${alignTop ? "items-start" : "items-center"}`}
|
||||
>
|
||||
@@ -64,15 +68,18 @@ export function SettingsSwitchRow({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
id,
|
||||
}: {
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
return (
|
||||
<SettingsRow
|
||||
id={id}
|
||||
label={label}
|
||||
description={description}
|
||||
control={<Switch checked={checked} onChange={onChange} label={label} disabled={disabled} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file AppearanceSettings.test.tsx
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the terminal font picker, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @description Tests for the Settings → Appearance section: the language switch, the text size, the terminal font and size pickers, and the mode switch and the per-mode theme cards writing through useTheme to `<html>`.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("AppearanceSettings", () => {
|
||||
root.classList.remove("dark");
|
||||
root.removeAttribute("data-theme");
|
||||
root.removeAttribute("data-mode");
|
||||
root.style.removeProperty("font-size");
|
||||
});
|
||||
|
||||
it("switches the interface language and holds the pet block", async () => {
|
||||
@@ -102,4 +103,33 @@ describe("AppearanceSettings", () => {
|
||||
expect(localStorage.getItem("agenthub.terminal.font")).toBe("system");
|
||||
expect(screen.getByText(/may lack some Vietnamese letters/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets the interface text size on <html>, the default leaving it unset", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AppearanceSettings />);
|
||||
const group = screen.getByRole("radiogroup", { name: "Interface text" });
|
||||
expect(within(group).getByRole("radio", { name: "Default" })).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true"
|
||||
);
|
||||
await user.click(within(group).getByRole("radio", { name: "Larger" }));
|
||||
expect(root.style.fontSize).toBe("18px");
|
||||
expect(localStorage.getItem("agenthub.ui.fontSize")).toBe("18");
|
||||
await user.click(within(group).getByRole("radio", { name: "Default" }));
|
||||
expect(root.style.fontSize).toBe("");
|
||||
});
|
||||
|
||||
it("picks the terminal font size and tells open terminals", async () => {
|
||||
const user = userEvent.setup();
|
||||
const heard = vi.fn();
|
||||
window.addEventListener("agenthub-font-size", heard);
|
||||
render(<AppearanceSettings />);
|
||||
expect(screen.getByRole("button", { name: /^Size/ })).toHaveTextContent("13px");
|
||||
await user.click(screen.getByRole("button", { name: /^Size/ }));
|
||||
await user.click(screen.getByRole("option", { name: "16px" }));
|
||||
expect(localStorage.getItem("agenthub.terminal.fontSize")).toBe("16");
|
||||
expect(screen.getByTestId("terminal-font-preview").style.fontSize).toBe("16px");
|
||||
expect(heard).toHaveBeenCalled();
|
||||
window.removeEventListener("agenthub-font-size", heard);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* @file Tests for Settings → Git → Your git identity: a user with no key
|
||||
* makes one, a key shows only its public half and copies it, replacing asks
|
||||
* first, and a member (S3) adds an HTTPS login of their own while the host's
|
||||
* view leaves HTTPS to the shared Git credentials table.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitIdentitySettings } from "../GitIdentitySettings";
|
||||
import { api, type GitIdentity } from "../../../lib/api";
|
||||
import { confirmDialog } from "../../../lib/confirm";
|
||||
import { copyText } from "../../../lib/clipboard";
|
||||
|
||||
vi.mock("../../../lib/api", () => ({
|
||||
api: {
|
||||
gitIdentity: {
|
||||
get: vi.fn(),
|
||||
generateKey: vi.fn(),
|
||||
removeKey: vi.fn(),
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||
|
||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||
account: "member",
|
||||
writable: true,
|
||||
ssh: null,
|
||||
https: [],
|
||||
author: { name: "An", email: "an@x.vn" },
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GitIdentitySettings", () => {
|
||||
it("makes a key when there is none, without asking", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Generate SSH key" }));
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(false);
|
||||
expect(await screen.findByLabelText("Public key")).toHaveValue(KEY.publicKey);
|
||||
});
|
||||
|
||||
it("copies the public key and asks before replacing it", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ ssh: KEY }));
|
||||
vi.mocked(api.gitIdentity.generateKey).mockResolvedValue(view({ ssh: KEY }));
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Copy public key" }));
|
||||
expect(copyText).toHaveBeenCalledWith(KEY.publicKey);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Replace key" }));
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() => expect(api.gitIdentity.generateKey).toHaveBeenCalledWith(true));
|
||||
});
|
||||
|
||||
it("lets a member save an https login of their own", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view());
|
||||
vi.mocked(api.gitIdentity.saveHttps).mockResolvedValue(
|
||||
view({ https: [{ protocol: "https", host: "github.com", username: "an", hasToken: true }] })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "Add login" }));
|
||||
await userEvent.type(screen.getByLabelText("Server URL"), "https://github.com/o/r");
|
||||
await userEvent.type(screen.getByLabelText("Username"), "an");
|
||||
await userEvent.type(screen.getByLabelText("Password or access token"), "tok");
|
||||
// The second Save: the first belongs to the commit-author form.
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[1]!);
|
||||
expect(api.gitIdentity.saveHttps).toHaveBeenCalledWith({
|
||||
host: "github.com",
|
||||
username: "an",
|
||||
password: "tok",
|
||||
});
|
||||
expect(await screen.findByText("github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns while the commit author is unset and saves it", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(view({ author: { name: null, email: null } }));
|
||||
vi.mocked(api.gitIdentity.saveAuthor).mockResolvedValue(view());
|
||||
render(<GitIdentitySettings />);
|
||||
expect(
|
||||
await screen.findByText(/commits from the dashboard and your agents fail/)
|
||||
).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Name"), "An Nguyen");
|
||||
await userEvent.type(screen.getByLabelText("Email"), "an@x.vn");
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Save" })[0]!);
|
||||
expect(api.gitIdentity.saveAuthor).toHaveBeenCalledWith({
|
||||
name: "An Nguyen",
|
||||
email: "an@x.vn",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the host's key read-only and no https list", async () => {
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ account: "host", writable: false, ssh: KEY })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByLabelText("Public key")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Replace key" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Add login" })).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user