Compare commits

...
10 Commits
24 changed files with 1409 additions and 391 deletions
+163 -59
View File
@@ -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 4webview)、esbuild、VitestCLI 为 Go + cobra。
pnpm ext:package
```
## 技术栈
- TypeScript
- VS Code Extension API
- reactive-vscode
- Vitest
## 许可证 ## 许可证
+1 -1
View File
@@ -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",
+30
View File
@@ -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

+23 -51
View File
@@ -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)
+313
View File
@@ -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 用 nullpostMessage 会丢 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()
})
})
+51
View File
@@ -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
}
}
+9 -26
View File
@@ -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,
})
})
})
+45
View File
@@ -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,
}
}
+1 -1
View File
@@ -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 }
+1
View File
@@ -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)}
+53 -160
View File
@@ -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('丢到另一张待办卡片 = setover 为前置)', () => {
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' })
})
})
+102
View File
@@ -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 } : {}),
}
}
+1 -1
View File
@@ -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 }
+1
View File
@@ -20,5 +20,6 @@
} }
}, },
"include": ["src"], "include": ["src"],
"exclude": ["src/**/*.test.ts"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }