dev-manager-tauri/src/components/dashboard/GiteaPanel.tsx
lanrtop 4cd941c975 feat(gitea): G7 一键接线——新建仓库后本地 init/origin/首推一键完成
绑定完成但本地未连通时(无 .git 或无 origin),Gitea 绑定区显示接线引导:
- 无 git → init + 首次提交 + 关联 origin + 推送
- 有 git 无 origin → 关联 origin + 推送当前分支
复用 publish.rs 存量命令(git_init_and_commit/git_remote_set/git_push),
补齐 GitHub 退役时遗落的"本地接线"半程(接线/放风筝边界裁决的落地)。

Rules-Applied: R01
2026-07-03 10:04:31 +09:00

512 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Gitea 集成,拆两半(ui-restructure N4):
// - GiteaInstancesSection:实例管理(全局配置)→ 挂设置页
// - GiteaPanel:当前项目仓库绑定 + webhook(项目级)→ 留项目蓝图治理面板
// 控制面在 gitea.rs,观测面(webhook 接收)在 mcp_server.rs
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
giteaSaveInstance,
giteaListInstances,
giteaDeleteInstance,
giteaUpdateInstance,
giteaTestConnection,
giteaGetRepoLink,
giteaListLinkedRepos,
giteaCreateRepo,
giteaMigrateRepo,
giteaLinkRepo,
giteaSetupWebhook,
giteaUnlinkRepo,
giteaPollNow,
scanLocalGit,
gitInitAndCommit,
gitRemoteSet,
gitPush,
type GiteaInstance,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
type BindMode = "create" | "migrate" | "link";
const CALLBACK_BASE_KEY = "gitea_callback_base";
const inputCls =
"w-full text-[11px] border border-gray-200 rounded px-2 py-1.5 outline-none focus:border-blue-400";
const btnCls =
"px-2 py-1 rounded text-[11px] border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors";
// ── 实例管理(全局,挂设置页)────────────────────────────────────────────────
export function GiteaInstancesSection() {
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const { data: instances = [] } = useQuery({
queryKey: ["gitea-instances"],
queryFn: giteaListInstances,
});
const [showAdd, setShowAdd] = useState(false);
const [instName, setInstName] = useState("");
const [instUrl, setInstUrl] = useState("");
const [instToken, setInstToken] = useState("");
const [testing, setTesting] = useState<string | null>(null);
const [editingTokenId, setEditingTokenId] = useState<string | null>(null);
const [newToken, setNewToken] = useState("");
const handleUpdateToken = async (id: string) => {
if (!newToken.trim()) return;
try {
await giteaUpdateInstance(id, { access_token: newToken.trim() });
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
setEditingTokenId(null);
setNewToken("");
showToast("Token 已更新 ✓ 可点「测试连接」验证");
} catch (e) {
showToast(`❌ ${e}`);
}
};
const handleAdd = async () => {
if (!instName.trim() || !instUrl.trim() || !instToken.trim()) return;
try {
await giteaSaveInstance({
name: instName.trim(),
url: instUrl.trim(),
access_token: instToken.trim(),
is_default: instances.length === 0,
});
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
setInstName(""); setInstUrl(""); setInstToken("");
setShowAdd(false);
showToast("Gitea 实例已添加 ✓");
} catch (e) {
showToast(`❌ ${e}`);
}
};
const handleTest = async (inst: GiteaInstance) => {
setTesting(inst.id);
try {
const user = await giteaTestConnection(inst.id);
showToast(`✓ 连接成功,账户: ${user.login}`);
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setTesting(null);
}
};
const handleDelete = async (inst: GiteaInstance) => {
if (!confirm(`删除实例「${inst.name}」?其下所有项目绑定会一并删除。`)) return;
try {
await giteaDeleteInstance(inst.id);
qc.invalidateQueries({ queryKey: ["gitea-instances"] });
qc.invalidateQueries({ queryKey: ["gitea-linked-repos"] });
showToast("已删除");
} catch (e) {
showToast(`❌ ${e}`);
}
};
return (
<section className="bg-white rounded-xl border border-gray-200 shadow-sm px-6 py-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700">🪁 Gitea 实例</h2>
{!showAdd && (
<button onClick={() => setShowAdd(true)} className="text-xs text-blue-500 hover:text-blue-600">
+ 添加实例
</button>
)}
</div>
<div className="space-y-2">
{instances.length === 0 && !showAdd && (
<p className="text-xs text-gray-400">尚未添加 Gitea 实例——添加后可在各项目蓝图的治理面板中绑定仓库</p>
)}
{instances.map((inst) => (
<div key={inst.id} className="space-y-1.5">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-gray-700 shrink-0">{inst.name}</span>
{inst.is_default && (
<span className="text-[10px] px-1 rounded bg-blue-50 text-blue-500 shrink-0">默认</span>
)}
<span className="text-gray-400 font-mono truncate flex-1">{inst.url}</span>
<button onClick={() => handleTest(inst)} disabled={testing === inst.id} className={btnCls}>
{testing === inst.id ? "…" : "测试连接"}
</button>
<button
onClick={() => { setEditingTokenId(editingTokenId === inst.id ? null : inst.id); setNewToken(""); }}
className={btnCls}
>
改 Token
</button>
<button
onClick={() => handleDelete(inst)}
className="px-2 py-1 rounded text-[11px] border border-gray-200 text-red-400 hover:bg-red-50"
>
删除
</button>
</div>
{editingTokenId === inst.id && (
<div className="flex gap-1.5 pl-2">
<input
className={inputCls}
type="password"
placeholder="新 Access Token(需 repository 读写 + user 读写权限)"
value={newToken}
onChange={(e) => setNewToken(e.target.value)}
autoFocus
/>
<button
onClick={() => handleUpdateToken(inst.id)}
disabled={!newToken.trim()}
className="px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shrink-0"
>
更新
</button>
</div>
)}
</div>
))}
{showAdd && (
<div className="space-y-2 border-t border-gray-100 pt-3">
<input className={inputCls} placeholder="实例名称(如:家里 NAS)" value={instName} onChange={(e) => setInstName(e.target.value)} />
<input className={inputCls} placeholder="https://gitea.example.com" value={instUrl} onChange={(e) => setInstUrl(e.target.value)} />
<input className={inputCls} type="password" placeholder="Access Token(用户设置 → 应用 → 生成令牌;权限:repository 读写 + user 读写)" value={instToken} onChange={(e) => setInstToken(e.target.value)} />
<div className="flex gap-1.5">
<button onClick={handleAdd} className="px-3 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-700">保存</button>
<button onClick={() => setShowAdd(false)} className="px-3 py-1.5 rounded text-xs border border-gray-200 text-gray-500 hover:bg-gray-50">取消</button>
</div>
</div>
)}
</div>
</section>
);
}
// ── 项目级绑定 + webhook(留项目蓝图治理面板)────────────────────────────────
interface Props {
projectId: string;
projectName: string;
projectPath?: string;
}
export function GiteaPanel({ projectId, projectName, projectPath }: Props) {
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const { data: instances = [] } = useQuery({
queryKey: ["gitea-instances"],
queryFn: giteaListInstances,
});
const { data: link } = useQuery({
queryKey: ["gitea-link", projectId],
queryFn: () => giteaGetRepoLink(projectId),
});
const { data: allLinks = [] } = useQuery({
queryKey: ["gitea-linked-repos"],
queryFn: giteaListLinkedRepos,
});
// G7 一键接线:绑定后检测本地 git 状态(无 git / 无 origin / 已接线)
const { data: localGit, refetch: refetchLocalGit } = useQuery({
queryKey: ["gitea-local-git", projectId],
queryFn: () => scanLocalGit(projectPath!),
enabled: !!link && !!projectPath,
staleTime: 30000,
retry: false,
});
const hasOrigin = localGit?.remotes.some((r) => r.name === "origin") ?? false;
const needsWiring = !!link && !!projectPath && localGit != null && (!localGit.has_git || !hasOrigin);
const [wiring, setWiring] = useState(false);
const handleWireUp = async () => {
if (!projectPath || !link?.clone_url) return;
setWiring(true);
try {
if (!localGit?.has_git || !localGit?.has_commits) {
await gitInitAndCommit(projectPath, "chore: 项目初始化(炼境接线)");
}
await gitRemoteSet(projectPath, link.clone_url);
const msg = await gitPush(projectPath);
showToast(`✓ 接线完成:${msg}`);
refetchLocalGit();
} catch (e) {
showToast(`❌ 接线失败: ${e}`);
} finally {
setWiring(false);
}
};
const refresh = () => {
qc.invalidateQueries({ queryKey: ["gitea-link", projectId] });
qc.invalidateQueries({ queryKey: ["gitea-linked-repos"] });
};
// ── 绑定操作 ──
const [bindMode, setBindMode] = useState<BindMode | null>(null);
const [bindInstanceId, setBindInstanceId] = useState("");
const [repoName, setRepoName] = useState("");
const [repoOwner, setRepoOwner] = useState("");
const [cloneAddr, setCloneAddr] = useState("");
const [migrateToken, setMigrateToken] = useState("");
const [binding, setBinding] = useState(false);
const effectiveInstanceId =
bindInstanceId || instances.find((i) => i.is_default)?.id || instances[0]?.id || "";
const handleBind = async () => {
if (!effectiveInstanceId || !repoName.trim()) return;
setBinding(true);
try {
if (bindMode === "create") {
await giteaCreateRepo(effectiveInstanceId, projectId, repoName.trim(), projectName, true);
} else if (bindMode === "migrate") {
if (!cloneAddr.trim()) { showToast("❌ 请填写源仓库地址"); return; }
await giteaMigrateRepo(
effectiveInstanceId, projectId, cloneAddr.trim(), repoName.trim(),
migrateToken.trim() || undefined, true
);
} else if (bindMode === "link") {
if (!repoOwner.trim()) { showToast("❌ 请填写仓库 owner"); return; }
await giteaLinkRepo(effectiveInstanceId, projectId, repoOwner.trim(), repoName.trim());
}
refresh();
setBindMode(null);
setRepoName(""); setRepoOwner(""); setCloneAddr(""); setMigrateToken("");
showToast("仓库绑定成功 ✓");
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setBinding(false);
}
};
// ── Webhook ──
const [callbackBase, setCallbackBase] = useState(
() => localStorage.getItem(CALLBACK_BASE_KEY) ?? "http://127.0.0.1:27190"
);
const [settingWebhook, setSettingWebhook] = useState(false);
const callbackUrl = `${callbackBase.replace(/\/+$/, "")}/webhook/gitea/${projectId}`;
const handleSetupWebhook = async () => {
setSettingWebhook(true);
try {
localStorage.setItem(CALLBACK_BASE_KEY, callbackBase);
await giteaSetupWebhook(projectId, callbackBase);
refresh();
showToast("Webhook 已注册 ✓ push 事件将实时进入飞轮");
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setSettingWebhook(false);
}
};
const handleUnlink = async () => {
if (!confirm("解绑当前项目与 Gitea 仓库?(Gitea 侧仓库不会删除)")) return;
try {
await giteaUnlinkRepo(projectId);
refresh();
showToast("已解绑");
} catch (e) {
showToast(`❌ ${e}`);
}
};
const [polling, setPolling] = useState(false);
const handlePollNow = async () => {
setPolling(true);
try {
const r = await giteaPollNow();
showToast(`✓ 轮询 ${r.polled} 个仓库,新采集 ${r.ingested} 条 commit`);
} catch (e) {
showToast(`❌ ${e}`);
} finally {
setPolling(false);
}
};
const copy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
showToast("已复制 ✓");
};
return (
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-3">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
🪁 Gitea 绑定
</p>
{/* ── 当前项目绑定 ── */}
<div className="space-y-2">
{link ? (
<>
<div className="flex items-center gap-2 text-[11px]">
<span className="text-gray-500 shrink-0">已绑定</span>
<span className="font-mono text-gray-700 truncate">
{link.gitea_owner}/{link.gitea_repo_name}
</span>
{link.clone_url && (
<button onClick={() => copy(link.clone_url!)} className="text-blue-400 hover:text-blue-600 shrink-0 text-[10px]">
复制 clone URL
</button>
)}
<button onClick={handleUnlink} className="ml-auto text-red-400 hover:text-red-500 shrink-0 text-[10px]">
解绑
</button>
</div>
{/* G7 本地接线:仓库已建但本地未连通时的一次性引导 */}
{needsWiring && (
<div className="bg-indigo-50 border border-indigo-100 rounded p-2 space-y-1.5">
<p className="text-[11px] text-indigo-700 font-medium">
{!localGit?.has_git ? "本地目录还不是 git 仓库" : "本地仓库尚未关联远端 origin"}
</p>
<div className="flex items-center gap-1.5">
<p className="flex-1 text-[10px] text-indigo-400 leading-relaxed">
{!localGit?.has_git
? "将执行:git init + 首次提交 + 关联 origin + 推送"
: "将执行:关联 origin + 推送当前分支"}
</p>
<button
onClick={handleWireUp}
disabled={wiring}
className="px-2.5 py-1 rounded text-[11px] bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shrink-0"
>
{wiring ? "接线中…" : "🔌 一键接线"}
</button>
</div>
<p className="text-[10px] text-indigo-300 leading-relaxed">
接线是一次性动作;日常 commit / push 在项目自己的 AI 会话中进行。
</p>
</div>
)}
{/* webhook 状态与注册 */}
<div className="bg-gray-50 rounded p-2 space-y-1.5">
<div className="flex items-center gap-1.5 text-[11px]">
<span className={`w-1.5 h-1.5 rounded-full ${link.webhook_id ? "bg-green-400" : "bg-gray-300"}`} />
<span className="text-gray-500">
{link.webhook_id ? `Webhook 已注册(#${link.webhook_id})` : "Webhook 未注册"}
</span>
</div>
<div className="flex gap-1.5">
<input
className={inputCls}
value={callbackBase}
onChange={(e) => setCallbackBase(e.target.value)}
placeholder="Gitea 服务器可达的宿主机地址,如 http://192.168.1.5:27190"
/>
</div>
<div className="flex items-center gap-1.5">
<code className="flex-1 text-[10px] text-gray-400 font-mono truncate" title={callbackUrl}>
{callbackUrl}
</code>
<button onClick={() => copy(callbackUrl)} className={btnCls}>复制</button>
<button
onClick={handleSetupWebhook}
disabled={settingWebhook}
className="px-2.5 py-1 rounded text-[11px] bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{settingWebhook ? "注册中…" : link.webhook_id ? "重设" : "注册 Webhook"}
</button>
</div>
<p className="text-[10px] text-gray-400 leading-relaxed">
需 Gitea 服务器能访问上述地址(MCP 端口默认 27190)。push 事件将实时写入飞轮 commit 数据。
</p>
<div className="flex items-center gap-1.5 border-t border-gray-100 pt-1.5">
<p className="flex-1 text-[10px] text-gray-400 leading-relaxed">
公网 Gitea + 内网炼境时 webhook 不可达——已由后台轮询兜底(每 10 分钟出站拉取)。
</p>
<button
onClick={handlePollNow}
disabled={polling}
className={btnCls}
>
{polling ? "拉取中…" : "立即拉取"}
</button>
</div>
</div>
</>
) : instances.length === 0 ? (
<p className="text-[11px] text-gray-400">
尚未配置 Gitea 实例——请先在「设置 → Gitea 实例」中添加
</p>
) : (
<>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-500">本项目未绑定:</span>
{(["create", "migrate", "link"] as BindMode[]).map((m) => (
<button
key={m}
onClick={() => setBindMode(bindMode === m ? null : m)}
className={`px-2 py-1 rounded text-[11px] border transition-colors ${
bindMode === m
? "border-blue-300 bg-blue-50 text-blue-600"
: "border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
{m === "create" ? "新建仓库" : m === "migrate" ? "迁移" : "绑定已有"}
</button>
))}
</div>
{bindMode && (
<div className="space-y-1.5 bg-gray-50 rounded p-2">
{instances.length > 1 && (
<select
className={inputCls}
value={effectiveInstanceId}
onChange={(e) => setBindInstanceId(e.target.value)}
>
{instances.map((i) => (
<option key={i.id} value={i.id}>{i.name}({i.url})</option>
))}
</select>
)}
{bindMode === "migrate" && (
<>
<input className={inputCls} placeholder="源仓库地址(https://github.com/user/repo.git)" value={cloneAddr} onChange={(e) => setCloneAddr(e.target.value)} />
<input className={inputCls} type="password" placeholder="源仓库 token(私有仓库需要,可留空)" value={migrateToken} onChange={(e) => setMigrateToken(e.target.value)} />
</>
)}
{bindMode === "link" && (
<input className={inputCls} placeholder="owner(Gitea 用户名或组织)" value={repoOwner} onChange={(e) => setRepoOwner(e.target.value)} />
)}
<input className={inputCls} placeholder="仓库名" value={repoName} onChange={(e) => setRepoName(e.target.value)} />
<button
onClick={handleBind}
disabled={binding || !effectiveInstanceId || !repoName.trim()}
className="px-2.5 py-1 rounded text-[11px] bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{binding ? "执行中…" : "确认"}
</button>
</div>
)}
</>
)}
</div>
{/* ── 全部绑定概览 ── */}
{allLinks.length > 0 && (
<div className="border-t border-gray-50 pt-2">
<p className="text-[10px] text-gray-400 mb-1">全部绑定({allLinks.length})</p>
<div className="space-y-0.5 max-h-24 overflow-y-auto">
{allLinks.map((r) => (
<div key={r.id} className="flex items-center gap-1.5 text-[10px] text-gray-500">
<span className={`w-1 h-1 rounded-full shrink-0 ${r.has_webhook ? "bg-green-400" : "bg-gray-300"}`} title={r.has_webhook ? "webhook 已注册" : "webhook 未注册"} />
<span className="font-mono truncate">{r.gitea_owner}/{r.gitea_repo_name}</span>
<span className="text-gray-300 truncate ml-auto shrink-0">{r.instance_name}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}