dev-manager-tauri/src/components/dashboard/ImportRepoModal.tsx
lanrtop 0baa304fbe feat: WSL路径支持、项目发布到GitHub、三灯状态指示
- 新增发布到GitHub功能(scan/init/commit/push/register全流程)
- 新增WSL发行版检测与UNC路径自动转换,路径存储改为Windows可访问格式
- 修复libgit2访问WSL UNC路径的owner验证问题(启动时全局禁用)
- ProjectCard新增本地项目/本地仓库/远程仓库三灯状态指示
- ProjectModal重构为基本信息+部署信息双标签页,固定高度稳定切换
- 添加项目支持顶部快速选择文件夹并自动填充ID和名称
- 设置页WSL区块改为下拉检测选择发行版
- Dialog组件支持fixedHeight固定高度模式
2026-04-01 01:36:21 +09:00

245 lines
9.3 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 { open as openDialog } from "@tauri-apps/plugin-dialog";
import {
githubListRepos,
githubGetToken,
githubGetAccount,
gitClone,
createProject,
getProjects,
getSpaces,
type GithubRepo,
} from "../../lib/commands";
import { syncProjectCatalog } from "../../lib/spacesSync";
import { useUIStore } from "../../store/ui";
interface Props {
onClose: () => void;
}
export function ImportRepoModal({ onClose }: Props) {
const queryClient = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<GithubRepo | null>(null);
const [localPath, setLocalPath] = useState("");
const [upstreamUrl, setUpstreamUrl] = useState("");
const [cloning, setCloning] = useState(false);
const { data: repos = [], isLoading, error } = useQuery({
queryKey: ["github-repos"],
queryFn: async () => {
const token = await githubGetToken();
if (!token) throw new Error("未登录 GitHub");
return githubListRepos(token);
},
});
const { data: spaces = [] } = useQuery({
queryKey: ["spaces"],
queryFn: getSpaces,
});
const currentSpace = spaces.find((s) => s.is_current);
const { data: existingProjects = [] } = useQuery({
queryKey: ["projects", currentSpace?.id],
queryFn: () => getProjects(undefined, currentSpace?.id),
enabled: !!currentSpace,
});
// 当前空间已有的 repo_url 集合(用于判重)
const importedUrls = new Set(
existingProjects.map((p) => p.repo_url).filter((u): u is string => !!u)
);
const filtered = repos.filter(
(r) =>
r.name.toLowerCase().includes(search.toLowerCase()) ||
r.full_name.toLowerCase().includes(search.toLowerCase())
);
async function handlePickDir() {
const dir = await openDialog({ directory: true, title: "选择本地存放目录" });
if (dir && selected) {
setLocalPath(`${dir}\\${selected.name}`.replace(/\\/g, "\\"));
} else if (dir) {
setLocalPath(dir as string);
}
}
async function handleImport() {
if (!selected || !localPath) return;
setCloning(true);
try {
const wsId = crypto.randomUUID();
await gitClone(selected.clone_url, localPath, upstreamUrl || undefined);
await createProject({
id: wsId,
name: selected.name,
description: selected.description ?? undefined,
win_path: localPath,
status: "active",
priority: "mid",
repo_url: selected.clone_url,
upstream_url: upstreamUrl || undefined,
default_branch: selected.default_branch,
space_id: currentSpace?.id,
});
queryClient.invalidateQueries({ queryKey: ["projects"] });
// 静默同步 catalog 到 .dev-spaces
(async () => {
try {
const account = await githubGetAccount();
const token = await githubGetToken();
if (token && account && currentSpace) {
await syncProjectCatalog(token, account.username, currentSpace.id, currentSpace.name, {
id: wsId,
name: selected.name,
description: selected.description ?? undefined,
repo_url: selected.clone_url,
default_branch: selected.default_branch,
platform: "windows",
});
}
} catch { /* 同步失败不影响导入 */ }
})();
showToast(`${selected.name} 已导入`);
onClose();
} catch (e) {
showToast(`导入失败: ${e}`);
} finally {
setCloning(false);
}
}
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-zinc-900 rounded-xl shadow-2xl w-[620px] max-h-[80vh] flex flex-col border border-zinc-700">
{/* 标题 */}
<div className="px-5 py-4 border-b border-zinc-700 flex items-center justify-between">
<h2 className="text-base font-semibold text-white"> GitHub </h2>
<button onClick={onClose} className="text-zinc-400 hover:text-white text-xl leading-none">×</button>
</div>
{isLoading && (
<div className="flex-1 flex items-center justify-center text-zinc-400 text-sm">
...
</div>
)}
{error && (
<div className="flex-1 flex items-center justify-center text-red-400 text-sm p-6">
{String(error)}
</div>
)}
{!isLoading && !error && (
<>
{/* 搜索 */}
<div className="px-4 py-3 border-b border-zinc-800">
<input
type="text"
placeholder="搜索仓库..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-500"
/>
</div>
{/* 仓库列表 */}
<div className="flex-1 overflow-y-auto min-h-0">
{filtered.map((repo) => {
const alreadyImported = importedUrls.has(repo.clone_url);
return (
<button
key={repo.id}
onClick={() => !alreadyImported && setSelected(repo)}
disabled={alreadyImported}
className={`w-full text-left px-4 py-3 border-b border-zinc-800 transition-colors ${
alreadyImported
? "opacity-40 cursor-not-allowed"
: selected?.id === repo.id
? "bg-zinc-800 border-l-2 border-l-blue-500"
: "hover:bg-zinc-800"
}`}
>
<div className="flex items-center gap-2">
<span className="text-white text-sm font-medium">{repo.name}</span>
{repo.private && (
<span className="text-xs px-1.5 py-0.5 bg-zinc-700 text-zinc-300 rounded"></span>
)}
{alreadyImported && (
<span className="text-xs px-1.5 py-0.5 bg-green-900 text-green-400 rounded"></span>
)}
<span className="text-zinc-500 text-xs ml-auto">{repo.default_branch}</span>
</div>
{repo.description && (
<p className="text-zinc-400 text-xs mt-0.5 truncate">{repo.description}</p>
)}
</button>
);
})}
{filtered.length === 0 && (
<div className="text-center text-zinc-500 text-sm py-8"></div>
)}
</div>
{/* 选中后的配置 */}
{selected && (
<div className="px-4 py-4 border-t border-zinc-700 space-y-3">
<div>
<label className="text-xs text-zinc-400 mb-1 block"></label>
<div className="flex gap-2">
<input
type="text"
value={localPath}
onChange={(e) => setLocalPath(e.target.value)}
placeholder="选择本地存放目录..."
className="flex-1 bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-500"
/>
<button
onClick={handlePickDir}
className="px-3 py-2 bg-zinc-700 text-zinc-200 text-sm rounded-lg hover:bg-zinc-600 transition-colors"
>
</button>
</div>
</div>
<div>
<label className="text-xs text-zinc-400 mb-1 block">
<span className="text-zinc-600"></span>
</label>
<input
type="text"
value={upstreamUrl}
onChange={(e) => setUpstreamUrl(e.target.value)}
placeholder="https://github.com/company/project.git"
className="w-full bg-zinc-800 text-white text-sm rounded-lg px-3 py-2 outline-none focus:ring-1 focus:ring-blue-500 placeholder:text-zinc-500"
/>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
onClick={onClose}
className="px-4 py-2 bg-zinc-700 text-zinc-200 text-sm rounded-lg hover:bg-zinc-600 transition-colors"
>
</button>
<button
onClick={handleImport}
disabled={!localPath || cloning}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{cloning ? "克隆中..." : `导入 ${selected.name}`}
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}