feat(lanes): git login from the dashboard, merge and conflict resolution (#45)

Part A — a fetch/pull/push against a remote that needs a login no longer
dies on "terminal prompts disabled": the server answers 409 EGITAUTH with
the host, the git bar asks for a username + password/access token and
resends the same action. AgentHub keeps its OWN credential store
(<CLAUDE_HOME>/agenthub-git-credentials/, 0700/0600), one login per host,
shared by every user of the install, managed from Settings -> Git
credentials. A login is only sent to one of the repo's own remote hosts,
saved only after git accepted it, never returned by any route, and reaches
git through an exclusive credential helper (never argv).

Part B — merge a branch (or the diverged upstream) from the git bar; a
merge that stops on conflicts lists them in the Changes panel with Keep
mine / Take theirs / Mark resolved, then Complete merge or Abort merge
(confirm gate). Clean tree, repo-root lane and an existing ref only;
pull/switch/hunk actions are blocked mid-merge; staging a conflicted file
with markers left is refused. Rebase/reset/stash stay out.
This commit is contained in:
2026-09-25 10:44:40 +07:00
parent 605ca9d65e
commit f514602697
30 changed files with 2005 additions and 59 deletions
+206
View File
@@ -0,0 +1,206 @@
/**
* @file GitCredentialsConfig.tsx
* @description The **Git credentials** tab of Settings (issue #45) — the
* logins AgentHub itself gives git for a dashboard fetch/pull/push, one per
* host, shared by everyone using this install. Lists them, replaces a token,
* adds one, deletes one. A token is write-only here: the server never returns
* one, so the form starts blank and the list shows only that one is saved.
* The git bar's login form saves into the same store.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, Pencil, Plus, Trash2 } from "lucide-react";
import { api, type GitCredential } from "../lib/api";
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";
export function GitCredentialsConfig() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<GitCredential[]>([]);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<{ url: string; username: string; token: string } | null>(null);
const load = useCallback(async () => {
try {
setItems((await api.gitCredentials.list()).items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
void load();
}, [load]);
const save = async () => {
if (!form) return;
setError(null);
let url: URL;
try {
url = new URL(form.url.trim());
} catch {
setError(t("gitCreds.badUrl"));
return;
}
try {
const r = await api.gitCredentials.save({
protocol: url.protocol.replace(/:$/, ""),
host: url.host,
username: form.username.trim(),
password: form.token,
});
setItems(r.items);
setForm(null);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const remove = async (c: GitCredential) => {
if (!window.confirm(t("gitCreds.removeConfirm"))) return;
try {
setItems((await api.gitCredentials.remove(c.protocol, c.host)).items);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-fg-primary">
<KeyRound className="h-4 w-4 text-fg-muted" />
{t("gitCreds.title")}
</h2>
<p className="mt-1 max-w-2xl text-xs text-fg-muted">{t("gitCreds.description")}</p>
</div>
<button
type="button"
onClick={() => setForm({ url: "", username: "", token: "" })}
className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-accent/40 bg-accent/15 px-3 py-1.5 text-xs font-medium text-accent transition-colors hover:bg-accent/25"
>
<Plus className="h-3.5 w-3.5" />
{t("gitCreds.add")}
</button>
</div>
{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 && (
<form
className="grid max-w-2xl gap-2 rounded-lg border border-border p-3 sm:grid-cols-3"
onSubmit={(e) => {
e.preventDefault();
void save();
}}
>
<input
aria-label={t("gitCreds.fieldHost")}
placeholder="https://git.example.com"
value={form.url}
onChange={(e) => setForm({ ...form, url: 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={!form.url.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="rounded border border-border-light px-3 py-1 text-xs text-fg-secondary hover:bg-surface-3"
>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-surface-2 text-[10px] uppercase tracking-wider text-fg-muted">
<tr>
<th className="px-3 py-2 font-medium">{t("gitCreds.colHost")}</th>
<th className="px-3 py-2 font-medium">{t("gitCreds.colUser")}</th>
<th className="px-3 py-2 font-medium">{t("gitCreds.colSaved")}</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{items.map((c) => (
<tr key={`${c.protocol}://${c.host}`} className="border-t border-border">
<td className="px-3 py-2 font-mono text-[11px] text-fg-primary">{c.host}</td>
<td className="px-3 py-2 text-fg-secondary">{c.username}</td>
<td className="px-3 py-2 text-fg-secondary">
{new Date(c.savedAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
<span className="inline-flex gap-1">
<button
type="button"
aria-label={`${t("gitCreds.replace")} ${c.host}`}
title={t("gitCreds.replace")}
onClick={() =>
setForm({
url: `${c.protocol}://${c.host}`,
username: c.username,
token: "",
})
}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${t("gitCreds.remove")} ${c.host}`}
title={t("gitCreds.remove")}
onClick={() => void remove(c)}
className="rounded p-1 text-fg-muted transition-colors hover:bg-surface-3 hover:text-status-danger"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</span>
</td>
</tr>
))}
{items.length === 0 && (
<tr className="border-t border-border">
<td colSpan={4} className="px-3 py-4 text-center text-fg-muted">
{t("gitCreds.empty")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}