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
This commit is contained in:
lanrtop 2026-07-02 13:15:56 +09:00
parent dcbf1f9355
commit 289b558278
38 changed files with 703 additions and 3274 deletions

View File

@ -6,7 +6,7 @@ description: >
成为每个项目的贴身导师。炼境本身亦被纳入其中,
形成可自我进化的开发智能闭环。
iteration: 1
updated: 2026-06-30
updated: 2026-07-02
@ -385,8 +385,17 @@ modules:
area: backend
status: done
progress: 100
updated: 2026-07-01
position: [3750, 0]
- id: github-decommission
name: GitHub 退役清理
area: backend
status: done
progress: 100
updated: 2026-07-02
position: [3750, 150]
edges:
# 前端依赖
- from: workspace-mgmt
@ -622,3 +631,6 @@ edges:
- from: gitea-integration
to: flywheel-intelligence
type: related
- from: gitea-integration
to: github-decommission
type: related

View File

@ -118,3 +118,10 @@
- depends: C
- files: src/components/dashboard/ProjectCard.tsx, src/lib/commands.ts
- acceptance: ProjectCard 右上角显示腐化评分小圆点(🟢🟡🔴),读取 `doc_freshness_cache` 缓存(无缓存则不显示);不发起新的 git 扫描(仅读缓存)
### ✅ F3. Agent 健康诊断 + 可复制修正指令
- status: done
- complexity: M
- depends: C, D
- files: src-tauri/src/commands/agent_infra.rs, src-tauri/src/lib.rs, src/lib/commands.ts, src/components/dashboard/AgentInfraPanel.tsx
- acceptance: `get_agent_health(project_path, project_id)` 聚合 infra stack + doc freshness生成问题列表 + Markdown 修正文本AgentInfraPanel 加载时从 cache 读健康徽章,「重新检测 & 生成修正指令」按钮触发完整诊断,结果显示健康状态+可复制 textarea含复制按钮

View File

@ -112,8 +112,8 @@ Gitea push → POST /webhook/gitea/:project_id
- files: src-tauri/src/db.rs, src-tauri/src/commands/gitea.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: gitea_instances + gitea_repos 表建好migratesave/list/delete/test_connection 命令可调test_connection 调 GET /api/v1/user 返回 Gitea 用户名验证 token 有效性
### 📋 G2. 仓库创建 / 迁移 / 绑定
- status: todo
### 🔵 G2. 仓库创建 / 迁移 / 绑定
- status: in_progress
- complexity: M
- depends: G1
- files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts
@ -126,8 +126,8 @@ Gitea push → POST /webhook/gitea/:project_id
- files: src-tauri/src/mcp_server.rs
- acceptance: POST /webhook/gitea/:project_id 路由上线HMAC-SHA256 验签通过后解析 commits 数组批量写 commit_metrics验签失败记 log_event单元测试覆盖 parse_push_payload + hmac_verify
### 📋 G4. Webhook 注册命令
- status: todo
### 🔵 G4. Webhook 注册命令
- status: in_progress
- complexity: M
- depends: G2, G3
- files: src-tauri/src/commands/gitea.rs, src/lib/commands.ts

View File

@ -5,7 +5,8 @@ status: deprecated
progress: 100
---
> ❄️ **已冻结2026-06-19**:全面统一自建 Gitea弃用 GitHub。功能保留不删但不再演进新接入走 onboarding-pack / gitea-integration 模块。
> ❄️ **已冻结2026-06-19**:全面统一自建 Gitea弃用 GitHub。
> 🗑️ **已退役2026-07-02**代码全部移除github.rs 删除、登录门槛拆除、OAuth 命令下线),详见 github-decommission 模块。本文件仅作历史记录。
## 概述
GitHub OAuth Device Flow 认证,获取 token 后支持 API 操作和 Git 推送。

View File

@ -0,0 +1,59 @@
# GitHub 退役清理
围绕自建 Gitea 的战略确定后(见 gitea-integration移除 GitHub 作为 git 后端的全部纠缠。
排查发现纠缠比蓝图标记的深GitHub OAuth 是应用登录门槛App.tsx 无账号连界面都进不去),
spaces.json 每 5 分钟同步 GitHub 仓库git_ops 所有凭据硬依赖 github_token。
## 边界(保留项)
- `server_software.rs` 的 GitHub 镜像管理:用途是 Releases 下载加速,与 git 后端无关,保留
- `gitea_migrate_repo` 对 GitHub 的引用:迁移功能需要读旧家,保留
- `cicd.rs` 后端命令:保留,待 gitea-integration「Gitea Actions workflow 生成」卡改造
- DB 表github_mirrors、settings 旧 key 等):代码停用但表不删,避免旧版本升级 migration 断裂R05
## 任务卡
### ✅ D1. 免登录改造(去掉 GitHub OAuth 登录门槛)
- status: done
- complexity: M
- files: src/App.tsx, src/components/auth/LoginPage.tsx, src/components/layout/Sidebar.tsx, src-tauri/src/commands/github.rs, src-tauri/src/commands/settings.rs, src-tauri/src/lib.rs, src/lib/commands.ts
- acceptance: 应用启动直接进主界面无登录页github.rs 及 8 个 OAuth 命令删除Sidebar 账户区与「应用设置」GitHub OAuth 弹窗删除;健康检查移除 GitHub Token 项GLOBAL_KEYS 移除 github_client_id/secrettypecheck + cargo test 通过
### ✅ D2. 移除 GitHub 发布 / 导入 / PR / CI 状态入口
- status: done
- complexity: M
- files: src/components/dashboard/PublishModal.tsx, src/components/dashboard/ImportRepoModal.tsx, src/components/dashboard/ProjectCard.tsx, src/components/dashboard/RepoRegistryModal.tsx, src/components/dashboard/CicdModal.tsx, src/components/manage/ManagePage.tsx, src-tauri/src/commands/publish.rs, src/lib/commands.ts
- acceptance: 发布按钮 / 从 GitHub 导入 / PR compare 链接 / Actions 运行状态显示全部移除publish.rs 仅删 github_create_repo / git_push_to_github / get_token通用工具WSL 路径、scan_local_git 等保留CicdModal 保留配置 tab、删 runs tabtypecheck 通过
### ✅ D3. 删除 spacesSync单机化跨空间 GitHub 云同步下线)
- status: done
- complexity: S
- files: src/lib/spacesSync.ts, src/hooks/useSpacesSync.ts, src/components/spaces/SpacesPage.tsx已无引用的死代码, src/components/dashboard/Dashboard.tsx, src/components/dashboard/ProjectCard.tsx, src/components/dashboard/ProjectModal.tsx
- acceptance: spacesJson prop 链App → Dashboard → ProjectCard拆除跨空间感知/接续 UI、可发现项目区块、push 后 catalog 上报全部移除typecheck 通过
### ✅ D4. git_ops 凭据去 GitHub 化
- status: done
- complexity: M
- files: src-tauri/src/commands/git_ops.rs
- acceptance: git_clone/fetch/pull/push/upstream_behind 不再硬依赖 settings.github_token凭据链改为 git credential helper → gitea_instances token 前缀匹配 → ssh agent → default无 token 时不再报「未登录 GitHub 账户」cargo test 通过
## 决策记录
- 免登录而非换 Gitea 登录:炼境是单人本地工具,"登录"概念本身无收益Gitea 身份已由 gitea_instances.access_token 承载,远端操作按需取凭据
- spacesSync 直接删而非迁 Gitea用户确认单机使用跨空间感知无真实场景
- git 凭据优先走 git credential helper与用户 CLI 使用同一凭据源Windows Credential ManagerGitHub/Gitea 通吃,炼境不再自管 git 凭据
## 复盘笔记
【复盘】GitHub 退役清理D1-D42026-07-02
任务类型: 架构调整
复杂度: M×3 + S×1
实际轮数: 1轮预估 2 轮)
是否返工: 否
有效策略: 先全量排查grep 全代码库再动手发现蓝图未标记的两个深耦合登录门槛、spacesSync 定时同步);按"前端入口 → 封装层 → 后端命令 → 凭据层"顺序拆除,每层完成即 typecheck
遗留风险: ① Gitea token 作 basic auth username 的凭据方式userpass_plaintext(token, ""))未在真实 Gitea 实例上验证过 push② CicdModal 仍生成 GitHub Actions 模板,待 gitea-integration 的 workflow 改造卡处理;③ 前端测试清零parseRepoPath 测试随功能删除vitest 配了 passWithNoTests
auto_applied_rules: R01, R05
rule_effectiveness: R01=有效(删命令时同步删 commands.ts 封装,无断层), R05=有效(保留 DB 表不删避免 migration 断裂)
【陷阱记录】servers.rs 存量测试 schema 漂移(顺手修复)
测试 setup 的 CREATE TABLE 手抄了 project_server_map 旧 schema缺 deploy_type/container_name/start_command 三列,与 db.rs migrate() 不一致导致 2 个测试常红。已补齐。根因:测试建表语句是手抄副本而非复用 migrate()schema 演进时必然漂移——后续新测试应从 db::migrate 复制对应段CONVENTIONS 测试模板已有此要求)。

View File

@ -1,6 +1,7 @@
# GitHub 发布
> ❄️ **已冻结2026-06-19**:全面统一自建 Gitea弃用 GitHub。功能保留不删不再演进项目发布走 Gitea见 gitea-integration 模块)。
> ❄️ **已冻结2026-06-19**:全面统一自建 Gitea弃用 GitHub。
> 🗑️ **已退役2026-07-02**发布链路代码全部移除PublishModal / ImportRepoModal / github_create_repo / git_push_to_github详见 github-decommission 模块。本文件仅作历史记录。
在炼境内通过 GitHub OAuth 直接将本地项目推送到远程仓库,支持新建仓库或关联已有仓库,完成发布后自动回填 remote URL。

View File

@ -8102,6 +8102,222 @@
"todo": 1,
"total": 182
}
},
{
"at": "2026-07-01T00:19:51Z",
"blocked_reasons": [],
"conventions_version": "1.8.0",
"delta": {
"newly_blocked": [],
"newly_done": [
"gitea-integration/G1. DB schema + Gitea 实例 CRUD"
],
"unblocked": []
},
"iteration": 1,
"modules": {
"blocked": 0,
"concept": 0,
"done": 47,
"in_progress": 2,
"planned": 0,
"stalled": [
"flywheel-intelligence"
],
"total": 51
},
"task_ids": {
"blocked": [],
"done": [
"agent-infrastructure/A. 框架层扫描 + 契约路径推断",
"agent-infrastructure/B. 三件套文件生成",
"agent-infrastructure/C. 文档腐化评分",
"agent-infrastructure/D. 栈确认 UI + 生成按钮",
"agent-infrastructure/E. 看板腐化指示器",
"api-keys/API 密钥 CRUD 与安全展示",
"auto-update/Capabilities 权限配置",
"auto-update/下载 & 安装",
"auto-update/更新检测",
"auto-update/自动检查更新",
"auto-update/错误提示",
"blueprint-buff/BlueprintView 跨项目切换",
"blueprint-buff/Buff 生命周期 Rust 命令",
"blueprint-buff/blueprint_buffs 数据表",
"blueprint-buff/git watcher + 任务匹配写回 + 飞轮快照",
"blueprint-buff/前端 Buff 管理 UI",
"blueprint-buff/前端实时刷新",
"blueprint-feedback/归档 command",
"blueprint-feedback/归档候选展示",
"blueprint-feedback/梦核分析入口(蓝图弹窗)",
"blueprint-feedback/梦核提示词增加停滞模块数据",
"blueprint-flywheel/get_flywheel_stats command",
"blueprint-flywheel/get_flywheel_stats 支持迭代感知",
"blueprint-flywheel/usage.json 埋点写入",
"blueprint-flywheel/梦核提示词生成 command",
"blueprint-flywheel/飞轮健康面板",
"blueprint-governance/CLAUDE.md 模板定义",
"blueprint-governance/CONVENTIONS.md 版本化",
"blueprint-governance/GovernancePanel 规则编辑器 UI",
"blueprint-governance/get_blueprint_status command",
"blueprint-governance/sync/梦核/summary 切换为 effective 内容",
"blueprint-governance/sync_blueprint_rules command",
"blueprint-governance/批量同步所有项目规则",
"blueprint-governance/用户版 CONVENTIONS 存储层初始化",
"blueprint-governance/用户版 CONVENTIONS 读写 command",
"blueprint-onboarding/generate_blueprint_prompt command",
"blueprint-onboarding/蓝图操作弹窗",
"blueprint-onboarding/项目列表蓝图状态徽章",
"blueprint-reader/合并返回完整蓝图数据",
"blueprint-reader/读取 manifest.yaml",
"blueprint-reader/读取 modules/*.md",
"blueprint-view/.blueprint/ 数据结构设计",
"blueprint-view/React Flow 节点图渲染",
"blueprint-view/blocked 状态支持",
"blueprint-view/下一步汇总面板",
"blueprint-view/任务卡复制派发",
"blueprint-view/批量执行提示词入口",
"blueprint-view/智能连线路由",
"blueprint-view/模块详情面板",
"blueprint-view/统计汇总条",
"blueprint-view/蓝图治理面板",
"blueprint-view/蓝图读取 Command",
"blueprint-view/边高亮与模块聚焦",
"blueprint-view/顶部状态行扩展",
"blueprint-view/项目卡片蓝图入口",
"cicd-workflow/Actions 运行历史",
"cicd-workflow/CI/CD 配置 CRUD",
"cicd-workflow/Tauri OSS 分发模板",
"cicd-workflow/Web SSH 部署模板",
"cicd-workflow/Workflow 文件生成",
"claude-autonomy-config/Beast 全局状态读取与展示",
"cloud-db-instances/云数据库实例信息管理",
"cloud-docker-apps/Docker 应用列表与状态管理",
"cloud-products/DB 迁移cloud_products 表",
"cloud-products/Dashboard 接入:云产品标签页",
"cloud-products/Rust CRUDcloud_products.rs",
"cloud-products/前端 PanelCloudProductsPanel.tsx",
"cloud-products/接线mod.rs + lib.rs + commands.ts",
"deploy-info/项目部署信息记录面板",
"devtools-scan/WSL 工具扫描",
"devtools-scan/Windows 工具扫描",
"devtools-scan/工具信息持久化",
"flywheel-intelligence/F1. Git 健康度后端——全量历史导入 + 跨项目聚合",
"flywheel-intelligence/F2. 飞轮面板——Git 健康度区块 + 全量导入按钮",
"flywheel-intelligence/MCP Server 支持 SSE 传输",
"flywheel-intelligence/复盘笔记覆盖率可见性",
"flywheel-intelligence/独立项目自动创建产品组",
"flywheel-intelligence/补测试:飞轮存量函数安全网",
"flywheel-intelligence/阶段一:复盘笔记接入梦核飞轮",
"flywheel-intelligence/阶段二usage.json 扩展采集维度",
"git-ops/上游状态检测",
"git-ops/分支管理",
"git-ops/基础操作",
"gitea-integration/G1. DB schema + Gitea 实例 CRUD",
"github-auth/OAuth Device Flow",
"github-auth/Token 管理",
"github-publish/GitHub 仓库创建与推送",
"health-center/T1 · Backend 健康检查聚合命令",
"health-center/T2 · 质量指标聚合",
"health-center/T3 · 前端健康中心页面",
"health-center/T4 · 扩展 MCP get_diagnostics",
"host-apps/宿主机应用清单与快启",
"mcp-config-inject/加入产品组时注入配置",
"mcp-config-inject/端口变更时重新注入所有配置",
"mcp-config-inject/退出产品组时清除配置",
"mcp-server/MCP 端口可配置Rust 侧)",
"mcp-server/启动时绑定 HTTP 端口",
"mcp-server/实现 MCP JSON-RPC 基础协议",
"mcp-server/实现产品组上下文工具",
"onboarding-pack/A 模板分发引擎",
"onboarding-pack/B apply_onboarding_pack 编排 + 前端入口",
"onboarding-pack/C1 commit_metrics 表 + Conventional 解析器",
"onboarding-pack/C2 挂接 buff watcher + 飞轮快照",
"onboarding-pack/D 数据可信度提示",
"product-group-mcp/产品组详情显示 MCP 注入状态",
"product-group-mcp/设置页添加 MCP 端口配置",
"project-dashboard/分组画布",
"project-dashboard/项目卡片列表",
"project-dashboard/项目发现面板",
"project-dashboard/项目快捷操作",
"project-env-scan/环境信息持久化",
"project-env-scan/项目环境检测",
"project-mentor/T1 · 后端聚合 commandMentorContext",
"project-mentor/T10 · 启动健康扫描 + 前端 Alert Banner",
"project-mentor/T11 · 项目历史快照",
"project-mentor/T2 · MCP 项目导师查询接口",
"project-mentor/T3 · 前端导师面板",
"project-mentor/T4 · 炼境自指接入",
"project-mentor/T5 · project_notes 数据层 + 后端命令",
"project-mentor/T6 · MCP append_project_note 工具",
"project-mentor/T7 · MentorPanel 展示 AI 笔记",
"project-mentor/T8 · 后端全组巡检命令",
"project-mentor/T9 · 前端全组巡检按钮",
"project-mgmt/子文件夹扫描",
"project-mgmt/批量导入项目",
"project-mgmt/项目 CRUD",
"project-mgmt/项目档案Profile",
"project-templates/P1+P3 模板扩充React 生态全覆盖)",
"project-templates/从模板创建项目",
"project-templates/内置模板种植",
"project-templates/模板 CRUD",
"project-tools/工具 CRUD",
"project-tools/工具启动",
"repo-registry/仓库导入",
"repo-registry/仓库挂载",
"repo-registry/仓库注册表管理",
"runtime-diagnostics/T1 · SQLite runtime_logs 表",
"runtime-diagnostics/T2 · 替换 eprintln! 为结构化日志",
"runtime-diagnostics/T3 · MCP Server 新增 get_diagnostics 工具",
"runtime-diagnostics/T4 · 注入失败结果返回前端",
"runtime-diagnostics/T5 · 设置页 MCP 真实健康检查",
"server-registry/服务器 CRUD 与项目关联",
"server-software/服务器软件扫描与展示",
"service-config/服务配置 CRUD 与环境分组",
"settings-page/WSL 发行版选择",
"settings-page/关于 & 更新",
"settings-page/终端配置",
"settings-page/编辑器检测",
"shell-launch/打开终端",
"shell-launch/打开编辑器",
"source-library/源码库分类管理与项目详情",
"sqlite-db/数据库迁移",
"sqlite-db/连接池初始化",
"ssh-config-sync/SSH Config 写入与同步",
"tags-groups/分组管理",
"tags-groups/标签管理",
"tray-service/关闭窗口最小化到托盘",
"tray-service/系统托盘图标与菜单",
"tray-service/集成 tauri-plugin-single-instance",
"user-project-todos/前端:备忘清单 UI 组件",
"user-project-todos/数据层user_project_todos 表 + Rust commands",
"v2rayn-import/当前节点读取",
"v2rayn-import/数据库路径检测",
"v2rayn-import/节点列表扫描",
"vscode-remote/VS Code Remote SSH 一键连接",
"workspace-mgmt/空间 CRUD",
"workspace-mgmt/空间级设置",
"workspace-mgmt/部署命令管理",
"wsl-proxy/代理一致性测试",
"wsl-proxy/代理同步",
"wsl-proxy/代理清除",
"wsl-proxy/代理状态检测"
]
},
"tasks": {
"avg_rounds_M": null,
"blocked": 0,
"complexity_dist": {
"L": 6,
"M": 72,
"S": 49
},
"dispatched": 0,
"done": 172,
"in_progress": 2,
"rework_count": 23,
"todo": 4,
"total": 188
}
}
]
}

View File

@ -46,7 +46,7 @@ docs/templates/ 炼境接入包模板草案agents-meta-rules
- 代码重构/简化:/simplify → /code-review
- 发现深层问题:/Dev-deep-think
<!-- BLUEPRINT_MANAGED_START version="1.6.0" -->
<!-- BLUEPRINT_MANAGED_START version="1.8.0" -->
## 项目蓝图
本项目使用 `.blueprint/` 目录维护项目全景架构图,驱动需求讨论和任务管理。
@ -55,7 +55,6 @@ docs/templates/ 炼境接入包模板草案agents-meta-rules
1. 读取 `.blueprint/manifest.yaml` 了解项目全景和当前状态
2. 读取 `.blueprint/CONVENTIONS.md` 了解完整工作流规则
3. 若 `.blueprint/rules.md` 存在,读取并与全局规则合并应用(项目私有规则,优先级高于全局)
### 需求讨论 → 蓝图同步规则
@ -63,7 +62,7 @@ docs/templates/ 炼境接入包模板草案agents-meta-rules
|----------|--------------|
| "我想加个 XX 功能" | 确认需求后manifest 新增模块status: concept创建 `.blueprint/modules/<id>.md` |
| "XX 功能需要 A、B、C" | 在模块文件中拆分为任务卡 |
| "这个方案确定了" | 模块 status → planned补充任务卡 acceptancefiles 可选) |
| "这个方案确定了" | 模块 status → planned补充任务卡 files/acceptance |
| "开始做 XX" | 模块 status → in_progress |
| 没提蓝图但在讨论功能 | 主动提议更新蓝图 |
@ -71,7 +70,7 @@ docs/templates/ 炼境接入包模板草案agents-meta-rules
| complexity | 执行流程 |
|-----------|---------|
| S / M | 直接拆任务卡,确保每张卡有 `acceptance` + `complexity` → 标记 📋`files` 可选) |
| S / M | 直接拆任务卡,确保每张卡有 `files` + `acceptance` + `complexity` → 标记 📋 |
| L复杂系统设计 | 先 `/architect` 输出设计文档,用户确认后 `/splitter` 拆成 S/M 任务卡 |
> L 级任务禁止直接写代码,必须先经过 `/architect``/splitter` 流程。

View File

@ -1,35 +1,21 @@
# 炼境契约路径映射
# 炼境自身版本,手工维护(等 agent-infrastructure 模块完成后由炼境自动生成)
# 供 AGENTS.md 元规则 T1 查询:「哪个文件是这个项目的契约?」
# 项目契约路径映射
# 由炼境根据技术栈扫描生成,技术栈变更后重新运行接入包生成
# 供 AGENTS.md 元规则 T1 查询:「哪个文件是这个项目的外部契约?」
contracts:
- label: API 接口Axum
paths:
- "src-tauri/src/mcp_server.rs"
doc: "路由变更需同步 API 文档"
- label: Tauri command 接口(前后端契约)
paths:
- "src-tauri/src/commands/**/*.rs"
- "src/lib/commands.ts"
doc: "新增 command 时两端必须同步src/lib/commands.ts 是唯一前端调用入口"
- label: 数据库 schema
paths:
- "src-tauri/src/db.rs"
doc: "SQLite 表结构变更必须在 migrate() 函数中补 migration 语句"
- label: MCP Server 接口
paths:
- "src-tauri/src/mcp_server.rs"
- "src-tauri/src/mcp_inject.rs"
doc: "MCP 接口变更影响 Claude Code 对炼境的调用,接口路由和参数需同步"
- label: 接入包分发模板
paths:
- "src-tauri/resources/onboarding/**"
doc: "模板变更需同步检查 docs/templates/agents-meta-rules.md设计层来源"
doc: "新增 command 时前后端必须同步"
architecture_docs:
- "AGENTS.md"
- "CLAUDE.md"
- ".blueprint/manifest.yaml"
agent_docs:
- "AGENTS.md"

View File

@ -781,6 +781,172 @@ pub struct CachedFreshness {
pub checked_at: String,
}
// ── Agent 健康诊断 ─────────────────────────────────────────────────────────────
#[derive(Debug, Serialize)]
pub struct AgentHealthIssue {
pub level: String, // "error" | "warn"
pub title: String,
pub fix: String,
}
#[derive(Debug, Serialize)]
pub struct AgentHealthReport {
pub overall: String, // "green" | "yellow" | "red"
pub issues: Vec<AgentHealthIssue>,
/// 可直接复制给子项目 agent 的修正指令文本Markdown
pub fix_prompt: String,
pub checked_at: String,
}
/// 诊断项目 agent 友好度,生成可复制的修正指令。
/// 内部调用 scan_agent_infra_stack + scan_doc_freshness同时刷新 doc_freshness_cache。
#[tauri::command]
pub fn get_agent_health(
project_path: String,
project_id: String,
) -> Result<AgentHealthReport, String> {
let root = Path::new(project_path.trim_end_matches(['/', '\\']));
if !root.exists() {
return Err(format!("目录不存在: {}", root.display()));
}
let stack = scan_agent_infra_stack(project_path.clone())?;
let freshness = scan_doc_freshness(project_path.clone(), project_id.clone())?;
let mut issues: Vec<AgentHealthIssue> = Vec::new();
let mut overall = "green".to_string();
if !stack.context_yaml_exists {
issues.push(AgentHealthIssue {
level: "error".into(),
title: "缺少 docs/ai-context/project-context.yaml".into(),
fix: "在炼境「Agent 基础设施」面板点击「扫描技术栈」→「确认并生成三件套」".into(),
});
overall = "red".to_string();
}
if !stack.meta_rules_present {
issues.push(AgentHealthIssue {
level: "warn".into(),
title: "AGENTS.md 缺少「文档防腐元规则」段落".into(),
fix: "在炼境「Agent 基础设施」面板生成三件套,或手动在 AGENTS.md 末尾添加 ## 文档防腐元规则 章节".into(),
});
if overall != "red" {
overall = "yellow".to_string();
}
}
match freshness.level.as_str() {
"red" if !stack.context_yaml_exists => {
// yaml 不存在导致的 red 已在上面记录,不重复
}
"red" => {
let stale_count = freshness.details.iter().filter(|d| d.level == "red").count();
issues.push(AgentHealthIssue {
level: "error".into(),
title: format!("文档严重腐化:{stale_count} 个契约文件更新超 30 天未同步 project-context.yaml"),
fix: "检查并更新 docs/ai-context/project-context.yaml 的契约描述,或重新生成三件套".into(),
});
overall = "red".to_string();
}
"yellow" => {
let stale_count = freshness.details.iter()
.filter(|d| d.level == "yellow" || d.level == "red")
.count();
issues.push(AgentHealthIssue {
level: "warn".into(),
title: format!("文档腐化风险:{stale_count} 个契约文件更新后 14-30 天未同步"),
fix: "检查并更新 docs/ai-context/project-context.yaml 中的契约描述".into(),
});
if overall != "red" {
overall = "yellow".to_string();
}
}
_ => {}
}
let proj_name = root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("未知项目");
let fix_prompt = build_fix_prompt(proj_name, &overall, &issues, &stack, &freshness);
Ok(AgentHealthReport {
overall,
issues,
fix_prompt,
checked_at: freshness.checked_at,
})
}
fn build_fix_prompt(
project_name: &str,
overall: &str,
issues: &[AgentHealthIssue],
stack: &AgentInfraStack,
freshness: &FreshnessResult,
) -> String {
let status_emoji = match overall {
"red" => "🔴",
"yellow" => "🟡",
_ => "🟢",
};
let status_label = match overall {
"red" => "需要修复",
"yellow" => "存在风险",
_ => "良好",
};
let date_label = freshness.checked_at.get(..10).unwrap_or(&freshness.checked_at);
let mut lines = vec![
format!("# Agent 友好度诊断 — {project_name}"),
format!("整体状态:{status_emoji} {status_label}(检测时间:{date_label}"),
String::new(),
];
if issues.is_empty() {
lines.push("✅ 所有检查通过,项目 agent 友好度良好,无需修复。".into());
} else {
lines.push("## 待修复项".into());
lines.push(String::new());
for issue in issues {
let icon = if issue.level == "error" { "" } else { "⚠️" };
lines.push(format!("{icon} **{}**", issue.title));
lines.push(format!(" 修复方法:{}", issue.fix));
lines.push(String::new());
}
}
let mut passed = Vec::new();
if stack.context_yaml_exists {
passed.push("docs/ai-context/project-context.yaml 已存在".to_string());
}
if stack.meta_rules_present {
passed.push("AGENTS.md 已包含文档防腐元规则".to_string());
}
if !stack.detected_contracts.is_empty() && freshness.level == "green" {
passed.push(format!(
"{} 个契约路径均在 14 天内同步",
stack.detected_contracts.len()
));
}
if !passed.is_empty() {
lines.push("## 已通过项".into());
lines.push(String::new());
for p in &passed {
lines.push(format!("{p}"));
}
lines.push(String::new());
}
lines.push("---".into());
lines.push("*由炼境 dev-manager 自动生成。请根据上述修复项逐项处理,完成后在炼境中点击「重新检测」刷新状态。*".into());
lines.join("\n")
}
// ── 单元测试 ──────────────────────────────────────────────────────────────────
#[cfg(test)]

View File

@ -12,39 +12,60 @@ pub struct GitStatus {
pub last_commit_time: Option<String>,
}
fn get_token() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
conn.query_row(
"SELECT value FROM settings WHERE key = 'github_token'",
[],
|row| row.get::<_, String>(0),
)
.map_err(|_| "未登录 GitHub 账户".to_string())
/// 按 URL 前缀匹配已注册的 Gitea 实例,返回其 access_token
fn gitea_token_for_url(url: &str) -> Option<String> {
let conn = db::pool().get().ok()?;
let mut stmt = conn.prepare("SELECT url, access_token FROM gitea_instances").ok()?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
.ok()?;
for row in rows.flatten() {
let (base, token) = row;
let base = base.trim_end_matches('/');
if !base.is_empty() && url.starts_with(base) {
return Some(token);
}
}
None
}
fn make_fetch_options(token: &str) -> FetchOptions<'static> {
let token = token.to_string();
/// 凭据链ssh agent → git credential helper与 CLI 同一凭据源)→ Gitea 实例 token → default
/// 不再依赖 GitHub 登录,任何 remoteGitea / GitHub / 其他)走系统已有凭据
fn make_callbacks() -> RemoteCallbacks<'static> {
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(move |_url, _username, _allowed| {
git2::Cred::userpass_plaintext(&token, "x-oauth-basic")
callbacks.credentials(|url, username_from_url, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) {
if let Ok(cred) = git2::Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) {
return Ok(cred);
}
}
if allowed.contains(git2::CredentialType::USER_PASS_PLAINTEXT) {
if let Ok(config) = git2::Config::open_default() {
if let Ok(cred) = git2::Cred::credential_helper(&config, url, username_from_url) {
return Ok(cred);
}
}
if let Some(token) = gitea_token_for_url(url) {
if let Ok(cred) = git2::Cred::userpass_plaintext(&token, "") {
return Ok(cred);
}
}
}
git2::Cred::default()
});
callbacks
}
fn make_fetch_options() -> FetchOptions<'static> {
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
fetch_opts.remote_callbacks(make_callbacks());
fetch_opts
}
/// clone 仓库到指定本地路径
#[tauri::command]
pub fn git_clone(repo_url: String, local_path: String, upstream_url: Option<String>) -> Result<(), String> {
let token = get_token()?;
let token_clone = token.clone();
let mut callbacks = RemoteCallbacks::new();
callbacks.credentials(move |_url, _username, _allowed| {
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
});
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
let fetch_opts = make_fetch_options();
let repo = RepoBuilder::new()
.fetch_options(fetch_opts)
@ -111,11 +132,10 @@ pub fn git_status(local_path: String) -> Result<GitStatus, String> {
/// fetch 远端最新信息(不修改本地工作区)
#[tauri::command]
pub fn git_fetch(local_path: String) -> Result<(), String> {
let token = get_token()?;
let repo = Repository::open(&local_path)
.map_err(|e| format!("无法打开仓库: {}", e))?;
let mut fetch_opts = make_fetch_options(&token);
let mut fetch_opts = make_fetch_options();
repo.find_remote("origin")
.map_err(|e| e.to_string())?
.fetch(&[] as &[&str], Some(&mut fetch_opts), None)
@ -127,12 +147,11 @@ pub fn git_fetch(local_path: String) -> Result<(), String> {
/// pullfetch + merge fast-forward
#[tauri::command]
pub fn git_pull(local_path: String) -> Result<String, String> {
let token = get_token()?;
let repo = Repository::open(&local_path)
.map_err(|e| format!("无法打开仓库: {}", e))?;
// fetch
let mut fetch_opts = make_fetch_options(&token);
let mut fetch_opts = make_fetch_options();
let mut remote = repo.find_remote("origin").map_err(|e| e.to_string())?;
remote
.fetch(&[] as &[&str], Some(&mut fetch_opts), None)
@ -194,7 +213,6 @@ pub fn git_head_commit(local_path: String) -> Result<Option<String>, String> {
/// 检查 upstream 远端落后情况(公司仓库是否有新提交)
#[tauri::command]
pub fn git_upstream_behind(local_path: String, default_branch: String) -> Result<usize, String> {
let token = get_token()?;
let repo = Repository::open(&local_path)
.map_err(|e| format!("无法打开仓库: {}", e))?;
@ -204,13 +222,7 @@ pub fn git_upstream_behind(local_path: String, default_branch: String) -> Result
}
// fetch upstream
let mut callbacks = RemoteCallbacks::new();
let token_clone = token.clone();
callbacks.credentials(move |_url, _username, _allowed| {
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
});
let mut fetch_opts = FetchOptions::new();
fetch_opts.remote_callbacks(callbacks);
let mut fetch_opts = make_fetch_options();
if let Ok(mut remote) = repo.find_remote("upstream") {
let _ = remote.fetch(&[&default_branch], Some(&mut fetch_opts), None);
@ -236,7 +248,6 @@ pub fn git_upstream_behind(local_path: String, default_branch: String) -> Result
/// push 当前分支到 origin
#[tauri::command]
pub fn git_push(local_path: String) -> Result<String, String> {
let token = get_token()?;
let repo = Repository::open(&local_path)
.map_err(|e| format!("无法打开仓库: {}", e))?;
@ -248,14 +259,8 @@ pub fn git_push(local_path: String) -> Result<String, String> {
let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
let mut callbacks = RemoteCallbacks::new();
let token_clone = token.clone();
callbacks.credentials(move |_url, _username, _allowed| {
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
});
let mut push_opts = git2::PushOptions::new();
push_opts.remote_callbacks(callbacks);
push_opts.remote_callbacks(make_callbacks());
repo.find_remote("origin")
.map_err(|e| e.to_string())?

View File

@ -1,359 +0,0 @@
use crate::db;
use crate::models::GitAccount;
use once_cell::sync::Lazy;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
// ── OAuth Authorization Code Flow 共享状态 ────────────────────────────────────
static OAUTH_RESULT: Lazy<Arc<Mutex<Option<Result<String, String>>>>> =
Lazy::new(|| Arc::new(Mutex::new(None)));
fn read_setting(key: &str) -> String {
let conn = match db::pool().get() {
Ok(c) => c,
Err(_) => return String::new(),
};
conn.query_row(
"SELECT value FROM settings WHERE key = ?1",
rusqlite::params![key],
|r| r.get::<_, String>(0),
)
.unwrap_or_default()
}
/// URL 编码(不依赖外部 crate
fn url_encode(s: &str) -> String {
let mut out = String::new();
for byte in s.as_bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9'
| b'-' | b'_' | b'.' | b'~' => out.push(*byte as char),
b => out.push_str(&format!("%{:02X}", b)),
}
}
out
}
/// 用 ureq 把 code 换成 access_token
fn exchange_code(client_id: &str, client_secret: &str, code: &str, port: u16)
-> Result<String, String>
{
let redirect_uri = format!("http://localhost:{}/callback", port);
let body = format!(
"client_id={}&client_secret={}&code={}&redirect_uri={}",
url_encode(client_id),
url_encode(client_secret),
url_encode(code),
url_encode(&redirect_uri),
);
let resp = ureq::post("https://github.com/login/oauth/access_token")
.set("Accept", "application/json")
.set("Content-Type", "application/x-www-form-urlencoded")
.send_string(&body)
.map_err(|e| format!("网络请求失败: {e}"))?;
let json: serde_json::Value = resp.into_json()
.map_err(|e| format!("解析响应失败: {e}"))?;
if let Some(token) = json["access_token"].as_str() {
return Ok(token.to_string());
}
Err(json["error_description"]
.as_str()
.or_else(|| json["error"].as_str())
.unwrap_or("token 交换失败")
.to_string())
}
/// 启动 Authorization Code Flow。
/// 绑定随机端口,后台等待浏览器回调,返回 GitHub 授权 URL。
#[tauri::command]
pub fn github_start_oauth() -> Result<String, String> {
let client_id = read_setting("github_client_id");
let client_secret = read_setting("github_client_secret");
if client_id.trim().is_empty() {
return Err("未配置 GitHub Client ID请在设置中填写".to_string());
}
if client_secret.trim().is_empty() {
return Err("未配置 GitHub Client Secret请在设置中填写".to_string());
}
// 绑定随机可用端口
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("无法绑定本地端口: {e}"))?;
let port = listener.local_addr().unwrap().port();
// 重置上一次结果
*OAUTH_RESULT.lock().unwrap() = None;
let client_id_c = client_id.clone();
let client_secret_c = client_secret.clone();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
// 从 "GET /callback?code=xxx HTTP/1.1" 提取 code
let code = request.lines().next().and_then(|line| {
let path = line.split_whitespace().nth(1)?;
let query = path.split('?').nth(1)?;
query
.split('&')
.find(|seg| seg.starts_with("code="))
.map(|seg| seg[5..].to_string())
});
let (status, html, result) = match code {
Some(code) => match exchange_code(&client_id_c, &client_secret_c, &code, port) {
Ok(token) => (
"200 OK",
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px;background:#f6f8fa'><h2 style='color:#1a7f37'>✅ 授权成功</h2><p style='color:#57606a'>可以关闭此标签页,返回应用继续操作。</p></body></html>",
Ok(token),
),
Err(e) => (
"400 Bad Request",
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 授权失败</h2><p>请返回应用重试。</p></body></html>",
Err(e),
),
},
None => (
"400 Bad Request",
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 未收到授权码</h2></body></html>",
Err("GitHub 未返回授权码".to_string()),
),
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
html.len()
);
let _ = stream.write_all(response.as_bytes());
*OAUTH_RESULT.lock().unwrap() = Some(result);
}
});
let redirect_uri = format!("http://localhost:{}/callback", port);
Ok(format!(
"https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=repo+user&allow_signup=false",
url_encode(&client_id),
url_encode(&redirect_uri),
))
}
/// 前端轮询None = 等待中Some(token) = 成功Err = 失败
#[tauri::command]
pub fn github_poll_oauth() -> Result<Option<String>, String> {
match &*OAUTH_RESULT.lock().unwrap() {
None => Ok(None),
Some(Ok(token)) => Ok(Some(token.clone())),
Some(Err(e)) => Err(e.clone()),
}
}
/// 检测 github.com:443 是否可达3 秒超时)
fn check_github_reachable() -> bool {
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let Ok(addrs) = "github.com:443".to_socket_addrs() else {
return false;
};
addrs
.into_iter()
.any(|addr| TcpStream::connect_timeout(&addr, Duration::from_secs(3)).is_ok())
}
/// 用系统默认浏览器打开 GitHub 授权页TcpListener 接收回调Authorization Code Flow
/// 使用系统浏览器避免内嵌 WebView2 的代理/渲染问题
#[tauri::command]
pub fn github_open_login_window(_app: tauri::AppHandle) -> Result<(), String> {
if !check_github_reachable() {
return Err("无法连接 GitHubgithub.com:443 超时),请先确认代理已开启".to_string());
}
let client_id = read_setting("github_client_id");
let client_secret = read_setting("github_client_secret");
if client_id.trim().is_empty() {
return Err("未配置 GitHub Client ID请在设置中填写".to_string());
}
if client_secret.trim().is_empty() {
return Err("未配置 GitHub Client Secret请在设置中填写".to_string());
}
*OAUTH_RESULT.lock().unwrap() = None;
// 绑定随机本地端口接收 GitHub 回调
let listener = TcpListener::bind("127.0.0.1:0")
.map_err(|e| format!("无法绑定本地端口: {e}"))?;
let port = listener.local_addr().unwrap().port();
let redirect_uri = format!("http://localhost:{}/callback", port);
let auth_url = format!(
"https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope=repo+user&allow_signup=false",
url_encode(&client_id),
url_encode(&redirect_uri),
);
// 后台线程:等待 GitHub 回调,换取 token
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buf = vec![0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let request = String::from_utf8_lossy(&buf[..n]).to_string();
let code = request.lines().next().and_then(|line| {
let path = line.split_whitespace().nth(1)?;
let query = path.split('?').nth(1)?;
query
.split('&')
.find(|s| s.starts_with("code="))
.map(|s| s[5..].to_string())
});
let (status, html, result) = match code {
Some(code) => match exchange_code(&client_id, &client_secret, &code, port) {
Ok(token) => (
"200 OK",
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px;background:#f6f8fa'><h2 style='color:#1a7f37'>✅ 授权成功</h2><p style='color:#57606a'>可以关闭此标签页,返回应用继续操作。</p></body></html>",
Ok(token),
),
Err(e) => (
"400 Bad Request",
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 授权失败</h2><p>请返回应用重试。</p></body></html>",
Err(e),
),
},
None => (
"400 Bad Request",
"<html><head><meta charset='utf-8'></head><body style='font-family:sans-serif;text-align:center;padding:80px'><h2 style='color:#d1242f'>❌ 未收到授权码</h2></body></html>",
Err("GitHub 未返回授权码".to_string()),
),
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}",
html.len()
);
let _ = stream.write_all(response.as_bytes());
*OAUTH_RESULT.lock().unwrap() = Some(result);
}
});
// 用系统默认浏览器打开(走系统代理,不受 WebView2 限制)
open::that(&auth_url).map_err(|e| format!("无法打开浏览器: {e}"))?;
Ok(())
}
/// 保存 GitHub 账户信息和 token登录成功后由前端调用
#[tauri::command]
pub fn github_save_account(
username: String,
avatar_url: String,
access_token: String,
) -> Result<GitAccount, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
// 已存在则复用同一个 id
let existing_id: Option<String> = conn
.query_row(
"SELECT id FROM git_accounts WHERE username = ?1",
rusqlite::params![username],
|row| row.get(0),
)
.ok();
let id = existing_id.unwrap_or_else(|| Uuid::new_v4().to_string());
conn.execute(
"INSERT INTO git_accounts (id, username, avatar_url, provider)
VALUES (?1, ?2, ?3, 'github')
ON CONFLICT(username) DO UPDATE SET avatar_url = excluded.avatar_url",
rusqlite::params![id, username, avatar_url],
)
.map_err(|e| e.to_string())?;
// token 存入 settings 表
conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('github_token', ?1)",
rusqlite::params![access_token],
)
.map_err(|e| e.to_string())?;
let account = conn
.query_row(
"SELECT id, username, avatar_url, provider, created_at FROM git_accounts WHERE id = ?1",
rusqlite::params![id],
GitAccount::from_row,
)
.map_err(|e| e.to_string())?;
Ok(account)
}
/// 获取当前已登录账户(无则返回 None
#[tauri::command]
pub fn github_get_account() -> Result<Option<GitAccount>, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
match conn.query_row(
"SELECT id, username, avatar_url, provider, created_at FROM git_accounts LIMIT 1",
[],
GitAccount::from_row,
) {
Ok(account) => Ok(Some(account)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.to_string()),
}
}
/// 获取已存储的 access_token前端调用 GitHub API 时使用)
#[tauri::command]
pub fn github_get_token() -> Result<Option<String>, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
match conn.query_row(
"SELECT value FROM settings WHERE key = 'github_token'",
[],
|row| row.get::<_, String>(0),
) {
Ok(token) if !token.is_empty() => Ok(Some(token)),
_ => Ok(None),
}
}
/// 获取 GitHub Client ID前端发起 Device Flow 时使用)
#[tauri::command]
pub fn github_get_client_id() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let value: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'github_client_id'",
[],
|row| row.get(0),
)
.unwrap_or_default();
if value.trim().is_empty() {
return Err("未配置 GitHub Client ID请在「设置」页面填写后再登录".to_string());
}
Ok(value)
}
/// 登出:清除账户和 token
#[tauri::command]
pub fn github_logout() -> Result<(), String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
conn.execute("DELETE FROM git_accounts", [])
.map_err(|e| e.to_string())?;
conn.execute(
"DELETE FROM settings WHERE key = 'github_token'",
[],
)
.map_err(|e| e.to_string())?;
Ok(())
}

View File

@ -8,7 +8,6 @@ pub mod projects;
pub mod settings;
pub mod shell;
pub mod spaces;
pub mod github;
pub mod git_ops;
pub mod proxy;
pub mod git_repos;

View File

@ -118,16 +118,6 @@ pub struct GitScanResult {
// ── 内部工具 ─────────────────────────────────────────────────────────────────
fn get_token() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
conn.query_row(
"SELECT value FROM settings WHERE key = 'github_token'",
[],
|row| row.get::<_, String>(0),
)
.map_err(|_| "未登录 GitHub 账户".to_string())
}
/// 检测疑似敏感文件(只扫一层 + 常见子目录,避免遍历 node_modules
fn scan_suspicious(root: &Path) -> Vec<String> {
let patterns = [
@ -471,48 +461,6 @@ pub fn scan_local_git(path: String) -> Result<GitScanResult, String> {
})
}
/// 调用 GitHub API 创建远端仓库,返回 clone_url
#[tauri::command]
pub fn github_create_repo(
name: String,
description: String,
private: bool,
) -> Result<String, String> {
let token = get_token()?;
let body = serde_json::json!({
"name": name,
"description": description,
"private": private,
"auto_init": false,
});
let resp = ureq::post("https://api.github.com/user/repos")
.set("Authorization", &format!("Bearer {}", token))
.set("Accept", "application/vnd.github+json")
.set("User-Agent", "dev-manager-tauri/1.0")
.send_json(body)
.map_err(|e| match e {
ureq::Error::Status(422, resp) => {
let msg = resp
.into_json::<serde_json::Value>()
.ok()
.and_then(|v| v["message"].as_str().map(|s| s.to_string()))
.unwrap_or("仓库名已存在或不合法".to_string());
msg
}
other => format!("GitHub API 请求失败: {other}"),
})?;
let json: serde_json::Value = resp
.into_json()
.map_err(|e| format!("解析响应失败: {e}"))?;
json["clone_url"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "未获取到仓库地址".to_string())
}
/// git init若还没有 .git并提交所有改动
#[tauri::command]
pub fn git_init_and_commit(path: String, message: String) -> Result<(), String> {
@ -569,32 +517,6 @@ pub fn git_remote_set(path: String, url: String) -> Result<(), String> {
}
}
/// push 到 origin设置 upstream tracking
#[tauri::command]
pub fn git_push_to_github(path: String, branch: String) -> Result<(), String> {
let token = get_token()?;
let win_path = wsl_to_win(&path);
let repo = open_repo(&win_path)?;
let refspec = format!("refs/heads/{}:refs/heads/{}", branch, branch);
let mut callbacks = git2::RemoteCallbacks::new();
let token_clone = token.clone();
callbacks.credentials(move |_url, _username, _allowed| {
git2::Cred::userpass_plaintext(&token_clone, "x-oauth-basic")
});
let mut push_opts = git2::PushOptions::new();
push_opts.remote_callbacks(callbacks);
repo.find_remote("origin")
.map_err(|e| format!("未配置 origin remote: {e}"))?
.push(&[refspec.as_str()], Some(&mut push_opts))
.map_err(|e| format!("push 失败: {e}"))?;
Ok(())
}
/// 写入 .gitignore 文件到指定目录
#[tauri::command]
pub fn write_gitignore(dir: String, content: String) -> Result<(), String> {

View File

@ -320,7 +320,8 @@ mod tests {
);
CREATE TABLE project_server_map (
project_id TEXT NOT NULL, server_id TEXT NOT NULL,
role TEXT DEFAULT 'production', deploy_path TEXT, notes TEXT,
role TEXT DEFAULT 'production', deploy_type TEXT DEFAULT 'bare',
deploy_path TEXT, container_name TEXT, start_command TEXT, notes TEXT,
PRIMARY KEY (project_id, server_id)
);",
)

View File

@ -6,7 +6,6 @@ use std::collections::HashMap;
/// 全局 key不按空间隔离属于整个应用/机器级别配置)
const GLOBAL_KEYS: &[&str] = &[
"github_client_id", "github_client_secret",
"wsl_distro", // 机器级别,与空间无关
"wsl_open_mode", // WSL 编辑器启动方式
"editors", "active_editors", "active_editor",
@ -17,7 +16,7 @@ const GLOBAL_KEYS: &[&str] = &[
];
/// 获取 settings
/// - 有 space_id返回该空间的 space_settings + 全局 keygithub_client_id/secret
/// - 有 space_id返回该空间的 space_settings + 全局 keyGLOBAL_KEYS
/// - 无 space_id返回全局 settings 表
#[tauri::command]
pub fn get_settings(space_id: Option<String>) -> Result<HashMap<String, String>, String> {
@ -326,43 +325,6 @@ fn check_project_paths() -> HealthItem {
result.unwrap_or_else(|e| err_item("project_paths", "项目本地路径", &e))
}
/// 检测 GitHub Token 是否有效settings.github_token
fn check_github_token() -> HealthItem {
let result: Result<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let token: Option<String> = conn.query_row(
"SELECT value FROM settings WHERE key = 'github_token'",
[], |row| row.get(0),
).ok();
let token = match token {
Some(t) if !t.trim().is_empty() => t.trim().to_string(),
_ => return Ok(skip_item("github_token", "GitHub Token", "未登录 GitHub 账户")),
};
match ureq::builder().timeout(std::time::Duration::from_secs(8)).build()
.get("https://api.github.com/user")
.set("Authorization", &format!("Bearer {}", token))
.set("User-Agent", "dev-manager-tauri")
.call()
{
Ok(resp) => {
let username = resp.into_json::<Value>().ok()
.and_then(|v| v["login"].as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "未知账户".into());
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "ok".into(),
detail: Some(format!("Token 有效,账户: {}", username)) })
}
Err(ureq::Error::Status(401, _)) | Err(ureq::Error::Status(403, _)) =>
Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "error".into(),
detail: Some("Token 已失效或权限不足,请重新登录 GitHub 账户".into()) }),
Err(e) => Ok(HealthItem { id: "github_token".into(), name: "GitHub Token".into(), status: "warn".into(),
detail: Some(format!("Token 已存储但无法联通 GitHub API可能需要代理: {}", e)) }),
}
})();
result.unwrap_or_else(|e| err_item("github_token", "GitHub Token", &e))
}
/// 检测活跃编辑器路径是否可用
/// 存储结构editors = [{id,name,path}]active_editors = ["id1","id2"]
fn check_editor() -> HealthItem {
@ -590,7 +552,6 @@ pub fn collect_health_report() -> HealthReport {
let h_mcp = thread::spawn(check_mcp);
let h_git = thread::spawn(check_git_paths);
let h_proj = thread::spawn(check_project_paths);
let h_github = thread::spawn(check_github_token);
let h_editor = thread::spawn(check_editor);
let h_db = thread::spawn(check_db_latency);
let h_quality = thread::spawn(collect_quality_metrics);
@ -599,7 +560,6 @@ pub fn collect_health_report() -> HealthReport {
h_mcp .join().unwrap_or_else(|_| err_item("mcp", "MCP Server", "检测线程异常")),
h_git .join().unwrap_or_else(|_| err_item("git_paths", "Git 仓库路径", "检测线程异常")),
h_proj .join().unwrap_or_else(|_| err_item("project_paths", "项目本地路径", "检测线程异常")),
h_github.join().unwrap_or_else(|_| err_item("github_token", "GitHub Token", "检测线程异常")),
h_editor.join().unwrap_or_else(|_| err_item("editor", "编辑器路径", "检测线程异常")),
h_db .join().unwrap_or_else(|_| err_item("db_latency", "数据库延迟", "检测线程异常")),
];
@ -626,9 +586,7 @@ mod tests {
use super::*;
#[test]
fn global_keys_contains_auth_and_infra() {
assert!(GLOBAL_KEYS.contains(&"github_client_id"));
assert!(GLOBAL_KEYS.contains(&"github_client_secret"));
fn global_keys_contains_infra() {
assert!(GLOBAL_KEYS.contains(&"mcp_port"));
assert!(GLOBAL_KEYS.contains(&"wsl_distro"));
}

View File

@ -18,7 +18,7 @@ pub(crate) fn silent_cmd(program: &str) -> std::process::Command {
cmd
}
use commands::{activity::*, blueprint::*, buff::{apply_blueprint_buff, remove_blueprint_buff, get_buff_status, list_buffed_projects}, canvas::*, cicd::*, claude_config::get_beast_global_status, devtools::*, git_ops::*, git_repos::*, github::*, groups::*, mentor::{add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes, get_startup_alerts, run_startup_health_scan}, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
use commands::{activity::*, blueprint::*, buff::{apply_blueprint_buff, remove_blueprint_buff, get_buff_status, list_buffed_projects}, canvas::*, cicd::*, claude_config::get_beast_global_status, devtools::*, git_ops::*, git_repos::*, groups::*, mentor::{add_project_note, get_group_mentor_report, get_mentor_context, get_project_notes, get_startup_alerts, run_startup_health_scan}, network::{get_wsl_ip, sync_wsl_proxy, get_wsl_proxy_status, clear_wsl_proxy, test_wsl_proxy_ip, test_win_proxy_ip}, project_scan::*, project_tools::*, projects::{batch_create_projects, create_project, delete_project, get_project, get_projects, scan_subfolders, update_project, update_project_tags}, proxy::*, publish::*, settings::{check_mcp_health, detect_editors, get_settings, run_health_check, search_editor_path, update_settings}, shell::*, spaces::*, tags::*, templates::*};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
@ -193,15 +193,6 @@ pub fn run() {
git_create_branch,
git_list_branches,
git_checkout_branch,
// github
github_save_account,
github_get_account,
github_get_token,
github_get_client_id,
github_logout,
github_start_oauth,
github_poll_oauth,
github_open_login_window,
// spaces
get_spaces,
create_space,
@ -218,12 +209,10 @@ pub fn run() {
update_git_repo_deploy,
mount_repo,
delete_git_repo,
// publish to github
// 本地 git 工具
scan_local_git,
github_create_repo,
git_init_and_commit,
git_remote_set,
git_push_to_github,
register_and_link_repo,
write_gitignore,
resolve_wsl_path,
@ -272,6 +261,7 @@ pub fn run() {
commands::agent_infra::generate_agent_infra_files,
commands::agent_infra::scan_doc_freshness,
commands::agent_infra::get_doc_freshness_cache,
commands::agent_infra::get_agent_health,
commands::commit_metrics::get_commit_stats,
commands::commit_metrics::ingest_full_git_history,
commands::commit_metrics::get_project_commit_health,

View File

@ -146,27 +146,6 @@ impl Space {
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct GitAccount {
pub id: String,
pub username: String,
pub avatar_url: Option<String>,
pub provider: String,
pub created_at: Option<String>,
}
impl GitAccount {
pub fn from_row(row: &Row<'_>) -> rusqlite::Result<Self> {
Ok(Self {
id: row.get("id")?,
username: row.get("username")?,
avatar_url: row.get("avatar_url")?,
provider: row.get("provider")?,
created_at: row.get("created_at")?,
})
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DeployCommand {
pub id: String,

View File

@ -1,5 +1,4 @@
import { useState, useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { OverviewPage } from "./components/overview/OverviewPage";
import { Dashboard } from "./components/dashboard/Dashboard";
import { ProjectModal } from "./components/dashboard/ProjectModal";
@ -10,24 +9,16 @@ import { HealthPage } from "./components/health/HealthPage";
import { SourceLibraryPage } from "./components/source-library/SourceLibraryPage";
import { Sidebar, type Page } from "./components/layout/Sidebar";
import { Toast } from "./components/ui/Toast";
import LoginPage from "./components/auth/LoginPage";
import { githubGetAccount, githubLogout, getStartupAlerts, type GitAccount, type StartupAlert } from "./lib/commands";
import { useSpacesSync } from "./hooks/useSpacesSync";
import { getStartupAlerts, type StartupAlert } from "./lib/commands";
import { useUIStore } from "./store/ui";
import { check as checkUpdate } from "@tauri-apps/plugin-updater";
export default function App() {
const [page, setPage] = useState<Page>("overview");
const [account, setAccount] = useState<GitAccount | null | undefined>(undefined);
const [alerts, setAlerts] = useState<StartupAlert[]>([]);
const [alertsDismissed, setAlertsDismissed] = useState(false);
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
useEffect(() => {
githubGetAccount().then(setAccount).catch(() => setAccount(null));
}, []);
// 启动时静默检查更新
useEffect(() => {
checkUpdate()
@ -41,39 +32,17 @@ export default function App() {
// 启动健康扫描结果(延迟 2s 等后端扫描完成)
useEffect(() => {
if (account == null) return;
const timer = setTimeout(() => {
getStartupAlerts()
.then((list) => { if (list.length > 0) setAlerts(list); })
.catch(() => {});
}, 2000);
return () => clearTimeout(timer);
}, [account]);
const handleLogout = async () => {
await githubLogout();
qc.clear();
setAccount(null);
};
const { data: spacesJson } = useSpacesSync(account !== null && account !== undefined);
if (account === undefined) return null;
if (account === null) {
return (
<div className="min-h-screen bg-zinc-900 text-white flex flex-col">
<LoginPage onLogin={setAccount} />
<Toast />
</div>
);
}
}, []);
return (
<div className="h-screen flex overflow-hidden">
<Sidebar
account={account}
onLogout={handleLogout}
page={page}
onPageChange={setPage}
/>
@ -98,7 +67,7 @@ export default function App() {
)}
<div className="flex-1 overflow-auto min-h-0">
{page === "overview" && <OverviewPage />}
{page === "dashboard" && <Dashboard spacesJson={spacesJson ?? null} />}
{page === "dashboard" && <Dashboard />}
{page === "source-library" && <SourceLibraryPage />}
{page === "devtools" && <DevToolsPage />}
{page === "proxy" && <ProxyPage />}

View File

@ -1,213 +0,0 @@
import { useState, useRef, useEffect } from "react";
import {
githubOpenLoginWindow,
githubPollOAuth,
githubFetchUser,
githubSaveAccount,
githubGetClientId,
updateSettings,
type GitAccount,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
type Step = "checking" | "setup" | "idle" | "waiting" | "done" | "error";
interface Props {
onLogin: (account: GitAccount) => void;
}
export default function LoginPage({ onLogin }: Props) {
const showToast = useUIStore((s) => s.showToast);
const [step, setStep] = useState<Step>("checking");
const [errorMsg, setErrorMsg] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [savingConfig, setSavingConfig] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const cancelledRef = useRef(false);
// 启动时检查是否已配置 OAuth App
useEffect(() => {
githubGetClientId()
.then(() => setStep("idle"))
.catch(() => setStep("setup"));
}, []);
useEffect(() => () => {
cancelledRef.current = true;
if (pollRef.current) clearInterval(pollRef.current);
}, []);
const stopPolling = () => {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
};
const handleSaveConfig = async () => {
if (!clientId.trim() || !clientSecret.trim()) {
showToast("❌ Client ID 和 Client Secret 均不能为空");
return;
}
setSavingConfig(true);
try {
await updateSettings({
github_client_id: clientId.trim(),
github_client_secret: clientSecret.trim(),
});
showToast("配置已保存 ✓");
setStep("idle");
} catch (e) {
showToast(`${e}`);
} finally {
setSavingConfig(false);
}
};
const handleLogin = async () => {
cancelledRef.current = false;
setErrorMsg("");
setStep("waiting");
try {
await githubOpenLoginWindow();
pollRef.current = setInterval(async () => {
if (cancelledRef.current) { stopPolling(); return; }
try {
const token = await githubPollOAuth();
if (token === null) return;
stopPolling();
const user = await githubFetchUser(token);
const account = await githubSaveAccount(user.login, user.avatar_url, token);
if (cancelledRef.current) return;
setStep("done");
onLogin(account);
} catch (e) {
stopPolling();
setStep("error");
setErrorMsg(String(e));
}
}, 2000);
} catch (e) {
setStep("error");
setErrorMsg(String(e));
}
};
const handleCancel = () => {
cancelledRef.current = true;
stopPolling();
setStep("idle");
setErrorMsg("");
};
return (
<div className="flex flex-col items-center justify-center h-full gap-8 p-8">
<div className="text-center">
<h1 className="text-2xl font-bold mb-2">Dev Space Manager</h1>
<p className="text-zinc-400 text-sm"> Git </p>
</div>
{step === "checking" && (
<div className="w-5 h-5 border-2 border-zinc-600 border-t-transparent rounded-full animate-spin" />
)}
{step === "setup" && (
<div className="w-80 bg-zinc-800 rounded-xl border border-zinc-700 p-5 space-y-4">
<div>
<p className="text-sm font-semibold text-white mb-1"> GitHub OAuth App</p>
<p className="text-xs text-zinc-400 leading-relaxed">
GitHub Settings Developer settings OAuth Apps
Callback URL <code className="bg-zinc-700 px-1 rounded">http://localhost</code>
Enable Device Flow
</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs text-zinc-400 mb-1">Client ID</label>
<input
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="Ov23li..."
className="w-full bg-zinc-900 border border-zinc-600 rounded-lg px-3 py-2 text-sm text-white font-mono outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-zinc-600"
/>
</div>
<div>
<label className="block text-xs text-zinc-400 mb-1">Client Secret</label>
<input
type="password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder="••••••••••••••••••••••••••••••••••••"
className="w-full bg-zinc-900 border border-zinc-600 rounded-lg px-3 py-2 text-sm text-white font-mono outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<p className="text-xs text-zinc-500">Secret SQLite Rust token </p>
<button
onClick={handleSaveConfig}
disabled={savingConfig}
className="w-full py-2 rounded-lg text-sm bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{savingConfig ? "保存中…" : "保存并继续"}
</button>
</div>
)}
{step === "idle" && (
<div className="flex flex-col items-center gap-3 w-72">
<button
onClick={handleLogin}
className="w-full px-6 py-2.5 bg-zinc-100 text-zinc-900 rounded-lg font-medium hover:bg-white transition-colors flex items-center justify-center gap-2"
>
<svg viewBox="0 0 16 16" className="w-4 h-4 fill-current shrink-0">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
使 GitHub
</button>
<button
onClick={() => setStep("setup")}
className="text-xs text-zinc-500 hover:text-zinc-300 underline"
>
OAuth App
</button>
</div>
)}
{step === "waiting" && (
<div className="flex flex-col items-center gap-4 p-6 bg-zinc-800 rounded-xl border border-zinc-700 w-72 text-center">
<div className="w-7 h-7 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-zinc-200"> GitHub </p>
<p className="text-xs text-zinc-500"></p>
<button onClick={handleCancel} className="text-xs text-zinc-500 hover:text-zinc-300 underline">
</button>
</div>
)}
{step === "done" && (
<p className="text-green-400 text-sm"></p>
)}
{step === "error" && (
<div className="flex flex-col items-center gap-3 w-80">
<p className="text-red-400 text-sm text-center">{errorMsg}</p>
{errorMsg.includes("代理") && (
<p className="text-xs text-zinc-500 text-center">
v2rayN
</p>
)}
<div className="flex gap-3">
<button onClick={handleCancel} className="px-4 py-2 bg-zinc-700 rounded-lg text-sm hover:bg-zinc-600">
</button>
<button
onClick={() => setStep("setup")}
className="px-4 py-2 bg-zinc-700 rounded-lg text-sm hover:bg-zinc-600 text-zinc-300"
>
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -1,10 +1,14 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
scanAgentInfraStack,
generateAgentInfraFiles,
getDocFreshnessCache,
getAgentHealth,
type AgentInfraStack,
type ContractInput,
type FileResult,
type CachedFreshness,
type AgentHealthReport,
} from "../../lib/commands";
const CONFIDENCE_BADGE: Record<string, string> = {
@ -31,6 +35,17 @@ const ACTION_COLOR: Record<string, string> = {
failed: "text-red-600",
};
const HEALTH_BADGE: Record<string, string> = {
green: "bg-green-50 text-green-700 border-green-200",
yellow: "bg-amber-50 text-amber-700 border-amber-200",
red: "bg-red-50 text-red-700 border-red-200",
};
const HEALTH_LABEL: Record<string, string> = {
green: "🟢 良好",
yellow: "🟡 有风险",
red: "🔴 需修复",
};
export function AgentInfraPanel({
projectPath,
projectId,
@ -45,6 +60,20 @@ export function AgentInfraPanel({
const [report, setReport] = useState<FileResult[] | null>(null);
const [error, setError] = useState<string | null>(null);
// 健康诊断状态
const [healthCache, setHealthCache] = useState<CachedFreshness | null>(null);
const [healthReport, setHealthReport] = useState<AgentHealthReport | null>(null);
const [diagnosing, setDiagnosing] = useState(false);
const [copied, setCopied] = useState(false);
// 组件挂载时读取缓存健康状态
useEffect(() => {
if (!projectId) return;
getDocFreshnessCache(projectId).then((cached) => {
if (cached) setHealthCache(cached);
});
}, [projectId]);
async function handleScan() {
setLoading(true);
setError(null);
@ -84,6 +113,29 @@ export function AgentInfraPanel({
}
}
async function handleDiagnose() {
if (!projectId) return;
setDiagnosing(true);
setError(null);
setHealthReport(null);
try {
const result = await getAgentHealth(projectPath, projectId);
setHealthReport(result);
setHealthCache({ score: result.overall, details: "[]", checked_at: result.checked_at });
} catch (e) {
setError(String(e));
} finally {
setDiagnosing(false);
}
}
async function handleCopy() {
if (!healthReport) return;
await navigator.clipboard.writeText(healthReport.fix_prompt);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-white rounded-lg border border-gray-100 p-3 space-y-2">
{/* 标题行 */}
@ -213,6 +265,78 @@ export function AgentInfraPanel({
</div>
)}
{/* 健康诊断区块 */}
{projectId && (
<div className="border-t border-gray-100 pt-2 space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider">
Agent
</p>
{healthCache && (
<span
className={`text-[10px] border px-1.5 py-0.5 rounded-full ${HEALTH_BADGE[healthCache.score] ?? HEALTH_BADGE.green}`}
>
{HEALTH_LABEL[healthCache.score] ?? healthCache.score}
</span>
)}
</div>
{healthCache && !healthReport && (
<p className="text-[10px] text-gray-400">
{healthCache.checked_at.slice(0, 10)}
</p>
)}
<button
onClick={handleDiagnose}
disabled={diagnosing}
className="w-full py-1.5 rounded-lg bg-violet-600 text-white text-xs hover:bg-violet-700 disabled:opacity-50 transition-colors"
>
{diagnosing ? "检测中…" : healthCache ? "🔄 重新检测 & 生成修正指令" : "🩺 生成修正指令"}
</button>
{/* 诊断结果 */}
{healthReport && (
<div className="space-y-1.5">
<p className="text-[10px] text-gray-400">
{healthReport.checked_at.slice(0, 10)}
</p>
{healthReport.issues.length === 0 ? (
<p className="text-[10px] text-green-600"> </p>
) : (
<div className="space-y-0.5">
{healthReport.issues.map((issue, idx) => (
<p key={idx} className="text-[10px] text-gray-600 flex gap-1 leading-relaxed">
<span className="flex-shrink-0">
{issue.level === "error" ? "❌" : "⚠️"}
</span>
<span className="min-w-0">{issue.title}</span>
</p>
))}
</div>
)}
{/* 可复制文本框 */}
<div className="relative">
<textarea
readOnly
value={healthReport.fix_prompt}
rows={6}
className="w-full text-[10px] font-mono bg-gray-50 border border-gray-200 rounded p-1.5 resize-none focus:outline-none"
/>
<button
onClick={handleCopy}
className="absolute top-1 right-1 text-[9px] bg-white border border-gray-200 rounded px-1.5 py-0.5 hover:bg-gray-50 transition-colors"
>
{copied ? "✓ 已复制" : "复制"}
</button>
</div>
</div>
)}
</div>
)}
{error && (
<p className="text-[11px] text-red-600 font-mono break-all">{error}</p>
)}

View File

@ -1,34 +1,27 @@
// CI/CD 部署配置与运行管理
// 完整部署教程与踩坑指南见 docs/tauri-cicd-guide.md
// 涵盖签名密钥、GitHub Secrets、Workflow 模板、OSS 上传、Tauri v2 更新格式等
// 涵盖签名密钥、Workflow 模板、OSS 上传、Tauri v2 更新格式等
// 运行历史/手动触发(原 GitHub Actions API已随 GitHub 退役移除,待 Gitea Actions 接入后重建
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
getCicdConfig,
saveCicdConfig,
generateWorkflow,
fetchActionsRuns,
triggerWorkflow,
parseRepoPath,
githubGetToken,
resolveWslPath,
type CicdConfig,
type ActionsRun,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
interface Props {
projectId: string;
projectName: string;
repoUrl?: string;
winPath?: string;
wslPath?: string;
platform?: string;
onClose: () => void;
}
type Tab = "config" | "runs";
const DEFAULT_CONFIG: Omit<CicdConfig, "id" | "project_id" | "created_at"> = {
config_type: "web_ssh",
trigger_branch: "main",
@ -41,36 +34,9 @@ const DEFAULT_CONFIG: Omit<CicdConfig, "id" | "project_id" | "created_at"> = {
oss_endpoint: "oss-cn-hangzhou.aliyuncs.com",
};
function runStatusBadge(run: ActionsRun) {
if (run.status === "in_progress" || run.status === "queued") {
return <span className="text-yellow-400 text-xs">🔄 </span>;
}
if (run.conclusion === "success") {
return <span className="text-green-400 text-xs"> </span>;
}
if (run.conclusion === "failure") {
return <span className="text-red-400 text-xs"> </span>;
}
if (run.conclusion === "cancelled") {
return <span className="text-zinc-400 text-xs"> </span>;
}
return <span className="text-zinc-400 text-xs">{run.status}</span>;
}
function relativeTime(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "刚刚";
if (m < 60) return `${m}分钟前`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}小时前`;
return `${Math.floor(h / 24)}天前`;
}
export function CicdModal({
projectId,
projectName,
repoUrl,
winPath,
wslPath,
platform,
@ -78,16 +44,12 @@ export function CicdModal({
}: Props) {
const queryClient = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const [tab, setTab] = useState<Tab>("config");
const [form, setForm] = useState<Omit<CicdConfig, "id" | "project_id" | "created_at">>({
...DEFAULT_CONFIG,
config_type: platform === "windows" ? "tauri_oss" : "web_ssh",
});
const [generatedSecrets, setGeneratedSecrets] = useState<string[] | null>(null);
const [generating, setGenerating] = useState(false);
const [triggering, setTriggering] = useState(false);
const repoPath = repoUrl ? parseRepoPath(repoUrl) : null;
// 加载已保存配置
const { data: saved } = useQuery<CicdConfig | null>({
@ -111,20 +73,6 @@ export function CicdModal({
}
}, [saved]);
// GitHub Actions runs
const { data: runs = [], refetch: refetchRuns, isFetching: fetchingRuns } = useQuery({
queryKey: ["actionsRuns", projectId],
queryFn: async () => {
if (!repoPath) return [];
const token = await githubGetToken();
if (!token) return [];
return fetchActionsRuns(token, repoPath, 8);
},
enabled: tab === "runs" && !!repoPath,
staleTime: 30000,
retry: false,
});
const saveMutation = useMutation({
mutationFn: () =>
saveCicdConfig({
@ -162,23 +110,6 @@ export function CicdModal({
}
}
async function handleTrigger() {
if (!repoPath) return;
setTriggering(true);
try {
const token = await githubGetToken();
if (!token) { showToast("未登录 GitHub"); return; }
const workflowFile = form.config_type === "tauri_oss" ? "release.yml" : "deploy.yml";
await triggerWorkflow(token, repoPath, workflowFile, form.trigger_branch);
showToast("已触发 workflow稍后刷新查看状态");
setTimeout(() => refetchRuns(), 3000);
} catch (e) {
showToast(`触发失败: ${e}`);
} finally {
setTriggering(false);
}
}
const isWebSsh = form.config_type === "web_ssh";
const hasSavedConfig = !!saved?.id;
const hasPath = !!(winPath || wslPath);
@ -197,27 +128,8 @@ export function CicdModal({
</button>
</div>
{/* Tab 切换 */}
<div className="flex border-b border-zinc-700 px-5">
{(["config", "runs"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`py-2.5 mr-4 text-sm border-b-2 transition-colors ${
tab === t
? "border-blue-500 text-white"
: "border-transparent text-zinc-400 hover:text-zinc-200"
}`}
>
{t === "config" ? "配置" : "运行历史"}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto min-h-0 p-4">
{/* ── 配置 Tab ── */}
{tab === "config" && (
<div className="space-y-4">
<div className="space-y-4">
{/* 类型选择 */}
<div>
<label className="text-xs text-zinc-400 mb-1 block"></label>
@ -364,74 +276,6 @@ export function CicdModal({
</div>
)}
</div>
)}
{/* ── 运行历史 Tab ── */}
{tab === "runs" && (
<div className="space-y-3">
{!repoPath ? (
<p className="text-zinc-500 text-sm text-center py-8">
GitHub CI
</p>
) : (
<>
<div className="flex items-center justify-between">
<p className="text-xs text-zinc-400">{repoPath}</p>
<div className="flex gap-2">
<button
onClick={() => refetchRuns()}
disabled={fetchingRuns}
className="text-xs text-zinc-400 hover:text-white transition-colors"
>
{fetchingRuns ? "刷新中…" : "↻ 刷新"}
</button>
<button
onClick={handleTrigger}
disabled={triggering || !hasSavedConfig}
title={!hasSavedConfig ? "请先保存配置" : ""}
className="px-3 py-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-xs rounded-lg transition-colors"
>
{triggering ? "触发中…" : "手动触发"}
</button>
</div>
</div>
{runs.length === 0 && !fetchingRuns && (
<p className="text-zinc-500 text-sm text-center py-8"></p>
)}
{runs.map((run) => (
<div
key={run.id}
className="bg-zinc-800 rounded-lg px-3 py-2.5 flex items-center gap-3"
>
<div className="w-20 shrink-0">{runStatusBadge(run)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">
#{run.run_number} · {run.head_branch}
</p>
<p className="text-xs text-zinc-400">{relativeTime(run.updated_at)}</p>
</div>
<a
href={run.html_url}
target="_blank"
rel="noreferrer"
className="text-xs text-blue-400 hover:text-blue-300 shrink-0"
onClick={(e) => {
e.preventDefault();
import("@tauri-apps/plugin-opener").then(({ openUrl }) =>
openUrl(run.html_url)
);
}}
>
</a>
</div>
))}
</>
)}
</div>
)}
</div>
</div>
</div>

View File

@ -12,10 +12,8 @@ import { ApiKeysPanel } from "./ApiKeysPanel";
import { CloudProductsPanel } from "./CloudProductsPanel";
import { BatchAddModal } from "./BatchAddModal";
import { MountRepoModal } from "./MountRepoModal";
import { DiscoveryPanel } from "./DiscoveryPanel";
import { ProjectToolsModal } from "./ProjectToolsModal";
import { StatusBadge } from "../ui/Badge";
import { getDiscoverableProjects, type SpacesJson } from "../../lib/spacesSync";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useUIStore } from "../../store/ui";
@ -23,11 +21,7 @@ type DashView = "board" | "manage";
type BoardTab = "groups" | "standalone" | "servers" | "software" | "apikeys" | "cloud";
type GroupView = "default" | "resource";
interface Props {
spacesJson: SpacesJson | null;
}
export function Dashboard({ spacesJson }: Props) {
export function Dashboard() {
const [view, setView] = useState<DashView>("board");
const [boardTab, setBoardTab] = useState<BoardTab>("groups");
const [showBatch, setShowBatch] = useState(false);
@ -53,15 +47,6 @@ export function Dashboard({ spacesJson }: Props) {
const mappedIds = new Set(maps.map((m) => m.project_id));
const standalone = projects.filter((p) => !mappedIds.has(p.id));
// 可从其他空间接续的项目(在 .dev-spaces 里有记录、但当前空间尚未有此 repo
const currentRepoUrls = new Set(
projects.map((p) => p.repo_url).filter((u): u is string => !!u)
);
const discoverableProjects =
spacesJson && currentSpace
? getDiscoverableProjects(spacesJson, currentSpace.id, currentRepoUrls)
: [];
return (
<div className="flex flex-col h-full">
{/* 子导航栏 */}
@ -192,14 +177,6 @@ export function Dashboard({ spacesJson }: Props) {
<div className="flex-1 overflow-y-auto min-h-0">
<div className="w-full px-8 py-8 space-y-8 pb-16">
{/* 跨空间发现面板(始终显示) */}
{discoverableProjects.length > 0 && currentSpace && (
<DiscoveryPanel
items={discoverableProjects}
currentSpaceId={currentSpace.id}
/>
)}
{/* ── 产品组标签 ── */}
{boardTab === "groups" && (
<>
@ -216,7 +193,6 @@ export function Dashboard({ spacesJson }: Props) {
key={g.id}
group={g}
members={members}
spacesJson={spacesJson}
/>
);
})}
@ -233,7 +209,7 @@ export function Dashboard({ spacesJson }: Props) {
{boardTab === "standalone" && (
<>
{standalone.length > 0 ? (
<StandaloneBoardSection standalone={standalone} spacesJson={spacesJson} />
<StandaloneBoardSection standalone={standalone} />
) : (
<div className="text-center py-24 text-gray-400">
<p className="text-4xl mb-4">📂</p>
@ -277,10 +253,9 @@ interface GroupMember {
interface GroupBoardSectionProps {
group: ProductGroup;
members: GroupMember[];
spacesJson: SpacesJson | null;
}
function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionProps) {
function GroupBoardSection({ group: g, members }: GroupBoardSectionProps) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [groupView, setGroupView] = useState<GroupView>("default");
@ -350,7 +325,6 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
key={project.id}
project={project}
role={role ?? undefined}
spacesJson={spacesJson}
onDeploy={setDeployTarget}
/>
))
@ -368,7 +342,6 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
key={project.id}
project={project}
role={role ?? undefined}
spacesJson={spacesJson}
onDeploy={setDeployTarget}
/>
))
@ -394,18 +367,17 @@ function GroupBoardSection({ group: g, members, spacesJson }: GroupBoardSectionP
/* ── ProjectRow ────────────────────────────────────────────────────────────── */
function ProjectRow({
project: p, role, spacesJson, onDeploy,
project: p, role, onDeploy,
}: {
project: Project;
role?: string;
spacesJson: SpacesJson | null;
onDeploy: (p: Project) => void;
}) {
return (
<div className="flex divide-x divide-gray-100 border-b border-gray-50 last:border-b-0">
{/* 左:项目卡片 */}
<div className="flex-1 min-w-0 p-4">
<ProjectCard project={p} role={role} spacesJson={spacesJson} />
<ProjectCard project={p} role={role} />
</div>
{/* 右:部署信息 */}
@ -580,10 +552,9 @@ function GlobalQuickLaunchBar() {
/* ── StandaloneBoardSection ────────────────────────────────────────────────── */
function StandaloneBoardSection({
standalone, spacesJson,
standalone,
}: {
standalone: Project[];
spacesJson: SpacesJson | null;
}) {
const [deployTarget, setDeployTarget] = useState<Project | null>(null);
const [selectedTagIds, setSelectedTagIds] = useState<Set<string>>(new Set());
@ -673,7 +644,7 @@ function StandaloneBoardSection({
</div>
{winProjects.length > 0
? winProjects.map((p) => (
<ProjectRow key={p.id} project={p} spacesJson={spacesJson} onDeploy={setDeployTarget} />
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
))
: <p className="text-xs text-gray-300 py-5 text-center border-b border-gray-50"></p>
}
@ -685,7 +656,7 @@ function StandaloneBoardSection({
</div>
{wslProjects.length > 0
? wslProjects.map((p) => (
<ProjectRow key={p.id} project={p} spacesJson={spacesJson} onDeploy={setDeployTarget} />
<ProjectRow key={p.id} project={p} onDeploy={setDeployTarget} />
))
: <p className="text-xs text-gray-300 py-5 text-center"></p>
}

View File

@ -1,251 +0,0 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-dialog";
import { createProject, gitClone } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import type { SpaceCatalogItem } from "../../lib/spacesSync";
export interface DiscoverableItem extends SpaceCatalogItem {
spaceId: string;
spaceName: string;
}
interface Props {
items: DiscoverableItem[];
currentSpaceId: string;
}
/* ── 内联克隆表单 ─────────────────────────────────────────── */
function CloneForm({
item,
spaceId,
onDone,
}: {
item: DiscoverableItem;
spaceId: string;
onDone: () => void;
}) {
const showToast = useUIStore((s) => s.showToast);
// 从 repo_url 提取仓库名作为默认 id 和文件夹名
const repoName = item.repo_url.split("/").pop()?.replace(/\.git$/, "") ?? item.name;
const [wsId, setWsId] = useState(repoName);
const [parentDir, setParentDir] = useState("");
const [platform, setPlatform] = useState<"windows" | "wsl">(
(item.platform as "windows" | "wsl") ?? "windows"
);
const [cloning, setCloning] = useState(false);
// Windows 侧路径 = 父目录 + \ + 仓库名
const winPath = parentDir ? `${parentDir}\\${repoName}` : "";
const pickParentDir = async () => {
const selected = await open({ directory: true, multiple: false });
if (selected) setParentDir(selected as string);
};
const handleClone = async () => {
if (!wsId.trim()) { showToast("❌ 项目 ID 不能为空"); return; }
if (platform === "windows" && !parentDir) {
showToast("❌ 请先选择本地目录"); return;
}
setCloning(true);
try {
if (platform === "windows") {
await gitClone(item.repo_url, winPath);
}
// WSL 模式下用户需要手动克隆,这里只注册项目
await createProject({
id: wsId.trim(),
name: item.name,
description: item.description,
tech_stack: item.tech_stack,
repo_url: item.repo_url,
default_branch: item.default_branch ?? "main",
platform,
space_id: spaceId,
win_path: platform === "windows" ? winPath : undefined,
});
showToast(`✓ 已${platform === "windows" ? "克隆并" : ""}添加项目「${item.name}`);
onDone();
} catch (e) {
showToast(`${e}`);
} finally {
setCloning(false);
}
};
const inputCls =
"w-full border border-gray-200 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-200 focus:border-indigo-400";
return (
<div className="border-t border-indigo-100 px-4 py-4 space-y-3 bg-indigo-50/40">
{/* 平台 */}
<div className="flex gap-2">
{(["windows", "wsl"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setPlatform(p)}
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
platform === p
? "border-indigo-400 bg-indigo-50 text-indigo-700 font-medium"
: "border-gray-200 text-gray-500 hover:bg-gray-50"
}`}
>
{p === "windows" ? "Windows" : "WSL / Linux"}
</button>
))}
</div>
{/* 项目 ID */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
ID
</label>
<input
className={`${inputCls} font-mono`}
value={wsId}
onChange={(e) => setWsId(e.target.value)}
placeholder={repoName}
/>
</div>
{/* 本地路径 */}
{platform === "windows" ? (
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
</label>
<div className="flex gap-2">
<input
className={`${inputCls} font-mono flex-1`}
value={winPath}
readOnly
placeholder="请先点击「选择目录」"
/>
<button
type="button"
onClick={pickParentDir}
className="px-3 py-2 rounded-lg border border-gray-200 text-xs text-gray-600 hover:bg-gray-50 shrink-0"
>
</button>
</div>
{parentDir && (
<p className="text-xs text-gray-400 mt-1">
<code>git clone</code>
</p>
)}
</div>
) : (
<div className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
WSL WSL <code>git clone {item.repo_url}</code>
</div>
)}
{/* 操作 */}
<div className="flex justify-end gap-2 pt-1">
<button
onClick={handleClone}
disabled={cloning || (platform === "windows" && !parentDir)}
className="px-4 py-2 rounded-lg text-xs bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{cloning
? "克隆中…"
: platform === "windows"
? "克隆到本地"
: "仅添加记录"}
</button>
</div>
</div>
);
}
/* ── 发现面板主体 ──────────────────────────────────────────── */
export function DiscoveryPanel({ items, currentSpaceId }: Props) {
const qc = useQueryClient();
const [expandedUrl, setExpandedUrl] = useState<string | null>(null);
if (items.length === 0) return null;
const handleCloned = () => {
setExpandedUrl(null);
qc.invalidateQueries({ queryKey: ["projects"] });
};
return (
<section className="rounded-xl border border-indigo-200 bg-indigo-50 shadow-sm overflow-hidden">
{/* 标题栏 */}
<div className="flex items-center gap-2 px-5 py-3 border-b border-indigo-100">
<span className="text-indigo-500"></span>
<h2 className="text-sm font-semibold text-indigo-800"></h2>
<span className="ml-auto text-xs text-indigo-400">{items.length} </span>
</div>
{/* 条目列表 */}
<div className="p-4 space-y-2">
{items.map((item) => {
const key = item.repo_url;
const expanded = expandedUrl === key;
return (
<div
key={key}
className="rounded-lg border border-indigo-100 bg-white overflow-hidden"
>
{/* 条目行 */}
<div className="flex items-center gap-3 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-gray-800">{item.name}</span>
{item.tech_stack && (
<span className="text-xs text-gray-400">{item.tech_stack}</span>
)}
{item.default_branch && (
<span className="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-500 font-mono">
{item.default_branch}
</span>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-indigo-400">
<span className="font-medium text-indigo-600">{item.spaceName}</span>
</span>
<span className="text-gray-300">·</span>
<span className="text-xs font-mono text-gray-400 truncate max-w-xs" title={item.repo_url}>
{item.repo_url}
</span>
</div>
{item.description && (
<p className="text-xs text-gray-400 mt-0.5">{item.description}</p>
)}
</div>
<button
onClick={() => setExpandedUrl(expanded ? null : key)}
className={`px-3 py-1.5 rounded-lg text-xs border shrink-0 transition-colors ${
expanded
? "border-gray-200 text-gray-500 hover:bg-gray-50"
: "bg-indigo-600 text-white hover:bg-indigo-700 border-transparent"
}`}
>
{expanded ? "收起" : "接续开发"}
</button>
</div>
{/* 克隆表单 */}
{expanded && (
<CloneForm
item={item}
spaceId={currentSpaceId}
onDone={handleCloned}
/>
)}
</div>
);
})}
</div>
</section>
);
}

View File

@ -1,244 +0,0 @@
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>
);
}

View File

@ -115,7 +115,7 @@ export function MountRepoModal({ spaceId, onClose }: Props) {
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-12 gap-2">
<p className="text-2xl">📦</p>
<p className="text-sm"></p>
<p className="text-xs text-gray-300">GitHub </p>
<p className="text-xs text-gray-300"></p>
</div>
) : (
<div className="flex-1 overflow-y-auto min-h-0">

View File

@ -1,20 +1,17 @@
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, githubGetToken, githubGetAccount, gitCreateBranch, gitListBranches, gitCheckoutBranch, fetchActionsRuns, parseRepoPath, getBlueprintStatus, getBuffStatus, getDocFreshnessCache, type Project, type Space, type ActionsRun, type BlueprintStatus, type BuffStatus, type CachedFreshness } from "../../lib/commands";
import { getActivity, getSettings, getProjectTools, openEditor, openTerminal, launchTool, gitStatus, gitFetch, gitPull, gitPush, gitUpstreamBehind, gitCreateBranch, gitListBranches, gitCheckoutBranch, getBlueprintStatus, getBuffStatus, getDocFreshnessCache, type Project, type BlueprintStatus, type BuffStatus, type CachedFreshness } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { ProjectToolsModal } from "./ProjectToolsModal";
import { ProjectEnvModal } from "./ProjectEnvModal";
import { CicdModal } from "./CicdModal";
import { PublishModal } from "./PublishModal";
import { BlueprintModal } from "./BlueprintModal";
import { BlueprintPromptModal } from "./BlueprintPromptModal";
import { MentorPanel } from "../mentor/MentorPanel";
import { UserTodoPanel } from "./UserTodoPanel";
import { ProjectServerPanel } from "./ProjectServerPanel";
import { ClaudeConfigModal } from "./ClaudeConfigModal";
import { getOtherSpacesInfo, recordPushToRemote, formatRelativeTime, type SpacesJson } from "../../lib/spacesSync";
import { openUrl } from "@tauri-apps/plugin-opener";
// ── 蓝图状态徽章 ──────────────────────────────────────────────────────────────
@ -57,16 +54,14 @@ function BlueprintStatusBadge({
interface Props {
project: Project;
role?: string;
spacesJson?: SpacesJson | null;
}
export function ProjectCard({ project: p, spacesJson }: Props) {
export function ProjectCard({ project: p }: Props) {
const [expanded, setExpanded] = useState(false);
const [showToolsMgr, setShowToolsMgr] = useState(false);
const [toolsMgrDefaultAdd, setToolsMgrDefaultAdd] = useState(false);
const [showEnvScan, setShowEnvScan] = useState(false);
const [showCicd, setShowCicd] = useState(false);
const [showPublish, setShowPublish] = useState(false);
const [showBlueprint, setShowBlueprint] = useState(false);
const [showBlueprintPrompt, setShowBlueprintPrompt] = useState(false);
const [showMentor, setShowMentor] = useState(false);
@ -131,32 +126,7 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
});
const pushMutation = useMutation({
mutationFn: async () => {
const msg = await gitPush(localPath!);
// push 成功后,把最新信息同步到 .dev-spaces
try {
const token = await githubGetToken();
const account = await githubGetAccount();
// 从 React Query 缓存取 spaces避免重复 DB 查询
const spaces = queryClient.getQueryData<Space[]>(["spaces"]) ?? [];
const currentSpace = spaces.find((s) => s.is_current);
if (token && account && currentSpace && p.repo_url && gitStat) {
await recordPushToRemote(
token,
account.username,
currentSpace.id,
currentSpace.name,
p.repo_url,
gitStat.branch,
gitStat.last_commit_msg ?? undefined
);
queryClient.invalidateQueries({ queryKey: ["spacesSync"] });
}
} catch {
// 同步失败不阻断主流程
}
return msg;
},
mutationFn: () => gitPush(localPath!),
onSuccess: (msg) => {
showToast(msg);
queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] });
@ -164,11 +134,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
onError: (e) => showToast(`Push 失败: ${e}`),
});
// 跨空间感知:其他空间对同一仓库的最新 push 信息
const otherSpacesInfo = spacesJson && p.repo_url && p.space_id
? getOtherSpacesInfo(spacesJson, p.space_id, p.repo_url)
: [];
// upstream 落后检查(有 upstream_url 且有本地路径时)
const { data: upstreamBehind = 0 } = useQuery({
queryKey: ["upstreamBehind", p.id],
@ -178,22 +143,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
retry: false,
});
// CI 最新 run 状态(有 repo_url 时才查)
const ciRepoPath = p.repo_url ? parseRepoPath(p.repo_url) : null;
const { data: latestRun } = useQuery<ActionsRun | null>({
queryKey: ["ciLatest", p.id],
queryFn: async () => {
if (!ciRepoPath) return null;
const token = await githubGetToken();
if (!token) return null;
const runs = await fetchActionsRuns(token, ciRepoPath, 1);
return runs[0] ?? null;
},
enabled: !!ciRepoPath,
staleTime: 60000,
retry: false,
});
// 蓝图路径:优先 win_pathWSL 项目仅当 wsl_path 为 UNC 格式时可从 Windows 侧访问
const blueprintPath = p.win_path
|| (p.wsl_path?.startsWith("\\\\wsl.") ? p.wsl_path : undefined);
@ -221,18 +170,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
retry: false,
});
// PR 链接:从 repo_url 解析出 GitHub 路径
const prUrl = (() => {
if (!p.repo_url || !gitStat) return null;
const match = p.repo_url.match(/github\.com[/:](.+?)(?:\.git)?$/);
if (!match) return null;
const repoPath = match[1];
const base = p.default_branch ?? "main";
const head = gitStat.branch;
if (head === base) return `https://github.com/${repoPath}/compare`;
return `https://github.com/${repoPath}/compare/${base}...${head}`;
})();
const { data: settings } = useQuery({
queryKey: ["settings", p.space_id],
queryFn: () => getSettings(p.space_id ?? undefined),
@ -329,30 +266,8 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
}
};
const [resuming, setResuming] = useState<string | null>(null); // spaceId 正在接续
const customTags = (p.tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
const handleResume = async (branch: string, spaceId: string) => {
if (!hasGit) { showToast("❌ 当前项目没有配置 git 路径"); return; }
if (gitStat?.has_changes) {
showToast("❌ 本地有未提交改动,请先 commit 或 stash 后再接续");
return;
}
setResuming(spaceId);
try {
await gitCheckoutBranch(localPath!, branch);
await gitPull(localPath!);
showToast(`已切换到 ${branch} 并拉取最新代码 ✓`);
queryClient.invalidateQueries({ queryKey: ["gitStatus", p.id] });
queryClient.invalidateQueries({ queryKey: ["branches", p.id] });
} catch (e) {
showToast(`接续失败: ${e}`);
} finally {
setResuming(null);
}
};
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
{/* ── 卡片头部 ── */}
@ -560,30 +475,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
{pushMutation.isPending ? "…" : "Push"}
</button>
)}
{prUrl && (
<button
onClick={() => openUrl(prUrl).catch(() => {})}
className="px-2 py-0.5 rounded border border-purple-200 bg-purple-50 text-purple-600 hover:bg-purple-100 transition-colors"
title="在 GitHub 发起 PR"
>
PR
</button>
)}
{latestRun && (
<button
onClick={() => setShowCicd(true)}
title={`CI: ${latestRun.conclusion ?? latestRun.status}`}
className="px-1.5 py-0.5 rounded border border-gray-200 bg-white hover:bg-gray-50 transition-colors text-[11px]"
>
{latestRun.status === "in_progress" || latestRun.status === "queued"
? "🔄"
: latestRun.conclusion === "success"
? "✅"
: latestRun.conclusion === "failure"
? "❌"
: "⊘"}
</button>
)}
</div>
</>
) : gitError ? (
@ -595,32 +486,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
</div>
)}
{/* ── 跨空间感知 ── */}
{otherSpacesInfo.length > 0 && (
<div className="mx-5 mb-3 flex flex-col gap-1.5">
{otherSpacesInfo.map((info) => (
<div key={info.spaceId} className="flex items-center gap-2 text-xs px-3 py-2 bg-indigo-50 rounded-lg border border-indigo-100">
<span className="text-indigo-400 shrink-0"></span>
<span className="text-indigo-700 font-medium shrink-0">{info.spaceName}</span>
<span className="font-mono text-indigo-500 shrink-0">{info.branch}</span>
{info.last_commit_msg && (
<span className="text-indigo-400 truncate flex-1">{info.last_commit_msg}</span>
)}
<span className="text-indigo-300 shrink-0">{formatRelativeTime(info.last_push_at)}</span>
{hasGit && (
<button
onClick={() => handleResume(info.branch, info.spaceId)}
disabled={resuming === info.spaceId}
className="px-2.5 py-1 rounded-md border border-indigo-300 bg-white text-indigo-700 hover:bg-indigo-100 disabled:opacity-50 shrink-0 transition-colors"
>
{resuming === info.spaceId ? "…" : "接续"}
</button>
)}
</div>
))}
</div>
)}
{/* ── Upstream 同步提示 ── */}
{upstreamBehind > 0 && (
<div className="mx-5 mb-3 flex items-center gap-2 text-xs px-3 py-2 bg-amber-50 rounded-lg border border-amber-200">
@ -746,16 +611,6 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
className="px-2.5 py-1.5 rounded-md text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 transition-colors" title="工具管理">
🧰
</button>
{/* 未完全绑定时显示「发布到 GitHub」 */}
{(!p.repo_id || !p.repo_url) && (p.win_path || p.wsl_path) && (
<button
onClick={() => setShowPublish(true)}
className="px-2.5 py-1.5 rounded-md text-xs border border-blue-200 text-blue-600 bg-blue-50 hover:bg-blue-100 transition-colors"
title="发布到 GitHub"
>
</button>
)}
<div className="w-px h-4 bg-gray-200 mx-0.5" />
<button onClick={() => openEdit(p)}
className="px-3 py-1.5 rounded-md text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 transition-colors">
@ -784,15 +639,10 @@ export function ProjectCard({ project: p, spacesJson }: Props) {
/>
)}
{showPublish && (
<PublishModal project={p} onClose={() => setShowPublish(false)} />
)}
{showCicd && (
<CicdModal
projectId={p.id}
projectName={p.name}
repoUrl={p.repo_url ?? undefined}
winPath={p.win_path ?? undefined}
wslPath={p.wsl_path ?? undefined}
platform={p.platform ?? undefined}

View File

@ -1,8 +1,7 @@
import { useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { open } from "@tauri-apps/plugin-dialog";
import { createProject, updateProject, getSpaces, githubGetToken, githubGetAccount, getTags, getProjectTags, setProjectTags, type Project } from "../../lib/commands";
import { syncProjectCatalog } from "../../lib/spacesSync";
import { createProject, updateProject, getSpaces, getTags, getProjectTags, setProjectTags, type Project } from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { Dialog } from "../ui/Dialog";
@ -93,27 +92,6 @@ const pickFolder = async (field: "wsl_path" | "win_path") => {
}
qc.invalidateQueries({ queryKey: ["projects"] });
qc.invalidateQueries({ queryKey: ["tagMaps"] });
// 若项目有 repo_url静默同步到 .dev-spaces catalog
const savedRepoUrl = isNew ? form.repo_url : (form.repo_url ?? editTarget?.repo_url);
if (savedRepoUrl && currentSpace) {
(async () => {
try {
const token = await githubGetToken();
const account = await githubGetAccount();
if (token && account) {
await syncProjectCatalog(token, account.username, currentSpace.id, currentSpace.name, {
id: (isNew ? form.id : editTarget!.id) ?? "",
name: form.name ?? "",
description: form.description,
tech_stack: form.tech_stack,
repo_url: savedRepoUrl,
default_branch: form.default_branch ?? editTarget?.default_branch,
platform: form.platform,
});
}
} catch { /* 同步失败不影响本地保存 */ }
})();
}
closeEdit();
} catch (e) {
showToast(`${e}`);

View File

@ -1,444 +0,0 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
scanLocalGit, githubCreateRepo, gitInitAndCommit,
gitRemoteSet, gitPushToGithub, registerAndLinkRepo,
type GitScanResult, type Project,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
interface Props {
project: Project;
onClose: () => void;
}
type Step = "scan" | "config" | "executing" | "done";
interface ExecLog {
label: string;
status: "pending" | "ok" | "error";
detail?: string;
}
const GITIGNORE_TEMPLATES: Record<string, string> = {
Node: `node_modules/\ndist/\n.next/\n.nuxt/\n*.log\n.env\n.env.*\n!.env.example\n`,
Rust: `target/\nCargo.lock\n*.pdb\n`,
Python: `__pycache__/\n*.py[cod]\n.venv/\nvenv/\n*.egg-info/\ndist/\n.env\n`,
Go: `*.exe\n*.test\n*.out\nvendor/\n`,
Java: `target/\n*.class\n*.jar\n.gradle/\nbuild/\n`,
VisualStudio: `bin/\nobj/\n*.user\n*.suo\n.vs/\n`,
};
export function PublishModal({ project, onClose }: Props) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const localPath = project.platform === "wsl" ? project.wsl_path : project.win_path;
const [step, setStep] = useState<Step>("scan");
const [scan, setScan] = useState<GitScanResult | null>(null);
const [scanning, setScanning] = useState(false);
const [scanError, setScanError] = useState("");
// 配置表单
const [repoName, setRepoName] = useState(
project.name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")
);
const [repoDesc, setRepoDesc] = useState(project.description ?? "");
const [isPrivate, setIsPrivate] = useState(false);
const [branch, setBranch] = useState("main");
const [commitMsg, setCommitMsg] = useState("Initial commit");
const [confirmedSensitive, setConfirmedSensitive] = useState(false);
const [selectedTemplates, setSelectedTemplates] = useState<string[]>([]);
// 执行日志
const [logs, setLogs] = useState<ExecLog[]>([]);
// ── 工具 ──────────────────────────────────────────────────────────────────
const updateLog = (index: number, status: ExecLog["status"], detail?: string) => {
setLogs((prev) => prev.map((l, i) => i === index ? { ...l, status, detail } : l));
};
// ── 步骤1扫描 ──────────────────────────────────────────────────────────
const handleScan = async () => {
if (!localPath) { showToast("❌ 项目没有配置本地路径"); return; }
setScanning(true);
try {
const result = await scanLocalGit(localPath);
setScan(result);
// 预选推荐模板
setSelectedTemplates(result.suggested_templates);
// 若已有分支名则使用
if (result.branch) setBranch(result.branch);
setStep("config");
} catch (e) {
setScanError(String(e));
} finally {
setScanning(false);
}
};
// ── 步骤2 → 步骤3执行 ─────────────────────────────────────────────────
const buildSteps = (): ExecLog[] => {
if (!scan) return [];
const steps: ExecLog[] = [];
const needInit = !scan.has_git || !scan.has_commits;
const needGitignore = !scan.has_gitignore && selectedTemplates.length > 0;
if (needGitignore) steps.push({ label: "生成 .gitignore", status: "pending" });
steps.push({ label: "在 GitHub 创建远端仓库", status: "pending" });
if (needInit) {
steps.push({ label: `初始化并提交代码(${commitMsg}`, status: "pending" });
}
steps.push({ label: "配置 origin remote", status: "pending" });
steps.push({ label: `推送到 GitHub (${branch})`, status: "pending" });
steps.push({ label: "注册到本地仓库", status: "pending" });
return steps;
};
const handleExecute = async () => {
if (!scan || !localPath) return;
if (scan.suspicious_files.length > 0 && !confirmedSensitive) {
showToast("❌ 请确认敏感文件处理后再继续");
return;
}
const initialLogs = buildSteps();
setLogs(initialLogs);
setStep("executing");
let logIdx = 0;
let cloneUrl = "";
try {
// 1. 生成 .gitignore
if (!scan.has_gitignore && selectedTemplates.length > 0) {
updateLog(logIdx, "pending", "写入中…");
const content = selectedTemplates
.map((t) => `# ${t}\n${GITIGNORE_TEMPLATES[t] ?? ""}`)
.join("\n");
const { invoke } = await import("@tauri-apps/api/core");
await invoke("write_gitignore", { dir: localPath, content });
updateLog(logIdx++, "ok");
}
// 2. 创建 GitHub 仓库
updateLog(logIdx, "pending", "调用 GitHub API…");
cloneUrl = await githubCreateRepo(repoName, repoDesc, isPrivate);
updateLog(logIdx++, "ok", cloneUrl);
// 3. 全新项目git init + 提交所有文件
const needCommit = !scan.has_git || !scan.has_commits;
if (needCommit) {
updateLog(logIdx, "pending", "git add & commit…");
await gitInitAndCommit(localPath, commitMsg);
updateLog(logIdx++, "ok");
}
// 4. 配置 remote
updateLog(logIdx, "pending", "git remote set…");
await gitRemoteSet(localPath, cloneUrl);
updateLog(logIdx++, "ok");
// 5. Push
updateLog(logIdx, "pending", `git push origin ${branch}`);
await gitPushToGithub(localPath, branch);
updateLog(logIdx++, "ok");
// 6. 注册本地仓库 + 关联
updateLog(logIdx, "pending", "写入本地仓库注册表…");
await registerAndLinkRepo(
project.id,
cloneUrl,
repoName,
repoDesc || undefined,
branch,
);
updateLog(logIdx++, "ok");
qc.invalidateQueries({ queryKey: ["projects"] });
qc.invalidateQueries({ queryKey: ["git-repos"] });
setStep("done");
} catch (e) {
updateLog(logIdx, "error", String(e));
}
};
// ── 渲染 ─────────────────────────────────────────────────────────────────
const hasRemoteConflict = scan?.remotes.some(
(r) => r.name === "origin" && !r.url.includes(repoName)
);
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-[580px] max-h-[85vh] flex flex-col border border-gray-200">
{/* 标题 */}
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between shrink-0">
<div>
<h2 className="text-base font-semibold text-gray-800"> GitHub</h2>
<p className="text-xs text-gray-400 mt-0.5">{project.name}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div className="flex-1 overflow-y-auto min-h-0 px-5 py-4 space-y-4">
{/* ── 步骤1扫描 ── */}
{step === "scan" && (
<div className="flex flex-col items-center justify-center py-10 gap-4">
<p className="text-2xl">🔍</p>
<p className="text-sm text-gray-600 text-center">
git
</p>
<p className="text-xs text-gray-400 font-mono bg-gray-50 px-3 py-1.5 rounded-lg">{localPath}</p>
<button
onClick={() => { setScanError(""); handleScan(); }}
disabled={scanning}
className="px-6 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50"
>
{scanning ? "扫描中…" : "开始扫描"}
</button>
{scanError && (
<div className="w-full bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs space-y-2">
<p className="font-semibold text-red-600"></p>
<pre className="text-red-700 whitespace-pre-wrap break-all font-mono leading-relaxed">{scanError}</pre>
<button
onClick={() => navigator.clipboard.writeText(scanError).then(() => showToast("已复制 ✓"))}
className="px-3 py-1 rounded border border-red-200 text-red-500 hover:bg-red-100 text-xs"
>
</button>
</div>
)}
</div>
)}
{/* ── 步骤2配置 ── */}
{step === "config" && scan && (
<>
{/* 扫描结果摘要 */}
<div className="bg-gray-50 rounded-lg px-4 py-3 text-xs space-y-1.5">
<p className="font-semibold text-gray-600 mb-2"></p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<span className="text-gray-500">Git </span>
<span className={scan.has_git ? "text-green-600" : "text-orange-500"}>
{scan.has_git ? "✓ 已初始化" : "✗ 未初始化,将自动 init"}
</span>
<span className="text-gray-500"></span>
<span className={scan.has_commits ? "text-green-600" : "text-orange-500"}>
{scan.has_commits ? "✓ 有历史提交" : "✗ 无提交,将自动提交"}
</span>
<span className="text-gray-500"></span>
<span className={scan.has_changes ? "text-orange-500" : "text-green-600"}>
{scan.has_changes ? `${scan.changed_files.length} 个文件` : "✓ 工作区干净"}
</span>
<span className="text-gray-500">.gitignore</span>
<span className={scan.has_gitignore ? "text-green-600" : "text-orange-500"}>
{scan.has_gitignore ? "✓ 已存在" : "✗ 不存在"}
</span>
{scan.remotes.length > 0 && (
<>
<span className="text-gray-500"> remote</span>
<span className="text-gray-700 font-mono truncate">{scan.remotes[0].url}</span>
</>
)}
</div>
</div>
{/* Remote 冲突警告 */}
{hasRemoteConflict && (
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs text-red-700">
origin remote
</div>
)}
{/* 敏感文件警告 */}
{scan.suspicious_files.length > 0 && (
<div className="bg-red-50 border border-red-200 rounded-lg px-4 py-3 text-xs space-y-2">
<p className="font-semibold text-red-700">🚨 </p>
<ul className="space-y-0.5">
{scan.suspicious_files.map((f) => (
<li key={f} className="font-mono text-red-600"> {f}</li>
))}
</ul>
<p className="text-red-500"> .gitignore </p>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={confirmedSensitive}
onChange={(e) => setConfirmedSensitive(e.target.checked)}
/>
<span className="text-red-700 font-medium"></span>
</label>
</div>
)}
{/* .gitignore 模板 */}
{!scan.has_gitignore && scan.suggested_templates.length > 0 && (
<div className="border border-gray-200 rounded-lg px-4 py-3 text-xs space-y-2">
<p className="font-semibold text-gray-600"> .gitignore</p>
<div className="flex flex-wrap gap-2">
{scan.suggested_templates.map((t) => (
<label key={t} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={selectedTemplates.includes(t)}
onChange={(e) =>
setSelectedTemplates((prev) =>
e.target.checked ? [...prev, t] : prev.filter((x) => x !== t)
)
}
/>
<span>{t}</span>
</label>
))}
</div>
</div>
)}
{/* 未提交内容:阻断发布 */}
{scan.has_changes && (
<div className="bg-orange-50 border border-orange-200 rounded-lg px-4 py-3 text-xs space-y-2">
<p className="font-semibold text-orange-700">
{scan.changed_files.length}
</p>
<div className="max-h-28 overflow-y-auto space-y-0.5">
{scan.changed_files.slice(0, 50).map((f, i) => (
<div key={i} className="flex items-center gap-2 font-mono text-gray-500">
<span className={`w-4 font-bold ${
f.status === "?" ? "text-green-500" :
f.status === "D" ? "text-red-500" : "text-yellow-500"
}`}>{f.status}</span>
<span className="truncate">{f.path}</span>
</div>
))}
{scan.changed_files.length > 50 && (
<p className="text-gray-400"> {scan.changed_files.length - 50} </p>
)}
</div>
<p className="text-orange-600">
<span className="font-semibold">VSCode</span> <span className="font-semibold">Claude</span>
</p>
</div>
)}
{/* 全新项目提示(无 git / 无提交记录,但工作区干净) */}
{!scan.has_changes && (!scan.has_git || !scan.has_commits) && (
<div className="border border-blue-100 bg-blue-50 rounded-lg px-4 py-3 text-xs space-y-2">
<p className="font-semibold text-blue-700">
{!scan.has_git ? "全新项目:将自动执行 git init 并提交所有文件" : "尚无提交记录:将自动提交当前所有文件"}
</p>
<div>
<label className="block text-blue-600 mb-1"> Commit </label>
<input
className="w-full border border-blue-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-200 bg-white"
value={commitMsg}
onChange={(e) => setCommitMsg(e.target.value)}
/>
</div>
</div>
)}
{/* GitHub 仓库配置 */}
<div className="border border-gray-200 rounded-lg px-4 py-3 space-y-3">
<p className="text-xs font-semibold text-gray-600">GitHub </p>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="col-span-2">
<label className="block text-gray-500 mb-1"></label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200"
value={repoName}
onChange={(e) => setRepoName(e.target.value)}
/>
</div>
<div className="col-span-2">
<label className="block text-gray-500 mb-1"></label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-blue-200"
value={repoDesc}
onChange={(e) => setRepoDesc(e.target.value)}
/>
</div>
<div>
<label className="block text-gray-500 mb-1"></label>
<input
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200"
value={branch}
onChange={(e) => setBranch(e.target.value)}
/>
</div>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 cursor-pointer text-sm text-gray-600">
<input
type="checkbox"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
/>
</label>
</div>
</div>
</div>
</>
)}
{/* ── 步骤3执行进度 ── */}
{(step === "executing" || step === "done") && (
<div className="space-y-2">
{logs.map((log, i) => (
<div key={i} className="flex items-start gap-3 text-sm">
<span className="shrink-0 mt-0.5">
{log.status === "pending" && <span className="text-blue-400 animate-pulse"></span>}
{log.status === "ok" && <span className="text-green-500"></span>}
{log.status === "error" && <span className="text-red-500"></span>}
</span>
<div className="flex-1 min-w-0">
<span className={log.status === "error" ? "text-red-600" : "text-gray-700"}>
{log.label}
</span>
{log.detail && (
<p className="text-xs text-gray-400 font-mono truncate mt-0.5">{log.detail}</p>
)}
</div>
</div>
))}
{step === "done" && (
<div className="mt-4 bg-green-50 border border-green-200 rounded-lg px-4 py-3 text-sm text-green-700 text-center">
🎉 绿
</div>
)}
</div>
)}
</div>
{/* 底部按钮 */}
<div className="flex justify-end gap-3 px-5 py-3 border-t border-gray-100 shrink-0">
<button
onClick={onClose}
className="px-4 py-2 text-sm border border-gray-200 rounded-lg text-gray-600 hover:bg-gray-50"
>
{step === "done" ? "关闭" : "取消"}
</button>
{step === "config" && (
<button
onClick={handleExecute}
disabled={
!repoName.trim() ||
!scan ||
scan.has_changes ||
(scan.suspicious_files.length > 0 && !confirmedSensitive)
}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
</button>
)}
</div>
</div>
</div>
);
}

View File

@ -2,11 +2,8 @@ import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getGitRepos,
importGitRepos,
updateGitRepoDeploy,
deleteGitRepo,
githubGetToken,
githubListRepos,
type GitRepo,
type UpdateGitRepoDeployInput,
} from "../../lib/commands";
@ -16,8 +13,6 @@ interface Props {
onClose: () => void;
}
type Tab = "registry" | "sync";
// ── 部署信息编辑表单 ──────────────────────────────────────────────────────────
function DeployForm({
@ -154,7 +149,7 @@ function RegistryTab() {
return (
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 py-16 gap-2">
<p className="text-2xl">📦</p>
<p className="text-sm"> GitHub </p>
<p className="text-sm"> / </p>
</div>
);
}
@ -220,151 +215,9 @@ function RegistryTab() {
);
}
// ── 从 GitHub 同步标签 ───────────────────────────────────────────────────────
function SyncTab() {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [importing, setImporting] = 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: existingRepos = [] } = useQuery({
queryKey: ["git-repos"],
queryFn: getGitRepos,
});
const existingUrls = new Set(existingRepos.map((r) => r.repo_url));
const filtered = repos.filter(
(r) =>
r.name.toLowerCase().includes(search.toLowerCase()) ||
r.full_name.toLowerCase().includes(search.toLowerCase())
);
const toggleSelect = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const selectAll = () => {
const newRepos = filtered.filter((r) => !existingUrls.has(r.clone_url));
setSelected(new Set(newRepos.map((r) => r.id)));
};
const handleImport = async () => {
const toImport = repos.filter((r) => selected.has(r.id));
if (toImport.length === 0) return;
setImporting(true);
try {
const inputs = toImport.map((r) => ({
id: crypto.randomUUID(),
repo_url: r.clone_url,
name: r.name,
description: r.description,
default_branch: r.default_branch,
}));
const imported = await importGitRepos(inputs);
qc.invalidateQueries({ queryKey: ["git-repos"] });
setSelected(new Set());
showToast(`已导入 ${imported.length} 个仓库 ✓`);
} catch (e) {
showToast(`${e}`);
} finally {
setImporting(false);
}
};
if (isLoading) return <div className="flex-1 flex items-center justify-center text-gray-400 text-sm"> GitHub </div>;
if (error) return <div className="flex-1 flex items-center justify-center text-red-400 text-sm p-6">{String(error)}</div>;
return (
<>
<div className="px-4 py-3 border-b border-gray-100 flex gap-2">
<input
type="text"
placeholder="搜索仓库…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 border border-gray-200 text-sm rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-200"
/>
<button onClick={selectAll} className="px-3 py-2 text-xs border border-gray-200 rounded-lg text-gray-500 hover:bg-gray-50 shrink-0">
</button>
<button
onClick={handleImport}
disabled={selected.size === 0 || importing}
className="px-3 py-2 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 shrink-0"
>
{importing ? "导入中…" : `导入 ${selected.size}`}
</button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{filtered.map((repo) => {
const alreadyImported = existingUrls.has(repo.clone_url);
const isSelected = selected.has(repo.id);
return (
<button
key={repo.id}
onClick={() => !alreadyImported && toggleSelect(repo.id)}
disabled={alreadyImported}
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-3 ${
alreadyImported
? "opacity-40 cursor-not-allowed"
: isSelected
? "bg-blue-50"
: "hover:bg-gray-50"
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 ${
alreadyImported ? "border-gray-200 bg-gray-100" :
isSelected ? "border-blue-500 bg-blue-500" : "border-gray-300"
}`}>
{(isSelected || alreadyImported) && (
<svg className="w-2.5 h-2.5 text-white" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z"/>
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-800">{repo.name}</span>
{repo.private && <span className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded"></span>}
{alreadyImported && <span className="text-xs px-1.5 py-0.5 bg-green-100 text-green-600 rounded"></span>}
<span className="text-xs text-gray-400 ml-auto font-mono">{repo.default_branch}</span>
</div>
{repo.description && <p className="text-xs text-gray-400 mt-0.5 truncate">{repo.description}</p>}
</div>
</button>
);
})}
{filtered.length === 0 && (
<div className="text-center text-gray-400 text-sm py-8"></div>
)}
</div>
</>
);
}
// ── 主模态框 ──────────────────────────────────────────────────────────────────
export function RepoRegistryModal({ onClose }: Props) {
const [tab, setTab] = useState<Tab>("registry");
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-[700px] max-h-[85vh] flex flex-col border border-gray-200">
@ -373,27 +226,8 @@ export function RepoRegistryModal({ onClose }: Props) {
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div className="border-b border-gray-100 px-5 flex">
{([
{ id: "registry" as Tab, label: "已注册仓库" },
{ id: "sync" as Tab, label: "从 GitHub 同步" },
]).map(({ id, label }) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
tab === id
? "border-blue-500 text-blue-600"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{label}
</button>
))}
</div>
<div className="flex flex-col flex-1 min-h-0">
{tab === "registry" ? <RegistryTab /> : <SyncTab />}
<RegistryTab />
</div>
</div>
</div>

View File

@ -1,9 +1,8 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSpaces, setCurrentSpace, createSpace, updateSpace, deleteSpace,
getSettings, updateSettings,
type Space, type GitAccount,
type Space,
} from "../../lib/commands";
import { useUIStore } from "../../store/ui";
import { RepoRegistryModal } from "../dashboard/RepoRegistryModal";
@ -21,17 +20,14 @@ const NAV_ITEMS: { id: Page; label: string }[] = [
];
interface Props {
account: GitAccount;
onLogout: () => void;
page: Page;
onPageChange: (page: Page) => void;
}
export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
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 [showAppSettings, setShowAppSettings] = useState(false);
const [showRepoRegistry, setShowRepoRegistry] = useState(false);
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
@ -172,41 +168,6 @@ export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
</button>
</div>
{/* 应用设置 */}
<div className="border-t border-slate-700 px-3 py-2 shrink-0">
<button
onClick={() => setShowAppSettings(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 fillRule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.063.13.002.21-.048.253l-.809.645a1.975 1.975 0 00-.703 1.248 5.33 5.33 0 000 .572c.025.385.284.781.703 1.248l.809.645c.05.043.111.123.048.253a6.37 6.37 0 01-.573.989c-.02.03-.085.076-.195.046l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.593 6.593 0 01-1.142 0c-.036-.003-.108-.036-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.372 6.372 0 01-.573-.989c-.063-.13-.002-.21.048-.253l.809-.645a1.974 1.974 0 00.703-1.248 5.322 5.322 0 000-.572 1.974 1.974 0 00-.703-1.248l-.809-.645c-.05-.043-.111-.123-.048-.253.163-.348.354-.679.573-.989.02-.03.085-.076.195-.046l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z" clipRule="evenodd"/>
</svg>
<span></span>
</button>
</div>
{/* GitHub 账户 */}
<div className="border-t border-slate-700 px-3 py-3 shrink-0">
<div className="flex items-center gap-2 mb-2">
{account.avatar_url ? (
<img src={account.avatar_url} alt="" className="w-6 h-6 rounded-full shrink-0" />
) : (
<div className="w-6 h-6 rounded-full bg-slate-600 flex items-center justify-center text-xs font-bold shrink-0">
{account.username[0]?.toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<p className="text-xs font-medium text-slate-200 truncate">{account.username}</p>
<p className="text-[10px] text-slate-500">GitHub</p>
</div>
</div>
<button
onClick={onLogout}
className="w-full text-left px-2 py-1.5 rounded-md text-xs text-slate-400 hover:bg-slate-700 hover:text-red-400 transition-colors"
>
退
</button>
</div>
</aside>
{/* 新建/编辑模态框 */}
@ -222,11 +183,6 @@ export function Sidebar({ account, onLogout, page, onPageChange }: Props) {
/>
)}
{/* 应用设置弹窗 */}
{showAppSettings && (
<AppSettingsModal onClose={() => setShowAppSettings(false)} />
)}
{/* 仓库管理弹窗 */}
{showRepoRegistry && (
<RepoRegistryModal onClose={() => setShowRepoRegistry(false)} />
@ -304,97 +260,3 @@ function SpaceFormModal({ space, onClose, onSaved }: {
);
}
// ── 应用设置弹窗GitHub OAuth全局级别─────────────────────────────────────
function AppSettingsModal({ onClose }: { onClose: () => void }) {
const showToast = useUIStore((s) => s.showToast);
const qc = useQueryClient();
const { data: settings } = useQuery({ queryKey: ["settings"], queryFn: () => getSettings() });
const [clientId, setClientId] = useState(settings?.github_client_id ?? "");
const [clientSecret, setClientSecret] = useState(settings?.github_client_secret ?? "");
const [saving, setSaving] = useState(false);
// settings 异步加载后回填
useEffect(() => {
if (settings) {
setClientId(settings.github_client_id ?? "");
setClientSecret(settings.github_client_secret ?? "");
}
}, [settings]);
const handleSave = async () => {
if (!clientId.trim() || !clientSecret.trim()) {
showToast("❌ Client ID 和 Client Secret 均不能为空");
return;
}
setSaving(true);
try {
await updateSettings({ github_client_id: clientId.trim(), github_client_secret: clientSecret.trim() });
qc.invalidateQueries({ queryKey: ["settings"] });
showToast("已保存 ✓");
onClose();
} catch (e) {
showToast(`${e}`);
} finally {
setSaving(false);
}
};
const inputCls = "w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono outline-none focus:ring-2 focus:ring-blue-200";
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-96 p-6 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-gray-800"></h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
<div className="space-y-3">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">GitHub OAuth</h3>
<p className="text-xs text-gray-400">
GitHub Settings Developer settings OAuth Apps
Callback URL <code className="bg-gray-100 px-1 rounded">http://localhost</code>。
</p>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Client ID</label>
<input
className={inputCls}
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="Ov23li..."
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Client Secret</label>
<input
type="password"
className={inputCls}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder="••••••••••••••••••••••••••••••••••••••••"
/>
<p className="text-xs text-gray-400 mt-1">
SQLite Rust token
</p>
</div>
</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={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>
);
}

View File

@ -10,7 +10,6 @@ import {
import { useUIStore } from "../../store/ui";
import { PriorityBadge, StatusBadge } from "../ui/Badge";
import { Dialog } from "../ui/Dialog";
import { ImportRepoModal } from "../dashboard/ImportRepoModal";
import { TemplateEditor } from "./TemplateEditor";
import { NewProjectModal } from "./NewProjectModal";
import { TagsManager } from "./TagsManager";
@ -58,7 +57,6 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
const qc = useQueryClient();
const [delTarget, setDelTarget] = useState<string | null>(null);
const [deleteLocal, setDeleteLocal] = useState(false);
const [showImport, setShowImport] = useState(false);
const [showNewProject, setShowNewProject] = useState(false);
const { data: projects = [] } = useQuery({
@ -80,12 +78,6 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700"> {projects.length} </h2>
<div className="flex gap-2">
<button
onClick={() => setShowImport(true)}
className="px-3 py-1.5 rounded-lg border border-gray-200 text-gray-600 text-sm hover:bg-gray-50"
>
</button>
<button
onClick={() => setShowNewProject(true)}
className="px-3 py-1.5 rounded-lg border border-indigo-200 text-indigo-600 bg-indigo-50 text-sm hover:bg-indigo-100"
@ -194,9 +186,6 @@ function ProjectsTab({ spaceId }: { spaceId?: string }) {
</Dialog>
)}
{showImport && (
<ImportRepoModal onClose={() => { setShowImport(false); qc.invalidateQueries({ queryKey: ["projects"] }); }} />
)}
{showNewProject && (
<NewProjectModal spaceId={spaceId} onClose={() => setShowNewProject(false)} />
)}

View File

@ -1,285 +0,0 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getSpaces, createSpace, updateSpace, deleteSpace, setCurrentSpace,
githubGetToken, githubGetAccount, gitStatus, getProjects, type Space,
} from "../../lib/commands";
import type { SpacesJson } from "../../lib/spacesSync";
import { formatRelativeTime, recordPushToRemote } from "../../lib/spacesSync";
import { useUIStore } from "../../store/ui";
interface Props {
spacesJson: SpacesJson | null;
}
export function SpacesPage({ spacesJson }: Props) {
const [showSpaceForm, setShowSpaceForm] = useState<Space | null | undefined>(undefined);
const [syncing, setSyncing] = useState(false);
const qc = useQueryClient();
const showToast = useUIStore((s) => s.showToast);
const { data: spaces = [], refetch: refetchSpaces } = useQuery({
queryKey: ["spaces"],
queryFn: getSpaces,
});
const currentSpace = spaces.find((s) => s.is_current);
const { data: projects = [] } = useQuery({
queryKey: ["projects", currentSpace?.id],
queryFn: () => getProjects(undefined, currentSpace?.id),
enabled: !!currentSpace,
});
const handleManualSync = async () => {
if (!currentSpace) return;
setSyncing(true);
try {
const token = await githubGetToken();
const account = await githubGetAccount();
if (!token || !account) {
showToast("未登录 GitHub无法同步");
return;
}
const syncable = projects.filter((p) => p.repo_url && (p.win_path || p.wsl_path));
let successCount = 0;
for (const project of syncable) {
const localPath = project.win_path ?? project.wsl_path!;
try {
const status = await gitStatus(localPath);
await recordPushToRemote(
token,
account.username,
currentSpace.id,
currentSpace.name,
project.repo_url!,
status.branch,
status.last_commit_msg
);
successCount++;
} catch (e) {
console.warn(`同步项目 ${project.name} 失败:`, e);
}
}
qc.invalidateQueries({ queryKey: ["spacesSync"] });
showToast(
syncable.length === 0
? "没有可同步的项目"
: `已同步 ${successCount}/${syncable.length} 个项目`
);
} catch (e) {
showToast("同步失败");
} finally {
setSyncing(false);
}
};
return (
<div className="max-w-3xl mx-auto w-full px-6 py-6 pb-12 space-y-8">
{/* 本机空间管理 */}
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-gray-700"></h2>
<div className="flex gap-2">
<button
onClick={handleManualSync}
disabled={syncing || !currentSpace}
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
title="将当前空间各项目的 git 状态同步到云端(.dev-spaces"
>
{syncing ? "同步中…" : "同步到云端"}
</button>
<button
onClick={() => setShowSpaceForm(null)}
className="px-3 py-1.5 rounded-lg text-xs bg-blue-600 text-white hover:bg-blue-700"
>
+
</button>
</div>
</div>
<div className="space-y-2">
{spaces.map((s) => (
<div
key={s.id}
className={`flex items-center gap-3 p-4 rounded-xl border transition-colors ${
s.is_current
? "border-blue-200 bg-blue-50"
: "border-gray-100 bg-white hover:bg-gray-50"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-800 text-sm">{s.name}</span>
{s.is_current && (
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-100 text-blue-700"></span>
)}
</div>
</div>
<div className="flex gap-1.5 shrink-0">
{!s.is_current && (
<button
onClick={async () => {
await setCurrentSpace(s.id);
refetchSpaces();
qc.invalidateQueries({ queryKey: ["projects"] });
}}
className="px-2.5 py-1 rounded border border-blue-200 text-xs text-blue-600 hover:bg-blue-50"
>
</button>
)}
<button
onClick={() => setShowSpaceForm(s)}
className="px-2.5 py-1 rounded border border-gray-200 text-xs text-gray-600 hover:bg-white"
>
</button>
{!s.is_current && (
<button
onClick={async () => {
await deleteSpace(s.id);
refetchSpaces();
}}
className="px-2.5 py-1 rounded border border-red-200 text-xs text-red-500 hover:bg-red-50"
>
</button>
)}
</div>
</div>
))}
{spaces.length === 0 && (
<div className="text-center py-12 text-gray-400 text-sm border border-dashed border-gray-200 rounded-xl">
</div>
)}
</div>
</section>
{/* 跨端动态 */}
{spacesJson && (
<section>
<h2 className="text-sm font-semibold text-gray-700 mb-4"></h2>
<div className="space-y-3">
{Object.entries(spacesJson.spaces)
.filter(([spaceId]) => !currentSpace || spaceId !== currentSpace.id)
.map(([spaceId, spaceInfo]) => {
const projectEntries = Object.entries(spaceInfo.projects);
if (projectEntries.length === 0) return null;
return (
<div key={spaceId} className="bg-white rounded-xl border border-gray-100 shadow-sm p-4">
<div className="flex items-center gap-2 mb-3">
<span className="text-indigo-500"></span>
<span className="font-medium text-gray-800 text-sm">{spaceInfo.name}</span>
<span className="text-xs text-gray-400">{projectEntries.length} </span>
</div>
<div className="space-y-1.5">
{projectEntries
.sort(([, a], [, b]) => b.last_push_at.localeCompare(a.last_push_at))
.map(([repoUrl, info]) => {
const repoName = repoUrl.split("/").pop()?.replace(/\.git$/, "") ?? repoUrl;
return (
<div
key={repoUrl}
className="flex items-center gap-2 text-xs text-gray-600 py-1 border-b border-gray-50 last:border-0"
>
<span className="font-mono text-gray-500 truncate flex-1" title={repoUrl}>{repoName}</span>
<span className="font-mono text-indigo-500 shrink-0">{info.branch}</span>
{info.last_commit_msg && (
<span className="text-gray-400 truncate max-w-[200px]" title={info.last_commit_msg}>
{info.last_commit_msg}
</span>
)}
<span className="text-gray-300 shrink-0">{formatRelativeTime(info.last_push_at)}</span>
</div>
);
})}
</div>
</div>
);
})}
{Object.keys(spacesJson.spaces).filter(
(id) => !currentSpace || id !== currentSpace.id
).length === 0 && (
<div className="text-center py-8 text-gray-400 text-sm">
</div>
)}
</div>
</section>
)}
{showSpaceForm !== undefined && (
<SpaceFormModal
space={showSpaceForm}
onClose={() => setShowSpaceForm(undefined)}
onSaved={() => {
setShowSpaceForm(undefined);
refetchSpaces();
showToast(showSpaceForm ? "已更新" : "已创建");
}}
/>
)}
</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-96 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
/>
</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>
);
}

View File

@ -1,24 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { githubGetToken, githubGetAccount } from "../lib/commands";
import { fetchRemoteSpaces, type SpacesJson } from "../lib/spacesSync";
/**
* spaces.json
* 5
*/
export function useSpacesSync(isLoggedIn: boolean) {
return useQuery<SpacesJson | null>({
queryKey: ["spacesSync"],
queryFn: async () => {
const token = await githubGetToken();
if (!token) return null;
const account = await githubGetAccount();
if (!account) return null;
return fetchRemoteSpaces(token, account.username);
},
enabled: isLoggedIn,
staleTime: 5 * 60 * 1000,
refetchInterval: isLoggedIn ? 5 * 60 * 1000 : false,
retry: false,
});
}

View File

@ -1,32 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseRepoPath } from "./commands";
describe("parseRepoPath", () => {
it("解析标准 HTTPS GitHub URL", () => {
expect(parseRepoPath("https://github.com/facebook/react")).toBe("facebook/react");
});
it("解析带 .git 后缀的 URL", () => {
expect(parseRepoPath("https://github.com/facebook/react.git")).toBe("facebook/react");
});
it("解析 SSH 格式 URL", () => {
expect(parseRepoPath("git@github.com:facebook/react.git")).toBe("facebook/react");
});
it("非 GitHub URL 返回 null", () => {
expect(parseRepoPath("https://gitlab.com/foo/bar")).toBeNull();
});
it("空字符串返回 null", () => {
expect(parseRepoPath("")).toBeNull();
});
it("纯域名无路径返回 null", () => {
expect(parseRepoPath("https://github.com")).toBeNull();
});
it("非 URL 的随机字符串返回 null", () => {
expect(parseRepoPath("not-a-url")).toBeNull();
});
});

View File

@ -1,5 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
export interface Project {
/** workspace.id也是旧 projects.id */
@ -330,75 +329,6 @@ export const openEditor = (projectId: string, env: "wsl" | "win", editorPath: st
export const openTerminal = (projectId: string, env: "wsl" | "win") =>
invoke<string>("open_terminal", { projectId, env });
// ── GitHub 账户DB 操作Rust 侧)────────────────────────────────────────────
export interface GitAccount {
id: string;
username: string;
avatar_url?: string;
provider: string;
created_at?: string;
}
export const githubGetAccount = () =>
invoke<GitAccount | null>("github_get_account");
export const githubGetToken = () =>
invoke<string | null>("github_get_token");
export const githubGetClientId = () =>
invoke<string>("github_get_client_id");
export const githubSaveAccount = (
username: string,
avatarUrl: string,
accessToken: string
) => invoke<GitAccount>("github_save_account", { username, avatarUrl, accessToken });
export const githubLogout = () =>
invoke<void>("github_logout");
// ── GitHub API前端 fetch───────────────────────────────────────────────────
export interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
clone_url: string;
ssh_url: string;
description?: string;
default_branch: string;
}
/** 打开应用内 GitHub 登录窗口(用户在窗口内完成授权,无需外部浏览器) */
export const githubOpenLoginWindow = () => invoke<void>("github_open_login_window");
/** 轮询授权结果null = 等待中string = token */
export const githubPollOAuth = () => invoke<string | null>("github_poll_oauth");
/** 启动本地回调服务器,返回授权 URL备用外部浏览器方式 */
export const githubStartOAuth = () => invoke<string>("github_start_oauth");
/** 用 token 获取 GitHub 用户信息 */
export async function githubFetchUser(token: string) {
const resp = await tauriFetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${token}`, "User-Agent": "dev-manager-tauri" },
});
if (!resp.ok) throw new Error("获取用户信息失败");
return resp.json() as Promise<{ login: string; avatar_url: string }>;
}
/** 获取用户的 GitHub 仓库列表 */
export async function githubListRepos(token: string): Promise<GithubRepo[]> {
const resp = await tauriFetch(
"https://api.github.com/user/repos?per_page=100&sort=updated&affiliation=owner",
{ headers: { Authorization: `Bearer ${token}`, "User-Agent": "dev-manager-tauri" } }
);
if (!resp.ok) throw new Error("获取仓库列表失败");
return resp.json();
}
// ── Spaces ────────────────────────────────────────────────────────────────────
export interface Space {
@ -623,7 +553,7 @@ export const mountRepo = (input: MountRepoInput) =>
export const deleteGitRepo = (id: string) =>
invoke<void>("delete_git_repo", { id });
// ── Publish to GitHub ─────────────────────────────────────────────────────────
// ── 本地 Git 工具(扫描 / init / remote / WSL 路径)───────────────────────────
export interface ChangedFile {
status: string; // "M" | "A" | "D" | "?" | "R"
@ -650,18 +580,12 @@ export interface GitScanResult {
export const scanLocalGit = (path: string) =>
invoke<GitScanResult>("scan_local_git", { path });
export const githubCreateRepo = (name: string, description: string, privateRepo: boolean) =>
invoke<string>("github_create_repo", { name, description, private: privateRepo });
export const gitInitAndCommit = (path: string, message: string) =>
invoke<void>("git_init_and_commit", { path, message });
export const gitRemoteSet = (path: string, url: string) =>
invoke<void>("git_remote_set", { path, url });
export const gitPushToGithub = (path: string, branch: string) =>
invoke<void>("git_push_to_github", { path, branch });
/** 将 Linux WSL 路径转换为 Windows UNC 路径(\\wsl.localhost\distro\... */
export const resolveWslPath = (path: string) =>
invoke<string>("resolve_wsl_path", { path });
@ -808,63 +732,6 @@ export const saveCicdConfig = (input: CicdConfig) =>
export const generateWorkflow = (projectId: string, projectPath: string) =>
invoke<WorkflowResult>("generate_workflow", { projectId, projectPath });
// ── GitHub Actions API前端直接调用使用已存储的 token────────────────────
export interface ActionsRun {
id: number;
name: string;
status: string; // "queued" | "in_progress" | "completed"
conclusion: string | null; // "success" | "failure" | "cancelled" | null
head_branch: string;
created_at: string;
updated_at: string;
html_url: string;
run_number: number;
}
/** 解析 repo_url 为 {owner}/{repo} 格式,失败返回 null */
export function parseRepoPath(repoUrl: string): string | null {
const m = repoUrl.match(/github\.com[/:](.+?)(?:\.git)?$/);
return m ? m[1] : null;
}
/** 获取最近 N 次 Actions run */
export async function fetchActionsRuns(
token: string,
repoPath: string,
perPage = 5
): Promise<ActionsRun[]> {
const res = await fetch(
`https://api.github.com/repos/${repoPath}/actions/runs?per_page=${perPage}`,
{ headers: { Authorization: `Bearer ${token}`, "User-Agent": "dev-manager-tauri" } }
);
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
const data = await res.json();
return data.workflow_runs ?? [];
}
/** 手动触发 workflow_dispatch */
export async function triggerWorkflow(
token: string,
repoPath: string,
workflowFile: string,
branch: string
): Promise<void> {
const res = await fetch(
`https://api.github.com/repos/${repoPath}/actions/workflows/${workflowFile}/dispatches`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "dev-manager-tauri",
"Content-Type": "application/json",
},
body: JSON.stringify({ ref: branch }),
}
);
if (!res.ok && res.status !== 204) throw new Error(`触发失败: ${res.status}`);
}
/** 根据节点数据构造 share link前端完成避免 Rust 侧 URL 编码依赖) */
export function buildShareLink(node: V2raynNode): string {
const remarks = encodeURIComponent(node.remarks);
@ -1678,6 +1545,22 @@ export const scanDocFreshness = (projectPath: string, projectId: string) =>
export const getDocFreshnessCache = (projectId: string) =>
invoke<CachedFreshness | null>('get_doc_freshness_cache', { projectId });
export interface AgentHealthIssue {
level: 'error' | 'warn';
title: string;
fix: string;
}
export interface AgentHealthReport {
overall: 'green' | 'yellow' | 'red';
issues: AgentHealthIssue[];
fix_prompt: string;
checked_at: string;
}
export const getAgentHealth = (projectPath: string, projectId: string) =>
invoke<AgentHealthReport>('get_agent_health', { projectPath, projectId });
export interface CommitStats {
total: number;
conventional: number;

View File

@ -1,326 +0,0 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
/**
*
*
* 使 GitHub Contents API .dev-spaces/spaces.json
* push
*
* spaces.json
* {
* "version": 1,
* "spaces": {
* "<space_id>": {
* "name": "会旅游的笔记本",
* "projects": {
* "<repo_url>": {
* "branch": "feature/login",
* "last_push_at": "2026-03-31T10:00:00Z",
* "last_commit_msg": "fix: login bug"
* }
* }
* }
* }
* }
*/
const DEV_SPACES_REPO = ".dev-spaces";
const SPACES_FILE = "spaces.json";
export interface SpaceProjectInfo {
branch: string;
last_push_at: string;
last_commit_msg?: string;
}
/** 项目档案条目:记录某个空间内某个项目的元信息 */
export interface SpaceCatalogItem {
id: string; // 在该空间内的 workspace_id
name: string;
description?: string;
tech_stack?: string;
repo_url: string;
default_branch?: string;
platform?: string;
}
export interface SpaceInfo {
name: string;
projects: Record<string, SpaceProjectInfo>; // key = repo_url记录 push 事件
catalog?: SpaceCatalogItem[]; // 项目档案列表,用于跨空间发现
}
export interface SpacesJson {
version: number;
spaces: Record<string, SpaceInfo>; // key = space_id
}
interface GithubFileResponse {
content: string;
sha: string;
encoding: string;
}
// ── GitHub Contents API ─────────────────────────────────────
async function apiHeaders(token: string) {
return {
Authorization: `Bearer ${token}`,
"User-Agent": "dev-manager-tauri",
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
};
}
/** 确保 .dev-spaces 仓库存在(不存在则创建) */
export async function ensureDevSpacesRepo(token: string, username: string): Promise<void> {
const headers = await apiHeaders(token);
// 检查是否已存在
const check = await tauriFetch(
`https://api.github.com/repos/${username}/${DEV_SPACES_REPO}`,
{ headers }
);
if (check.ok) return;
// 创建私有仓库
const create = await tauriFetch("https://api.github.com/user/repos", {
method: "POST",
headers,
body: JSON.stringify({
name: DEV_SPACES_REPO,
private: true,
description: "Dev Space Manager - 跨设备开发空间配置",
auto_init: true,
}),
});
if (!create.ok) {
const err = await create.json().catch(() => ({}));
// 422 通常是仓库已存在,忽略
if (create.status !== 422) {
throw new Error(`创建 .dev-spaces 仓库失败: ${JSON.stringify(err)}`);
}
}
}
/** 读取 spaces.json返回内容和 sha用于后续更新 */
export async function readSpacesJson(
token: string,
username: string
): Promise<{ data: SpacesJson; sha: string } | null> {
const headers = await apiHeaders(token);
const resp = await tauriFetch(
`https://api.github.com/repos/${username}/${DEV_SPACES_REPO}/contents/${SPACES_FILE}`,
{ headers }
);
if (resp.status === 404) return null;
if (!resp.ok) throw new Error(`读取 spaces.json 失败: ${resp.status}`);
const file: GithubFileResponse = await resp.json();
const content = atob(file.content.replace(/\n/g, ""));
const data: SpacesJson = JSON.parse(content);
return { data, sha: file.sha };
}
/** 将 SpacesJson 编码为 base64 content */
function encodeSpacesJson(data: SpacesJson): string {
const json = JSON.stringify(data, null, 2);
const bytes = new TextEncoder().encode(json);
// 分块处理,防止大文件时 String.fromCharCode(...bytes) 超出调用栈
let binary = "";
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
/** 写入 spaces.jsoncreate 或 update409 冲突时自动重新获取 sha 重试一次 */
export async function writeSpacesJson(
token: string,
username: string,
data: SpacesJson,
sha?: string
): Promise<void> {
const headers = await apiHeaders(token);
const content = encodeSpacesJson(data);
const doWrite = async (currentSha?: string) => {
const body: Record<string, string> = {
message: "chore: update dev spaces config",
content,
};
if (currentSha) body.sha = currentSha;
return tauriFetch(
`https://api.github.com/repos/${username}/${DEV_SPACES_REPO}/contents/${SPACES_FILE}`,
{ method: "PUT", headers, body: JSON.stringify(body) }
);
};
let resp = await doWrite(sha);
// 409 说明远端 sha 已变,重新读取最新 sha 再重试一次
if (resp.status === 409) {
const latest = await readSpacesJson(token, username);
resp = await doWrite(latest?.sha);
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(`写入 spaces.json 失败: ${JSON.stringify(err)}`);
}
}
// ── 高层操作 ────────────────────────────────────────────────
/**
* spaces.json
*
*/
export async function fetchRemoteSpaces(
token: string,
username: string
): Promise<SpacesJson | null> {
try {
await ensureDevSpacesRepo(token, username);
const result = await readSpacesJson(token, username);
return result?.data ?? null;
} catch (e) {
console.warn("跨空间同步失败(不影响本地功能):", e);
return null;
}
}
/**
* Push push
*/
export async function recordPushToRemote(
token: string,
username: string,
spaceId: string,
spaceName: string,
repoUrl: string,
branch: string,
lastCommitMsg?: string
): Promise<void> {
await ensureDevSpacesRepo(token, username);
const existing = await readSpacesJson(token, username);
const now = new Date().toISOString();
const spacesData: SpacesJson = existing?.data ?? { version: 1, spaces: {} };
if (!spacesData.spaces[spaceId]) {
spacesData.spaces[spaceId] = { name: spaceName, projects: {} };
}
spacesData.spaces[spaceId].name = spaceName;
spacesData.spaces[spaceId].projects[repoUrl] = {
branch,
last_push_at: now,
last_commit_msg: lastCommitMsg,
};
await writeSpacesJson(token, username, spacesData, existing?.sha);
}
/**
* .dev-spaces catalog
* /
*/
export async function syncProjectCatalog(
token: string,
username: string,
spaceId: string,
spaceName: string,
project: {
id: string;
name: string;
description?: string;
tech_stack?: string;
repo_url: string;
default_branch?: string;
platform?: string;
}
): Promise<void> {
await ensureDevSpacesRepo(token, username);
const existing = await readSpacesJson(token, username);
const spacesData: SpacesJson = existing?.data ?? { version: 1, spaces: {} };
if (!spacesData.spaces[spaceId]) {
spacesData.spaces[spaceId] = { name: spaceName, projects: {} };
}
spacesData.spaces[spaceId].name = spaceName;
const catalog = spacesData.spaces[spaceId].catalog ?? [];
const idx = catalog.findIndex((c) => c.repo_url === project.repo_url);
const item: SpaceCatalogItem = {
id: project.id,
name: project.name,
description: project.description,
tech_stack: project.tech_stack,
repo_url: project.repo_url,
default_branch: project.default_branch,
platform: project.platform,
};
if (idx >= 0) catalog[idx] = item;
else catalog.push(item);
spacesData.spaces[spaceId].catalog = catalog;
await writeSpacesJson(token, username, spacesData, existing?.sha);
}
/**
* SpacesJson
*/
export function getOtherSpacesInfo(
spacesJson: SpacesJson,
currentSpaceId: string,
repoUrl: string
): Array<{ spaceId: string; spaceName: string } & SpaceProjectInfo> {
const result: Array<{ spaceId: string; spaceName: string } & SpaceProjectInfo> = [];
for (const [spaceId, spaceInfo] of Object.entries(spacesJson.spaces)) {
if (spaceId === currentSpaceId) continue;
const proj = spaceInfo.projects[repoUrl];
if (proj) {
result.push({ spaceId, spaceName: spaceInfo.name, ...proj });
}
}
// 按时间倒序
result.sort((a, b) => b.last_push_at.localeCompare(a.last_push_at));
return result;
}
/**
* repo_url
*/
export function getDiscoverableProjects(
spacesJson: SpacesJson,
currentSpaceId: string,
currentRepoUrls: Set<string>
): Array<SpaceCatalogItem & { spaceId: string; spaceName: string }> {
const result: Array<SpaceCatalogItem & { spaceId: string; spaceName: string }> = [];
// 用 repo_url 去重(同一仓库可能出现在多个其他空间)
const seen = new Set<string>();
for (const [spaceId, spaceInfo] of Object.entries(spacesJson.spaces)) {
if (spaceId === currentSpaceId) continue;
for (const item of (spaceInfo.catalog ?? [])) {
if (!currentRepoUrls.has(item.repo_url) && !seen.has(item.repo_url)) {
seen.add(item.repo_url);
result.push({ ...item, spaceId, spaceName: spaceInfo.name });
}
}
}
return result;
}
/** 格式化相对时间(如 "3小时前" */
export function formatRelativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}天前`;
return new Date(isoString).toLocaleDateString("zh-CN");
}

View File

@ -7,5 +7,7 @@ export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
// GitHub 退役清理删掉了唯一的测试文件parseRepoPath暂无前端纯逻辑测试
passWithNoTests: true,
},
});