diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..db03286 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,70 @@ +# Changelog + +## v0.4.0 (2026-03-12) + +### 🔧 架构重构 +- **配置架构重构**:从 `Platform → User → Project` 层级结构重构为扁平的 `Projects + Groups + Credentials` 模型 + - 项目支持多远程仓库指向不同平台,不再受限于单一平台层级 + - 新增分组(Group)管理,支持自定义分组和图标 + - 新增凭据(Credential)管理,支持 GitHub / Gitee / Gitea 等多平台凭据 + - 侧边栏完全重写,适配新的分组→项目树结构 + +### ✨ 新功能 +- **一键多端推送**:Push All 按钮,一键将当前分支推送到所有远程仓库 + - 顶部操作栏新增 "Push All" 按钮(仅在有多个远程仓库时启用) + - 标签管理新增 "推送到所有远程" 按钮 + - 推送结果逐一展示每个远程仓库的成功/失败状态 +- **项目代理开关**:每个项目可独立控制是否使用系统代理 + - 三种模式:跟随全局 / 强制使用代理 / 强制不使用代理 + - 顶部操作栏新增代理设置下拉菜单 + - Pull / Push / Fetch / Push All 等所有网络操作均尊重项目级代理设置 + +### 🔨 后端改进 +- `GitClient` 新增 `RunWithProxy()` 方法,支持按调用级别覆盖全局代理设置 +- 所有网络操作(Pull/Push/Fetch/PushToAllRemotes/PushTagToAllRemotes)均支持项目级代理 +- 配置管理方法重构:Group CRUD / Credential CRUD / Project 分组管理 +- 新增 `PushToAllRemotes()` / `PushTagToAllRemotes()` 服务方法 +- 新增 `GetProjectProxy()` / `SetProjectProxy()` 服务方法 + +--- + +## v0.3.1 (2026-02-27) + +### ✨ 新功能 +- **多 Remote 远程仓库支持** + - 新增 RemoteList / AddRemote / RemoveRemote 远程仓库管理 + - 所有远程操作支持指定 remote(Push/Pull/Fetch/PushTag/DeleteTag 等) + - 顶部信息栏新增 remote 选择器下拉菜单 + - 支持切换当前操作的远程仓库、添加/删除远程仓库 + +--- + +## v0.3.0 (2026-02-25) + +### ✨ 新功能 +- **Git 安装检测**:启动时检测 Git 是否已安装,支持自动下载安装 Git for Windows +- **冲突处理**:合并冲突检测、冲突文件编辑器、标记已解决、中止/完成合并 +- **提交搜索**:支持按提交信息关键字和作者搜索 +- **批量操作**:侧边栏批量 Pull/Push 所有项目 + +--- + +## v0.2.0 (2026-02-20) + +### ✨ 新功能 +- 分支管理(创建/删除/合并) +- 远程分支(查看/检出/删除) +- Stash 贮藏管理 +- Git 全局配置设置(user.name / user.email) +- Tag 标签管理 +- CI/CD 发布流水线 +- NSIS 安装包 + +--- + +## v0.1.0 + +- 初始版本 +- 基本 Git 操作(Clone / Pull / Push / Commit) +- 文件变更查看和 Diff 展示 +- 系统代理自动检测 diff --git a/README.md b/README.md index b3d9ec4..a32cff4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A cross-platform desktop Git repository management tool built with **Go + Wails v3 + Nuxt 4 + Vue 3**. -Manage multiple Git repositories across different platforms (GitHub, Gitee, Gitea, etc.) from a single unified interface — like a lightweight, standalone version of VS Code's Git panel. +Manage multiple Git repositories across different platforms (GitHub, Gitee, Gitea, etc.) from a single unified interface — like a lightweight, standalone version of VS Code’s Git panel. ![Go](https://img.shields.io/badge/Go-1.25-00ADD8?logo=go&logoColor=white) ![Wails](https://img.shields.io/badge/Wails-v3-red?logo=go&logoColor=white) @@ -14,10 +14,12 @@ Manage multiple Git repositories across different platforms (GitHub, Gitee, Gite ## ✨ Features -### Multi-Platform Management +### Project & Group Management - Manage repositories from **GitHub**, **Gitee**, **Gitea**, and custom Git platforms -- Organize projects by **Platform → User → Project** hierarchy -- Add / remove platforms, users, and projects through the GUI +- Organize projects into custom **Groups** (e.g. `github/user`, `gitea`, `work`) +- Manage **Credentials** per platform (GitHub / Gitee / Gitea with custom base URL) +- **Clone** remote repositories directly from the GUI with group assignment +- Add / remove / move projects between groups ### VS Code-Style Git Operations - **View changed files** with a collapsible directory tree structure @@ -27,51 +29,96 @@ Manage multiple Git repositories across different platforms (GitHub, Gitee, Gite - **Diff viewer** with syntax-highlighted additions and deletions - **Binary file detection** — skips preview for images, executables, etc. +### Multi-Remote Support +- Manage multiple remotes per project (origin, upstream, etc.) +- **Remote selector** dropdown to switch active remote +- Add / remove remotes through the GUI +- **Push to All Remotes** — one-click push current branch to every remote +- **Push Tag to All Remotes** — push tags to all remotes at once + +### Per-Project Proxy Control +- Each project can independently control proxy usage +- Three modes: **Follow Global** / **Force Proxy On** / **Force Proxy Off** +- All network operations (Pull / Push / Fetch / Push All) respect per-project proxy settings +- System proxy auto-detection from Windows registry / macOS scutil / Linux gsettings + ### Commit History - Browse commit history with author, time, and message -- Click a commit to view its **changed file list** (like VS Code) +- Click a commit to view its **changed file list** - Select a file to view its **per-file diff** - **Version rollback** — hard / mixed / soft reset with confirmation dialog +- **Revert commit** — create a reverse commit +- **Search commits** — filter by message keyword and/or author ### Branch Management -- View all local branches with current branch indicator +- View all local and remote branches +- **Create / Delete / Merge** branches - **Switch branches** via dropdown selector -- **Pull / Push / Fetch** with current branch awareness +- **Checkout remote branches** to local +- **Delete remote branches** + +### Tag Management +- View all tags with hash, message, and timestamp +- **Create tags** (lightweight or annotated) +- **Push tags** to selected remote or all remotes +- **Delete tags** (local and remote) + +### Stash Management +- **Save** current changes with optional message +- **Apply / Pop / Drop** stash entries +- View stash list with descriptions + +### Merge Conflict Resolution +- Detect merge state and display conflict indicator +- View and edit conflict files in a built-in editor +- **Mark as resolved / Abort merge / Complete merge** + +### Batch Operations +- View all projects overview (branch, changes, push status) +- **Batch Pull / Push** all projects at once +- Results displayed per project + +### Git Installation Detection +- Auto-detect Git installation on startup +- If not installed, show a full-screen guide +- **Auto-download and install** Git for Windows with progress tracking ### Desktop Experience - **System tray** — close window hides to tray, click tray icon to restore - **Resizable panels** — drag splitters between sidebar, file list, and viewer - **Dark theme** — Catppuccin Mocha color scheme with Ant Design Vue - **Native folder picker** — select project paths via OS file dialog +- Git global config editor (user.name / user.email) ## 🏗️ Tech Stack | Layer | Technology | |-------|-----------| -| Backend | Go 1.25, Wails v3 (alpha.74) | +| Backend | Go 1.25, Wails v3 | | Frontend | Nuxt 4, Vue 3.5, Vite 7 | | UI Library | Ant Design Vue 4, @ant-design/icons-vue | -| Git Operations | Git CLI with proxy support, 30s timeout | +| Git Operations | Git CLI wrapper with per-project proxy support | | Config | YAML-based persistent configuration | -| Build | Taskfile v3 | +| Build | Taskfile v3, NSIS installer (Windows) | ## 📁 Project Structure ``` GitPilot/ -├── main.go # Wails app entry, system tray, window management -├── config.yaml # Platform/user/project configuration +├── cmd/main.go # Wails app entry, system tray, window management +├── config.yaml # Projects / Groups / Credentials configuration +├── CHANGELOG.md # Version history ├── Taskfile.yml # Build tasks ├── go.mod -├── cmd/ # CLI utilities ├── config/ -│ └── config.go # YAML config loader/saver +│ └── config.go # YAML config types & loader/saver ├── internal/ -│ ├── proxy.go # System proxy detection +│ ├── proxy.go # System proxy detection (Windows/macOS/Linux) │ ├── git/ -│ │ └── client.go # Git CLI wrapper (34 operations) +│ │ └── client.go # Git CLI wrapper with RunWithProxy support │ └── app/ -│ └── service.go # AppService — 34 methods exposed to frontend +│ ├── service.go # AppService — 72 methods exposed to frontend +│ └── gitsetup.go # Git installation detection & auto-install ├── frontend/ │ ├── nuxt.config.ts │ ├── package.json @@ -81,10 +128,12 @@ GitPilot/ │ ├── layouts/ │ │ └── default.vue # Ant Design dark theme provider │ └── components/ -│ ├── Sidebar.vue # Platform/user/project tree sidebar -│ ├── ContentArea.vue # Git status, diff, history, branches -│ └── FileTreeNode.vue # Recursive file tree with actions -└── build/ # Wails build configuration +│ ├── Sidebar.vue # Group/project tree, batch ops, credentials +│ ├── ContentArea.vue # Git status, diff, history, branches, tags +│ ├── FileTreeNode.vue # Recursive file tree with stage/discard +│ ├── CommitFileTreeNode.vue # Commit file tree viewer +│ └── GitCheck.vue # Git installation check screen +└── build/ # Wails build config, NSIS installer scripts ``` ## 🚀 Getting Started @@ -108,14 +157,9 @@ cd GitPilot cd frontend && npm install && cd .. # Generate Wails bindings -wails3 generate bindings - -# Build frontend -cd frontend && npx nuxi generate && cd .. +wails3 generate bindings -d frontend/bindings # Run in development mode -task dev -# or wails3 dev -config ./build/config.yml -port 9245 ``` @@ -129,19 +173,28 @@ The binary will be output to the `bin/` directory. ## 📋 API Overview -GitPilot exposes **34 backend methods** to the frontend via Wails bindings: +GitPilot exposes **72+ backend methods** to the frontend via Wails bindings: | Category | Methods | |----------|---------| -| **Projects** | GetProjectTree, AddProject, RemoveProject, SelectDirectory | -| **Platforms** | AddPlatform, UpdatePlatform, RemovePlatform, GetPlatformInfo | -| **Users** | AddUser, UpdateUser, RemoveUser, GetUserInfo | +| **Project Management** | GetProjectTree, AddProject, RemoveProject, CloneProject, MoveProjectToGroup, SelectDirectory | +| **Group Management** | GetGroups, AddGroup, UpdateGroup, RemoveGroup | +| **Credential Management** | GetCredentials, AddCredential, UpdateCredential, RemoveCredential | | **Git Status** | GetProjectStatus, GetProjectChangedFiles, GetFileContent, GetFileDiff, GetFileDiffStaged | | **Staging** | StageFiles, UnstageFiles, StageAll, UnstageAll | | **Commit** | CommitChanges, DiscardFiles | -| **Remote** | PullProject, PushProject, FetchProject | -| **History** | GetCommitLog, GetCommitDiff, GetCommitFiles, GetCommitFileDiff | -| **Branches** | GetBranches, SwitchBranch, ResetProject | +| **Remote Operations** | PullProject, PushProject, FetchProject, PushToAllRemotes, PushTagToAllRemotes | +| **Remote Management** | GetRemotes, AddRemote, RemoveRemote | +| **Proxy Control** | GetProjectProxy, SetProjectProxy | +| **Commit History** | GetCommitLog, GetCommitDiff, GetCommitFiles, GetCommitFileDiff, RevertCommit, SearchCommitLog | +| **Branch Management** | GetBranches, SwitchBranch, CreateBranch, DeleteBranch, MergeBranch, ResetProject | +| **Remote Branches** | GetRemoteBranches, CheckoutRemoteBranch, DeleteRemoteBranch | +| **Tags** | GetTags, CreateTag, DeleteTag, PushTag | +| **Stash** | StashSave, GetStashList, StashApply, StashPop, StashDrop | +| **Conflict Resolution** | IsMerging, GetConflictFiles, GetConflictFileContent, SaveConflictFile, ResolveConflictFile, AbortMerge | +| **Batch Operations** | GetAllProjectOverview, BatchPull, BatchPush | +| **Settings** | GetGitGlobalConfig, SetGitGlobalConfig, GetAppSettings, UpdateAppSettings | +| **Git Setup** | CheckGitInstalled, SelectGitInstallDir, InstallGit | ## 📄 License diff --git a/README_CN.md b/README_CN.md index 0b99bcd..0bf5405 100644 --- a/README_CN.md +++ b/README_CN.md @@ -14,10 +14,12 @@ ## ✨ 功能特性 -### 多平台管理 +### 项目与分组管理 - 管理来自 **GitHub**、**Gitee**、**Gitea** 及自建 Git 平台的仓库 -- 按 **平台 → 用户 → 项目** 层级组织 -- 通过图形界面添加/删除平台、用户和项目 +- 按自定义**分组**组织项目(如 `github/user`、`gitea`、`work`) +- 按平台管理**凭据**(GitHub / Gitee / Gitea,支持自定义 Base URL) +- 直接在界面中**克隆**远程仓库并分配分组 +- 添加 / 删除 / 移动项目到不同分组 ### VS Code 风格的 Git 操作 - 以可折叠的**目录树结构**查看变更文件 @@ -27,51 +29,96 @@ - **Diff 查看器** — 语法高亮显示增删内容 - **二进制文件检测** — 自动跳过图片、可执行文件等的预览 +### 多远程仓库支持 +- 每个项目支持管理多个远程仓库(origin、upstream 等) +- **远程仓库选择器** — 下拉切换当前操作的远程仓库 +- 通过界面添加 / 删除远程仓库 +- **一键推送到所有远程** — 将当前分支推送到全部远程仓库 +- **标签多端推送** — 一键将标签推送到所有远程仓库 + +### 项目级代理控制 +- 每个项目可独立控制是否使用代理 +- 三种模式:**跟随全局** / **强制使用代理** / **强制不使用代理** +- 所有网络操作(Pull / Push / Fetch / Push All)均尊重项目级代理设置 +- 系统代理自动检测(Windows 注册表 / macOS scutil / Linux gsettings) + ### 提交历史 - 浏览提交历史,显示作者、时间和提交信息 -- 点击提交查看该版本的**变更文件列表**(与 VS Code 一致) +- 点击提交查看该版本的**变更文件列表** - 选中文件查看**单文件 diff** - **版本回滚** — 支持硬回滚/混合回滚/软回滚,操作前弹出确认对话框 +- **撤销提交** — 生成反向提交 +- **提交搜索** — 按提交信息关键字和/或作者过滤 ### 分支管理 -- 查看所有本地分支,标记当前分支 +- 查看所有本地分支和远程分支 +- **创建 / 删除 / 合并**分支 - 通过下拉菜单**切换分支** -- **拉取/推送/获取远程信息**,自动识别当前分支 +- **检出远程分支**到本地 +- **删除远程分支** + +### 标签管理 +- 查看所有标签(显示哈希、描述、时间戳) +- **创建标签**(轻量标签或附注标签) +- **推送标签**到指定远程或所有远程 +- **删除标签**(本地和远程同步删除) + +### 贮藏管理 +- **保存**当前变更(支持自定义描述) +- **应用 / 弹出 / 删除**贮藏条目 +- 查看贮藏列表及描述 + +### 合并冲突处理 +- 自动检测合并状态并显示冲突提示 +- 在内置编辑器中查看和编辑冲突文件 +- **标记已解决 / 中止合并 / 完成合并** + +### 批量操作 +- 查看所有项目概览(分支、变更数、推送状态) +- **批量 Pull / Push** 所有项目 +- 按项目显示操作结果 + +### Git 安装检测 +- 启动时自动检测 Git 是否已安装 +- 未安装时显示全屏引导界面 +- 支持**自动下载安装** Git for Windows,实时显示进度 ### 桌面体验 -- **系统托盘** — 关闭窗口自动隐藏到托盘,点击托盘图标恢复窗口 +- **系统托盘** — 关闭窗口自动隐藏到托盘,点击图标恢复 - **可拖拽调整面板** — 侧边栏、文件列表、查看器之间的分隔条可拖拽 - **暗色主题** — Catppuccin Mocha 配色方案 + Ant Design Vue - **原生文件夹选择器** — 通过系统对话框选择项目路径 +- Git 全局配置编辑(user.name / user.email) ## 🏗️ 技术栈 | 层级 | 技术 | |------|------| -| 后端 | Go 1.25, Wails v3 (alpha.74) | +| 后端 | Go 1.25, Wails v3 | | 前端 | Nuxt 4, Vue 3.5, Vite 7 | | UI 组件库 | Ant Design Vue 4, @ant-design/icons-vue | -| Git 操作 | Git CLI 封装,支持代理,30 秒超时 | +| Git 操作 | Git CLI 封装,支持项目级代理控制 | | 配置 | 基于 YAML 的持久化配置 | -| 构建 | Taskfile v3 | +| 构建 | Taskfile v3, NSIS 安装包(Windows) | ## 📁 项目结构 ``` GitPilot/ -├── main.go # Wails 应用入口,系统托盘,窗口管理 -├── config.yaml # 平台/用户/项目配置文件 +├── cmd/main.go # Wails 应用入口,系统托盘,窗口管理 +├── config.yaml # 项目 / 分组 / 凭据配置文件 +├── CHANGELOG.md # 版本更新日志 ├── Taskfile.yml # 构建任务 ├── go.mod -├── cmd/ # 命令行工具 ├── config/ -│ └── config.go # YAML 配置加载/保存 +│ └── config.go # YAML 配置类型 & 加载/保存 ├── internal/ -│ ├── proxy.go # 系统代理检测 +│ ├── proxy.go # 系统代理检测(Windows/macOS/Linux) │ ├── git/ -│ │ └── client.go # Git CLI 封装(34 个操作) +│ │ └── client.go # Git CLI 封装,支持 RunWithProxy │ └── app/ -│ └── service.go # AppService — 34 个方法暴露给前端 +│ ├── service.go # AppService — 72 个方法暴露给前端 +│ └── gitsetup.go # Git 安装检测 & 自动安装 ├── frontend/ │ ├── nuxt.config.ts │ ├── package.json @@ -81,10 +128,12 @@ GitPilot/ │ ├── layouts/ │ │ └── default.vue # Ant Design 暗色主题配置 │ └── components/ -│ ├── Sidebar.vue # 平台/用户/项目树形侧边栏 -│ ├── ContentArea.vue # Git 状态、Diff、历史、分支 -│ └── FileTreeNode.vue # 递归文件树组件(含操作按钮) -└── build/ # Wails 构建配置 +│ ├── Sidebar.vue # 分组/项目树、批量操作、凭据管理 +│ ├── ContentArea.vue # Git 状态、Diff、历史、分支、标签 +│ ├── FileTreeNode.vue # 递归文件树(含暂存/丢弃操作) +│ ├── CommitFileTreeNode.vue # 提交文件树查看器 +│ └── GitCheck.vue # Git 安装检测界面 +└── build/ # Wails 构建配置,NSIS 安装脚本 ``` ## 🚀 快速开始 @@ -108,14 +157,9 @@ cd GitPilot cd frontend && npm install && cd .. # 生成 Wails 绑定 -wails3 generate bindings - -# 构建前端 -cd frontend && npx nuxi generate && cd .. +wails3 generate bindings -d frontend/bindings # 开发模式运行 -task dev -# 或者 wails3 dev -config ./build/config.yml -port 9245 ``` @@ -129,19 +173,28 @@ task build ## 📋 API 概览 -GitPilot 通过 Wails 绑定向前端暴露了 **34 个后端方法**: +GitPilot 通过 Wails 绑定向前端暴露了 **72+ 个后端方法**: | 类别 | 方法 | |------|------| -| **项目管理** | GetProjectTree, AddProject, RemoveProject, SelectDirectory | -| **平台管理** | AddPlatform, UpdatePlatform, RemovePlatform, GetPlatformInfo | -| **用户管理** | AddUser, UpdateUser, RemoveUser, GetUserInfo | +| **项目管理** | GetProjectTree, AddProject, RemoveProject, CloneProject, MoveProjectToGroup, SelectDirectory | +| **分组管理** | GetGroups, AddGroup, UpdateGroup, RemoveGroup | +| **凭据管理** | GetCredentials, AddCredential, UpdateCredential, RemoveCredential | | **Git 状态** | GetProjectStatus, GetProjectChangedFiles, GetFileContent, GetFileDiff, GetFileDiffStaged | | **暂存操作** | StageFiles, UnstageFiles, StageAll, UnstageAll | | **提交操作** | CommitChanges, DiscardFiles | -| **远程操作** | PullProject, PushProject, FetchProject | -| **提交历史** | GetCommitLog, GetCommitDiff, GetCommitFiles, GetCommitFileDiff | -| **分支管理** | GetBranches, SwitchBranch, ResetProject | +| **远程操作** | PullProject, PushProject, FetchProject, PushToAllRemotes, PushTagToAllRemotes | +| **远程仓库管理** | GetRemotes, AddRemote, RemoveRemote | +| **代理控制** | GetProjectProxy, SetProjectProxy | +| **提交历史** | GetCommitLog, GetCommitDiff, GetCommitFiles, GetCommitFileDiff, RevertCommit, SearchCommitLog | +| **分支管理** | GetBranches, SwitchBranch, CreateBranch, DeleteBranch, MergeBranch, ResetProject | +| **远程分支** | GetRemoteBranches, CheckoutRemoteBranch, DeleteRemoteBranch | +| **标签管理** | GetTags, CreateTag, DeleteTag, PushTag | +| **贮藏管理** | StashSave, GetStashList, StashApply, StashPop, StashDrop | +| **冲突处理** | IsMerging, GetConflictFiles, GetConflictFileContent, SaveConflictFile, ResolveConflictFile, AbortMerge | +| **批量操作** | GetAllProjectOverview, BatchPull, BatchPush | +| **设置** | GetGitGlobalConfig, SetGitGlobalConfig, GetAppSettings, UpdateAppSettings | +| **Git 安装** | CheckGitInstalled, SelectGitInstallDir, InstallGit | ## 📄 开源协议 diff --git a/build/config.yml b/build/config.yml index 6ee37c4..822f227 100644 --- a/build/config.yml +++ b/build/config.yml @@ -9,7 +9,7 @@ info: description: "Git Repository Management Tool" copyright: "(c) 2025, GitPilot" comments: "GitPilot - Git Repository Manager" - version: "0.3.1" + version: "0.4.0" dev_mode: root_path: . diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist index 29d014f..a3d1904 100644 --- a/build/darwin/Info.plist +++ b/build/darwin/Info.plist @@ -10,11 +10,11 @@ CFBundleIdentifier com.gitpilot.app CFBundleVersion - 0.3.1 + 0.4.0 CFBundleGetInfoString Git Repository Management Tool CFBundleShortVersionString - 0.3.1 + 0.4.0 CFBundleIconFile icons LSMinimumSystemVersion diff --git a/build/windows/info.json b/build/windows/info.json index a8528dd..3f2106e 100644 --- a/build/windows/info.json +++ b/build/windows/info.json @@ -1,10 +1,10 @@ { "fixed": { - "file_version": "0.3.1" + "file_version": "0.4.0" }, "info": { "0000": { - "ProductVersion": "0.3.1", + "ProductVersion": "0.4.0", "CompanyName": "GitPilot", "FileDescription": "Git Repository Management Tool", "LegalCopyright": "© 2025, GitPilot", diff --git a/config.yaml b/config.yaml index bd2223d..b077af8 100644 --- a/config.yaml +++ b/config.yaml @@ -1,22 +1,22 @@ -platforms: - gitea: +projects: + - name: GitPilot + path: F:\Project\GitPilot + group: github/zhuy1228 +groups: + - name: github/zhuy1228 + icon: github + - name: gitee/zhuyuj + icon: gitee + - name: gitea + icon: gitea +credentials: + - platform: github + username: zhuy1228 + - platform: gitee + username: zhuyuj + - platform: gitea base_url: http://192.168.1.10:3000 - users: [] - gitee: - base_url: "" - users: - - username: zhuyuj - token: "" - projects: [] - github: - base_url: "" - users: - - username: zhuy1228 - token: "" - projects: - - name: GitPilot - path: F:\Project\GitPilot - enabled: false + username: "" settings: concurrency: 6 network_check: true diff --git a/config/config.go b/config/config.go index 98d29c9..4e72ffd 100644 --- a/config/config.go +++ b/config/config.go @@ -8,32 +8,41 @@ import ( "gopkg.in/yaml.v3" ) -type Project struct { - Name string `yaml:"name"` - Path string `yaml:"path"` - Enabled bool `yaml:"enabled"` // 可选,默认 true +// ProjectItem 项目配置(以路径为核心) +type ProjectItem struct { + Name string `yaml:"name"` + Path string `yaml:"path"` + Group string `yaml:"group,omitempty"` // 所属分组名,可为空表示"未分组" + UseProxy *bool `yaml:"use_proxy,omitempty"` // 是否走系统代理,nil 表示跟随全局 } -type User struct { - Username string `yaml:"username"` - Token string `yaml:"token"` // 可选:未来扩展 API 功能 - Projects []Project `yaml:"projects"` +// Group 项目分组 +type Group struct { + Name string `yaml:"name"` + Icon string `yaml:"icon,omitempty"` // 可选图标标识 } -type Platform struct { - BaseURL string `yaml:"base_url"` // GitHub/Gitee 可为空,Gitea 需要 - Users []User `yaml:"users"` +// Credential 凭证信息(独立于项目) +type Credential struct { + Platform string `yaml:"platform"` // github / gitee / gitea / ... + BaseURL string `yaml:"base_url,omitempty"` // 自建平台地址 + Username string `yaml:"username"` + Token string `yaml:"token,omitempty"` } +// Settings 应用设置 type Settings struct { Concurrency int `yaml:"concurrency"` NetworkCheck bool `yaml:"network_check"` LogLevel string `yaml:"log_level"` } +// AppConfig 应用配置(扁平化结构) type AppConfig struct { - Platforms map[string]Platform `yaml:"platforms"` - Settings Settings `yaml:"settings"` + Projects []ProjectItem `yaml:"projects"` + Groups []Group `yaml:"groups"` + Credentials []Credential `yaml:"credentials"` + Settings Settings `yaml:"settings"` } // configPath 返回 config.yaml 的绝对路径(与可执行文件同目录) @@ -53,18 +62,26 @@ func LoadConfig() (*AppConfig, error) { } defer file.Close() - var appConfig *AppConfig - - err = yaml.NewDecoder(file).Decode(&appConfig) - if err != nil { + var appConfig AppConfig + if err := yaml.NewDecoder(file).Decode(&appConfig); err != nil { return nil, err } - return appConfig, nil + if appConfig.Projects == nil { + appConfig.Projects = []ProjectItem{} + } + if appConfig.Groups == nil { + appConfig.Groups = []Group{} + } + if appConfig.Credentials == nil { + appConfig.Credentials = []Credential{} + } + + return &appConfig, nil } // SaveConfig 将配置保存到 config.yaml 文件 -func SaveConfig(config *AppConfig) error { +func SaveConfig(cfg *AppConfig) error { file, err := os.OpenFile(configPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return err @@ -72,10 +89,8 @@ func SaveConfig(config *AppConfig) error { defer file.Close() enc := yaml.NewEncoder(file) enc.SetIndent(2) - if err := enc.Encode(config); err != nil { + if err := enc.Encode(cfg); err != nil { return fmt.Errorf("写入配置文件失败: %w", err) } - return enc.Close() - } diff --git a/frontend/app/components/ContentArea.vue b/frontend/app/components/ContentArea.vue index 7260aad..eaf6441 100644 --- a/frontend/app/components/ContentArea.vue +++ b/frontend/app/components/ContentArea.vue @@ -112,6 +112,9 @@ const showSettings = ref(false) const settingsLoading = ref(false) const gitConfig = ref({ userName: '', userEmail: '' }) +// ---- 代理开关 ---- +const projectProxy = ref(null) // null: 跟随全局, true: 强制使用, false: 强制不使用 + // ---- 冲突处理 ---- const isMerging = ref(false) const conflictFiles = ref([]) @@ -266,6 +269,8 @@ async function loadStatus() { if (remotes.value.length && !remotes.value.find(r => r.name === currentRemote.value)) { currentRemote.value = remotes.value[0].name } + // 同步代理设置 + projectProxy.value = result.useProxy ?? null } else { errorMsg.value = '获取项目状态返回空值: ' + JSON.stringify(result) loadingBase.value = false @@ -542,6 +547,85 @@ async function gitAction(action) { } } +// ---- 多端推送 ---- +async function pushToAllRemotes() { + if (!props.project?.path) return + actionLoading.value = 'pushAll' + try { + const results = await AppService.PushToAllRemotes(props.project.path) + const successCount = results.filter(r => r.success).length + const failCount = results.length - successCount + if (failCount === 0) { + message.success(`已成功推送到全部 ${successCount} 个远程仓库`) + } else { + Modal.info({ + title: '多端推送结果', + content: h('div', results.map(r => + h('div', { style: `padding: 4px 0; color: ${r.success ? 'var(--success, #a6e3a1)' : '#f38ba8'}` }, [ + h('span', { style: 'font-weight: 600; margin-right: 8px;' }, r.remote + ':'), + h('span', r.success ? '✓ 成功' : '✗ ' + r.message), + ]) + )), + }) + } + await loadStatus() + } catch (e) { + console.error('多端推送失败:', e) + Modal.error({ title: '多端推送失败', content: String(e) }) + } finally { + actionLoading.value = '' + } +} + +async function pushTagToAllRemotes(tag) { + if (!props.project?.path) return + try { + const results = await AppService.PushTagToAllRemotes(props.project.path, tag.name) + const successCount = results.filter(r => r.success).length + const failCount = results.length - successCount + if (failCount === 0) { + message.success(`标签 ${tag.name} 已推送到全部 ${successCount} 个远程仓库`) + } else { + Modal.info({ + title: '标签多端推送结果', + content: h('div', results.map(r => + h('div', { style: `padding: 4px 0; color: ${r.success ? 'var(--success, #a6e3a1)' : '#f38ba8'}` }, [ + h('span', { style: 'font-weight: 600; margin-right: 8px;' }, r.remote + ':'), + h('span', r.success ? '✓ 成功' : '✗ ' + r.message), + ]) + )), + }) + } + } catch (e) { + console.error('标签多端推送失败:', e) + Modal.error({ title: '推送失败', content: String(e) }) + } +} + +// ---- 代理开关 ---- +async function toggleProxy(checked) { + if (!props.project?.path) return + const useProxy = checked ? true : false + try { + await AppService.SetProjectProxy(props.project.path, useProxy) + projectProxy.value = useProxy + } catch (e) { + console.error('设置代理失败:', e) + Modal.error({ title: '设置代理失败', content: String(e) }) + } +} + +async function resetProxy() { + if (!props.project?.path) return + try { + await AppService.SetProjectProxy(props.project.path, null) + projectProxy.value = null + message.success('已恢复跟随全局设置') + } catch (e) { + console.error('重置代理失败:', e) + } +} + function getStatusColor(status) { const colors = { 'M': 'orange', 'A': 'green', 'D': 'red', '?': 'default' } return colors[status] || 'default' @@ -1399,6 +1483,30 @@ const currentRemoteUrl = computed(() => { + + + + + + + + Fetch @@ -1411,6 +1519,12 @@ const currentRemoteUrl = computed(() => { Push + + + + Push All + + @@ -1813,11 +1927,16 @@ const currentRemoteUrl = computed(() => { {{ tag.name }}
- + + + + + + @@ -2078,6 +2197,41 @@ const currentRemoteUrl = computed(() => { flex-shrink: 0; } +/* 代理设置下拉 */ +.proxy-dropdown { + background: var(--dropdown-bg, #1e1e2e); + border: 1px solid var(--border-color, #313244); + border-radius: 8px; + padding: 4px 0; + min-width: 180px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} +.proxy-dropdown-title { + padding: 6px 12px; + font-size: 11px; + color: var(--text-muted); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border-color, #313244); +} +.proxy-dropdown-item { + padding: 8px 12px; + display: flex; + align-items: center; + justify-content: space-between; + cursor: pointer; + font-size: 13px; + color: var(--text-primary, #cdd6f4); + transition: background 0.15s; +} +.proxy-dropdown-item:hover { + background: var(--hover-bg, rgba(137, 180, 250, 0.08)); +} +.proxy-dropdown-item.active { + color: var(--accent, #89b4fa); +} + /* 主体区域 */ .project-body { flex: 1; diff --git a/frontend/app/components/Sidebar.vue b/frontend/app/components/Sidebar.vue index 02f384d..d372083 100644 --- a/frontend/app/components/Sidebar.vue +++ b/frontend/app/components/Sidebar.vue @@ -3,7 +3,6 @@ import { ref, onMounted, computed, h } from 'vue' import { FolderOutlined, FolderOpenOutlined, - UserOutlined, PlusOutlined, EditOutlined, DeleteOutlined, @@ -15,6 +14,9 @@ import { CheckCircleOutlined, CloseCircleOutlined, BranchesOutlined, + KeyOutlined, + GroupOutlined, + SwapOutlined, } from '@ant-design/icons-vue' import { message, Modal } from 'ant-design-vue' import { AppService } from '../../bindings/github.com/zhuy1228/GitPilot/internal/app' @@ -31,13 +33,27 @@ const selectedKeys = ref([]) // 添加项目弹窗 const showAddDialog = ref(false) -const addForm = ref({ platform: '', username: '', name: '', path: '' }) +const addForm = ref({ name: '', path: '', group: '' }) // 克隆项目弹窗 const showCloneDialog = ref(false) -const cloneForm = ref({ platform: '', username: '', repoURL: '', parentDir: '', name: '' }) +const cloneForm = ref({ repoURL: '', parentDir: '', name: '', group: '' }) const cloneLoading = ref(false) +// 分组弹窗 +const showGroupDialog = ref(false) +const groupDialogMode = ref('add') +const groupForm = ref({ name: '', icon: '', oldName: '' }) + +// 凭证弹窗 +const showCredentialDialog = ref(false) +const credentialDialogMode = ref('add') +const credentialForm = ref({ platform: '', baseUrl: '', username: '', token: '', oldPlatform: '', oldUsername: '' }) + +// 凭证列表弹窗 +const showCredentialList = ref(false) +const credentials = ref([]) + // ---- 批量操作 ---- const showBatchModal = ref(false) const batchOverviews = ref([]) @@ -54,24 +70,23 @@ const batchResultMap = computed(() => { // 右键菜单 const contextMenu = ref({ visible: false, x: 0, y: 0, type: '', data: {} }) -// 平台弹窗 -const showPlatformDialog = ref(false) -const platformDialogMode = ref('add') -const platformForm = ref({ name: '', baseUrl: '', oldName: '' }) +// 获取所有分组名(用于下拉选择) +const groupNames = computed(() => { + const names = [] + ;(tree.value || []).forEach(node => { + if (node.type === 'group') { + names.push(node.label) + } + }) + return names +}) -// 用户弹窗 -const showUserDialog = ref(false) -const userDialogMode = ref('add') -const userForm = ref({ platform: '', username: '', token: '', oldUsername: '' }) - -// 构建 key → 原始节点 的映射,避免 a-tree event node 丢失自定义字段 +// 构建 key → 原始节点 的映射 const nodeMap = computed(() => { const map = {} - ;(tree.value || []).forEach(platform => { - platform.children?.forEach(user => { - user.children?.forEach(proj => { - map[proj.key] = proj - }) + ;(tree.value || []).forEach(group => { + group.children?.forEach(proj => { + map[proj.key] = proj }) }) return map @@ -79,26 +94,18 @@ const nodeMap = computed(() => { // 转换后端树数据为 a-tree 格式 const treeData = computed(() => { - return (tree.value || []).map(platform => ({ - key: platform.key, - title: platform.label, - type: 'platform', + return (tree.value || []).map(group => ({ + key: group.key, + title: group.label, + type: 'group', + icon: group.icon, isLeaf: false, - children: (platform.children || []).map(user => ({ - key: user.key, - title: user.label, - type: 'user', - platformKey: platform.key, - isLeaf: false, - children: (user.children || []).map(proj => ({ - key: proj.key, - title: proj.label, - type: 'project', - path: proj.path, - platformKey: platform.key, - username: user.label, - isLeaf: true, - })) + children: (group.children || []).map(proj => ({ + key: proj.key, + title: proj.label, + type: 'project', + path: proj.path, + isLeaf: true, })) })) }) @@ -110,11 +117,6 @@ async function loadTree() { const keys = [] tree.value.forEach(node => { keys.push(node.key) - if (node.children) { - node.children.forEach(child => { - keys.push(child.key) - }) - } }) expandedKeys.value = keys } catch (e) { @@ -125,10 +127,8 @@ async function loadTree() { function onTreeSelect(keys, { node }) { if (node.type === 'project') { selectedKeys.value = keys - // 从 nodeMap 中取 path,避免 a-tree 事件节点丢失自定义字段 const raw = nodeMap.value[node.key] const path = raw?.path || node.path || '' - console.log('[Sidebar] select project:', node.key, 'path:', path) emit('select-project', { key: node.key, label: node.title || raw?.label, path, type: 'project' }) } } @@ -138,15 +138,11 @@ function onRightClick({ event, node }) { event.preventDefault() const type = node.type const data = {} - if (type === 'platform') { - data.key = node.key - } else if (type === 'user') { - data.platformKey = node.platformKey - data.username = node.title + if (type === 'group') { + data.groupName = node.title } else if (type === 'project') { - data.platformKey = node.platformKey - data.username = node.username data.name = node.title + data.path = node.path } contextMenu.value = { visible: true, x: event.clientX, y: event.clientY, type, data } } @@ -164,40 +160,36 @@ function onContextMenuClick({ key: action }) { const { type, data } = contextMenu.value hideContextMenu() switch (action) { - case 'add-platform': openAddPlatformDialog(); break - case 'add-user': openAddUserDialog(data.key || data.platformKey); break - case 'edit-platform': openEditPlatformDialog(data.key); break - case 'remove-platform': removePlatform(data.key); break - case 'add-project': openAddDialog(data.platformKey, data.username); break - case 'clone-project': openCloneDialog(data.platformKey, data.username); break - case 'edit-user': openEditUserDialog(data.platformKey, data.username); break - case 'remove-user': removeUser(data.platformKey, data.username); break - case 'remove-project': removeProject(data.platformKey, data.username, data.name); break + case 'add-group': openAddGroupDialog(); break + case 'edit-group': openEditGroupDialog(data.groupName); break + case 'remove-group': removeGroup(data.groupName); break + case 'add-project': openAddDialog(data.groupName); break + case 'clone-project': openCloneDialog(data.groupName); break + case 'remove-project': removeProject(data.path); break + case 'manage-credentials': openCredentialList(); break } } const contextMenuItems = computed(() => { const { type } = contextMenu.value if (type === 'empty') { - return [{ key: 'add-platform', label: '添加平台', icon: h(PlusOutlined) }] - } - if (type === 'platform') { return [ - { key: 'add-user', label: '添加用户', icon: h(UserOutlined) }, - { key: 'edit-platform', label: '编辑平台', icon: h(EditOutlined) }, + { key: 'add-group', label: '添加分组', icon: h(GroupOutlined) }, + { key: 'add-project', label: '添加项目', icon: h(FolderOutlined) }, + { key: 'clone-project', label: '克隆项目', icon: h(CloudDownloadOutlined) }, { type: 'divider' }, - { key: 'add-platform', label: '添加平台', icon: h(PlusOutlined) }, - { type: 'divider' }, - { key: 'remove-platform', label: '删除平台', danger: true, icon: h(DeleteOutlined) }, + { key: 'manage-credentials', label: '凭证管理', icon: h(KeyOutlined) }, ] } - if (type === 'user') { + if (type === 'group') { return [ { key: 'add-project', label: '添加项目', icon: h(FolderOutlined) }, { key: 'clone-project', label: '克隆项目', icon: h(CloudDownloadOutlined) }, - { key: 'edit-user', label: '编辑用户', icon: h(EditOutlined) }, { type: 'divider' }, - { key: 'remove-user', label: '删除用户', danger: true, icon: h(DeleteOutlined) }, + { key: 'edit-group', label: '编辑分组', icon: h(EditOutlined) }, + { key: 'add-group', label: '添加分组', icon: h(GroupOutlined) }, + { type: 'divider' }, + { key: 'remove-group', label: '删除分组', danger: true, icon: h(DeleteOutlined) }, ] } if (type === 'project') { @@ -211,109 +203,49 @@ const contextMenuItems = computed(() => { return [] }) -// --- 平台操作 --- -function openAddPlatformDialog() { - platformDialogMode.value = 'add' - platformForm.value = { name: '', baseUrl: '', oldName: '' } - showPlatformDialog.value = true +// --- 分组操作 --- +function openAddGroupDialog() { + groupDialogMode.value = 'add' + groupForm.value = { name: '', icon: 'folder', oldName: '' } + showGroupDialog.value = true } -async function openEditPlatformDialog(platformKey) { - try { - const info = await AppService.GetPlatformInfo(platformKey) - platformDialogMode.value = 'edit' - platformForm.value = { name: info.name, baseUrl: info.baseUrl || '', oldName: info.name } - showPlatformDialog.value = true - } catch (e) { - console.error('获取平台信息失败:', e) - } +function openEditGroupDialog(groupName) { + groupDialogMode.value = 'edit' + groupForm.value = { name: groupName, icon: '', oldName: groupName } + showGroupDialog.value = true } -async function savePlatform() { - if (!platformForm.value.name) return +async function saveGroup() { + if (!groupForm.value.name) return try { - if (platformDialogMode.value === 'add') { - await AppService.AddPlatform(platformForm.value.name, platformForm.value.baseUrl) + if (groupDialogMode.value === 'add') { + await AppService.AddGroup(groupForm.value.name, groupForm.value.icon || 'folder') } else { - await AppService.UpdatePlatform(platformForm.value.oldName, platformForm.value.baseUrl) + await AppService.UpdateGroup(groupForm.value.oldName, groupForm.value.name, groupForm.value.icon) } - showPlatformDialog.value = false + showGroupDialog.value = false await loadTree() emit('tree-updated') } catch (e) { - console.error('操作失败:', e) + message.error('操作失败: ' + String(e)) } } -async function removePlatform(name) { - if (!confirm(`确定删除平台 "${name}" 及其所有用户和项目吗?`)) return +async function removeGroup(name) { + if (!confirm(`确定删除分组 "${name}" 吗?分组下的项目将移到"未分组"。`)) return try { - await AppService.RemovePlatform(name) + await AppService.RemoveGroup(name) await loadTree() emit('tree-updated') } catch (e) { - console.error('删除失败:', e) - } -} - -// --- 用户操作 --- -function openAddUserDialog(platformKey) { - userDialogMode.value = 'add' - userForm.value = { platform: platformKey, username: '', token: '', oldUsername: '' } - showUserDialog.value = true -} - -async function openEditUserDialog(platformKey, username) { - try { - const info = await AppService.GetUserInfo(platformKey, username) - userDialogMode.value = 'edit' - userForm.value = { - platform: platformKey, - username: info.username, - token: info.token || '', - oldUsername: info.username - } - showUserDialog.value = true - } catch (e) { - console.error('获取用户信息失败:', e) - } -} - -async function saveUser() { - if (!userForm.value.username) return - try { - if (userDialogMode.value === 'add') { - await AppService.AddUser(userForm.value.platform, userForm.value.username, userForm.value.token) - } else { - await AppService.UpdateUser( - userForm.value.platform, - userForm.value.oldUsername, - userForm.value.username, - userForm.value.token - ) - } - showUserDialog.value = false - await loadTree() - emit('tree-updated') - } catch (e) { - console.error('操作失败:', e) - } -} - -async function removeUser(platform, username) { - if (!confirm(`确定删除用户 "${username}" 及其所有项目吗?`)) return - try { - await AppService.RemoveUser(platform, username) - await loadTree() - emit('tree-updated') - } catch (e) { - console.error('删除失败:', e) + message.error('删除失败: ' + String(e)) } } // --- 项目操作 --- -function openAddDialog(platformKey, username) { - addForm.value = { platform: platformKey, username: username, name: '', path: '' } +function openAddDialog(groupName) { + addForm.value = { name: '', path: '', group: groupName || '' } showAddDialog.value = true } @@ -322,7 +254,6 @@ async function pickDirectory() { const path = await AppService.SelectDirectory() if (path) { addForm.value.path = path - // 如果项目名称为空,自动用文件夹名称填充 if (!addForm.value.name) { const parts = path.replace(/\\/g, '/').split('/') addForm.value.name = parts[parts.length - 1] || '' @@ -336,23 +267,18 @@ async function pickDirectory() { async function addProject() { if (!addForm.value.name || !addForm.value.path) return try { - await AppService.AddProject( - addForm.value.platform, - addForm.value.username, - addForm.value.name, - addForm.value.path - ) + await AppService.AddProject(addForm.value.name, addForm.value.path, addForm.value.group) showAddDialog.value = false await loadTree() emit('tree-updated') } catch (e) { - console.error('添加项目失败:', e) + message.error('添加项目失败: ' + String(e)) } } // --- 克隆项目 --- -function openCloneDialog(platformKey, username) { - cloneForm.value = { platform: platformKey, username: username, repoURL: '', parentDir: '', name: '' } +function openCloneDialog(groupName) { + cloneForm.value = { repoURL: '', parentDir: '', name: '', group: groupName || '' } showCloneDialog.value = true } @@ -368,7 +294,6 @@ async function pickCloneDirectory() { } function onRepoURLChange() { - // 从仓库 URL 自动提取项目名称 if (!cloneForm.value.name && cloneForm.value.repoURL) { const url = cloneForm.value.repoURL.trim() const match = url.match(/\/([^\/]+?)(\.git)?$/) @@ -383,32 +308,96 @@ async function cloneProject() { cloneLoading.value = true try { await AppService.CloneProject( - cloneForm.value.platform, - cloneForm.value.username, cloneForm.value.repoURL, cloneForm.value.parentDir, - cloneForm.value.name + cloneForm.value.name, + cloneForm.value.group ) showCloneDialog.value = false message.success('克隆成功') await loadTree() emit('tree-updated') } catch (e) { - console.error('克隆项目失败:', e) message.error('克隆失败: ' + String(e)) } finally { cloneLoading.value = false } } -async function removeProject(platform, username, name) { +async function removeProject(path) { + const name = path.split(/[/\\]/).pop() if (!confirm(`确定删除项目 "${name}" 吗?`)) return try { - await AppService.RemoveProject(platform, username, name) + await AppService.RemoveProject(path) await loadTree() emit('tree-updated') } catch (e) { - console.error('删除项目失败:', e) + message.error('删除项目失败: ' + String(e)) + } +} + +// --- 凭证管理 --- +async function openCredentialList() { + try { + const list = await AppService.GetCredentials() + credentials.value = list || [] + } catch (e) { + credentials.value = [] + } + showCredentialList.value = true +} + +function openAddCredentialDialog() { + credentialDialogMode.value = 'add' + credentialForm.value = { platform: '', baseUrl: '', username: '', token: '', oldPlatform: '', oldUsername: '' } + showCredentialDialog.value = true +} + +function openEditCredentialDialog(cred) { + credentialDialogMode.value = 'edit' + credentialForm.value = { + platform: cred.platform, + baseUrl: cred.baseUrl || '', + username: cred.username, + token: cred.token || '', + oldPlatform: cred.platform, + oldUsername: cred.username + } + showCredentialDialog.value = true +} + +async function saveCredential() { + if (!credentialForm.value.platform || !credentialForm.value.username) return + try { + if (credentialDialogMode.value === 'add') { + await AppService.AddCredential( + credentialForm.value.platform, + credentialForm.value.baseUrl, + credentialForm.value.username, + credentialForm.value.token + ) + } else { + await AppService.UpdateCredential( + credentialForm.value.oldPlatform, + credentialForm.value.oldUsername, + credentialForm.value.baseUrl, + credentialForm.value.token + ) + } + showCredentialDialog.value = false + await openCredentialList() + } catch (e) { + message.error('操作失败: ' + String(e)) + } +} + +async function removeCredential(platform, username) { + if (!confirm(`确定删除凭证 "${platform}/${username}" 吗?`)) return + try { + await AppService.RemoveCredential(platform, username) + await openCredentialList() + } catch (e) { + message.error('删除失败: ' + String(e)) } } @@ -485,17 +474,15 @@ async function doBatchPush() { } } -// 平台 SVG Logo -const platformLogos = { +// 分组图标 +const groupIcons = { github: ``, gitee: ``, gitea: ``, - default: `` } -function getPlatformColor(name) { - const colors = { github: '#e6edf3', gitee: '#c71d23', gitea: '#609926' } - return colors[name] || 'var(--text-secondary)' +function getGroupIcon(icon) { + return groupIcons[icon] || null } onMounted(() => { @@ -511,7 +498,7 @@ onMounted(() => { - + @@ -529,21 +516,20 @@ onMounted(() => { @rightClick="onRightClick" @contextmenu.stop > - + + + {{ g }} + + @@ -613,12 +598,6 @@ onMounted(() => { :width="480" > - - - - - - { + + + {{ g }} + +
目标路径: {{ cloneForm.parentDir }}/{{ cloneForm.name }} @@ -647,53 +631,109 @@ onMounted(() => { - + - + + + + + + 📁 文件夹 + 🐙 GitHub + 🟠 Gitee + 🍵 Gitea + + + + + + + +
+ + + 添加凭证 + +
+
+ 暂无凭证 +
+
+
+
+
+ + {{ cred.platform }} + + {{ cred.username }} +
+
{{ cred.baseUrl }}
+
+ + + + + + + + +
+
+
+ + + + + + + GitHub + Gitee + Gitea + GitLab + + + - - - - - - - - - - - - - + - + @@ -815,7 +855,7 @@ onMounted(() => { gap: 6px; } -.platform-logo { +.group-logo { width: 16px; height: 16px; display: inline-flex; @@ -824,7 +864,7 @@ onMounted(() => { flex-shrink: 0; } -.platform-logo :deep(svg) { +.group-logo :deep(svg) { width: 16px; height: 16px; } @@ -967,4 +1007,41 @@ onMounted(() => { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } } + +/* 凭证列表 */ +.credential-list { + max-height: 400px; + overflow-y: auto; +} + +.credential-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 4px; + border-bottom: 1px solid var(--border-color, rgba(255,255,255,0.04)); +} + +.credential-item:hover { + background: var(--bg-hover, rgba(255,255,255,0.04)); +} + +.credential-info { + flex: 1; + min-width: 0; +} + +.credential-name { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; +} + +.credential-url { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/config/index.js b/frontend/bindings/github.com/zhuy1228/GitPilot/config/index.js index 471e641..1293ae2 100644 --- a/frontend/bindings/github.com/zhuy1228/GitPilot/config/index.js +++ b/frontend/bindings/github.com/zhuy1228/GitPilot/config/index.js @@ -3,5 +3,6 @@ // This file is automatically generated. DO NOT EDIT export { + Group, Settings } from "./models.js"; diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/config/models.js b/frontend/bindings/github.com/zhuy1228/GitPilot/config/models.js index 6995763..d720b37 100644 --- a/frontend/bindings/github.com/zhuy1228/GitPilot/config/models.js +++ b/frontend/bindings/github.com/zhuy1228/GitPilot/config/models.js @@ -6,6 +6,48 @@ // @ts-ignore: Unused imports import { Create as $Create } from "@wailsio/runtime"; +/** + * Group 项目分组 + */ +export class Group { + /** + * Creates a new Group instance. + * @param {Partial} [$$source = {}] - The source object to create the Group. + */ + constructor($$source = {}) { + if (!("Name" in $$source)) { + /** + * @member + * @type {string} + */ + this["Name"] = ""; + } + if (!("Icon" in $$source)) { + /** + * 可选图标标识 + * @member + * @type {string} + */ + this["Icon"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Group instance from a string or object. + * @param {any} [$$source = {}] + * @returns {Group} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new Group(/** @type {Partial} */($$parsedSource)); + } +} + +/** + * Settings 应用设置 + */ export class Settings { /** * Creates a new Settings instance. diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js index beae97e..45476dc 100644 --- a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js +++ b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/appservice.js @@ -32,25 +32,36 @@ export function AbortMerge(path) { } /** - * AddPlatform 添加新平台 - * @param {string} name + * AddCredential 添加凭证 + * @param {string} platform * @param {string} baseURL + * @param {string} username + * @param {string} token * @returns {$CancellablePromise} */ -export function AddPlatform(name, baseURL) { - return $Call.ByID(526003212, name, baseURL); +export function AddCredential(platform, baseURL, username, token) { + return $Call.ByID(1627284202, platform, baseURL, username, token); } /** - * AddProject 添加项目到指定平台/用户下 - * @param {string} platform - * @param {string} username + * AddGroup 添加分组 * @param {string} name - * @param {string} path + * @param {string} icon * @returns {$CancellablePromise} */ -export function AddProject(platform, username, name, path) { - return $Call.ByID(2299402672, platform, username, name, path); +export function AddGroup(name, icon) { + return $Call.ByID(4237035530, name, icon); +} + +/** + * AddProject 添加项目 + * @param {string} name + * @param {string} path + * @param {string} group + * @returns {$CancellablePromise} + */ +export function AddProject(name, path, group) { + return $Call.ByID(2299402672, name, path, group); } /** @@ -64,17 +75,6 @@ export function AddRemote(path, name, url) { return $Call.ByID(3697916441, path, name, url); } -/** - * AddUser 添加用户到指定平台 - * @param {string} platform - * @param {string} username - * @param {string} token - * @returns {$CancellablePromise} - */ -export function AddUser(platform, username, token) { - return $Call.ByID(2264426704, platform, username, token); -} - /** * BatchPull 批量拉取指定项目 * @param {string[]} paths @@ -120,16 +120,15 @@ export function CheckoutRemoteBranch(path, remoteBranch) { } /** - * CloneProject 克隆远程仓库到本地目录,并添加到项目树 - * @param {string} platform - * @param {string} username + * CloneProject 克隆远程仓库到本地目录,并添加到项目列表 * @param {string} repoURL * @param {string} parentDir * @param {string} name + * @param {string} group * @returns {$CancellablePromise} */ -export function CloneProject(platform, username, repoURL, parentDir, name) { - return $Call.ByID(2347019414, platform, username, repoURL, parentDir, name); +export function CloneProject(repoURL, parentDir, name, group) { + return $Call.ByID(2347019414, repoURL, parentDir, name, group); } /** @@ -313,6 +312,16 @@ export function GetConflictFiles(path) { })); } +/** + * GetCredentials 获取所有凭证 + * @returns {$CancellablePromise<$models.CredentialInfo[]>} + */ +export function GetCredentials() { + return $Call.ByID(4214051290).then(/** @type {($result: any) => any} */(($result) => { + return $$createType16($result); + })); +} + /** * GetFileContent 获取文件内容 * @param {string} projectPath @@ -349,18 +358,17 @@ export function GetFileDiffStaged(projectPath, filePath) { */ export function GetGitGlobalConfig() { return $Call.ByID(154811497).then(/** @type {($result: any) => any} */(($result) => { - return $$createType16($result); + return $$createType18($result); })); } /** - * GetPlatformInfo 获取平台信息 - * @param {string} name - * @returns {$CancellablePromise<$models.PlatformInfo | null>} + * GetGroups 获取所有分组 + * @returns {$CancellablePromise} */ -export function GetPlatformInfo(name) { - return $Call.ByID(2668095547, name).then(/** @type {($result: any) => any} */(($result) => { - return $$createType18($result); +export function GetGroups() { + return $Call.ByID(1600884836).then(/** @type {($result: any) => any} */(($result) => { + return $$createType20($result); })); } @@ -371,10 +379,19 @@ export function GetPlatformInfo(name) { */ export function GetProjectChangedFiles(path) { return $Call.ByID(2302591462, path).then(/** @type {($result: any) => any} */(($result) => { - return $$createType20($result); + return $$createType22($result); })); } +/** + * GetProjectProxy 获取项目代理设置(nil 表示跟随全局) + * @param {string} path + * @returns {$CancellablePromise} + */ +export function GetProjectProxy(path) { + return $Call.ByID(2515497911, path); +} + /** * GetProjectStatus 获取项目 git 状态 * @param {string} path @@ -382,17 +399,17 @@ export function GetProjectChangedFiles(path) { */ export function GetProjectStatus(path) { return $Call.ByID(3451089027, path).then(/** @type {($result: any) => any} */(($result) => { - return $$createType22($result); + return $$createType24($result); })); } /** - * GetProjectTree 获取项目树,供前端侧边栏渲染 + * GetProjectTree 获取项目树,按分组组织 * @returns {$CancellablePromise<$models.TreeNode[]>} */ export function GetProjectTree() { return $Call.ByID(651189689).then(/** @type {($result: any) => any} */(($result) => { - return $$createType24($result); + return $$createType26($result); })); } @@ -415,7 +432,7 @@ export function GetRemoteBranches(path, remote) { */ export function GetRemotes(path) { return $Call.ByID(975520027, path).then(/** @type {($result: any) => any} */(($result) => { - return $$createType26($result); + return $$createType28($result); })); } @@ -426,7 +443,7 @@ export function GetRemotes(path) { */ export function GetStashList(path) { return $Call.ByID(1948257945, path).then(/** @type {($result: any) => any} */(($result) => { - return $$createType28($result); + return $$createType30($result); })); } @@ -437,18 +454,6 @@ export function GetStashList(path) { */ export function GetTags(path) { return $Call.ByID(3979169621, path).then(/** @type {($result: any) => any} */(($result) => { - return $$createType30($result); - })); -} - -/** - * GetUserInfo 获取用户信息 - * @param {string} platform - * @param {string} username - * @returns {$CancellablePromise<$models.UserInfo | null>} - */ -export function GetUserInfo(platform, username) { - return $Call.ByID(2674655707, platform, username).then(/** @type {($result: any) => any} */(($result) => { return $$createType32($result); })); } @@ -482,6 +487,16 @@ export function MergeBranch(path, branch) { return $Call.ByID(278161076, path, branch); } +/** + * MoveProjectToGroup 移动项目到指定分组 + * @param {string} path + * @param {string} group + * @returns {$CancellablePromise} + */ +export function MoveProjectToGroup(path, group) { + return $Call.ByID(1699187666, path, group); +} + /** * PullProject 拉取项目(当前分支,指定 remote) * @param {string} path @@ -514,23 +529,54 @@ export function PushTag(path, name, remote) { } /** - * RemovePlatform 删除平台 - * @param {string} name - * @returns {$CancellablePromise} + * PushTagToAllRemotes 一键推送标签到所有远程仓库 + * @param {string} path + * @param {string} tagName + * @returns {$CancellablePromise<$models.PushAllResult[]>} */ -export function RemovePlatform(name) { - return $Call.ByID(2222448051, name); +export function PushTagToAllRemotes(path, tagName) { + return $Call.ByID(2343581171, path, tagName).then(/** @type {($result: any) => any} */(($result) => { + return $$createType34($result); + })); } /** - * RemoveProject 从指定平台/用户下删除项目 + * PushToAllRemotes 一键推送到所有远程仓库(当前分支) + * @param {string} path + * @returns {$CancellablePromise<$models.PushAllResult[]>} + */ +export function PushToAllRemotes(path) { + return $Call.ByID(1027392731, path).then(/** @type {($result: any) => any} */(($result) => { + return $$createType34($result); + })); +} + +/** + * RemoveCredential 删除凭证 * @param {string} platform * @param {string} username + * @returns {$CancellablePromise} + */ +export function RemoveCredential(platform, username) { + return $Call.ByID(414538393, platform, username); +} + +/** + * RemoveGroup 删除分组(分组下的项目变为"未分组") * @param {string} name * @returns {$CancellablePromise} */ -export function RemoveProject(platform, username, name) { - return $Call.ByID(2748109581, platform, username, name); +export function RemoveGroup(name) { + return $Call.ByID(3268966203, name); +} + +/** + * RemoveProject 删除项目(按路径匹配) + * @param {string} path + * @returns {$CancellablePromise} + */ +export function RemoveProject(path) { + return $Call.ByID(2748109581, path); } /** @@ -543,16 +589,6 @@ export function RemoveRemote(path, name) { return $Call.ByID(2915623022, path, name); } -/** - * RemoveUser 从平台删除用户 - * @param {string} platform - * @param {string} username - * @returns {$CancellablePromise} - */ -export function RemoveUser(platform, username) { - return $Call.ByID(1409517275, platform, username); -} - /** * ResetProject 版本回滚(git reset) * mode: "hard"(丢弃所有更改), "soft"(保留更改到暂存区), "mixed"(保留更改到工作区) @@ -644,6 +680,17 @@ export function SetGitGlobalConfig(name, email) { return $Call.ByID(3875064981, name, email); } +/** + * SetProjectProxy 设置项目代理开关 + * useProxy: true=强制启用, false=强制禁用, nil(传空)=跟随全局 + * @param {string} path + * @param {boolean | null} useProxy + * @returns {$CancellablePromise} + */ +export function SetProjectProxy(path, useProxy) { + return $Call.ByID(4094650651, path, useProxy); +} + /** * StageAll 暂存所有变更文件 * @param {string} path @@ -742,25 +789,26 @@ export function UpdateAppSettings(logLevel) { } /** - * UpdatePlatform 修改平台信息(base_url) - * @param {string} name - * @param {string} baseURL - * @returns {$CancellablePromise} - */ -export function UpdatePlatform(name, baseURL) { - return $Call.ByID(3258014550, name, baseURL); -} - -/** - * UpdateUser 修改用户信息(用户名、token) + * UpdateCredential 更新凭证 * @param {string} platform - * @param {string} oldUsername - * @param {string} newUsername + * @param {string} username + * @param {string} baseURL * @param {string} token * @returns {$CancellablePromise} */ -export function UpdateUser(platform, oldUsername, newUsername, token) { - return $Call.ByID(1631729342, platform, oldUsername, newUsername, token); +export function UpdateCredential(platform, username, baseURL, token) { + return $Call.ByID(2448329924, platform, username, baseURL, token); +} + +/** + * UpdateGroup 修改分组 + * @param {string} oldName + * @param {string} newName + * @param {string} icon + * @returns {$CancellablePromise} + */ +export function UpdateGroup(oldName, newName, icon) { + return $Call.ByID(1322206248, oldName, newName, icon); } // Private type creation functions @@ -779,21 +827,23 @@ const $$createType11 = $models.CommitLog.createFrom; const $$createType12 = $Create.Array($$createType11); const $$createType13 = $models.ConflictFileInfo.createFrom; const $$createType14 = $Create.Array($$createType13); -const $$createType15 = $models.GitConfig.createFrom; -const $$createType16 = $Create.Nullable($$createType15); -const $$createType17 = $models.PlatformInfo.createFrom; +const $$createType15 = $models.CredentialInfo.createFrom; +const $$createType16 = $Create.Array($$createType15); +const $$createType17 = $models.GitConfig.createFrom; const $$createType18 = $Create.Nullable($$createType17); -const $$createType19 = $models.FileInfo.createFrom; +const $$createType19 = config$0.Group.createFrom; const $$createType20 = $Create.Array($$createType19); -const $$createType21 = $models.ProjectStatus.createFrom; -const $$createType22 = $Create.Nullable($$createType21); -const $$createType23 = $models.TreeNode.createFrom; -const $$createType24 = $Create.Array($$createType23); -const $$createType25 = $models.RemoteItem.createFrom; +const $$createType21 = $models.FileInfo.createFrom; +const $$createType22 = $Create.Array($$createType21); +const $$createType23 = $models.ProjectStatus.createFrom; +const $$createType24 = $Create.Nullable($$createType23); +const $$createType25 = $models.TreeNode.createFrom; const $$createType26 = $Create.Array($$createType25); -const $$createType27 = $models.StashInfo.createFrom; +const $$createType27 = $models.RemoteItem.createFrom; const $$createType28 = $Create.Array($$createType27); -const $$createType29 = $models.TagInfo.createFrom; +const $$createType29 = $models.StashInfo.createFrom; const $$createType30 = $Create.Array($$createType29); -const $$createType31 = $models.UserInfo.createFrom; -const $$createType32 = $Create.Nullable($$createType31); +const $$createType31 = $models.TagInfo.createFrom; +const $$createType32 = $Create.Array($$createType31); +const $$createType33 = $models.PushAllResult.createFrom; +const $$createType34 = $Create.Array($$createType33); diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js index 5e6d162..64fb09e 100644 --- a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js +++ b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/index.js @@ -13,15 +13,15 @@ export { CommitFileInfo, CommitLog, ConflictFileInfo, + CredentialInfo, FileInfo, GitConfig, GitStatus, - PlatformInfo, ProjectOverview, ProjectStatus, + PushAllResult, RemoteItem, StashInfo, TagInfo, - TreeNode, - UserInfo + TreeNode } from "./models.js"; diff --git a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js index c1e18bb..dee7268 100644 --- a/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js +++ b/frontend/bindings/github.com/zhuy1228/GitPilot/internal/app/models.js @@ -238,6 +238,58 @@ export class ConflictFileInfo { } } +/** + * CredentialInfo 凭证信息(前端展示用) + */ +export class CredentialInfo { + /** + * Creates a new CredentialInfo instance. + * @param {Partial} [$$source = {}] - The source object to create the CredentialInfo. + */ + constructor($$source = {}) { + if (!("platform" in $$source)) { + /** + * @member + * @type {string} + */ + this["platform"] = ""; + } + if (!("baseUrl" in $$source)) { + /** + * @member + * @type {string} + */ + this["baseUrl"] = ""; + } + if (!("username" in $$source)) { + /** + * @member + * @type {string} + */ + this["username"] = ""; + } + if (!("token" in $$source)) { + /** + * @member + * @type {string} + */ + this["token"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new CredentialInfo instance from a string or object. + * @param {any} [$$source = {}] + * @returns {CredentialInfo} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new CredentialInfo(/** @type {Partial} */($$parsedSource)); + } +} + /** * FileInfo 文件信息 */ @@ -373,44 +425,6 @@ export class GitStatus { } } -/** - * PlatformInfo 平台信息 - */ -export class PlatformInfo { - /** - * Creates a new PlatformInfo instance. - * @param {Partial} [$$source = {}] - The source object to create the PlatformInfo. - */ - constructor($$source = {}) { - if (!("name" in $$source)) { - /** - * @member - * @type {string} - */ - this["name"] = ""; - } - if (!("baseUrl" in $$source)) { - /** - * @member - * @type {string} - */ - this["baseUrl"] = ""; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new PlatformInfo instance from a string or object. - * @param {any} [$$source = {}] - * @returns {PlatformInfo} - */ - static createFrom($$source = {}) { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new PlatformInfo(/** @type {Partial} */($$parsedSource)); - } -} - /** * ProjectOverview 项目概览信息(轻量级) */ @@ -528,6 +542,14 @@ export class ProjectStatus { */ this["changedFiles"] = []; } + if (!("useProxy" in $$source)) { + /** + * nil=跟随全局, true=开启, false=关闭 + * @member + * @type {boolean | null} + */ + this["useProxy"] = null; + } Object.assign(this, $$source); } @@ -551,6 +573,51 @@ export class ProjectStatus { } } +/** + * PushAllResult 一键多端推送结果 + */ +export class PushAllResult { + /** + * Creates a new PushAllResult instance. + * @param {Partial} [$$source = {}] - The source object to create the PushAllResult. + */ + constructor($$source = {}) { + if (!("remote" in $$source)) { + /** + * @member + * @type {string} + */ + this["remote"] = ""; + } + if (!("success" in $$source)) { + /** + * @member + * @type {boolean} + */ + this["success"] = false; + } + if (!("message" in $$source)) { + /** + * @member + * @type {string} + */ + this["message"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new PushAllResult instance from a string or object. + * @param {any} [$$source = {}] + * @returns {PushAllResult} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new PushAllResult(/** @type {Partial} */($$parsedSource)); + } +} + /** * RemoteItem 远程仓库信息 */ @@ -718,7 +785,7 @@ export class TreeNode { } if (!("type" in $$source)) { /** - * platform, user, project + * group, project * @member * @type {string} */ @@ -731,6 +798,13 @@ export class TreeNode { */ this["path"] = undefined; } + if (/** @type {any} */(false)) { + /** + * @member + * @type {string | undefined} + */ + this["icon"] = undefined; + } if (/** @type {any} */(false)) { /** * @member @@ -748,53 +822,15 @@ export class TreeNode { * @returns {TreeNode} */ static createFrom($$source = {}) { - const $$createField4_0 = $$createType5; + const $$createField5_0 = $$createType5; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("children" in $$parsedSource) { - $$parsedSource["children"] = $$createField4_0($$parsedSource["children"]); + $$parsedSource["children"] = $$createField5_0($$parsedSource["children"]); } return new TreeNode(/** @type {Partial} */($$parsedSource)); } } -/** - * UserInfo 用户信息 - */ -export class UserInfo { - /** - * Creates a new UserInfo instance. - * @param {Partial} [$$source = {}] - The source object to create the UserInfo. - */ - constructor($$source = {}) { - if (!("username" in $$source)) { - /** - * @member - * @type {string} - */ - this["username"] = ""; - } - if (!("token" in $$source)) { - /** - * @member - * @type {string} - */ - this["token"] = ""; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new UserInfo instance from a string or object. - * @param {any} [$$source = {}] - * @returns {UserInfo} - */ - static createFrom($$source = {}) { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new UserInfo(/** @type {Partial} */($$parsedSource)); - } -} - // Private type creation functions const $$createType0 = RemoteItem.createFrom; const $$createType1 = $Create.Array($$createType0); diff --git a/internal/app/service.go b/internal/app/service.go index 4fd5e5f..6f102c4 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -28,8 +28,10 @@ func New() *AppService { if err != nil { log.Printf("加载配置失败: %v, 使用默认配置", err) cfg = &config.AppConfig{ - Platforms: make(map[string]config.Platform), - Settings: config.Settings{Concurrency: 6, NetworkCheck: true, LogLevel: "info"}, + Projects: []config.ProjectItem{}, + Groups: []config.Group{}, + Credentials: []config.Credential{}, + Settings: config.Settings{Concurrency: 6, NetworkCheck: true, LogLevel: "info"}, } } return &AppService{ @@ -54,51 +56,91 @@ func (s *AppService) SelectDirectory() (string, error) { return path, nil } -// --- 平台/项目 树形结构 --- +// --- 项目/分组 树形结构 --- // TreeNode 前端侧边栏树节点 type TreeNode struct { Key string `json:"key"` Label string `json:"label"` - Type string `json:"type"` // platform, user, project + Type string `json:"type"` // group, project Path string `json:"path,omitempty"` + Icon string `json:"icon,omitempty"` Children []TreeNode `json:"children,omitempty"` } -// GetProjectTree 获取项目树,供前端侧边栏渲染 +// GetProjectTree 获取项目树,按分组组织 func (s *AppService) GetProjectTree() []TreeNode { - var tree []TreeNode - for platformName, platform := range s.config.Platforms { - platformNode := TreeNode{ - Key: platformName, - Label: platformName, - Type: "platform", + // 按分组归类项目 + groupProjects := make(map[string][]config.ProjectItem) + for _, proj := range s.config.Projects { + g := proj.Group + if g == "" { + g = "未分组" } - for _, user := range platform.Users { - userNode := TreeNode{ - Key: platformName + "/" + user.Username, - Label: user.Username, - Type: "user", - } - for _, proj := range user.Projects { - userNode.Children = append(userNode.Children, TreeNode{ - Key: platformName + "/" + user.Username + "/" + proj.Name, - Label: proj.Name, - Type: "project", - Path: proj.Path, - }) - } - platformNode.Children = append(platformNode.Children, userNode) - } - tree = append(tree, platformNode) + groupProjects[g] = append(groupProjects[g], proj) } + + // 构建分组 → icon 映射 + groupIcon := make(map[string]string) + for _, g := range s.config.Groups { + groupIcon[g.Name] = g.Icon + } + + var tree []TreeNode + // 先输出已定义的分组(保持顺序) + rendered := make(map[string]bool) + for _, g := range s.config.Groups { + projects, ok := groupProjects[g.Name] + if !ok { + projects = nil + } + node := TreeNode{ + Key: "group:" + g.Name, + Label: g.Name, + Type: "group", + Icon: g.Icon, + } + for _, proj := range projects { + node.Children = append(node.Children, TreeNode{ + Key: "project:" + proj.Path, + Label: proj.Name, + Type: "project", + Path: proj.Path, + }) + } + tree = append(tree, node) + rendered[g.Name] = true + } + + // 输出"未分组"或不在 groups 列表里的项目 + for groupName, projects := range groupProjects { + if rendered[groupName] { + continue + } + node := TreeNode{ + Key: "group:" + groupName, + Label: groupName, + Type: "group", + Icon: "folder", + } + for _, proj := range projects { + node.Children = append(node.Children, TreeNode{ + Key: "project:" + proj.Path, + Label: proj.Name, + Type: "project", + Path: proj.Path, + }) + } + tree = append(tree, node) + } + return tree } // --- 项目管理 --- -// CloneProject 克隆远程仓库到本地目录,并添加到项目树 -func (s *AppService) CloneProject(platform, username, repoURL, parentDir, name string) error { +// CloneProject 克隆远程仓库到本地目录,并添加到项目列表 +func (s *AppService) CloneProject(repoURL, parentDir, name, group string) error { if repoURL == "" { return fmt.Errorf("仓库地址不能为空") } @@ -109,198 +151,219 @@ func (s *AppService) CloneProject(platform, username, repoURL, parentDir, name s return fmt.Errorf("项目名称不能为空") } - // 目标路径: parentDir/name targetPath := parentDir + "/" + name - // 执行 git clone _, err := s.gitClient.Clone(repoURL, targetPath) if err != nil { return fmt.Errorf("克隆失败: %w", err) } - // 克隆成功后添加到项目树 - return s.AddProject(platform, username, name, targetPath) + return s.AddProject(name, targetPath, group) } -// AddProject 添加项目到指定平台/用户下 -func (s *AppService) AddProject(platform, username, name, path string) error { - p, ok := s.config.Platforms[platform] - if !ok { - return fmt.Errorf("平台 %s 不存在", platform) +// AddProject 添加项目 +func (s *AppService) AddProject(name, path, group string) error { + // 检查路径重复 + for _, proj := range s.config.Projects { + if proj.Path == path { + return fmt.Errorf("项目路径 %s 已存在", path) + } } - for i, user := range p.Users { - if user.Username == username { - // 检查重复 - for _, proj := range user.Projects { - if proj.Name == name { - return fmt.Errorf("项目 %s 已存在", name) - } + s.config.Projects = append(s.config.Projects, config.ProjectItem{ + Name: name, + Path: path, + Group: group, + }) + // 如果分组不存在,自动创建 + if group != "" { + found := false + for _, g := range s.config.Groups { + if g.Name == group { + found = true + break } - s.config.Platforms[platform].Users[i].Projects = append( - s.config.Platforms[platform].Users[i].Projects, - config.Project{Name: name, Path: path}, - ) + } + if !found { + s.config.Groups = append(s.config.Groups, config.Group{Name: group, Icon: "folder"}) + } + } + return config.SaveConfig(s.config) +} + +// RemoveProject 删除项目(按路径匹配) +func (s *AppService) RemoveProject(path string) error { + for i, proj := range s.config.Projects { + if proj.Path == path { + s.config.Projects = append(s.config.Projects[:i], s.config.Projects[i+1:]...) return config.SaveConfig(s.config) } } - return fmt.Errorf("用户 %s 不存在于平台 %s", username, platform) + return fmt.Errorf("项目不存在: %s", path) } -// RemoveProject 从指定平台/用户下删除项目 -func (s *AppService) RemoveProject(platform, username, name string) error { - p, ok := s.config.Platforms[platform] - if !ok { - return fmt.Errorf("平台 %s 不存在", platform) - } - for i, user := range p.Users { - if user.Username == username { - projects := user.Projects - for j, proj := range projects { - if proj.Name == name { - s.config.Platforms[platform].Users[i].Projects = append(projects[:j], projects[j+1:]...) - return config.SaveConfig(s.config) +// MoveProjectToGroup 移动项目到指定分组 +func (s *AppService) MoveProjectToGroup(path, group string) error { + for i, proj := range s.config.Projects { + if proj.Path == path { + s.config.Projects[i].Group = group + // 自动创建分组 + if group != "" { + found := false + for _, g := range s.config.Groups { + if g.Name == group { + found = true + break + } + } + if !found { + s.config.Groups = append(s.config.Groups, config.Group{Name: group, Icon: "folder"}) } } - return fmt.Errorf("项目 %s 不存在", name) + return config.SaveConfig(s.config) } } - return fmt.Errorf("用户 %s 不存在于平台 %s", username, platform) + return fmt.Errorf("项目不存在: %s", path) } -// --- 平台管理 --- +// --- 分组管理 --- -// AddPlatform 添加新平台 -func (s *AppService) AddPlatform(name, baseURL string) error { +// GetGroups 获取所有分组 +func (s *AppService) GetGroups() []config.Group { + return s.config.Groups +} + +// AddGroup 添加分组 +func (s *AppService) AddGroup(name, icon string) error { if name == "" { - return fmt.Errorf("平台名称不能为空") + return fmt.Errorf("分组名称不能为空") } - if _, ok := s.config.Platforms[name]; ok { - return fmt.Errorf("平台 %s 已存在", name) + for _, g := range s.config.Groups { + if g.Name == name { + return fmt.Errorf("分组 %s 已存在", name) + } } - s.config.Platforms[name] = config.Platform{ - BaseURL: baseURL, - Users: []config.User{}, + if icon == "" { + icon = "folder" } + s.config.Groups = append(s.config.Groups, config.Group{Name: name, Icon: icon}) return config.SaveConfig(s.config) } -// UpdatePlatform 修改平台信息(base_url) -func (s *AppService) UpdatePlatform(name, baseURL string) error { - p, ok := s.config.Platforms[name] - if !ok { - return fmt.Errorf("平台 %s 不存在", name) +// UpdateGroup 修改分组 +func (s *AppService) UpdateGroup(oldName, newName, icon string) error { + if newName == "" { + return fmt.Errorf("分组名称不能为空") } - p.BaseURL = baseURL - s.config.Platforms[name] = p - return config.SaveConfig(s.config) + for i, g := range s.config.Groups { + if g.Name == oldName { + // 如果改名,检查冲突并同步更新项目的分组引用 + if oldName != newName { + for _, g2 := range s.config.Groups { + if g2.Name == newName { + return fmt.Errorf("分组 %s 已存在", newName) + } + } + for j, proj := range s.config.Projects { + if proj.Group == oldName { + s.config.Projects[j].Group = newName + } + } + } + s.config.Groups[i].Name = newName + if icon != "" { + s.config.Groups[i].Icon = icon + } + return config.SaveConfig(s.config) + } + } + return fmt.Errorf("分组 %s 不存在", oldName) } -// RemovePlatform 删除平台 -func (s *AppService) RemovePlatform(name string) error { - if _, ok := s.config.Platforms[name]; !ok { - return fmt.Errorf("平台 %s 不存在", name) +// RemoveGroup 删除分组(分组下的项目变为"未分组") +func (s *AppService) RemoveGroup(name string) error { + for i, g := range s.config.Groups { + if g.Name == name { + s.config.Groups = append(s.config.Groups[:i], s.config.Groups[i+1:]...) + // 将该分组下项目归入未分组 + for j, proj := range s.config.Projects { + if proj.Group == name { + s.config.Projects[j].Group = "" + } + } + return config.SaveConfig(s.config) + } } - delete(s.config.Platforms, name) - return config.SaveConfig(s.config) + return fmt.Errorf("分组 %s 不存在", name) } -// --- 用户管理 --- +// --- 凭证管理 --- -// AddUser 添加用户到指定平台 -func (s *AppService) AddUser(platform, username, token string) error { - p, ok := s.config.Platforms[platform] - if !ok { - return fmt.Errorf("平台 %s 不存在", platform) +// CredentialInfo 凭证信息(前端展示用) +type CredentialInfo struct { + Platform string `json:"platform"` + BaseURL string `json:"baseUrl"` + Username string `json:"username"` + Token string `json:"token"` +} + +// GetCredentials 获取所有凭证 +func (s *AppService) GetCredentials() []CredentialInfo { + var result []CredentialInfo + for _, c := range s.config.Credentials { + result = append(result, CredentialInfo{ + Platform: c.Platform, + BaseURL: c.BaseURL, + Username: c.Username, + Token: c.Token, + }) + } + return result +} + +// AddCredential 添加凭证 +func (s *AppService) AddCredential(platform, baseURL, username, token string) error { + if platform == "" { + return fmt.Errorf("平台不能为空") } if username == "" { return fmt.Errorf("用户名不能为空") } - for _, user := range p.Users { - if user.Username == username { - return fmt.Errorf("用户 %s 已存在于平台 %s", username, platform) + // 检查重复(同平台同用户名) + for _, c := range s.config.Credentials { + if c.Platform == platform && c.Username == username { + return fmt.Errorf("凭证已存在: %s/%s", platform, username) } } - p.Users = append(p.Users, config.User{ + s.config.Credentials = append(s.config.Credentials, config.Credential{ + Platform: platform, + BaseURL: baseURL, Username: username, Token: token, - Projects: []config.Project{}, }) - s.config.Platforms[platform] = p return config.SaveConfig(s.config) } -// UpdateUser 修改用户信息(用户名、token) -func (s *AppService) UpdateUser(platform, oldUsername, newUsername, token string) error { - p, ok := s.config.Platforms[platform] - if !ok { - return fmt.Errorf("平台 %s 不存在", platform) - } - if newUsername == "" { - return fmt.Errorf("新用户名不能为空") - } - for i, user := range p.Users { - if user.Username == oldUsername { - // 如果改了用户名,检查新名字是否冲突 - if oldUsername != newUsername { - for _, u := range p.Users { - if u.Username == newUsername { - return fmt.Errorf("用户 %s 已存在于平台 %s", newUsername, platform) - } - } - } - s.config.Platforms[platform].Users[i].Username = newUsername - s.config.Platforms[platform].Users[i].Token = token +// UpdateCredential 更新凭证 +func (s *AppService) UpdateCredential(platform, username, baseURL, token string) error { + for i, c := range s.config.Credentials { + if c.Platform == platform && c.Username == username { + s.config.Credentials[i].BaseURL = baseURL + s.config.Credentials[i].Token = token return config.SaveConfig(s.config) } } - return fmt.Errorf("用户 %s 不存在于平台 %s", oldUsername, platform) + return fmt.Errorf("凭证不存在: %s/%s", platform, username) } -// RemoveUser 从平台删除用户 -func (s *AppService) RemoveUser(platform, username string) error { - p, ok := s.config.Platforms[platform] - if !ok { - return fmt.Errorf("平台 %s 不存在", platform) - } - for i, user := range p.Users { - if user.Username == username { - s.config.Platforms[platform] = config.Platform{ - BaseURL: p.BaseURL, - Users: append(p.Users[:i], p.Users[i+1:]...), - } +// RemoveCredential 删除凭证 +func (s *AppService) RemoveCredential(platform, username string) error { + for i, c := range s.config.Credentials { + if c.Platform == platform && c.Username == username { + s.config.Credentials = append(s.config.Credentials[:i], s.config.Credentials[i+1:]...) return config.SaveConfig(s.config) } } - return fmt.Errorf("用户 %s 不存在于平台 %s", username, platform) -} - -// GetUserInfo 获取用户信息 -func (s *AppService) GetUserInfo(platform, username string) (*UserInfo, error) { - p, ok := s.config.Platforms[platform] - if !ok { - return nil, fmt.Errorf("平台 %s 不存在", platform) - } - for _, user := range p.Users { - if user.Username == username { - return &UserInfo{ - Username: user.Username, - Token: user.Token, - }, nil - } - } - return nil, fmt.Errorf("用户 %s 不存在于平台 %s", username, platform) -} - -// GetPlatformInfo 获取平台信息 -func (s *AppService) GetPlatformInfo(name string) (*PlatformInfo, error) { - p, ok := s.config.Platforms[name] - if !ok { - return nil, fmt.Errorf("平台 %s 不存在", name) - } - return &PlatformInfo{ - Name: name, - BaseURL: p.BaseURL, - }, nil + return fmt.Errorf("凭证不存在: %s/%s", platform, username) } // --- Git 操作 --- @@ -312,6 +375,7 @@ type ProjectStatus struct { Remotes []RemoteItem `json:"remotes"` CurrentRemote string `json:"currentRemote"` ChangedFiles []FileInfo `json:"changedFiles"` + UseProxy *bool `json:"useProxy"` // nil=跟随全局, true=开启, false=关闭 } // RemoteItem 远程仓库信息 @@ -328,18 +392,6 @@ type FileInfo struct { Staged bool `json:"staged"` } -// UserInfo 用户信息 -type UserInfo struct { - Username string `json:"username"` - Token string `json:"token"` -} - -// PlatformInfo 平台信息 -type PlatformInfo struct { - Name string `json:"name"` - BaseURL string `json:"baseUrl"` -} - // GetProjectStatus 获取项目 git 状态 func (s *AppService) GetProjectStatus(path string) (*ProjectStatus, error) { // 先校验路径是否存在 @@ -367,12 +419,22 @@ func (s *AppService) GetProjectStatus(path string) (*ProjectStatus, error) { currentRemote = remotes[0].Name } + // 获取项目代理设置 + var useProxy *bool + for _, proj := range s.config.Projects { + if proj.Path == path { + useProxy = proj.UseProxy + break + } + } + return &ProjectStatus{ Branch: strings.TrimSpace(branch), RemoteURL: remoteURL, Remotes: remotes, CurrentRemote: currentRemote, ChangedFiles: []FileInfo{}, + UseProxy: useProxy, }, nil } @@ -547,7 +609,8 @@ func (s *AppService) PullProject(path, remote string) (string, error) { if err != nil { return "", fmt.Errorf("获取当前分支失败: %w", err) } - return s.gitClient.PullFrom(path, remote, strings.TrimSpace(branch)) + proxy := s.GetProjectProxy(path) + return s.gitClient.RunWithProxy(path, proxy, "pull", remote, strings.TrimSpace(branch)) } // PushProject 推送项目(当前分支,指定 remote) @@ -559,7 +622,102 @@ func (s *AppService) PushProject(path, remote string) (string, error) { if err != nil { return "", fmt.Errorf("获取当前分支失败: %w", err) } - return s.gitClient.PushTo(path, remote, strings.TrimSpace(branch)) + proxy := s.GetProjectProxy(path) + return s.gitClient.RunWithProxy(path, proxy, "push", remote, strings.TrimSpace(branch)) +} + +// PushAllResult 一键多端推送结果 +type PushAllResult struct { + Remote string `json:"remote"` + Success bool `json:"success"` + Message string `json:"message"` +} + +// PushToAllRemotes 一键推送到所有远程仓库(当前分支) +func (s *AppService) PushToAllRemotes(path string) ([]PushAllResult, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, fmt.Errorf("项目路径不存在: %s", path) + } + branch, err := s.gitClient.Branch(path) + if err != nil { + return nil, fmt.Errorf("获取当前分支失败: %w", err) + } + branch = strings.TrimSpace(branch) + + remoteList, err := s.gitClient.RemoteList(path) + if err != nil { + return nil, fmt.Errorf("获取远程仓库列表失败: %w", err) + } + + proxy := s.GetProjectProxy(path) + var results []PushAllResult + for _, r := range remoteList { + result := PushAllResult{Remote: r.Name} + _, pushErr := s.gitClient.RunWithProxy(path, proxy, "push", r.Name, branch) + if pushErr != nil { + result.Message = pushErr.Error() + } else { + result.Success = true + result.Message = "推送成功" + } + results = append(results, result) + } + return results, nil +} + +// PushTagToAllRemotes 一键推送标签到所有远程仓库 +func (s *AppService) PushTagToAllRemotes(path, tagName string) ([]PushAllResult, error) { + if _, err := os.Stat(path); os.IsNotExist(err) { + return nil, fmt.Errorf("项目路径不存在: %s", path) + } + tagName = strings.TrimSpace(tagName) + if tagName == "" { + return nil, fmt.Errorf("标签名不能为空") + } + + remoteList, err := s.gitClient.RemoteList(path) + if err != nil { + return nil, fmt.Errorf("获取远程仓库列表失败: %w", err) + } + + proxy := s.GetProjectProxy(path) + var results []PushAllResult + for _, r := range remoteList { + result := PushAllResult{Remote: r.Name} + _, pushErr := s.gitClient.RunWithProxy(path, proxy, "push", r.Name, tagName) + if pushErr != nil { + result.Message = pushErr.Error() + } else { + result.Success = true + result.Message = "推送成功" + } + results = append(results, result) + } + return results, nil +} + +// --- 项目代理开关 --- + +// GetProjectProxy 获取项目代理设置(nil 表示跟随全局) +func (s *AppService) GetProjectProxy(path string) *bool { + for _, proj := range s.config.Projects { + if proj.Path == path { + return proj.UseProxy + } + } + return nil +} + +// SetProjectProxy 设置项目代理开关 +// useProxy: true=强制启用, false=强制禁用, nil(传空)=跟随全局 +func (s *AppService) SetProjectProxy(path string, useProxy *bool) error { + for i, proj := range s.config.Projects { + if proj.Path == path { + s.config.Projects[i].UseProxy = useProxy + return config.SaveConfig(s.config) + } + } + return fmt.Errorf("项目不存在: %s", path) } // GetCommitDiff 获取指定提交的 diff @@ -612,10 +770,11 @@ func (s *AppService) GetCommitFileDiff(path, hash, filePath string) (string, err // FetchProject 拉取远程信息(指定 remote,空则 fetch --all) func (s *AppService) FetchProject(path, remote string) (string, error) { + proxy := s.GetProjectProxy(path) if remote == "" { - return s.gitClient.Fetch(path) + return s.gitClient.RunWithProxy(path, proxy, "fetch") } - return s.gitClient.FetchRemote(path, remote) + return s.gitClient.RunWithProxy(path, proxy, "fetch", remote) } // CommitLog 提交记录 @@ -1269,30 +1428,26 @@ type ProjectOverview struct { // GetAllProjectOverview 获取所有项目的概览状态 func (s *AppService) GetAllProjectOverview() []ProjectOverview { var results []ProjectOverview - for platformName, platform := range s.config.Platforms { - for _, user := range platform.Users { - for _, proj := range user.Projects { - overview := ProjectOverview{ - Key: platformName + "/" + user.Username + "/" + proj.Name, - Name: proj.Name, - Path: proj.Path, - } - if _, err := os.Stat(proj.Path); os.IsNotExist(err) { - overview.Error = "路径不存在" - results = append(results, overview) - continue - } - branch, hasChanges, unpushed, err := s.gitClient.QuickStatus(proj.Path) - if err != nil { - overview.Error = err.Error() - } else { - overview.Branch = branch - overview.HasChanges = hasChanges - overview.Unpushed = unpushed - } - results = append(results, overview) - } + for _, proj := range s.config.Projects { + overview := ProjectOverview{ + Key: proj.Path, + Name: proj.Name, + Path: proj.Path, } + if _, err := os.Stat(proj.Path); os.IsNotExist(err) { + overview.Error = "路径不存在" + results = append(results, overview) + continue + } + branch, hasChanges, unpushed, err := s.gitClient.QuickStatus(proj.Path) + if err != nil { + overview.Error = err.Error() + } else { + overview.Branch = branch + overview.HasChanges = hasChanges + overview.Unpushed = unpushed + } + results = append(results, overview) } return results } diff --git a/internal/git/client.go b/internal/git/client.go index a445b76..805f95b 100644 --- a/internal/git/client.go +++ b/internal/git/client.go @@ -29,10 +29,20 @@ func NewGitClient() *GitClient { // Run 执行 git 命令,支持超时和代理设置 func (g *GitClient) Run(path string, args ...string) (string, error) { + return g.RunWithProxy(path, nil, args...) +} + +// RunWithProxy 执行 git 命令,useProxy 可覆盖全局代理设置 +// useProxy == nil 时跟随 GitClient 自身的 Enabled 设置 +func (g *GitClient) RunWithProxy(path string, useProxy *bool, args ...string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), g.Timeout) defer cancel() header := []string{"-C", path, "-c", "core.quotePath=false"} - if g.Enabled { + enableProxy := g.Enabled + if useProxy != nil { + enableProxy = *useProxy + } + if enableProxy { header = append(header, "-c", "http.proxy="+g.Proxy, "-c", "https.proxy="+g.Proxy) } argsArr := append(header, args...)