Compare commits
10
Commits
75dec8fa90
...
0945b9ee30
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0945b9ee30 | ||
|
|
f6c878edaa | ||
|
|
aff36b09f0 | ||
|
|
06fb5ecec2 | ||
|
|
eee0170a63 | ||
|
|
9f9d6be911 | ||
|
|
58ff296f30 | ||
|
|
8d86d3ef95 | ||
|
|
4de284578e | ||
|
|
6c05b7f7e3 |
+163
-59
@@ -1,92 +1,196 @@
|
|||||||
# Superpowers-clurdra
|
# Superpowers-clurdra
|
||||||
|
|
||||||
一个用于 VS Code 的 [obra/superpowers](https://github.com/obra/superpowers) 文档浏览与执行插件,聚焦 `docs/superpowers/` 目录下的 spec 和 plan 文件。
|
把 Gitea 工单变成 VS Code 里的看板,每张卡片背后挂着一组 AI 会话(头脑风暴 → 实施 → 审查 → 测试),从建工单到合并 PR 全程在编辑器里完成。
|
||||||
|
|
||||||
## 功能
|
- 数据源:**Gitea** 工单(主)+ **YouTrack** 工单(可选镜像)
|
||||||
|
- 执行者:`claude` CLI 负责规划 / 实施 / 测试 / 提交,`codex` 负责审查
|
||||||
|
- 状态存放:工单评论里的一段 state JSON,多人、多机共享;本机专属字段(会话 id、worktree 路径等)留在本机
|
||||||
|
- 联动:Gitea webhook 推回本机,PR 开了自动审查、审查意见自动注入实施终端、合并后自动清 worktree 和分支
|
||||||
|
|
||||||
- 在活动栏提供 `Superpowers` 入口,打开专用面板
|
## 工作流
|
||||||
- 自动扫描 `docs/superpowers/specs` 和 `docs/superpowers/plans`
|
|
||||||
- 用表格展示 spec、plan、日期、进度与状态
|
|
||||||
- 支持刷新、预览打开、删除关联 spec/plan 文件
|
|
||||||
- 支持在面板里直接运行 plan
|
|
||||||
- 运行 plan 前自动创建独立 git worktree,隔离实现过程
|
|
||||||
- 支持把 plan 状态标记为进行中、需要测试、已完成
|
|
||||||
|
|
||||||
## 适用目录结构
|
```
|
||||||
|
待办 ──拖到进行中──▶ 进行中 ──PR 打开──▶ 审查 ──拖到完成──▶ 完成
|
||||||
```text
|
│ │ │ │
|
||||||
docs/
|
头脑风暴会话 实施会话 审查会话(codex) 插件代为合并 PR
|
||||||
superpowers/
|
产出 spec / plan 独立 worktree 意见注入实施终端 关工单 · 删 worktree · 删分支
|
||||||
specs/
|
+ feature 分支 测试会话可随时开
|
||||||
plans/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
插件会读取 Markdown 文件的第一个 H1 作为标题,并从文件名中提取日期。
|
1. **新建工单**:待办列头的 `+` → 写需求(可粘贴截图)→ 开一个 `claude` 头脑风暴会话,它用 `spx issue create` 建工单并回填 spec / plan 路径。
|
||||||
|
2. **实施**:把卡拖到「进行中」(或点计划文件行的 ▶)。插件创建 `feature/<slug>` 分支 + git worktree,在里面开 `issue-N-实施` 终端并发实施 prompt;实施方在 PR body 里写 `Closes #N`。
|
||||||
|
3. **审查**:PR opened / synchronize 的 webhook 到达后,插件自动开 `issue-N-审查`(codex);审查意见通过 `spx pr review-comment` 发到 PR,webhook 再把意见注入实施终端形成闭环。
|
||||||
|
4. **测试**:工单面板里 ▶ 开 `issue-N-测试` 会话(`/pr-acceptance-testing`)。
|
||||||
|
5. **完成**:拖到「完成」列。有 PR 就由插件合并(冲突时开 `issue-N-冲突解决` 会话,解决后再拖一次),然后关工单、把会话记录拷回主仓库、删 worktree、删 feature 分支。
|
||||||
|
6. **回退**:进行中 / 审查中的卡可「重置为待办」,关 PR、删 worktree、清分支和实施痕迹,spec / plan 保留。
|
||||||
|
|
||||||
## Plan 运行方式
|
其他看板规则:
|
||||||
|
|
||||||
运行 plan 时,插件会:
|
- 待办列里卡片拖到另一张卡上 = 设前置依赖,拖回列头 = 清依赖。前置未完成的卡显示 🔒,不能拖出待办、不能开会话。待办列按依赖链渲染成缩进树。
|
||||||
|
- 完成列按合并时间倒序,其余列按工单号倒序。
|
||||||
|
- 每张卡首次开会话时随机分配一个颜色,之后该工单所有终端 tab 用同色圆点。
|
||||||
|
- 选中卡片会聚焦它的终端(新建 > 实施 > 规划 > 审查 > 测试);点终端 tab 也会反向选中卡片。
|
||||||
|
- 键盘:`↑↓←→` 移动选中,`Enter` 恢复实施会话(没有则头脑风暴会话),`Esc` 取消选中。
|
||||||
|
- 看板范围:工具栏 👤/👥 在「我创建或指派给我的」与「仓库全部」之间切换。
|
||||||
|
|
||||||
1. 根据 plan 文件名生成 `feature/...` 分支名
|
## 环境要求
|
||||||
2. 按配置创建 git worktree
|
|
||||||
3. 用 `systemd-run --user` 在 worktree 目录后台执行 `opencode run`
|
|
||||||
|
|
||||||
默认执行命令等价于:
|
| 工具 | 用途 |
|
||||||
|
|---|---|
|
||||||
|
| `git` | worktree、分支、fetch / merge |
|
||||||
|
| `claude` | 规划、实施、测试、冲突解决、提交、PR 摘要(找不到时会提示常见位置 `~/.local/bin`) |
|
||||||
|
| `codex` | 审查会话(开启自动审查时必需) |
|
||||||
|
| `tea` | `spx` 从 `~/.config/tea/config.yml` 读取 Gitea host + token;审查 prompt 也用 `tea pulls` 看 PR |
|
||||||
|
| `spx` | 本仓库 `cli/` 里的 Go CLI,见下文 |
|
||||||
|
| `opencli` | prompts 里统一以 `opencli spx ...` 调用 spx |
|
||||||
|
| `bash` | 执行钩子脚本 |
|
||||||
|
|
||||||
|
- 仅支持 Gitea(`https://<host>/api/v1`),不支持 GitHub。
|
||||||
|
- POSIX 环境。删 worktree 前扫 `/proc` 杀占用进程只在 Linux 生效,macOS 上降级为直接删。
|
||||||
|
- 依赖内置 `vscode.git` 扩展检测工作区改动(提交按钮)。
|
||||||
|
|
||||||
|
### 安装 spx
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemd-run --user --unit=opencode-plan-xxx --working-directory '/path/to/worktree' zsh -c 'opencode run '\''实施 docs/superpowers/plans/xxx.md'\'' --model '\''alibaba-coding-plan-cn/glm-5'\'' --agent '\''build'\'''
|
cd cli && make install # 编译到 bin/spx 并安装到 ~/.local/bin/
|
||||||
```
|
```
|
||||||
|
|
||||||
## 配置项
|
`spx` 命令:
|
||||||
|
|
||||||
插件提供这些 VS Code 配置:
|
| 命令 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `spx issue create --title ... [--body-file F] [--spec P] [--plan P] [--state-json J]` | 建工单并认领;spec / plan 同时写 body marker 和 state JSON |
|
||||||
|
| `spx issue marker --issue N --type spec\|plan --value P` | 更新 body 里的 `<!-- spx:spec=... -->` / `<!-- spx:plan=... -->` 并同步 state |
|
||||||
|
| `spx issue state get --issue N` | 读 state JSON |
|
||||||
|
| `spx issue state merge --issue N --state-json J` | 浅合并写回(按 schema 校验) |
|
||||||
|
| `spx pr review-comment --pr N --body-file F` | 发审查评论,自动加 `<!-- spx:review=1 -->` 标记 |
|
||||||
|
|
||||||
- `superpowers.runMessage`: 运行 plan 时传给 `opencode run` 的消息模板,支持 `$plan_relative_path`
|
全局 flag:`--repo OWNER/REPO`(默认从 origin 推断)、`--host`、`--json`、`--cwd`。tea 用 keyring 存 token 时需设 `GITEA_TOKEN` 环境变量。
|
||||||
- `superpowers.runModel`: 运行 plan 时使用的模型
|
|
||||||
- `superpowers.runAgent`: 运行 plan 时使用的 agent
|
|
||||||
- `superpowers.worktreeDirectory`: worktree 目录模板,支持 `$project_root`、`$project_name`、`$feature_name`
|
|
||||||
|
|
||||||
默认值:
|
### Gitea 配置
|
||||||
|
|
||||||
|
1. 打开看板 → 设置 → 认证,填 Gitea host 和 Personal Access Token(存 VS Code SecretStorage)。状态栏会常驻显示当前身份 `spx: <login>`,token 失效变红;Gitea 邮箱和 `git config user.email` 不一致时会警告。
|
||||||
|
2. 在仓库上配一个 webhook 指向本机的 `http://<公网地址>/webhook`(本机默认监听 `17421`,通常经 frp 之类反代暴露),事件至少勾选 **Issues、Issue Comment、Pull Request、Push**。建议设置 secret 并在插件设置里同填,否则不校验签名。
|
||||||
|
3. webhook 会广播到每个开发者的机器,只有工单 assignee 所在的机器执行副作用(写状态、触发审查、注入终端),其他机器只刷新 UI。实施即认领,所以正常情况下不用手动指派。
|
||||||
|
|
||||||
|
### YouTrack(可选)
|
||||||
|
|
||||||
|
设置 → YouTrack 填 base URL、Permanent Token、项目 shortName 后,工具栏出现「导入 YouTrack 工单」,QuickPick 多选后镜像到看板(卡片 id 形如 `youtrack:LXF-12`)。状态同样以评论形式存在 YouTrack 工单上;拖到完成列执行 `youtrackCloseCommand`(留空则自动取项目第一个已解决状态)。
|
||||||
|
|
||||||
|
## 面板
|
||||||
|
|
||||||
|
编辑器主区的 webview = 顶部工具栏 + 四列看板 + 底部四个 tab。
|
||||||
|
|
||||||
|
**工具栏**:分支同步(把远端 `autoBuildBranch` 快进到 `devBranch`,纯远端、从不 force push)· `.env*` 文件锁(批量 chmod 只读,首次启动默认锁定)· 提交代码(有未提交改动时才显示,后台跑无头 `claude` 提交)· 导入 YouTrack 工单 · 看板范围 · 刷新 · 设置。
|
||||||
|
|
||||||
|
**工单 tab**:state JSON 的属性网格。会话 id 行双击恢复会话、▶ 新开、✕ 关 tab;spec / plan 行双击打开文件,plan 行 ▶ 实施;PR 行双击打开浏览器、▶ 生成 PR 变更摘要(写到 `docs/pr-diff/`);分支行 🔀 本地预合并;工作树行双击用 VS Code / PyCharm 打开、🗑 删除。顶部有重置为待办、关闭工单、删除工单(关 tab → 删 worktree → 关 PR → 删分支 → 删 issue)、查看日志。
|
||||||
|
|
||||||
|
**Profile tab**:工作区级 KV 表(行 = key,列 = dev / prod 等),值可多行、可贴图,落盘 `.spx/profiles.json`。
|
||||||
|
|
||||||
|
**会话 tab**:项目级 Claude 会话管理器——新建(可指定 profile 和首条 prompt)、重命名、恢复、导入已 `/rename` 的会话。落盘 `.spx/session-names.json`。
|
||||||
|
|
||||||
|
**改动 tab**:PR 文件审阅。左侧文件树 + 统计(可过滤 `tests/`),右侧内联 diff(统一 / 分栏),「已查看」按文件变更指纹失效,每个文件可用 deepseek 生成一句话改动说明。
|
||||||
|
|
||||||
|
也可以从外部拉起会话:`vscode://clurdra.superpowers-vscode-clurdra/create-session?profile=<名>&name=<tab名>&prompt=<提示词>&cwd=<路径>`(参数需两层 urlencode)。
|
||||||
|
|
||||||
|
## 会话与 profile
|
||||||
|
|
||||||
|
| 会话 | 终端名 | CLI | 工作目录 | profile |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 新建工单 | `issue-new-<nonce>-规划` | claude | 主仓库 | 弹窗里选 |
|
||||||
|
| 头脑风暴(续) | `issue-N-规划` | claude | 主仓库 | 工单 `brainstormProfilePath` |
|
||||||
|
| 实施 | `issue-N-实施` | claude `--effort high` | worktree | 工单 `profilePath` |
|
||||||
|
| 审查 | `issue-N-审查` | codex | worktree | 设置里的 `codexModel` / `codexReasoningEffort` |
|
||||||
|
| 测试 | `issue-N-测试` | claude | worktree | 工单 `testProfilePath` |
|
||||||
|
| 冲突解决 | `issue-N-冲突解决` | claude `--effort high` | worktree | 设置 `conflictResolutionProfilePath` |
|
||||||
|
|
||||||
|
所有终端固定开在编辑器第二分组。claude 会话统一以 `--settings <profile.json> --dangerously-skip-permissions` 启动,后台无头调用会剥掉 `ANTHROPIC_*` 环境变量,让 profile 成为唯一鉴权来源。
|
||||||
|
|
||||||
|
profile 是 Claude 的 settings JSON 文件。目录按 `$SUPERPOWERS_PROFILES_DIR` → 设置 `profilesDirectory` → `~/Sources/cruldra-profile/claude-config/profiles` / `~/.claude/profile-settings` / `~/.claude/profiles` 顺序探测;三种会话 profile 留空时各自回落默认 profile(`offical.json` / `official.json`),互不回退。跨机器的绝对路径按文件名重新映射。
|
||||||
|
|
||||||
|
prompts 在 `prompts/*.md`,随 VSIX 一起安装,改完即生效,也可在设置 → 提示词里覆盖:
|
||||||
|
|
||||||
|
| 文件 | 占位符 |
|
||||||
|
|---|---|
|
||||||
|
| `brainstorm.md` | `{userRequest}` `{nonce}` |
|
||||||
|
| `brainstorm-continue.md` | `{issueNumber}` |
|
||||||
|
| `implement-plan.md` | `{planFile}` `{issueNumber}` |
|
||||||
|
| `review.md` | `{prNumber}` |
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|
全部在看板的设置弹窗里(存 globalState),不走 VS Code 原生 settings。
|
||||||
|
|
||||||
|
| 分组 | 键 | 默认 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 认证 | `devBranch` | `main` | 日常开发分支 |
|
||||||
|
| | `autoBuildBranch` | 空 | Jenkins 等监听的分支;空 = 与 `devBranch` 相同,分支同步按钮禁用 |
|
||||||
|
| | `conflictResolutionProfilePath` | 空 | 冲突解决会话 profile |
|
||||||
|
| | `commitProfilePath` | `deepseek-v4-pro` | 「提交代码」用的 profile;未配置时提交直接失败 |
|
||||||
|
| | `codexModel` / `codexReasoningEffort` | 空 | 审查会话的 codex `-c` 覆盖;空 = 用 codex 自己的 config |
|
||||||
|
| 网络 | `webhookPort` | `17421` | 本机 webhook 端口 |
|
||||||
|
| | `webhookSecret` | 空 | Gitea webhook secret,空 = 不校验签名 |
|
||||||
|
| | `autoReview` | `true` | 全局自动审查;工单级 `autoReview` 可覆盖 |
|
||||||
|
| | `profilesDirectory` | 空 | Claude profiles 目录,空 = 自动探测 |
|
||||||
|
| YouTrack | `youtrackBaseUrl` / `youtrackProjectShortName` / `youtrackCloseCommand` | 空 | 见上文 |
|
||||||
|
| 钩子 | `worktreeDirectory` | `~/Sources/worktree/$project_name/$feature_name` | 占位符 `$project_root` `$project_name` `$feature_name` |
|
||||||
|
| | `worktreeOpenWith` / `pycharmCommand` | `vscode` / `pycharm` | 工作树行双击的打开方式 |
|
||||||
|
| | `worktreePostCreateScript` | `.spx/worktree-post-create.sh` | worktree 创建后 |
|
||||||
|
| | `worktreePreRemoveScript` | `.spx/worktree-pre-remove.sh` | worktree 删除前 |
|
||||||
|
| | `implTabPreCreateScript` | `.spx/impl-tab-pre-create.sh` | 实施 / 测试 / 冲突解决 tab 创建前(复用 tab 不触发) |
|
||||||
|
| | `implTabPostCloseScript` | `.spx/impl-tab-post-close.sh` | 实施 tab 关闭后 |
|
||||||
|
|
||||||
|
钩子用 `bash <script>` 执行,cwd 为 worktree,超时 30s,脚本不存在静默跳过,失败只告警不阻断。可用环境变量:`WORKTREE_PATH` `WORKSPACE_ROOT` `BRANCH` `ISSUE_NUMBER` `MAIN_BRANCH`。
|
||||||
|
|
||||||
|
## 工单上的 state JSON
|
||||||
|
|
||||||
|
以工单最后一条 state 评论为准,写入是读 → 浅合并 → 追加新评论,带一次乐观并发重放。schema 在 `schemas/state-json.schema.json`(`spx` 写入前按它校验)。
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `column` | `todo` / `in-progress` / `review` / `done` |
|
||||||
|
| `specFile` / `planFile` | 相对主仓库的 md 路径 |
|
||||||
|
| `branch` / `pr` / `prMerged` / `prMergedAt` | 实施分支与 PR 状态 |
|
||||||
|
| `implementStatus` | `running` / `done` / `failed` |
|
||||||
|
| `color` | 终端 tab 颜色 |
|
||||||
|
| `autoReview` | 工单级自动审查开关 |
|
||||||
|
| `sessionId` / `implementSessionId` / `reviewSessionId` / `testSessionId` | 各会话 id |
|
||||||
|
| `profilePath` / `brainstormProfilePath` / `testProfilePath` | 各会话 profile |
|
||||||
|
| `worktreePath` / `prDiffFile` | 本机路径 |
|
||||||
|
|
||||||
|
最后两行(会话 id、profile、路径共 9 个字段)是**本机字段**,只存在 VS Code workspaceState 里,不写进共享评论,所以同一工单在不同机器上可以各自有会话。
|
||||||
|
|
||||||
|
## 工作区约定
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"superpowers.runMessage": "实施 $plan_relative_path",
|
|
||||||
"superpowers.runModel": "alibaba-coding-plan-cn/glm-5",
|
|
||||||
"superpowers.runAgent": "build",
|
|
||||||
"superpowers.worktreeDirectory": "$project_root.worktrees/$feature_name"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
docs/superpowers/specs/<slug>/spec.md
|
||||||
|
docs/superpowers/plans/<slug>/plan.md
|
||||||
|
docs/pr-diff/pr-<pr>-issue-<n>.md # PR 变更摘要
|
||||||
|
.spx/profiles.json # Profile tab
|
||||||
|
.spx/session-names.json # 会话 tab
|
||||||
|
.spx/pr-review-confirmed.json # 改动 tab 已查看态
|
||||||
|
.spx/pr-file-summaries.json # 改动 tab 文件说明
|
||||||
|
.spx/youtrack-imported.json # YouTrack 导入集
|
||||||
|
.spx/profile-assets/ # Profile 值里粘贴的图片
|
||||||
|
.spx/*.sh # 钩子脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
实施分支名 `feature/<slug>`,slug 取 plan 路径里 `plans/` 后的一段。
|
||||||
|
|
||||||
## 开发
|
## 开发
|
||||||
|
|
||||||
安装依赖:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
```
|
pnpm dev # webview (Vite) + 扩展 (esbuild) 双 watch,配合 .vscode/launch.json 的 Extension 调试
|
||||||
|
pnpm test # vitest,只测不依赖 vscode API 的纯逻辑模块
|
||||||
常用命令:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm build
|
|
||||||
pnpm dev
|
|
||||||
pnpm test
|
|
||||||
pnpm lint
|
pnpm lint
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
pnpm ext:package # 构建并打 VSIX,产物在本目录
|
||||||
```
|
```
|
||||||
|
|
||||||
打包扩展:
|
安装本地包:`code --install-extension superpowers-vscode-clurdra-<ver>.vsix --force`(用了 VS Code profile 的窗口要加 `--profile <name>`)。
|
||||||
|
|
||||||
```bash
|
技术栈:TypeScript、VS Code Extension API、React 19 + Tailwind 4(webview)、esbuild、Vitest;CLI 为 Go + cobra。
|
||||||
pnpm ext:package
|
|
||||||
```
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- TypeScript
|
|
||||||
- VS Code Extension API
|
|
||||||
- reactive-vscode
|
|
||||||
- Vitest
|
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"publisher": "clurdra",
|
"publisher": "clurdra",
|
||||||
"name": "superpowers-vscode-clurdra",
|
"name": "superpowers-vscode-clurdra",
|
||||||
"displayName": "Superpowers-clurdra",
|
"displayName": "Superpowers-clurdra",
|
||||||
"version": "0.2.90",
|
"version": "0.2.95",
|
||||||
"packageManager": "pnpm@10.27.0",
|
"packageManager": "pnpm@10.27.0",
|
||||||
"description": "Superpowers specs and plans Kanban explorer",
|
"description": "Superpowers specs and plans Kanban explorer",
|
||||||
"author": "clurdra",
|
"author": "clurdra",
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#4F46E5"/><stop offset="1" stop-color="#9333EA"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="bolt" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#FEF3C7"/><stop offset="1" stop-color="#FBBF24"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="sh" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="6" stdDeviation="6" flood-color="#1E1B4B" flood-opacity="0.45"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="256" height="256" rx="56" fill="url(#bg)"/>
|
||||||
|
<!-- 三列看板:卡片用白色半透明,形成板面层次 -->
|
||||||
|
<g fill="#fff" fill-opacity="0.22">
|
||||||
|
<rect x="40" y="48" width="52" height="160" rx="12"/>
|
||||||
|
<rect x="102" y="48" width="52" height="160" rx="12"/>
|
||||||
|
<rect x="164" y="48" width="52" height="160" rx="12"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" fill-opacity="0.95">
|
||||||
|
<rect x="48" y="58" width="36" height="26" rx="7"/>
|
||||||
|
<rect x="48" y="92" width="36" height="26" rx="7"/>
|
||||||
|
<rect x="48" y="126" width="36" height="26" rx="7"/>
|
||||||
|
<rect x="110" y="58" width="36" height="26" rx="7"/>
|
||||||
|
<rect x="110" y="92" width="36" height="26" rx="7"/>
|
||||||
|
<rect x="172" y="58" width="36" height="26" rx="7"/>
|
||||||
|
</g>
|
||||||
|
<!-- 闪电:superpower,压在板面右下 -->
|
||||||
|
<path d="M168 104 L120 172 H150 L136 228 L192 152 H160 Z" fill="url(#bolt)" filter="url(#sh)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 16 KiB |
@@ -33,6 +33,7 @@ import * as profileAssets from './handlers/profileAssets'
|
|||||||
import * as sessions from './handlers/sessions'
|
import * as sessions from './handlers/sessions'
|
||||||
import * as settings from './handlers/settings'
|
import * as settings from './handlers/settings'
|
||||||
import * as terminals from './handlers/terminals'
|
import * as terminals from './handlers/terminals'
|
||||||
|
import { focusedTerminal, FocusedTerminalTracker } from './handlers/terminalTabs'
|
||||||
import * as toolbar from './handlers/toolbar'
|
import * as toolbar from './handlers/toolbar'
|
||||||
import * as worktree from './handlers/worktree'
|
import * as worktree from './handlers/worktree'
|
||||||
import * as youtrackIssues from './handlers/youtrackIssues'
|
import * as youtrackIssues from './handlers/youtrackIssues'
|
||||||
@@ -195,7 +196,7 @@ export class KanbanWebviewPanel {
|
|||||||
gitStateDisposable: { dispose: () => void } | undefined
|
gitStateDisposable: { dispose: () => void } | undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近一次 onDidChangeActiveTerminal 触发反选 webview 的时间戳(ms epoch)。
|
* 最近一次终端 tab 切换触发反选 webview 的时间戳(ms epoch)。
|
||||||
* `handleSessionFocus` 收到 webview 回发的 session/focus 时检查这个,
|
* `handleSessionFocus` 收到 webview 回发的 session/focus 时检查这个,
|
||||||
* 距离 <200ms 且工单号相同 → 跳过优先级跳转,避免点审查 tab 自动弹回实施 tab。
|
* 距离 <200ms 且工单号相同 → 跳过优先级跳转,避免点审查 tab 自动弹回实施 tab。
|
||||||
*/
|
*/
|
||||||
@@ -205,22 +206,11 @@ export class KanbanWebviewPanel {
|
|||||||
lastReverseSelectIssueNumber = -1
|
lastReverseSelectIssueNumber = -1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 上一次被 `handleActiveTerminalChanged` 处理的 terminal 引用。同一 terminal
|
* 当前有焦点的会话终端 tab。由 tab 事件推导「活动分组的活动 tab」再做 diff,
|
||||||
* 短时间内重复触发(OSC title 改写、shell prompt 重绘等导致的 onDidChangeTabs
|
* 只有焦点真正落到另一个终端 tab 时才反向选中工单:OSC title 改写不会重复
|
||||||
* 误触)直接 noop,避免 webview 反复 setPendingSelectId 造成 UI 闪烁。
|
* 触发,单 tab 分组从看板点回来(无 isActive / activeTerminal 变化)也能触发。
|
||||||
* `onDidCloseTerminal` 中清掉该 ref。
|
|
||||||
*/
|
*/
|
||||||
// internal: handler 模块访问
|
private readonly focusedTerminalTracker = new FocusedTerminalTracker<Terminal>()
|
||||||
lastActiveTerminalRef: Terminal | undefined
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 我们自己 `show()` 引发的 active terminal 变更会回声到
|
|
||||||
* `handleActiveTerminalChanged`,若再反向选中工单就会和正向(选中→聚焦)
|
|
||||||
* 形成死循环、CPU 飙升。handleSessionFocus show 前把目标终端记在这里,
|
|
||||||
* handleActiveTerminalChanged 命中即吞掉这一次回声(基于引用、不靠时间窗)。
|
|
||||||
*/
|
|
||||||
// internal: handler 模块访问
|
|
||||||
programmaticTabReveal: Terminal | undefined
|
|
||||||
|
|
||||||
// internal: context 供 handler 模块访问
|
// internal: context 供 handler 模块访问
|
||||||
private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) {
|
private constructor(public readonly context: ExtensionContext, panel: WebviewPanel) {
|
||||||
@@ -285,8 +275,6 @@ export class KanbanWebviewPanel {
|
|||||||
const origin = this.terminalOrigin.get(closed)
|
const origin = this.terminalOrigin.get(closed)
|
||||||
// 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。
|
// 反查 terminalOrigin 给详情面板推 *TabOpen: false,让关闭按钮消失。
|
||||||
this.untrackClosedTerminal(closed)
|
this.untrackClosedTerminal(closed)
|
||||||
if (closed === this.lastActiveTerminalRef)
|
|
||||||
this.lastActiveTerminalRef = undefined
|
|
||||||
if (origin?.kind === 'implement' || origin?.kind === 'test')
|
if (origin?.kind === 'implement' || origin?.kind === 'test')
|
||||||
void this.dispatchImplTabPostCloseAsync(origin.issueNumber)
|
void this.dispatchImplTabPostCloseAsync(origin.issueNumber)
|
||||||
// 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。
|
// 冲突解决会话不进 terminalOrigin,按终端名触发 post-close 钩子。
|
||||||
@@ -294,39 +282,12 @@ export class KanbanWebviewPanel {
|
|||||||
if (conflictMatch)
|
if (conflictMatch)
|
||||||
void this.dispatchImplTabPostCloseAsync(Number(conflictMatch[1]))
|
void this.dispatchImplTabPostCloseAsync(Number(conflictMatch[1]))
|
||||||
}),
|
}),
|
||||||
// 用户在 column 2 切换终端 tab 时,反向选中看板上对应的工单卡片。
|
// 用户把焦点切到 column 2 的终端 tab 时,反向选中看板上对应的工单卡片。
|
||||||
window.onDidChangeActiveTerminal((terminal) => {
|
// 会话终端一律开在编辑区(见 resolveTerminalLocation),tabs API 即唯一真相:
|
||||||
if (!terminal)
|
// 分组内切 tab 走 onDidChangeTabs,分组激活(单 tab 分组从看板点回来)
|
||||||
return
|
// 走 onDidChangeTabGroups,两者都只是触发一次重新推导。
|
||||||
this.handleActiveTerminalChanged(terminal)
|
window.tabGroups.onDidChangeTabs(() => this.syncFocusedTerminalTab()),
|
||||||
}),
|
window.tabGroups.onDidChangeTabGroups(() => this.syncFocusedTerminalTab()),
|
||||||
// 补充监听 tab 切换事件:当 column 2 只有一个 terminal tab 时,
|
|
||||||
// 它始终是 active terminal,`onDidChangeActiveTerminal` 不会触发;
|
|
||||||
// 但用户点击该 tab 仍会让它成为 active tab,`onDidChangeTabs` 会以
|
|
||||||
// `changed` + `isActive=true` 的形式投递事件。两者并存时
|
|
||||||
// `handleActiveTerminalChanged` 内的 `lastReverseSelectAt` 时间窗
|
|
||||||
// 已能去重,不会回环放大。
|
|
||||||
window.tabGroups.onDidChangeTabs((e) => {
|
|
||||||
// e.opened 也要处理:新开的终端 tab(如双击会话 id 打开)走 opened,
|
|
||||||
// 不在 changed 里,且 show(preserveFocus) 不一定触发 onDidChangeActiveTerminal,
|
|
||||||
// 否则新 tab 不会反向选中对应工单。
|
|
||||||
for (const tab of [...e.opened, ...e.changed]) {
|
|
||||||
if (!tab.isActive)
|
|
||||||
continue
|
|
||||||
if (!(tab.input instanceof TabInputTerminal))
|
|
||||||
continue
|
|
||||||
// TabInputTerminal 只有构造器、无任何字段,只能用 tab.label
|
|
||||||
// 反查 `window.terminals` 里的实例。终端 name 常被 shell 的
|
|
||||||
// OSC title 序列改写(追加 git branch 等后缀),所以双向 startsWith
|
|
||||||
// 兜底,参考 `injectIntoImplTerminal` 里的命名匹配策略。
|
|
||||||
const label = tab.label
|
|
||||||
const term = window.terminals.find(
|
|
||||||
t => label.startsWith(t.name) || t.name.startsWith(label),
|
|
||||||
)
|
|
||||||
if (term)
|
|
||||||
this.handleActiveTerminalChanged(term)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Start observing the workspace repo so we can hide the "提交代码"
|
// Start observing the workspace repo so we can hide the "提交代码"
|
||||||
@@ -335,6 +296,17 @@ export class KanbanWebviewPanel {
|
|||||||
toolbar.setupGitWatcher(this)
|
toolbar.setupGitWatcher(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private syncFocusedTerminalTab(): void {
|
||||||
|
const term = focusedTerminal(
|
||||||
|
window.tabGroups.activeTabGroup.activeTab,
|
||||||
|
input => input instanceof TabInputTerminal,
|
||||||
|
window.terminals,
|
||||||
|
)
|
||||||
|
const activated = this.focusedTerminalTracker.sync(term)
|
||||||
|
if (activated)
|
||||||
|
this.handleActiveTerminalChanged(activated)
|
||||||
|
}
|
||||||
|
|
||||||
static createOrShow(context: ExtensionContext): void {
|
static createOrShow(context: ExtensionContext): void {
|
||||||
if (KanbanWebviewPanel.current) {
|
if (KanbanWebviewPanel.current) {
|
||||||
KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active)
|
KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
closeIssue,
|
closeIssue,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
deleteIssue,
|
deleteIssue,
|
||||||
|
getDependencies,
|
||||||
getPullRequest,
|
getPullRequest,
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
mergePullRequest,
|
mergePullRequest,
|
||||||
@@ -35,6 +36,7 @@ import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
|
|||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
import { cleanupFeatureBranch } from './branchCleanup'
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
import * as sessions from './sessions'
|
import * as sessions from './sessions'
|
||||||
|
import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset'
|
||||||
|
|
||||||
export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> {
|
export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber: number, toColumn: IssueColumn): Promise<void> {
|
||||||
if (toColumn === 'in-progress') {
|
if (toColumn === 'in-progress') {
|
||||||
@@ -42,6 +44,11 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toColumn === 'todo') {
|
||||||
|
await handleResetToTodo(panel, issueNumber)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (toColumn !== 'done') {
|
if (toColumn !== 'done') {
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
@@ -554,6 +561,290 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
|
const rollback = (fromColumn: IssueColumn | undefined): void => {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'issue/patch',
|
||||||
|
issueNumber,
|
||||||
|
patch: { column: fromColumn ?? 'in-progress' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||||
|
if (!workspaceRoot) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: '请先打开一个工作区文件夹',
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const remote = await detectRepo(workspaceRoot)
|
||||||
|
if (!remote) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: '当前工作区没有 Gitea 远程仓库',
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await getToken(panel.context, remote.host)
|
||||||
|
if (!token) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: '请先完成 Gitea 配置',
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ① 读 state → 守卫(todo no-op / done 拒绝)→ 确认;取消 rollback 必须先有 fromColumn
|
||||||
|
let prStr: string | undefined
|
||||||
|
let worktreePath: string | undefined
|
||||||
|
let fromColumn: IssueColumn | undefined
|
||||||
|
let featureBranchFromState: string | undefined
|
||||||
|
try {
|
||||||
|
const stateObj = await panel.readIssueState(issueNumber)
|
||||||
|
if (typeof stateObj.pr === 'string' && stateObj.pr.length > 0)
|
||||||
|
prStr = stateObj.pr
|
||||||
|
if (typeof stateObj.worktreePath === 'string' && stateObj.worktreePath.length > 0)
|
||||||
|
worktreePath = stateObj.worktreePath
|
||||||
|
if (
|
||||||
|
typeof stateObj.column === 'string'
|
||||||
|
&& ['todo', 'in-progress', 'review', 'done'].includes(stateObj.column)
|
||||||
|
) {
|
||||||
|
fromColumn = stateObj.column as IssueColumn
|
||||||
|
}
|
||||||
|
if (typeof stateObj.branch === 'string' && stateObj.branch.length > 0)
|
||||||
|
featureBranchFromState = stateObj.branch
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `读取工单 #${issueNumber} 状态失败`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `读取工单 #${issueNumber} 状态失败: ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromColumn === 'todo')
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!canResetToTodo(fromColumn ?? 'in-progress')) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `已完成的工单 #${issueNumber} 不能重置为待办`,
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const choice = await window.showWarningMessage(
|
||||||
|
`确定将工单 #${issueNumber} 重置为待办?将停止会话、删除 worktree / 未合并 PR / feature 分支,实施进度不可恢复。`,
|
||||||
|
{ modal: true },
|
||||||
|
'重置为待办',
|
||||||
|
)
|
||||||
|
if (choice !== '重置为待办') {
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 停会话 → 删 worktree(失败即停)→ 未合并 PR 关掉 → 清 feature 分支
|
||||||
|
for (const [terminal, origin] of panel.terminalOrigin) {
|
||||||
|
if (origin.issueNumber === issueNumber) {
|
||||||
|
try {
|
||||||
|
terminal.dispose()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
// dispose 失败也无所谓,VS Code 自己会清掉关闭事件
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (worktreePath) {
|
||||||
|
const abs = resolveWorktreePath(worktreePath, workspaceRoot)
|
||||||
|
if (fs.existsSync(abs)) {
|
||||||
|
const settingsForHook = getSettings(panel.context)
|
||||||
|
await panel.dispatchWorktreeHook('pre-remove', {
|
||||||
|
workspaceRoot,
|
||||||
|
worktreePath: abs,
|
||||||
|
branch: resolveFeatureBranch({ stateBranch: featureBranchFromState }),
|
||||||
|
issueNumber,
|
||||||
|
mainBranch: settingsForHook.devBranch || 'main',
|
||||||
|
customScriptPath: settingsForHook.worktreePreRemoveScript,
|
||||||
|
})
|
||||||
|
const killed = killProcessesUsingWorktree(abs)
|
||||||
|
if (killed.length > 0) {
|
||||||
|
const list = killed
|
||||||
|
.map(h => ` pid ${h.pid}: ${h.cmd.length > 80 ? `${h.cmd.slice(0, 80)}…` : h.cmd}`)
|
||||||
|
.join('\n')
|
||||||
|
logger.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'panel',
|
||||||
|
message: `重置待办前已杀掉占用进程 (issue #${issueNumber})`,
|
||||||
|
details: list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await removeWorktreeDir(workspaceRoot, abs)
|
||||||
|
logger.add({
|
||||||
|
level: 'info',
|
||||||
|
source: 'panel',
|
||||||
|
message: `已清理 worktree ${worktreePath} (issue #${issueNumber})`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `清理 worktree 失败 (issue #${issueNumber})`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `删除 worktree 失败 #${issueNumber}: ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let prHeadRef: string | undefined
|
||||||
|
if (prStr) {
|
||||||
|
const prIndex = Number.parseInt(prStr, 10)
|
||||||
|
if (!Number.isFinite(prIndex)) {
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `工单 #${issueNumber} 的 PR 字段无效: ${prStr}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pullRequest = await getPullRequest({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
index: prIndex,
|
||||||
|
})
|
||||||
|
prHeadRef = pullRequest.headRef
|
||||||
|
if (!pullRequest.merged) {
|
||||||
|
await closeIssue({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
issueNumber: prIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `处理 PR #${prIndex} 失败 (issue #${issueNumber})`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `处理 PR #${prIndex} 失败 (issue #${issueNumber}): ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureBranch = resolveFeatureBranch({
|
||||||
|
stateBranch: featureBranchFromState,
|
||||||
|
prHeadRef,
|
||||||
|
})
|
||||||
|
if (featureBranch) {
|
||||||
|
const settingsForBranchCleanup = getSettings(panel.context)
|
||||||
|
await cleanupFeatureBranch({
|
||||||
|
workspaceRoot,
|
||||||
|
branch: featureBranch,
|
||||||
|
issueNumber,
|
||||||
|
devBranch: settingsForBranchCleanup.devBranch,
|
||||||
|
autoBuildBranch: settingsForBranchCleanup.autoBuildBranch,
|
||||||
|
remote,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 落盘清空实施痕迹 → UI patch 用 null(postMessage 会丢 undefined)
|
||||||
|
try {
|
||||||
|
await panel.mergeIssueState(issueNumber, todoResetStateExtra())
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'error',
|
||||||
|
source: 'panel',
|
||||||
|
message: `持久化 column=todo 失败 (issue #${issueNumber})`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'error',
|
||||||
|
message: `保存工单 #${issueNumber} 状态失败: ${message}`,
|
||||||
|
dismissOnTimer: 6000,
|
||||||
|
})
|
||||||
|
rollback(fromColumn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'issue/patch',
|
||||||
|
issueNumber,
|
||||||
|
patch: todoResetUiPatch(),
|
||||||
|
})
|
||||||
|
panel.postMessage({
|
||||||
|
type: 'toast/show',
|
||||||
|
id: makeNonce(),
|
||||||
|
level: 'success',
|
||||||
|
message: `工单 #${issueNumber} 已重置为待办`,
|
||||||
|
dismissOnTimer: 5000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a "drag to in-progress" kanban move.
|
* Handle a "drag to in-progress" kanban move.
|
||||||
*
|
*
|
||||||
@@ -1401,6 +1692,27 @@ export async function handleSetDependency(panel: KanbanWebviewPanel, issueNumber
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Gitea 允许多依赖,loader 只取第一个;拖到另一张卡片换父时必须先清掉旧前置。
|
||||||
|
const deps = await getDependencies({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
index: issueNumber,
|
||||||
|
})
|
||||||
|
for (const dep of deps) {
|
||||||
|
if (dep.number !== prerequisiteNumber) {
|
||||||
|
await removeDependency({
|
||||||
|
host: remote.host,
|
||||||
|
token,
|
||||||
|
owner: remote.owner,
|
||||||
|
repo: remote.repo,
|
||||||
|
index: issueNumber,
|
||||||
|
dependencyIndex: dep.number,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!deps.some(d => d.number === prerequisiteNumber)) {
|
||||||
await addDependency({
|
await addDependency({
|
||||||
host: remote.host,
|
host: remote.host,
|
||||||
token,
|
token,
|
||||||
@@ -1410,6 +1722,7 @@ export async function handleSetDependency(panel: KanbanWebviewPanel, issueNumber
|
|||||||
dependencyIndex: prerequisiteNumber,
|
dependencyIndex: prerequisiteNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
logger.add({
|
logger.add({
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { findTerminalByTabLabel, focusedTerminal, FocusedTerminalTracker } from './terminalTabs'
|
||||||
|
|
||||||
|
const t371 = { name: 'issue-371-测试' }
|
||||||
|
const t257 = { name: 'issue-257-实施 (feature/billing)' }
|
||||||
|
const t37 = { name: 'issue-37-测试' }
|
||||||
|
const terminals = [t37, t371, t257]
|
||||||
|
|
||||||
|
describe('findTerminalByTabLabel', () => {
|
||||||
|
it('精确匹配优先', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-371-测试')).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tab label 被 OSC title 追加后缀时按前缀匹配', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-371-测试 (main)')).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('terminal name 带后缀而 label 是原名时也能匹配', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-257-实施')).toBe(t257)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('issue-37 不会误配 issue-371', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'issue-37-测试')).toBe(t37)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('找不到返回 undefined', () => {
|
||||||
|
expect(findTerminalByTabLabel(terminals, 'Project Dashboard')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('focusedTerminal', () => {
|
||||||
|
const isTerminal = (input: unknown): boolean => input === 'terminal'
|
||||||
|
|
||||||
|
it('活动 tab 是终端 → 解析出对应终端', () => {
|
||||||
|
expect(focusedTerminal({ label: 'issue-371-测试', input: 'terminal' }, isTerminal, terminals)).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('活动 tab 不是终端(看板 webview)→ undefined', () => {
|
||||||
|
expect(focusedTerminal({ label: 'Superpowers Kanban', input: 'webview' }, isTerminal, terminals)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('没有活动 tab → undefined', () => {
|
||||||
|
expect(focusedTerminal(undefined, isTerminal, terminals)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('focusedTerminalTracker', () => {
|
||||||
|
it('只开一个终端 tab:点回看板再点回该 tab 要再次触发', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
expect(tracker.sync(undefined)).toBeUndefined()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同一终端重复上报(label 改写)不触发', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
expect(tracker.sync(t371)).toBeUndefined()
|
||||||
|
expect(tracker.sync(t371)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在终端之间切换触发新终端', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
expect(tracker.sync(t257)).toBe(t257)
|
||||||
|
expect(tracker.sync(t371)).toBe(t371)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('焦点离开终端不触发', () => {
|
||||||
|
const tracker = new FocusedTerminalTracker<{ name: string }>()
|
||||||
|
tracker.sync(t371)
|
||||||
|
expect(tracker.sync(undefined)).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* 「用户把焦点切到哪个会话终端 tab」的纯逻辑,与 vscode API 解耦以便单测。
|
||||||
|
*
|
||||||
|
* 反向选中工单只关心一件事:活动分组的活动 tab 是不是某个会话终端。
|
||||||
|
* 由 tab 事件推导这个状态再与上次比较,而不是逐个事件猜测用户意图——
|
||||||
|
* 这样单 tab 分组重新激活(无 isActive 变化、无 activeTerminal 变化)
|
||||||
|
* 与 OSC title 改写(同一终端反复上报)都能得到正确结果。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface TerminalLike {
|
||||||
|
readonly name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TabLike {
|
||||||
|
readonly label: string
|
||||||
|
readonly input: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `TabInputTerminal` 没有任何字段,只能用 tab.label 反查 `window.terminals`。
|
||||||
|
* 终端 name 常被 shell 的 OSC title 序列改写(追加 git branch 等后缀),
|
||||||
|
* 精确匹配失败后再双向 startsWith 兜底。
|
||||||
|
*/
|
||||||
|
export function findTerminalByTabLabel<T extends TerminalLike>(terminals: readonly T[], label: string): T | undefined {
|
||||||
|
return terminals.find(t => t.name === label)
|
||||||
|
?? terminals.find(t => label.startsWith(t.name) || t.name.startsWith(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 活动 tab 对应的会话终端;活动 tab 不是终端(如看板 webview)时为 undefined。 */
|
||||||
|
export function focusedTerminal<T extends TerminalLike>(
|
||||||
|
activeTab: TabLike | undefined,
|
||||||
|
isTerminalTab: (input: unknown) => boolean,
|
||||||
|
terminals: readonly T[],
|
||||||
|
): T | undefined {
|
||||||
|
if (!activeTab || !isTerminalTab(activeTab.input))
|
||||||
|
return undefined
|
||||||
|
return findTerminalByTabLabel(terminals, activeTab.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记住上次有焦点的终端,只在焦点真正落到「另一个」终端 tab 上时报告。 */
|
||||||
|
export class FocusedTerminalTracker<T> {
|
||||||
|
private current: T | undefined
|
||||||
|
|
||||||
|
/** 返回新获得焦点的终端;焦点未变或离开终端 tab 时返回 undefined。 */
|
||||||
|
sync(next: T | undefined): T | undefined {
|
||||||
|
if (next === this.current)
|
||||||
|
return undefined
|
||||||
|
this.current = next
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ export function findExistingTerminal(panel: KanbanWebviewPanel, expectedName: st
|
|||||||
* no-op (user has to press Enter to spawn one).
|
* no-op (user has to press Enter to spawn one).
|
||||||
*/
|
*/
|
||||||
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
|
export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: number): void {
|
||||||
// 如果这次 session/focus 是 onDidChangeActiveTerminal 反选触发的回路
|
// 如果这次 session/focus 是终端 tab 切换反选触发的回路
|
||||||
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
|
// (而不是用户主动点卡片切换工单),跳过优先级跳转,否则会把用户刚刚
|
||||||
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
|
// 点的"审查 tab"弹回到优先级更高的"实施 tab"。
|
||||||
const REVERSE_LOOP_WINDOW_MS = 200
|
const REVERSE_LOOP_WINDOW_MS = 200
|
||||||
@@ -135,10 +135,8 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
|
|||||||
// side-map is the only way to find it by issueNumber.
|
// side-map is the only way to find it by issueNumber.
|
||||||
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
|
const newIssueTerm = panel.newIssueTerminals.get(issueNumber)
|
||||||
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
|
if (newIssueTerm && newIssueTerm.exitStatus === undefined) {
|
||||||
if (newIssueTerm !== window.activeTerminal) {
|
if (newIssueTerm !== window.activeTerminal)
|
||||||
panel.programmaticTabReveal = newIssueTerm
|
|
||||||
newIssueTerm.show(true)
|
newIssueTerm.show(true)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
|
// Priority 1-4: 实施 > 规划 > 审查 > 测试. Match by terminal.name since we know
|
||||||
@@ -152,12 +150,10 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
|
|||||||
for (const name of namePriority) {
|
for (const name of namePriority) {
|
||||||
const term = findExistingTerminal(panel, name)
|
const term = findExistingTerminal(panel, name)
|
||||||
if (term) {
|
if (term) {
|
||||||
// 已经是 active terminal 就不重复 show(避免无谓的回声事件);
|
// preserveFocus 只在分组内切 tab、不激活分组,因此不会被当成
|
||||||
// 否则记下程序化 reveal,让随后的 active 变更被吞掉,不回环。
|
// 用户切到终端而反向选中;已是 active terminal 则无需重复 show。
|
||||||
if (term !== window.activeTerminal) {
|
if (term !== window.activeTerminal)
|
||||||
panel.programmaticTabReveal = term
|
|
||||||
term.show(true)
|
term.show(true)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,27 +161,14 @@ export function handleSessionFocus(panel: KanbanWebviewPanel, issueNumber: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 反向选中:用户在 column 2 切换 terminal tab 时,从 terminal.name 解析
|
* 反向选中:用户把焦点切到 column 2 的某个 terminal tab 时,从 terminal.name
|
||||||
* issueNumber 并通知 webview 选中对应工单。
|
* 解析 issueNumber 并通知 webview 选中对应工单。去重与回声抑制由调用方的
|
||||||
|
* `FocusedTerminalTracker` 负责,这里每次调用都视为一次真实切换。
|
||||||
*
|
*
|
||||||
* 终端名按 `issue-${N}-(规划|实施|审查)` 命名;`issue-new-${nonce}-...`
|
* 终端名按 `issue-${N}-(规划|实施|审查|测试)` 命名;`issue-new-${nonce}-...`
|
||||||
* 是新建工单流程的占位 tab,没有 issue number,跳过。
|
* 是新建工单流程的占位 tab,没有 issue number,跳过。
|
||||||
*/
|
*/
|
||||||
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
|
export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal: Terminal): void {
|
||||||
// Dedupe by reference: OSC title rewrites / shell prompt updates fire
|
|
||||||
// onDidChangeTabs repeatedly for the same terminal. Skip if it's the
|
|
||||||
// same reference we just handled.
|
|
||||||
// 吞掉「我们自己 show() 引发的」active 变更回声:handleSessionFocus 在程序化
|
|
||||||
// show 前把目标终端记到 programmaticTabReveal,这里命中就 return,不反向选中,
|
|
||||||
// 从根上打断 选中→聚焦→选中 的死循环(基于引用,不依赖脆弱的时间窗)。
|
|
||||||
// 每次调用都清空,因此只压制紧随其后的那一次回声,用户真实点 tab 不受影响。
|
|
||||||
const reveal = panel.programmaticTabReveal
|
|
||||||
panel.programmaticTabReveal = undefined
|
|
||||||
if (terminal === reveal)
|
|
||||||
return
|
|
||||||
if (terminal === panel.lastActiveTerminalRef)
|
|
||||||
return
|
|
||||||
panel.lastActiveTerminalRef = terminal
|
|
||||||
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
|
// Primary: `issue-${N}-(规划|实施|审查|测试)` — the steady-state naming.
|
||||||
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
|
const m = terminal.name.match(/^issue-(\d+)-(规划|实施|审查|测试)/)
|
||||||
if (m) {
|
if (m) {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canResetToTodo, todoResetStateExtra, todoResetUiPatch } from './todoReset'
|
||||||
|
|
||||||
|
describe('canResetToTodo', () => {
|
||||||
|
it('允许从 in-progress 重置', () => {
|
||||||
|
expect(canResetToTodo('in-progress')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('允许从 review 重置', () => {
|
||||||
|
expect(canResetToTodo('review')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已在 todo 不允许(handler 走 no-op)', () => {
|
||||||
|
expect(canResetToTodo('todo')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('done 不允许(handler 拒绝并 rollback)', () => {
|
||||||
|
expect(canResetToTodo('done')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('todoResetStateExtra', () => {
|
||||||
|
it('清空实施痕迹并回到 todo,保留规划侧字段不在 extra 里', () => {
|
||||||
|
const extra = todoResetStateExtra()
|
||||||
|
expect(extra).toEqual({
|
||||||
|
column: 'todo',
|
||||||
|
branch: '',
|
||||||
|
implementStatus: '',
|
||||||
|
pr: '',
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: '',
|
||||||
|
worktreePath: '',
|
||||||
|
implementSessionId: '',
|
||||||
|
reviewSessionId: '',
|
||||||
|
})
|
||||||
|
expect(extra).not.toHaveProperty('sessionId')
|
||||||
|
expect(extra).not.toHaveProperty('planFile')
|
||||||
|
expect(extra).not.toHaveProperty('specFile')
|
||||||
|
expect(extra).not.toHaveProperty('profilePath')
|
||||||
|
expect(extra).not.toHaveProperty('brainstormProfilePath')
|
||||||
|
expect(extra).not.toHaveProperty('testProfilePath')
|
||||||
|
expect(extra).not.toHaveProperty('color')
|
||||||
|
expect(extra).not.toHaveProperty('autoReview')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('todoResetUiPatch', () => {
|
||||||
|
it('用 null 清空 UI 字段,避免 postMessage 丢掉 undefined', () => {
|
||||||
|
expect(todoResetUiPatch()).toEqual({
|
||||||
|
column: 'todo',
|
||||||
|
branch: null,
|
||||||
|
implementStatus: null,
|
||||||
|
pr: null,
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: null,
|
||||||
|
worktreePath: null,
|
||||||
|
implementSessionId: null,
|
||||||
|
reviewSessionId: null,
|
||||||
|
worktreeExists: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { IssueColumn } from '../../gitea/types'
|
||||||
|
|
||||||
|
export function canResetToTodo(fromColumn: IssueColumn | undefined): boolean {
|
||||||
|
return fromColumn === 'in-progress' || fromColumn === 'review'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todoResetStateExtra(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
column: 'todo',
|
||||||
|
branch: '',
|
||||||
|
implementStatus: '',
|
||||||
|
pr: '',
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: '',
|
||||||
|
worktreePath: '',
|
||||||
|
implementSessionId: '',
|
||||||
|
reviewSessionId: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todoResetUiPatch(): {
|
||||||
|
column: 'todo'
|
||||||
|
branch: null
|
||||||
|
implementStatus: null
|
||||||
|
pr: null
|
||||||
|
prMerged: false
|
||||||
|
prMergedAt: null
|
||||||
|
worktreePath: null
|
||||||
|
implementSessionId: null
|
||||||
|
reviewSessionId: null
|
||||||
|
worktreeExists: false
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
column: 'todo',
|
||||||
|
branch: null,
|
||||||
|
implementStatus: null,
|
||||||
|
pr: null,
|
||||||
|
prMerged: false,
|
||||||
|
prMergedAt: null,
|
||||||
|
worktreePath: null,
|
||||||
|
implementSessionId: null,
|
||||||
|
reviewSessionId: null,
|
||||||
|
worktreeExists: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export type ExtensionToWebview
|
|||||||
= | { type: 'issues/loading' }
|
= | { type: 'issues/loading' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||||
| { type: 'issue/select-by-number', issueNumber: number }
|
| { type: 'issue/select-by-number', issueNumber: number }
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ export function App() {
|
|||||||
onMergeBranch={mergeBranch}
|
onMergeBranch={mergeBranch}
|
||||||
onDeleteIssue={deleteIssue}
|
onDeleteIssue={deleteIssue}
|
||||||
onCloseIssue={closeIssue}
|
onCloseIssue={closeIssue}
|
||||||
|
onResetToTodo={n => changeColumn(n, 'todo', selectedIssue?.source, selectedIssue?.externalId)}
|
||||||
onCloseSessionTab={closeSessionTab}
|
onCloseSessionTab={closeSessionTab}
|
||||||
onStartBrainstormSession={startBrainstormSession}
|
onStartBrainstormSession={startBrainstormSession}
|
||||||
onUpdateAutoReview={updateIssueAutoReview}
|
onUpdateAutoReview={updateIssueAutoReview}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ interface BottomTabsProps {
|
|||||||
onMergeBranch: (issueNumber: number, branch: string) => void
|
onMergeBranch: (issueNumber: number, branch: string) => void
|
||||||
onDeleteIssue?: (issueNumber: number) => void
|
onDeleteIssue?: (issueNumber: number) => void
|
||||||
onCloseIssue: (issueNumber: number) => void
|
onCloseIssue: (issueNumber: number) => void
|
||||||
|
onResetToTodo: (issueNumber: number) => void
|
||||||
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
onCloseSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
||||||
onStartBrainstormSession: (issueNumber: number) => void
|
onStartBrainstormSession: (issueNumber: number) => void
|
||||||
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
onUpdateAutoReview: (issueNumber: number, value: boolean) => void
|
||||||
@@ -128,6 +129,7 @@ export function BottomTabs(props: BottomTabsProps) {
|
|||||||
onMergeBranch={props.onMergeBranch}
|
onMergeBranch={props.onMergeBranch}
|
||||||
onDeleteIssue={props.onDeleteIssue}
|
onDeleteIssue={props.onDeleteIssue}
|
||||||
onCloseIssue={props.onCloseIssue}
|
onCloseIssue={props.onCloseIssue}
|
||||||
|
onResetToTodo={props.onResetToTodo}
|
||||||
onCloseSessionTab={props.onCloseSessionTab}
|
onCloseSessionTab={props.onCloseSessionTab}
|
||||||
onStartBrainstormSession={props.onStartBrainstormSession}
|
onStartBrainstormSession={props.onStartBrainstormSession}
|
||||||
onUpdateAutoReview={props.onUpdateAutoReview}
|
onUpdateAutoReview={props.onUpdateAutoReview}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { CircleSlash, ExternalLink, GitMerge, Play, Terminal, Trash2, X } from 'lucide-react'
|
import { CircleSlash, ExternalLink, GitMerge, Play, RotateCcw, Terminal, Trash2, X } from 'lucide-react'
|
||||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||||
import type { Issue, IssueColumn } from '../types'
|
import type { Issue, IssueColumn } from '../types'
|
||||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||||
@@ -68,6 +68,8 @@ interface IssueDetailPanelProps {
|
|||||||
onDeleteIssue?: (issueNumber: number) => void
|
onDeleteIssue?: (issueNumber: number) => void
|
||||||
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
||||||
onCloseIssue: (issueNumber: number) => void
|
onCloseIssue: (issueNumber: number) => void
|
||||||
|
/** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */
|
||||||
|
onResetToTodo: (issueNumber: number) => void
|
||||||
/** Close the matching session terminal tab. The extension watches
|
/** Close the matching session terminal tab. The extension watches
|
||||||
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
|
* onDidCloseTerminal and clears the corresponding `*TabOpen` flag, which
|
||||||
* makes the X button vanish on its own. */
|
* makes the X button vanish on its own. */
|
||||||
@@ -117,6 +119,7 @@ export function IssueDetailPanel({
|
|||||||
onMergeBranch,
|
onMergeBranch,
|
||||||
onDeleteIssue,
|
onDeleteIssue,
|
||||||
onCloseIssue,
|
onCloseIssue,
|
||||||
|
onResetToTodo,
|
||||||
onCloseSessionTab,
|
onCloseSessionTab,
|
||||||
onStartBrainstormSession,
|
onStartBrainstormSession,
|
||||||
onUpdateAutoReview,
|
onUpdateAutoReview,
|
||||||
@@ -572,6 +575,17 @@ export function IssueDetailPanel({
|
|||||||
avoid acting on a gitea issue of the same number. */}
|
avoid acting on a gitea issue of the same number. */}
|
||||||
{issue.source !== 'youtrack' && (
|
{issue.source !== 'youtrack' && (
|
||||||
<>
|
<>
|
||||||
|
{(issue.column === 'in-progress' || issue.column === 'review') && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onResetToTodo(issue.number)}
|
||||||
|
title="重置为待办"
|
||||||
|
aria-label="重置为待办"
|
||||||
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCloseIssue(issue.number)}
|
onClick={() => onCloseIssue(issue.number)}
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import type { CollisionDetection, DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
||||||
|
import type { Issue, IssueColumn } from '../types'
|
||||||
import {
|
import {
|
||||||
|
closestCenter,
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
|
pointerWithin,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core'
|
} from '@dnd-kit/core'
|
||||||
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'
|
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { IssueCard } from './IssueCard'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { KanbanColumn } from './KanbanColumn'
|
|
||||||
import { isIssueLocked } from '../lib/dependencies'
|
import { isIssueLocked } from '../lib/dependencies'
|
||||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||||
import type { Issue, IssueColumn } from '../types'
|
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
|
||||||
import { COLUMN_ORDER } from '../types'
|
import { COLUMN_ORDER } from '../types'
|
||||||
|
import { IssueCard } from './IssueCard'
|
||||||
|
import { KanbanColumn } from './KanbanColumn'
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
issues: Issue[]
|
issues: Issue[]
|
||||||
@@ -28,27 +31,18 @@ interface KanbanBoardProps {
|
|||||||
onCreateIssue?: () => void
|
onCreateIssue?: () => void
|
||||||
selectedId?: string | null
|
selectedId?: string | null
|
||||||
onSelectIssue?: (id: string | null) => void
|
onSelectIssue?: (id: string | null) => void
|
||||||
/**
|
|
||||||
* Called when a card moves to a new column via drag-and-drop, in addition
|
|
||||||
* to the optimistic `onIssuesChange`. Currently only invoked for moves
|
|
||||||
* targeting `done`; other column changes remain client-visual-only.
|
|
||||||
*/
|
|
||||||
onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
|
onColumnChange?: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
|
||||||
/**
|
|
||||||
* Called when a drag gesture establishes a new prerequisite relationship
|
|
||||||
* (drop onto another todo card's middle 1/3) or moves to a sibling whose
|
|
||||||
* parent differs from the active card's current parent.
|
|
||||||
*/
|
|
||||||
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
|
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
|
||||||
/**
|
|
||||||
* Called when a drag gesture clears an existing prerequisite (e.g. drop
|
|
||||||
* onto the todo column's empty area, or onto a sibling at root level).
|
|
||||||
*/
|
|
||||||
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
|
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function isColumnId(id: string): id is IssueColumn {
|
const kanbanCollisionDetection: CollisionDetection = (args) => {
|
||||||
return (COLUMN_ORDER as string[]).includes(id)
|
const pointerHits = pointerWithin(args)
|
||||||
|
if (pointerHits.length > 0) {
|
||||||
|
const cardHits = pointerHits.filter(c => !isColumnId(String(c.id)))
|
||||||
|
return cardHits.length > 0 ? cardHits : pointerHits
|
||||||
|
}
|
||||||
|
return closestCenter(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,174 +146,73 @@ export function KanbanBoard({
|
|||||||
setActiveId(String(event.active.id))
|
setActiveId(String(event.active.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify the drop position relative to the `over` card. Middle 1/3
|
|
||||||
* means nest (set prerequisite); the upper/lower 2/3 means sibling reorder.
|
|
||||||
* The 1/3-2/3 split is intentionally wider than 1/4-1/2-1/4 because the
|
|
||||||
* sortable strategy reflows during drag, so the user needs more room.
|
|
||||||
*/
|
|
||||||
function detectDropZone(event: DragEndEvent): 'middle' | 'edge' {
|
|
||||||
const activeRect = event.active.rect.current.translated
|
|
||||||
const overRect = event.over?.rect
|
|
||||||
if (!activeRect || !overRect)
|
|
||||||
return 'edge'
|
|
||||||
const activeCenterY = (activeRect.top + activeRect.bottom) / 2
|
|
||||||
const overHeight = overRect.height
|
|
||||||
// Widen the nest zone to the middle 60% of the over card so user doesn't
|
|
||||||
// need pixel-precision. Edge zone is just the top/bottom 20% slivers,
|
|
||||||
// which are still enough for sibling-reorder gestures.
|
|
||||||
const middleStart = overRect.top + overHeight * 0.2
|
|
||||||
const middleEnd = overRect.top + overHeight * 0.8
|
|
||||||
return activeCenterY >= middleStart && activeCenterY <= middleEnd ? 'middle' : 'edge'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks up the prerequisite chain from `target`, returning true if
|
|
||||||
* `maybeAncestor` appears anywhere on the way. Uses a visited set so a
|
|
||||||
* pre-existing cycle in the data doesn't spin forever.
|
|
||||||
*/
|
|
||||||
function isDescendant(maybeAncestor: number, target: number, all: Issue[]): boolean {
|
|
||||||
const visited = new Set<number>()
|
|
||||||
let current: Issue | undefined = all.find(i => i.number === target)
|
|
||||||
while (current && current.prerequisite != null) {
|
|
||||||
if (visited.has(current.number))
|
|
||||||
return false
|
|
||||||
visited.add(current.number)
|
|
||||||
if (current.prerequisite === maybeAncestor)
|
|
||||||
return true
|
|
||||||
current = all.find(i => i.number === current!.prerequisite)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd(event: DragEndEvent): void {
|
function handleDragEnd(event: DragEndEvent): void {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
if (!over)
|
if (!over)
|
||||||
return
|
return
|
||||||
|
|
||||||
const activeIdStr = String(active.id)
|
const result = resolveKanbanDrag({
|
||||||
const overIdStr = String(over.id)
|
issues,
|
||||||
if (activeIdStr === overIdStr)
|
activeId: String(active.id),
|
||||||
|
overId: String(over.id),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.type === 'none')
|
||||||
return
|
return
|
||||||
|
|
||||||
const activeIndex = issues.findIndex(i => i.id === activeIdStr)
|
if (result.type === 'reorder') {
|
||||||
if (activeIndex < 0)
|
onIssuesChange(arrayMove(issues, result.fromIndex, result.toIndex))
|
||||||
return
|
|
||||||
const activeItem = issues[activeIndex]
|
|
||||||
|
|
||||||
// Lock check: a todo card whose prerequisite is still unfinished must
|
|
||||||
// stay in the todo column. Resolve target column from either the over
|
|
||||||
// column id or the over card's column.
|
|
||||||
const prereqIssue = activeItem.prerequisite != null
|
|
||||||
? issues.find(i => i.number === activeItem.prerequisite)
|
|
||||||
: undefined
|
|
||||||
const isLocked = !!prereqIssue && prereqIssue.column !== 'done'
|
|
||||||
if (isLocked && activeItem.column === 'todo') {
|
|
||||||
const overItemForLock = isColumnId(overIdStr)
|
|
||||||
? undefined
|
|
||||||
: issues.find(i => i.id === overIdStr)
|
|
||||||
const targetColumn: IssueColumn | undefined = isColumnId(overIdStr)
|
|
||||||
? overIdStr
|
|
||||||
: overItemForLock?.column
|
|
||||||
if (targetColumn && targetColumn !== 'todo')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropped onto a column container
|
if (result.type === 'column') {
|
||||||
if (isColumnId(overIdStr)) {
|
const idx = issues.findIndex(i => i.number === result.issueNumber)
|
||||||
// Drop onto the todo column's empty area while the card is already in
|
if (idx < 0)
|
||||||
// todo → clear prerequisite (move to root level, append at column end).
|
return
|
||||||
if (overIdStr === 'todo' && activeItem.column === 'todo' && activeItem.prerequisite != null) {
|
const item = issues[idx]
|
||||||
const prevPrereq = activeItem.prerequisite
|
|
||||||
const next = [...issues]
|
const next = [...issues]
|
||||||
next.splice(activeIndex, 1)
|
next.splice(idx, 1)
|
||||||
next.push({ ...activeItem, prerequisite: undefined })
|
const overIdx = isColumnId(String(over.id))
|
||||||
|
? -1
|
||||||
|
: next.findIndex(i => i.id === String(over.id))
|
||||||
|
const insertAt = overIdx < 0 ? next.length : overIdx
|
||||||
|
next.splice(insertAt, 0, { ...item, column: result.toColumn })
|
||||||
onIssuesChange(next)
|
onIssuesChange(next)
|
||||||
onDependencyClear?.(activeItem.number, prevPrereq)
|
onColumnChange?.(result.issueNumber, result.toColumn, result.source, result.externalId)
|
||||||
return
|
|
||||||
}
|
|
||||||
if (activeItem.column === overIdStr)
|
|
||||||
return
|
|
||||||
const next = [...issues]
|
|
||||||
next.splice(activeIndex, 1)
|
|
||||||
next.push({ ...activeItem, column: overIdStr })
|
|
||||||
onIssuesChange(next)
|
|
||||||
onColumnChange?.(activeItem.number, overIdStr, activeItem.source, activeItem.externalId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropped onto another card
|
if (result.type === 'dependency-set') {
|
||||||
const overIndex = issues.findIndex(i => i.id === overIdStr)
|
const idx = issues.findIndex(i => i.number === result.issueNumber)
|
||||||
if (overIndex < 0)
|
if (idx < 0)
|
||||||
return
|
return
|
||||||
const overItem = issues[overIndex]
|
const item = issues[idx]
|
||||||
|
|
||||||
// Cross-column or non-todo-to-non-todo: keep the existing semantics.
|
|
||||||
if (activeItem.column !== 'todo' || overItem.column !== 'todo') {
|
|
||||||
if (activeItem.column === overItem.column) {
|
|
||||||
onIssuesChange(arrayMove(issues, activeIndex, overIndex))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Cross-column: drop activeItem at overItem's position in the new column
|
|
||||||
const next = [...issues]
|
const next = [...issues]
|
||||||
next.splice(activeIndex, 1)
|
next.splice(idx, 1)
|
||||||
const newOverIndex = next.findIndex(i => i.id === overIdStr)
|
const overIdx = next.findIndex(i => i.number === result.prerequisiteNumber)
|
||||||
const insertAt = newOverIndex < 0 ? next.length : newOverIndex
|
const insertAt = overIdx < 0 ? next.length : overIdx + 1
|
||||||
next.splice(insertAt, 0, { ...activeItem, column: overItem.column })
|
next.splice(insertAt, 0, { ...item, prerequisite: result.prerequisiteNumber })
|
||||||
onIssuesChange(next)
|
onIssuesChange(next)
|
||||||
onColumnChange?.(activeItem.number, overItem.column, activeItem.source, activeItem.externalId)
|
onDependencySet?.(result.issueNumber, result.prerequisiteNumber)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both in todo: distinguish nest (middle 1/3) vs sibling reorder (edge).
|
const idx = issues.findIndex(i => i.number === result.issueNumber)
|
||||||
const zone = detectDropZone(event)
|
if (idx < 0)
|
||||||
if (zone === 'middle') {
|
|
||||||
// Don't allow nesting onto our own descendant (would form a cycle).
|
|
||||||
if (isDescendant(activeItem.number, overItem.number, issues))
|
|
||||||
return
|
|
||||||
// Already nested under this same parent → nothing to do.
|
|
||||||
if (activeItem.prerequisite === overItem.number)
|
|
||||||
return
|
return
|
||||||
|
const item = issues[idx]
|
||||||
const next = [...issues]
|
const next = [...issues]
|
||||||
next.splice(activeIndex, 1)
|
next.splice(idx, 1)
|
||||||
const newOverIndex = next.findIndex(i => i.id === overIdStr)
|
next.push({ ...item, prerequisite: undefined })
|
||||||
const insertAt = newOverIndex < 0 ? next.length : newOverIndex + 1
|
|
||||||
next.splice(insertAt, 0, { ...activeItem, prerequisite: overItem.number })
|
|
||||||
onIssuesChange(next)
|
onIssuesChange(next)
|
||||||
onDependencySet?.(activeItem.number, overItem.number)
|
onDependencyClear?.(result.issueNumber, result.prerequisiteNumber)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// edge: sibling reorder. Inherit the over card's prerequisite as parent.
|
|
||||||
const newParent = overItem.prerequisite
|
|
||||||
if (newParent === activeItem.prerequisite) {
|
|
||||||
// Same parent (including both undefined): pure reorder.
|
|
||||||
onIssuesChange(arrayMove(issues, activeIndex, overIndex))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Cycle guard: refuse to set the new parent if it would put active under
|
|
||||||
// its own descendant. Still reorder visually.
|
|
||||||
const cycle = newParent != null && isDescendant(activeItem.number, newParent, issues)
|
|
||||||
if (cycle) {
|
|
||||||
onIssuesChange(arrayMove(issues, activeIndex, overIndex))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const next = [...issues]
|
|
||||||
next.splice(activeIndex, 1)
|
|
||||||
const newOverIndex = next.findIndex(i => i.id === overIdStr)
|
|
||||||
const insertAt = newOverIndex < 0 ? next.length : newOverIndex
|
|
||||||
next.splice(insertAt, 0, { ...activeItem, prerequisite: newParent })
|
|
||||||
onIssuesChange(next)
|
|
||||||
if (newParent == null)
|
|
||||||
onDependencyClear?.(activeItem.number, activeItem.prerequisite!)
|
|
||||||
else
|
|
||||||
onDependencySet?.(activeItem.number, newParent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
collisionDetection={kanbanCollisionDetection}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
onDragCancel={() => setActiveId(null)}
|
onDragCancel={() => setActiveId(null)}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { ManagedSession, ManagedSessionsData } from '../types'
|
|||||||
import { Download, Plus, Terminal, Trash2, X } from 'lucide-react'
|
import { Download, Plus, Terminal, Trash2, X } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { SelectMenu } from './ui/select-menu'
|
||||||
|
|
||||||
interface ManagedSessionsPanelProps {
|
interface ManagedSessionsPanelProps {
|
||||||
data: ManagedSessionsData
|
data: ManagedSessionsData
|
||||||
profiles: ClaudeProfile[]
|
profiles: ClaudeProfile[]
|
||||||
@@ -143,18 +145,15 @@ export function ManagedSessionsPanel({
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">Profile</span>
|
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">Profile</span>
|
||||||
<select
|
<SelectMenu
|
||||||
value={selectedProfile}
|
value={selectedProfile}
|
||||||
onChange={e => setSelectedProfile(e.target.value)}
|
onChange={setSelectedProfile}
|
||||||
className="min-w-0 flex-1 rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
|
options={profiles.length === 0
|
||||||
>
|
? [{ value: '', label: '(无可用 profile)' }]
|
||||||
{profiles.length === 0 && (
|
: profiles.map(p => ({ value: p.path, label: p.name }))}
|
||||||
<option value="">(无可用 profile)</option>
|
variant="input"
|
||||||
)}
|
className="min-w-0 flex-1"
|
||||||
{profiles.map(p => (
|
/>
|
||||||
<option key={p.path} value={p.path}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">名字</span>
|
<span className="w-12 shrink-0 text-xs text-[var(--vscode-descriptionForeground)]">名字</span>
|
||||||
@@ -385,24 +384,18 @@ function SessionRow({ session, profiles, onRename, onResume, onDelete, onCloseTa
|
|||||||
<span className="truncate">{basename(session.profilePath)}</span>
|
<span className="truncate">{basename(session.profilePath)}</span>
|
||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<select
|
<SelectMenu
|
||||||
value={pickedProfile}
|
value={pickedProfile}
|
||||||
|
onChange={setUserPicked}
|
||||||
title="换 profile 后点行打开;已打开的终端下次才生效"
|
title="换 profile 后点行打开;已打开的终端下次才生效"
|
||||||
onClick={e => e.stopPropagation()}
|
variant="ghost"
|
||||||
onMouseDown={e => e.stopPropagation()}
|
options={[
|
||||||
onChange={(e) => {
|
...(storedMissing
|
||||||
e.stopPropagation()
|
? [{ value: pickedProfile, label: basename(pickedProfile) || '(未设置)' }]
|
||||||
setUserPicked(e.target.value)
|
: []),
|
||||||
}}
|
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||||||
className="min-w-0 bg-transparent py-0 pr-1 text-[10px] text-[var(--vscode-descriptionForeground)] outline-none focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]"
|
]}
|
||||||
>
|
/>
|
||||||
{storedMissing && (
|
|
||||||
<option value={pickedProfile}>{basename(pickedProfile) || '(未设置)'}</option>
|
|
||||||
)}
|
|
||||||
{profiles.map(p => (
|
|
||||||
<option key={p.path} value={p.path}>{p.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
{created ? <span className="shrink-0">{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created}</span> : null}
|
{created ? <span className="shrink-0">{(profiles.length > 0 || Boolean(basename(session.profilePath))) ? ' · ' : ''}{created}</span> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { ReactElement } from 'react'
|
|||||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { SelectMenu } from './ui/select-menu'
|
||||||
|
|
||||||
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
|
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
|
||||||
|
|
||||||
@@ -487,22 +488,20 @@ export function SettingsModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<select
|
<SelectMenu
|
||||||
value={conflictResolutionProfilePath}
|
value={conflictResolutionProfilePath}
|
||||||
onChange={e => setConflictResolutionProfilePath(e.target.value)}
|
onChange={setConflictResolutionProfilePath}
|
||||||
|
variant="input"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
options={[
|
||||||
<option value="">(默认)</option>
|
{ value: '', label: '(默认)' },
|
||||||
{profiles.map(p => (
|
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||||||
<option key={p.path} value={p.path}>{p.name}</option>
|
...(conflictResolutionProfilePath
|
||||||
))}
|
&& !profiles.some(p => p.path === conflictResolutionProfilePath)
|
||||||
{conflictResolutionProfilePath
|
? [{ value: conflictResolutionProfilePath, label: `自定义:${conflictResolutionProfilePath}` }]
|
||||||
&& !profiles.some(p => p.path === conflictResolutionProfilePath) && (
|
: []),
|
||||||
<option value={conflictResolutionProfilePath}>
|
]}
|
||||||
{`自定义:${conflictResolutionProfilePath}`}
|
/>
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
@@ -513,22 +512,20 @@ export function SettingsModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<select
|
<SelectMenu
|
||||||
value={commitProfilePath}
|
value={commitProfilePath}
|
||||||
onChange={e => setCommitProfilePath(e.target.value)}
|
onChange={setCommitProfilePath}
|
||||||
|
variant="input"
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
options={[
|
||||||
<option value="">(未配置)</option>
|
{ value: '', label: '(未配置)' },
|
||||||
{profiles.map(p => (
|
...profiles.map(p => ({ value: p.path, label: p.name })),
|
||||||
<option key={p.path} value={p.path}>{p.name}</option>
|
...(commitProfilePath
|
||||||
))}
|
&& !profiles.some(p => p.path === commitProfilePath)
|
||||||
{commitProfilePath
|
? [{ value: commitProfilePath, label: `自定义:${commitProfilePath}` }]
|
||||||
&& !profiles.some(p => p.path === commitProfilePath) && (
|
: []),
|
||||||
<option value={commitProfilePath}>
|
]}
|
||||||
{`自定义:${commitProfilePath}`}
|
/>
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
@@ -728,20 +725,19 @@ export function SettingsModal({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && (
|
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && (
|
||||||
<select
|
<SelectMenu
|
||||||
value={youtrackProjectShortName}
|
value={youtrackProjectShortName}
|
||||||
onChange={e => setYoutrackProjectShortName(e.target.value)}
|
onChange={setYoutrackProjectShortName}
|
||||||
className={`${inputClass} mt-2`}
|
className={`${inputClass} mt-2`}
|
||||||
>
|
variant="input"
|
||||||
<option value="">— 选择项目 —</option>
|
options={[
|
||||||
{youtrackProjects.projects.map(p => (
|
{ value: '', label: '— 选择项目 —' },
|
||||||
<option key={p.id} value={p.shortName}>
|
...youtrackProjects.projects.map(p => ({
|
||||||
{p.shortName}
|
value: p.shortName,
|
||||||
{' · '}
|
label: `${p.shortName} · ${p.name}`,
|
||||||
{p.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
{youtrackProjects.status === 'error' && (
|
{youtrackProjects.status === 'error' && (
|
||||||
<span className="mt-1 block text-[10px] text-state-red">
|
<span className="mt-1 block text-[10px] text-state-red">
|
||||||
@@ -891,14 +887,16 @@ export function SettingsModal({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<select
|
<SelectMenu
|
||||||
value={worktreeOpenWith}
|
value={worktreeOpenWith}
|
||||||
onChange={e => setWorktreeOpenWith(e.target.value as 'vscode' | 'pycharm')}
|
onChange={v => setWorktreeOpenWith(v as 'vscode' | 'pycharm')}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
>
|
variant="input"
|
||||||
<option value="vscode">VS Code</option>
|
options={[
|
||||||
<option value="pycharm">PyCharm</option>
|
{ value: 'vscode', label: 'VS Code' },
|
||||||
</select>
|
{ value: 'pycharm', label: 'PyCharm' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
{worktreeOpenWith === 'pycharm' && (
|
{worktreeOpenWith === 'pycharm' && (
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
* 背景色,依赖 VS Code 主题 CSS 变量。
|
* 背景色,依赖 VS Code 主题 CSS 变量。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
@@ -24,6 +23,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { SelectMenu } from './ui/select-menu'
|
||||||
|
|
||||||
// --- 类型定义 ---
|
// --- 类型定义 ---
|
||||||
|
|
||||||
@@ -193,22 +194,16 @@ function PropertyRow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<select
|
<SelectMenu
|
||||||
value={String(value ?? '')}
|
value={String(value ?? '')}
|
||||||
onChange={e => onChange(propDef.key, e.target.value)}
|
onChange={v => onChange(propDef.key, v)}
|
||||||
className={`${inputClass} appearance-none`}
|
className={inputClass}
|
||||||
style={{ backgroundColor: 'var(--vscode-dropdown-background)', color: 'var(--vscode-dropdown-foreground)' }}
|
variant="input"
|
||||||
>
|
options={(propDef.options ?? []).map(o => ({
|
||||||
{(propDef.options ?? []).map(o => (
|
value: String(o.value),
|
||||||
<option
|
label: o.label,
|
||||||
key={String(o.value)}
|
}))}
|
||||||
value={String(o.value)}
|
/>
|
||||||
style={{ backgroundColor: 'var(--vscode-dropdown-background)', color: 'var(--vscode-dropdown-foreground)' }}
|
|
||||||
>
|
|
||||||
{o.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
case 'multiline':
|
case 'multiline':
|
||||||
@@ -536,6 +531,11 @@ function PropertyRow({
|
|||||||
ref={el => registerCellRef(fullKey, el)}
|
ref={el => registerCellRef(fullKey, el)}
|
||||||
data-property-cell={fullKey}
|
data-property-cell={fullKey}
|
||||||
onClickCapture={(e) => {
|
onClickCapture={(e) => {
|
||||||
|
// portal 弹层对祖先捕获处理器隐身:点击落在 SelectMenu 弹层内时不选中也不拦截,
|
||||||
|
// 让 click 正常落到选项按钮,避免被外层的 stopPropagation/preventDefault 吞掉。
|
||||||
|
const src = e.target
|
||||||
|
if (src instanceof Element && src.closest('[data-select-menu]'))
|
||||||
|
return
|
||||||
if (isSelected)
|
if (isSelected)
|
||||||
return
|
return
|
||||||
onSelect(fullKey)
|
onSelect(fullKey)
|
||||||
@@ -611,8 +611,11 @@ export function PropertyGrid({
|
|||||||
if (selectedKey == null)
|
if (selectedKey == null)
|
||||||
return
|
return
|
||||||
function handleMouseDown(e: MouseEvent): void {
|
function handleMouseDown(e: MouseEvent): void {
|
||||||
|
const t = e.target
|
||||||
|
if (!(t instanceof Node))
|
||||||
|
return
|
||||||
const root = rootRef.current
|
const root = rootRef.current
|
||||||
if (root && e.target instanceof Node && !root.contains(e.target))
|
if (root && !root.contains(t) && !(t instanceof Element && t.closest('[data-select-menu]')))
|
||||||
setSelectedKey(null)
|
setSelectedKey(null)
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', handleMouseDown)
|
document.addEventListener('mousedown', handleMouseDown)
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import type { KeyboardEvent, MouseEvent, ReactElement } from 'react'
|
||||||
|
import { Check, ChevronDown } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface SelectMenuOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectMenuProps {
|
||||||
|
value: string
|
||||||
|
options: SelectMenuOption[]
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
title?: string
|
||||||
|
disabled?: boolean
|
||||||
|
variant?: 'input' | 'ghost'
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_MENU_HEIGHT = 240
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义下拉组件,替代原生 select。
|
||||||
|
*
|
||||||
|
* 原生 select 的展开弹层是 Chromium 系统层,VS Code 暗色主题下样式脱节,
|
||||||
|
* 这里用手写弹层 + createPortal 渲染到 body,避免被 overflow 容器裁掉。
|
||||||
|
*/
|
||||||
|
export function SelectMenu({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
placeholder = '请选择',
|
||||||
|
title,
|
||||||
|
disabled = false,
|
||||||
|
variant = 'input',
|
||||||
|
className,
|
||||||
|
}: SelectMenuProps): ReactElement {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [highlight, setHighlight] = useState(-1)
|
||||||
|
const [pos, setPos] = useState<{ top: number, left: number, width: number } | null>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const optionRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||||
|
|
||||||
|
const selected = options.find(o => o.value === value) ?? null
|
||||||
|
|
||||||
|
const computePos = useCallback((): void => {
|
||||||
|
const el = triggerRef.current
|
||||||
|
if (!el)
|
||||||
|
return
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
const menuHeight = Math.min(options.length * 32, MAX_MENU_HEIGHT)
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom
|
||||||
|
const spaceAbove = rect.top
|
||||||
|
const openUp = spaceBelow < menuHeight && spaceAbove > spaceBelow
|
||||||
|
setPos({
|
||||||
|
top: openUp ? Math.max(8, rect.top - menuHeight - 4) : rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
width: Math.max(rect.width, 120),
|
||||||
|
})
|
||||||
|
}, [options.length])
|
||||||
|
|
||||||
|
const openMenu = useCallback((): void => {
|
||||||
|
computePos()
|
||||||
|
setOpen(true)
|
||||||
|
setHighlight(options.findIndex(o => o.value === value))
|
||||||
|
}, [computePos, options, value])
|
||||||
|
|
||||||
|
const closeMenu = useCallback((): void => {
|
||||||
|
setOpen(false)
|
||||||
|
setHighlight(-1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectOption = useCallback((index: number): void => {
|
||||||
|
const opt = options[index]
|
||||||
|
if (opt)
|
||||||
|
onChange(opt.value)
|
||||||
|
closeMenu()
|
||||||
|
}, [options, onChange, closeMenu])
|
||||||
|
|
||||||
|
const moveHighlight = useCallback((dir: 1 | -1): void => {
|
||||||
|
if (options.length === 0)
|
||||||
|
return
|
||||||
|
setHighlight((prev) => {
|
||||||
|
if (prev < 0)
|
||||||
|
return dir === 1 ? 0 : options.length - 1
|
||||||
|
return (prev + dir + options.length) % options.length
|
||||||
|
})
|
||||||
|
}, [options.length])
|
||||||
|
|
||||||
|
// 弹层打开时把高亮项滚进视野(初始定位 + 键盘移动都靠它)。
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open)
|
||||||
|
return
|
||||||
|
const el = optionRefs.current[highlight]
|
||||||
|
el?.scrollIntoView({ block: 'nearest' })
|
||||||
|
}, [open, highlight])
|
||||||
|
|
||||||
|
// 外点击关闭:mousedown 捕获阶段,落在触发器/弹层内不关。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open)
|
||||||
|
return
|
||||||
|
function onPointerDown(e: globalThis.MouseEvent): void {
|
||||||
|
const t = e.target
|
||||||
|
if (t instanceof Node && !triggerRef.current?.contains(t) && !menuRef.current?.contains(t))
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onPointerDown, true)
|
||||||
|
return () => document.removeEventListener('mousedown', onPointerDown, true)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// 窗口 resize / 容器滚动时重算位置,让弹层始终贴住触发器。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open)
|
||||||
|
return
|
||||||
|
function onScroll(): void {
|
||||||
|
computePos()
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', onScroll)
|
||||||
|
document.addEventListener('scroll', onScroll, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', onScroll)
|
||||||
|
document.removeEventListener('scroll', onScroll, true)
|
||||||
|
}
|
||||||
|
}, [open, computePos])
|
||||||
|
|
||||||
|
function onTriggerClick(e: MouseEvent<HTMLButtonElement>): void {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (disabled)
|
||||||
|
return
|
||||||
|
if (open)
|
||||||
|
closeMenu()
|
||||||
|
else
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTriggerMouseDown(e: MouseEvent): void {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTriggerKeyDown(e: KeyboardEvent<HTMLButtonElement>): void {
|
||||||
|
if (disabled)
|
||||||
|
return
|
||||||
|
const key = e.key
|
||||||
|
if (!open) {
|
||||||
|
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch (key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
moveHighlight(1)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
moveHighlight(-1)
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
if (highlight >= 0)
|
||||||
|
selectOption(highlight)
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
closeMenu()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerClasses
|
||||||
|
= variant === 'input'
|
||||||
|
? 'rounded border border-[var(--vscode-input-border,var(--vscode-panel-border))] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] focus:ring-1 focus:ring-inset focus:ring-[var(--vscode-focusBorder)]'
|
||||||
|
: 'rounded bg-transparent px-1 py-0 text-[10px] text-[var(--vscode-descriptionForeground)] hover:bg-[var(--vscode-toolbar-hoverBackground)]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={onTriggerClick}
|
||||||
|
onMouseDown={onTriggerMouseDown}
|
||||||
|
onKeyDown={onTriggerKeyDown}
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 items-center justify-between gap-1',
|
||||||
|
triggerClasses,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('min-w-0 truncate', selected ? '' : 'opacity-50')}>
|
||||||
|
{selected?.label ?? placeholder}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={cn('size-3.5 shrink-0', variant === 'ghost' && 'size-3')} />
|
||||||
|
</button>
|
||||||
|
{open && pos && createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
role="listbox"
|
||||||
|
data-select-menu=""
|
||||||
|
tabIndex={-1}
|
||||||
|
className="fixed z-50 overflow-auto rounded border border-[var(--vscode-panel-border)] bg-[var(--vscode-editorWidget-background,var(--vscode-editor-background))] shadow-md"
|
||||||
|
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: MAX_MENU_HEIGHT }}
|
||||||
|
>
|
||||||
|
{options.length === 0
|
||||||
|
? (
|
||||||
|
<div className="px-2 py-3 text-center text-xs text-[var(--vscode-descriptionForeground)]">
|
||||||
|
无选项
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: options.map((opt, i) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
ref={(el) => { optionRefs.current[i] = el }}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={opt.value === value}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
selectOption(i)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-1.5 px-2 py-1 text-left text-xs',
|
||||||
|
i === highlight
|
||||||
|
? 'bg-[var(--vscode-list-activeSelectionBackground)] text-[var(--vscode-list-activeSelectionForeground)]'
|
||||||
|
: 'text-[var(--vscode-foreground)] hover:bg-[var(--vscode-list-hoverBackground)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('grid size-3.5 shrink-0 place-items-center', opt.value === value ? 'opacity-100' : 'opacity-0')}>
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
</span>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate">{opt.label}</span>
|
||||||
|
{opt.hint && <span className="truncate text-[10px] opacity-70">{opt.hint}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import type { Issue, IssueColumn } from '../types'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { isColumnId, resolveKanbanDrag } from './kanbanDrag'
|
||||||
|
|
||||||
|
function issue(
|
||||||
|
number: number,
|
||||||
|
column: IssueColumn,
|
||||||
|
extra: Partial<Issue> = {},
|
||||||
|
): Issue {
|
||||||
|
return {
|
||||||
|
id: `repo#${number}`,
|
||||||
|
number,
|
||||||
|
title: `issue ${number}`,
|
||||||
|
column,
|
||||||
|
htmlUrl: '',
|
||||||
|
...extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isColumnId', () => {
|
||||||
|
it('识别四列 id', () => {
|
||||||
|
expect(isColumnId('todo')).toBe(true)
|
||||||
|
expect(isColumnId('in-progress')).toBe(true)
|
||||||
|
expect(isColumnId('review')).toBe(true)
|
||||||
|
expect(isColumnId('done')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝卡片 id', () => {
|
||||||
|
expect(isColumnId('repo#1')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveKanbanDrag', () => {
|
||||||
|
it('active===over → none', () => {
|
||||||
|
const issues = [issue(1, 'todo')]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#1' }))
|
||||||
|
.toEqual({ type: 'none' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('丢到另一张待办卡片 = set(over 为前置)', () => {
|
||||||
|
const issues = [issue(1, 'todo'), issue(2, 'todo')]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
|
||||||
|
.toEqual({ type: 'dependency-set', issueNumber: 1, prerequisiteNumber: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('丢到待办列空白且已有依赖 = clear', () => {
|
||||||
|
const issues = [issue(1, 'todo', { prerequisite: 2 }), issue(2, 'todo')]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
|
||||||
|
.toEqual({ type: 'dependency-clear', issueNumber: 1, prerequisiteNumber: 2 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('over 是列且已在 todo 且无依赖 = none', () => {
|
||||||
|
const issues = [issue(1, 'todo')]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
|
||||||
|
.toEqual({ type: 'none' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨列仍是 column', () => {
|
||||||
|
const issues = [issue(1, 'in-progress'), issue(2, 'todo')]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
|
||||||
|
.toEqual({ type: 'column', issueNumber: 1, toColumn: 'todo' })
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
|
||||||
|
.toEqual({ type: 'column', issueNumber: 1, toColumn: 'todo' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨列带上 source / externalId', () => {
|
||||||
|
const issues = [issue(1, 'review', { source: 'youtrack', externalId: 'LXF-1' })]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'todo' }))
|
||||||
|
.toEqual({
|
||||||
|
type: 'column',
|
||||||
|
issueNumber: 1,
|
||||||
|
toColumn: 'todo',
|
||||||
|
source: 'youtrack',
|
||||||
|
externalId: 'LXF-1',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('环拒绝:不能把祖先挂到自己的后代下', () => {
|
||||||
|
const issues = [
|
||||||
|
issue(1, 'todo'),
|
||||||
|
issue(2, 'todo', { prerequisite: 1 }),
|
||||||
|
issue(3, 'todo', { prerequisite: 2 }),
|
||||||
|
]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#3' }))
|
||||||
|
.toEqual({ type: 'none' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已经是这个 parent → none', () => {
|
||||||
|
const issues = [issue(1, 'todo', { prerequisite: 2 }), issue(2, 'todo')]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
|
||||||
|
.toEqual({ type: 'none' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('换 parent 也是 dependency-set,不先 clear', () => {
|
||||||
|
const issues = [
|
||||||
|
issue(1, 'todo', { prerequisite: 2 }),
|
||||||
|
issue(2, 'todo'),
|
||||||
|
issue(3, 'todo'),
|
||||||
|
]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#3' }))
|
||||||
|
.toEqual({ type: 'dependency-set', issueNumber: 1, prerequisiteNumber: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('同列非 todo → reorder', () => {
|
||||||
|
const issues = [issue(1, 'in-progress'), issue(2, 'in-progress')]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
|
||||||
|
.toEqual({ type: 'reorder', fromIndex: 0, toIndex: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('锁定:todo 且前置未完成,目标列不是 todo → none', () => {
|
||||||
|
const issues = [
|
||||||
|
issue(1, 'todo', { prerequisite: 2 }),
|
||||||
|
issue(2, 'in-progress'),
|
||||||
|
]
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'in-progress' }))
|
||||||
|
.toEqual({ type: 'none' })
|
||||||
|
expect(resolveKanbanDrag({ issues, activeId: 'repo#1', overId: 'repo#2' }))
|
||||||
|
.toEqual({ type: 'none' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Issue, IssueColumn } from '../types'
|
||||||
|
import { COLUMN_ORDER } from '../types'
|
||||||
|
import { isIssueLocked } from './dependencies'
|
||||||
|
|
||||||
|
export function isColumnId(id: string): id is IssueColumn {
|
||||||
|
return (COLUMN_ORDER as string[]).includes(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KanbanDragResult
|
||||||
|
= | { type: 'none' }
|
||||||
|
| { type: 'column', issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string }
|
||||||
|
| { type: 'dependency-set', issueNumber: number, prerequisiteNumber: number }
|
||||||
|
| { type: 'dependency-clear', issueNumber: number, prerequisiteNumber: number }
|
||||||
|
| { type: 'reorder', fromIndex: number, toIndex: number }
|
||||||
|
|
||||||
|
function isDescendant(maybeAncestor: number, target: number, all: Issue[]): boolean {
|
||||||
|
const visited = new Set<number>()
|
||||||
|
let current: Issue | undefined = all.find(i => i.number === target)
|
||||||
|
while (current && current.prerequisite != null) {
|
||||||
|
if (visited.has(current.number))
|
||||||
|
return false
|
||||||
|
visited.add(current.number)
|
||||||
|
if (current.prerequisite === maybeAncestor)
|
||||||
|
return true
|
||||||
|
current = all.find(i => i.number === current!.prerequisite)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveKanbanDrag(opts: {
|
||||||
|
issues: Issue[]
|
||||||
|
activeId: string
|
||||||
|
overId: string
|
||||||
|
}): KanbanDragResult {
|
||||||
|
const { issues, activeId, overId } = opts
|
||||||
|
if (activeId === overId)
|
||||||
|
return { type: 'none' }
|
||||||
|
|
||||||
|
const activeItem = issues.find(i => i.id === activeId)
|
||||||
|
if (!activeItem)
|
||||||
|
return { type: 'none' }
|
||||||
|
|
||||||
|
// ① 锁:待办且前置未完成,只能在 todo 列内操作
|
||||||
|
const locked = isIssueLocked(activeItem, issues).locked && activeItem.column === 'todo'
|
||||||
|
const overIsColumn = isColumnId(overId)
|
||||||
|
const overItem = overIsColumn ? undefined : issues.find(i => i.id === overId)
|
||||||
|
const targetColumn: IssueColumn | undefined = overIsColumn ? overId : overItem?.column
|
||||||
|
if (locked && targetColumn && targetColumn !== 'todo')
|
||||||
|
return { type: 'none' }
|
||||||
|
|
||||||
|
// ② over 是列:已在 todo 且有依赖 → 清依赖;已在该列 → none;否则换列
|
||||||
|
if (overIsColumn) {
|
||||||
|
if (overId === 'todo' && activeItem.column === 'todo' && activeItem.prerequisite != null) {
|
||||||
|
return {
|
||||||
|
type: 'dependency-clear',
|
||||||
|
issueNumber: activeItem.number,
|
||||||
|
prerequisiteNumber: activeItem.prerequisite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeItem.column === overId)
|
||||||
|
return { type: 'none' }
|
||||||
|
return {
|
||||||
|
type: 'column',
|
||||||
|
issueNumber: activeItem.number,
|
||||||
|
toColumn: overId,
|
||||||
|
...(activeItem.source ? { source: activeItem.source } : {}),
|
||||||
|
...(activeItem.externalId ? { externalId: activeItem.externalId } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!overItem)
|
||||||
|
return { type: 'none' }
|
||||||
|
|
||||||
|
// ③ over 是卡片:双方 todo → 设依赖;同列非 todo → 重排;跨列 → 换列
|
||||||
|
if (activeItem.column === 'todo' && overItem.column === 'todo') {
|
||||||
|
if (isDescendant(activeItem.number, overItem.number, issues))
|
||||||
|
return { type: 'none' }
|
||||||
|
if (activeItem.prerequisite === overItem.number)
|
||||||
|
return { type: 'none' }
|
||||||
|
return {
|
||||||
|
type: 'dependency-set',
|
||||||
|
issueNumber: activeItem.number,
|
||||||
|
prerequisiteNumber: overItem.number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeItem.column === overItem.column) {
|
||||||
|
return {
|
||||||
|
type: 'reorder',
|
||||||
|
fromIndex: issues.findIndex(i => i.id === activeId),
|
||||||
|
toIndex: issues.findIndex(i => i.id === overId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'column',
|
||||||
|
issueNumber: activeItem.number,
|
||||||
|
toColumn: overItem.column,
|
||||||
|
...(activeItem.source ? { source: activeItem.source } : {}),
|
||||||
|
...(activeItem.externalId ? { externalId: activeItem.externalId } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ export type ExtensionToWebview
|
|||||||
= | { type: 'issues/loading' }
|
= | { type: 'issues/loading' }
|
||||||
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string | null, reviewSessionId?: string | null, reviewSessionFileExists?: boolean, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||||
| { type: 'issue/select-by-number', issueNumber: number }
|
| { type: 'issue/select-by-number', issueNumber: number }
|
||||||
|
|||||||
@@ -20,5 +20,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user