dev-manager-tauri/src/components/layout/Sidebar.tsx
lanrtop 289b558278 refactor(github): GitHub 全面退役——免登录 + 发布/导入/同步链路移除 + 凭据去 GitHub 化
D1 免登录改造:App 启动直接进主界面,删 LoginPage/github.rs/OAuth 全套命令、
   Sidebar 账户区与 GitHub OAuth 设置弹窗、健康检查 GitHub Token 项
D2 移除发布/导入/PR/CI 入口:删 PublishModal/ImportRepoModal、ProjectCard 的
   PR compare 链接与 Actions 状态灯、RepoRegistryModal 同步 tab、CicdModal runs tab、
   publish.rs 的 github_create_repo/git_push_to_github
D3 spacesSync 单机化:删 spacesSync/useSpacesSync/SpacesPage/DiscoveryPanel,
   拆除 App→Dashboard→ProjectCard 的 spacesJson 链
D4 git_ops 凭据链重写:ssh agent → git credential helper → gitea_instances token
   前缀匹配 → default,不再依赖 github_token

保留:server_software GitHub 镜像(Releases 下载加速)、gitea_migrate 迁移入口、
cicd.rs(待 Gitea Actions 改造卡);DB 表不删(R05 migration 兼容)

顺带:修复 servers.rs 两个存量测试的 schema 漂移(测试建表缺 deploy_type 列);
      包含会话前未提交的 agent-infra F3(get_agent_health 健康诊断,与 lib.rs/
      commands.ts 物理耦合无法拆分提交)

验证:cargo test 53 passed / typecheck 全绿 / vitest passWithNoTests
2026-07-02 13:15:56 +09:00

263 lines
11 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.

import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSpaces, setCurrentSpace, createSpace, updateSpace, deleteSpace,
type Space,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { RepoRegistryModal } from "../dashboard/RepoRegistryModal";
export type Page = "overview" | "dashboard" | "source-library" | "devtools" | "proxy" | "settings" | "health";
const NAV_ITEMS: { id: Page; label: string }[] = [
{ id: "overview", label: "仪表盘" },
{ id: "dashboard", label: "项目管理" },
{ id: "source-library", label: "源码库" },
{ id: "devtools", label: "开发环境" },
{ id: "proxy", label: "WSL 代理" },
{ id: "health", label: "健康中心" },
{ id: "settings", label: "设置" },
];
interface Props {
page: Page;
onPageChange: (page: Page) => void;
}
export function Sidebar({ page, onPageChange }: Props) {
const [spaceOpen, setSpaceOpen] = useState(true);
const [showForm, setShowForm] = useState<Space | null | undefined>(undefined);
const [deleteTarget, setDeleteTarget] = useState<Space | null>(null);
const [showRepoRegistry, setShowRepoRegistry] = useState(false);
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const { data: spaces = [], refetch } = useQuery({
queryKey: ["spaces"],
queryFn: getSpaces,
});
const currentSpace = spaces.find((s) => s.is_current);
const handleSwitch = async (id: string) => {
await setCurrentSpace(id);
refetch();
qc.invalidateQueries({ queryKey: ["projects"] });
};
const handleDelete = async (space: Space) => {
await deleteSpace(space.id);
refetch();
showToast("已删除");
setDeleteTarget(null);
};
return (
<>
<aside className="w-48 shrink-0 bg-slate-900 border-r border-slate-700 flex flex-col">
{/* Logo */}
<div className="h-14 flex items-center px-4 border-b border-slate-700 shrink-0">
<span className="font-bold text-white text-sm tracking-tight"></span>
</div>
{/* 导航区(随当前空间) */}
<nav className="px-2 pt-3 pb-2 space-y-0.5 shrink-0">
{NAV_ITEMS.map(({ id, label }) => (
<button
key={id}
onClick={() => onPageChange(id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
page === id
? "bg-slate-700 text-white font-medium"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200"
}`}
>
{label}
</button>
))}
</nav>
{/* 弹性留白 */}
<div className="flex-1" />
{/* 空间下拉列表 */}
<div className="border-t border-slate-700">
{/* 标题行 */}
<button
onClick={() => setSpaceOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-2.5 text-xs font-semibold text-slate-400 uppercase tracking-wider hover:text-slate-200 transition-colors"
>
<span></span>
<div className="flex items-center gap-1.5">
<span
onClick={(e) => { e.stopPropagation(); setShowForm(null); }}
className="w-4 h-4 flex items-center justify-center rounded text-slate-500 hover:text-white hover:bg-slate-700 transition-colors leading-none cursor-pointer"
title="新建空间"
>
+
</span>
<svg
className={`w-3 h-3 transition-transform ${spaceOpen ? "rotate-180" : ""}`}
viewBox="0 0 16 16" fill="currentColor"
>
<path d="M4.427 7.427l3.396 3.396a.25.25 0 00.354 0l3.396-3.396A.25.25 0 0011.396 7H4.604a.25.25 0 00-.177.427z"/>
</svg>
</div>
</button>
{/* 空间列表 */}
{spaceOpen && (
<div className="px-2 pb-2 space-y-0.5">
{spaces.length === 0 && (
<p className="text-xs text-slate-500 px-2 py-2 text-center"> + </p>
)}
{spaces.map((s) => {
const isCurrent = s.id === currentSpace?.id;
return (
<div
key={s.id}
onClick={() => !isCurrent && handleSwitch(s.id)}
className={`group flex items-center gap-1.5 px-2 py-1.5 rounded-lg transition-colors ${
isCurrent
? "bg-slate-700 text-white"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200 cursor-pointer"
}`}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${isCurrent ? "bg-blue-400" : "bg-transparent"}`} />
<span className="flex-1 text-xs truncate">{s.name}</span>
{/* UD 操作 */}
<div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<button
onClick={(e) => { e.stopPropagation(); setShowForm(s); }}
className="p-0.5 rounded hover:bg-slate-600 text-slate-400 hover:text-white"
title="编辑"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.013 1.427a1.75 1.75 0 012.474 0l1.086 1.086a1.75 1.75 0 010 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 01-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61zm1.414 1.06a.25.25 0 00-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 000-.354l-1.086-1.086zM11.189 6.25L9.75 4.81 3.22 11.34a.25.25 0 00-.064.108l-.65 2.278 2.278-.65a.25.25 0 00.108-.065L11.19 6.25z"/>
</svg>
</button>
{!isCurrent && (
<button
onClick={(e) => { e.stopPropagation(); setDeleteTarget(s); }}
className="p-0.5 rounded hover:bg-red-900 text-slate-400 hover:text-red-400"
title="删除"
>
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M11 1.75V3h2.25a.75.75 0 010 1.5H2.75a.75.75 0 010-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75zM4.496 6.675a.75.75 0 10-1.492.15l.66 6.6A1.75 1.75 0 005.405 15h5.19c.9 0 1.652-.681 1.741-1.576l.66-6.6a.75.75 0 10-1.492-.149l-.66 6.6a.25.25 0 01-.249.225h-5.19a.25.25 0 01-.249-.225l-.66-6.6z"/>
</svg>
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* 仓库管理 */}
<div className="border-t border-slate-700 px-3 py-2 shrink-0">
<button
onClick={() => setShowRepoRegistry(true)}
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-xs text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
>
<svg className="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8z"/>
</svg>
<span></span>
</button>
</div>
</aside>
{/* 新建/编辑模态框 */}
{showForm !== undefined && (
<SpaceFormModal
space={showForm}
onClose={() => setShowForm(undefined)}
onSaved={() => {
setShowForm(undefined);
refetch();
showToast(showForm ? "已更新" : "已创建");
}}
/>
)}
{/* 仓库管理弹窗 */}
{showRepoRegistry && (
<RepoRegistryModal onClose={() => setShowRepoRegistry(false)} />
)}
{/* 删除确认 */}
{deleteTarget && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-80 p-6 space-y-4">
<h2 className="text-base font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-600">
<span className="font-medium">{deleteTarget.name}</span>
</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteTarget(null)} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"></button>
<button onClick={() => handleDelete(deleteTarget)} className="px-4 py-2 rounded-lg text-sm bg-red-600 text-white hover:bg-red-700"></button>
</div>
</div>
</div>
)}
</>
);
}
function SpaceFormModal({ space, onClose, onSaved }: {
space: Space | null;
onClose: () => void;
onSaved: () => void;
}) {
const [name, setName] = useState(space?.name ?? "");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!name.trim()) return;
setSaving(true);
try {
if (space) {
await updateSpace(space.id, name.trim());
} else {
await createSpace(name.trim());
}
onSaved();
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-80 p-6 space-y-4">
<h2 className="text-base font-semibold text-gray-800">{space ? "编辑空间" : "新建空间"}</h2>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1"></label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="笔记本 / 日本服务器 / 公司台式机"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-200"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
</div>
<div className="flex justify-end gap-3 pt-2">
<button onClick={onClose} className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-600 hover:bg-gray-50"></button>
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
>
{saving ? "保存中…" : "保存"}
</button>
</div>
</div>
</div>
);
}