diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e21b395..4c1ad1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,10 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, windows-latest] node-version: [20, 22] steps: - uses: actions/checkout@v4 @@ -31,7 +32,7 @@ jobs: run: npm run check - name: Syntax check - run: node --check lib/index.js && node --check lib/client.js + run: node --check lib/index.js && node --check lib/web-actions.js && node --check lib/client.js - name: Unit and integration tests run: node --test diff --git a/AGENTS.md b/AGENTS.md index 429c4d1..fd8d3bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ English · [简体中文](AGENTS_ZH.md) ## Current development -- PR #6 merged the first read-only right-sidebar panel for Issue #1 as **0.3.0**, release point 85fa7d1c4a7e6f7c274d83df242d10c65e319d24. GitHub v0.3.0 is public and its installed entry passed 28 service checks plus real Web acceptance. npm registry availability, installed-package acceptance and latest=next=0.3.0 are confirmed. See docs/release-report-0.3.0.md. Do not overwrite npm 0.2.1 or add GUI writes. +- PR #6 merged the first read-only right-sidebar panel for Issue #1 as **0.3.0**, release point 85fa7d1c4a7e6f7c274d83df242d10c65e319d24. GitHub v0.3.0 is public and its installed entry passed 28 service checks plus real Web acceptance. npm registry availability, installed-package acceptance and latest=next=0.3.0 are confirmed. See docs/release-report-0.3.0.md. Published versions remain immutable. The current explicit goal authorizes completing GUI operations in a new release, then closing Issue #1 after real acceptance. - Source: `src/index.ts`, `src/web-host.ts`, browser-safe `src/panel-types.ts`, `src/client/`. Build Host declarations/JS and generated `lib/client.js`; keep ESM and existing commands/tools. - English primary documents and `_ZH.md` counterparts: README, CONTRIBUTING, AGENTS; also paired Web usage documents. Synchronize facts, commands and validation boundaries. README stays a short product page; engineering details belong in CONTRIBUTING/docs and maintenance constraints here. Do not add a skill just for a documentation edit. - Web evidence: `docs/web-panel-validation.md`. Old release CI does not certify a new development SHA. @@ -36,7 +36,7 @@ English · [简体中文](AGENTS_ZH.md) - Run build/check/test/pack for relevant source changes. Add regressions for actual fixes, not tests that merely mirror implementation. - Build artifacts with source in the same commit. After a commit, rebuild in a clean checkout and run `git diff --exit-code lib`; normal pre-commit artifact differences are expected. - Use independent DSH_HOME/profiles and temporary Git repositories. Do not touch daily profiles. Run the exact-host service script when appropriate; mocks do not replace host, Web or model validation. -- Web: authenticate through the official Connection carrier; derive cwd from an existing Session, never request-supplied cwd/root/argv. DSH 0.2.0-rc.2 has one admitted operator, not tenant-specific session ACLs. Refuse unknown Sessions and constrain paths; disable external diff/textconv and keep routes read-only. +- Web: authenticate through the official Connection carrier; derive cwd from an existing Session, never request-supplied cwd/root/argv. DSH 0.2.0-rc.2 has one admitted operator, not tenant-specific session ACLs. Refuse unknown Sessions and constrain paths; disable external diff/textconv. Writes use the separate same-origin JSON prepare/execute confirmation contract, Session/repository snapshot checks and single-use tokens; never expose arbitrary argv. - Client: generate the loader factory from TSX with a small build; externalize shell-owned React/JSX runtime. Never hand-edit client.js or bundle a second React. Dispose UI, styles, slots, tabs, routes and in-flight requests on disable; ignore stale responses on navigation. - Report actual type/unit/real-Git/host/Web/model/platform evidence separately, including failed or unexecuted checks. @@ -52,8 +52,8 @@ English · [简体中文](AGENTS_ZH.md) - The 0.3.0 dual channels, bundle registration and read-only panel are released. Market PR #6296 closed unmerged and the 2026-10-02 public catalog has zero exact repository matches; investigate separately before claiming search/install availability. - P2 / Issue #1 is released: read-only status, diff and history; reuse the sidebar carrier, not turn-based workspace-change summaries. That summary excludes pre-existing edits and is not an index/worktree Git source. -- P3: review stage/commit/branch/stash buttons separately. Turn-bound approval is unresolved for clicks outside an open turn; read authentication is not write authorization. -- Stage prompts: `docs/dsh-tasks/README.md`; 03–05 are complete, 06 has mount-prefix/slow Web cancellation remaining, 07 is write approval/recovery design. Adapt them to actual completed evidence rather than repeating historical instructions. Plan/history documents remain clearly dated. +- P3 / current authorized work: stage/unstage, staged-only commit, branches, stash and backup restore are implemented; finish installed acceptance and publish 0.4.0 before closing the issue. Native approval.request requires an open model turn; explicit operator GUI confirmation is separate, never a fabricated turn or a model-tool grant. See docs/superpowers/specs/2026-10-02-git-actions-design.md. +- Stage prompts: `docs/dsh-tasks/README.md`; 03–05 are complete, 06 has mount-prefix/slow Web cancellation remaining, 07 design is implemented by the current Git actions contract. Adapt them to actual completed evidence rather than repeating historical instructions. Plan/history documents remain clearly dated. - A successful publish exit/HTTP 202 is acceptance, not installability. Allow official scanning and registry propagation; check actual availability/gitHead/tarball and installed behavior before latest promotion. A successful dist-tag write also requires a confirming tag read; do not repeat publish to address delays. - Finalize release date before publish. Version/lock/CHANGELOG/source/types/lib and tag/Release/npm gitHead/artifacts identify one release point. Publish each version once to next, verify, then promote with dist-tag. Changed published content needs a new version. - AGENTS/docs/team state are engineering resources, not npm runtime files. Pack only closed runtime outputs, bilingual READMEs, license, patch and referenced assets; verify actual file list. Published 0.2.0/0.2.1 contents are immutable. @@ -65,3 +65,9 @@ English · [简体中文](AGENTS_ZH.md) - Root screenshots.json references the capability illustration and actual released read-only panel screenshots in docs/images. The banner is an illustration; panel images use isolated fixture repositories. Catalog readiness and catalog inclusion are different states. - Old manual inserts must migrate to id/config overrides when enabling the bundle. DSH 0.2.0-rc.2 does not automatically add a bundle layer when upgrading an old ordinary dependency. - Preserve the isolated WSL runtimes `~/dsh-node-runtimes/node-v20.20.2-linux-x64` and `node-v22.23.3-linux-x64` unless removal is specifically requested. System Node 18 was not used for the prior matrix. + +## 0.4.0 candidate + +- Source commits: 8b9efc1 actions, 293ff98 content previews, 9871360 native Host/Origin seam, da406ac retained controls on refresh. Windows build/check and 55 tests passed before the last UI-only fix; its client test and type check passed. a44650c fixes reviewed rename/stash scope and passed full Windows build/check/57 tests. Run final candidate checks/CI and installed Web before release. +- New Web minimum Git 2.32 is required for stash show --include-untracked; only actual tested versions may be claimed verified. Connection HTTP bridge uses dsh.internal URL; validate mandatory Origin against its trusted Host authority. +- Use a unique development tarball path per SHA; pnpm can cache a repeated filename. Compare installed lib hashes, restart the isolated profile, and never hand-edit installed outputs. diff --git a/AGENTS_ZH.md b/AGENTS_ZH.md index 1a3cdc9..688bdf6 100644 --- a/AGENTS_ZH.md +++ b/AGENTS_ZH.md @@ -17,7 +17,7 @@ ## 当前开发任务 -- PR #6 已保留历史合并 Issue #1 首版只读面板为 0.3.0,发布点 85fa7d1c4a7e6f7c274d83df242d10c65e319d24;GitHub v0.3.0 已公开,固定 tag 实装的 28 项服务及真实 Web 通过。npm 注册表可见、正式实装及 latest=next=0.3.0 已确认;实际状态见 docs/release-report-0.3.0.md;不覆盖 npm 0.2.1,不混入 GUI 写操作。 +- PR #6 已保留历史合并 Issue #1 首版只读面板为 0.3.0,发布点 85fa7d1c4a7e6f7c274d83df242d10c65e319d24;GitHub v0.3.0 已公开,固定 tag 实装的 28 项服务及真实 Web 通过。npm 注册表可见、正式实装及 latest=next=0.3.0 已确认;实际状态见 docs/release-report-0.3.0.md;旧版本不可覆盖;当前明确目标已授权补完整 GUI 操作,正式实装验收后关闭 Issue #1。 - README / CONTRIBUTING / AGENTS 采用英文主文档和 `_ZH.md` 中文对应页;面板使用文档同样配对,修改时同步事实、命令和验收边界。 - 新 Web 证据见 `docs/web-panel-validation.md`;不能将旧发布点的 CI 当作本分支 CI。 - 当前源码为 src/index.ts、src/web-host.ts、src/panel-types.ts 和 src/client/;客户端通过小型构建生成官方 loader 包装,复用宿主 React,不手改 lib/client.js、不捆绑第二份 React。 @@ -47,7 +47,7 @@ ## 后续顺序与发布 -- P1:`0.2.1` 双渠道发布、bundle 自动启用、README 展示与市场收录申请已完成;市场 PR #6296 等待上游审查合并及目录同步,之后核验市场搜索与实际安装。GUI 另开阶段。 +- P1:`0.2.1` 双渠道发布、bundle 自动启用、README 展示与市场收录申请已完成;市场 PR #6296 已关闭未合并、无说明评论;目录没有本仓库精确匹配,不宣称已收录。GUI 另开阶段。 - P2:[Issue #1](https://github.com/MashedPotato817/dsh-git-plugin/issues/1) 的 Web 只读 Git 面板:状态、diff、历史首期已实现;继续补验及用户验收。 - P3:再考虑 stage/commit/branch/stash 等写操作,核对 DSH 权限与审批及恢复方式。 - 版本文件、CHANGELOG、源码、编译配置、测试和 `lib/` 一起纳入发布提交;确保 tag、Release、npm gitHead 与包内产物对应同一提交。 @@ -66,7 +66,7 @@ ## 后续任务入口(2026-10-01 核查) -- 任务分包见 [docs/dsh-tasks/README.md](docs/dsh-tasks/README.md):03–05 已完成并发布;06 仅余挂载前缀/真实慢 Web 请求取消,07 先设计 GUI 写操作审批和恢复;逐包推进。 +- 任务分包见 [docs/dsh-tasks/README.md](docs/dsh-tasks/README.md):03–05 已完成并发布;06 仅余挂载前缀/真实慢 Web 请求取消,07 的审批与恢复设计已由当前 Git 操作契约实现;逐包推进。 - 原候选 feba7b8,定时器修复 922408d,发布点 c83f332;PR/main 当前 SHA CI 均通过,协作文档与模板已进入默认分支。包 02 的 npm、GitHub、latest 推广与双渠道安装均完成;随后 03–05 的只读 Web 已实现为 0.3.0,继续 06/07 的剩余项目。 - Linux Node 20/22 已用**隔离运行时**(WSL2 Ubuntu 24.04,`~/dsh-node-runtimes/node-v20.20.2-linux-x64`、`node-v22.23.3-linux-x64`)完成修复后 build/check/test 复验;修复候选 922408d 的 npm ci/build/产物门/check/语法/test/pack 全部通过。系统 Node 18.19.1 未用于验证。证据见 `docs/validation-report-0.2.0.md`。未推送 SHA 的干净检出从本地主仓库克隆。 @@ -83,3 +83,13 @@ - 贡献指南见 CONTRIBUTING.md;Issue 表单在 .github/ISSUE_TEMPLATE,PR 使用 .github/pull_request_template.md。报告必须区分本地、真实宿主、模型会话与当前 SHA 的 CI 证据。 - 大任务先明确 Issue 范围与验收条件,拆阶段提交;维护者负责分流、审查与合并/发布。模板需进入默认分支后生效,不宣称标签、分支保护或远端设置已经启用。 + +## 当前完整目标 + +按 docs/superpowers/specs/2026-10-02-git-actions-design.md 实施暂存/取消暂存、仅暂存提交、分支、stash 和备份还原。人工 GUI 写确认独立于需要 open turn 的模型审批;同源 JSON、一次性确认、状态复核,不伪造模型回合。完整实装/CI/双渠道发布后回复并关闭 Issue #1,不把只读首期当作完整交付。 + +## 0.4.0 候选 + +- 源码提交:8b9efc1 操作,293ff98 内容预览,9871360 原生 Host/Origin,da406ac 刷新保留操作区。最后界面小修前 Windows build/check 和 55 项测试通过;小修后的客户端与类型检查通过。a44650c 修复审查指出的 rename/stash 范围,完整 Windows build/check/57 项通过;发布前须复跑最终候选/CI/正式安装 Web。 +- 新 Web 能力要求 Git 2.32(stash show --include-untracked),仅实际测试版本声明已验证。原生 bridge 的 URL 为 dsh.internal,强制 Origin 绑定已受信 Host authority。 +- 每个开发 SHA 使用不同 tarball 文件路径,避免 pnpm 同名缓存;比较实际安装 lib 哈希并重启独立 profile,不手改安装产物。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ff74431..1707187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,25 @@ Actual changes to `dsh-git-plugin`, following [Keep a Changelog](https://keepach ## [Unreleased] +## [0.4.0] - 2026-10-02 + +### Added + +- Complete Web Git workflow for Issue #1: selected/bulk stage and unstage, staged-only commit with hooks, local branches, stash save/apply/drop, and tracked-file restore with a retained stash backup. +- Mandatory operator preview/confirmation with two-minute single-use tokens; Session/root/snapshot rechecks, per-repository mutation locks, same-authority JSON POST and lifecycle cancellation. Model tools remain read-only. +- Real-Git/DOM regressions and an optional installed-Web operation acceptance script. + +### Fixed + +- Bind Origin to the native carrier Host rather than the bridge's synthetic dsh.internal URL. +- Stash operations omit inherited literal-pathspec mode so native Git cleans saved untracked files. +- Confirmation previews include selected untracked content and chosen stash diffs. + +### Notes + +- Requires Git >=2.32 for untracked stash previews; tested versions are documented separately. UI labels remain Chinese. External Git processes can race panel snapshots; native hooks are trusted repository code. + + ## [0.3.0] - 2026-10-01 ### Added diff --git a/CHANGELOG_ZH.md b/CHANGELOG_ZH.md index 4fe7fc6..5400c15 100644 --- a/CHANGELOG_ZH.md +++ b/CHANGELOG_ZH.md @@ -7,6 +7,25 @@ ## [Unreleased] +## [0.4.0] - 2026-10-02 + +### 新增 + +- 完整 Web Git 工作流:单文件/批量暂存及取消暂存、仅提交暂存区并执行钩子、本地分支、stash 保存/应用/删除、保留 stash 备份后还原已跟踪文件。 +- 人工预览确认、两分钟一次性凭证、会话/仓库/快照复核、每仓库写锁、受信 Host/Origin JSON POST 与启停取消。模型工具保持只读。 +- 真实 Git、DOM 回归及可单独运行的正式安装 Web 操作验收脚本。 + +### 修复 + +- Origin 绑定原生载体 Host,避免 dsh.internal 内部地址误拒绝页面点击。 +- stash 操作不继承 literal-pathspec 模式,保证 Git 清理已保存的未跟踪文件。 +- 确认页展示选定未跟踪文件内容及所选 stash 差异。 + +### 说明 + +- 未跟踪 stash 预览需要 Git >=2.32,实际验证版本另行记录。界面仍为中文;外部 Git 进程可能与快照并发,Git 原生钩子属于受信仓库代码。 + + ## [0.3.0] - 2026-10-01 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3d35f0..c11b2a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,10 +19,10 @@ Bug reports, compatibility evidence, documentation, and focused implementations ### Fixed GitHub tag ```bash -dsh plugin --profile web add github:MashedPotato817/dsh-git-plugin#v0.3.0 +dsh plugin --profile web add github:MashedPotato817/dsh-git-plugin#v0.4.0 ``` -npm 0.2.1 and this tag contain compiled outputs and the bundle patch. They need no installation-time build. New installations register automatically; restart the profile. Migrate older manual 0.2.0 inserts instead of keeping duplicate rows. +npm 0.4.0 and this tag contain compiled outputs and the bundle patch. They need no installation-time build. New installations register automatically; restart the profile. Migrate older manual 0.2.0 inserts instead of keeping duplicate rows. ### Source installation @@ -36,7 +36,7 @@ dsh plugin --profile web add . Check your checkout first: v0.2.0 requires a manual insert; v0.2.1 declares a bundle patch. A bundle supplies the row; user configuration overrides it by id. Upgrading an existing ordinary dependency does not automatically add the bundle layer in DSH 0.2.0-rc.2. See [migration evidence](docs/marketplace-submission.md). -Rebuild after editing source, then restart that profile. The Web panel exists only on the development branch until a new release; neither published 0.2.1 channel contains it. +Rebuild after editing source, then restart that profile. The Web panel shipped in 0.3.0; 0.4.0 adds confirmed operator actions. Published 0.2.x packages contain commands/tools only. ## Configuration @@ -44,7 +44,7 @@ Rebuild after editing source, then restart that profile. The Web panel exists on |---|---|---| | `maxBytes` | `1048576` | stdout byte cap per Git call | | `stderrMaxBytes` | `65536` | stderr byte cap | -| `timeoutMs` | `30000` | Per-command, tool, or preCommit deadline in ms | +| `timeoutMs` | `30000` | Per-Git-call, tool, or preCommit deadline in ms | | `graceMs` | `3000` | Process termination grace period in ms | | `preCommit` | `[]` | One argv command; failure or timeout aborts the commit | @@ -78,9 +78,9 @@ For a 0.2.1 bundle, override the existing row instead: | `ctx.tools` | Four read-only Git tools | | `ctx.systemPrompt` | Git tool and commit guidance | | `ctx.subprocess` | Plain argv, output caps, cancellation and deadlines | -| Optional Web services | Authenticated routes and Session-derived repository reads | +| Optional Web services | Authenticated reads and confirmed operator writes in a Session-derived repository | -Commands resolve the repository from the session cwd, including exactly one immediate child repository. Tools return text. Disabling removes commands, tools and prompt registrations; the development panel also removes routes, tabs, slots, styles and pending requests. +Commands resolve the repository from the session cwd, including exactly one immediate child repository. Tools return text. Disabling removes commands, tools and prompt registrations; the panel also removes routes, tabs, slots, styles and pending requests. | Tool | Arguments and behavior | |---|---| @@ -95,16 +95,16 @@ Commands resolve the repository from the session cwd, including exactly one imme |---|---|---| | DSH | `>=0.2.0-rc.2 <0.3.0-0` | Only `0.2.0-rc.2` | | Node.js | `>=20` | Windows 24.19.0; historical Linux 20.20.2 / 22.23.3 matrix | -| Git | `>=2.24`, including `--end-of-options` | Windows 2.53.0.windows.2; historical Linux 2.43.0 | +| Git | `>=2.32`, including stash show --include-untracked | Windows 2.53.0.windows.2; historical Linux 2.43.0 | -Release evidence: [0.2.0](docs/release-report-0.2.0.md), [0.2.1](docs/release-report-0.2.1.md), and [original validation](docs/validation-report-0.2.0.md). Development panel evidence: [Web validation](docs/web-panel-validation.md). +Release evidence: [0.2.0](docs/release-report-0.2.0.md), [0.2.1](docs/release-report-0.2.1.md), [0.3.0](docs/release-report-0.3.0.md), and [original validation](docs/validation-report-0.2.0.md). Panel evidence: [Web validation](docs/web-panel-validation.md). - DSH 0.1.x and `0.2.0-rc.1` are unsupported. Other versions within the declaration must still be verified. Plugin 0.1.0 is not compatible with DSH 0.2.0-rc.2. - This is a DSH-hosted plugin, not a standalone Node CLI. Ordinary Node loading needs the SDK peers supplied separately. - Repository discovery checks the session cwd and immediate children. Multiple candidates require choosing a session cwd inside the desired repository. - Slow preCommit checks need an explicit higher `timeoutMs`. - Node-only Linux tests or CI do not prove the complete Linux DSH runtime works. Upstream development peer engine warnings are recorded in the original validation report. -- Real model sessions, other DSH versions and the complete Linux Web host remain unverified. Published 0.2.1 contains commands and tools; the read-only panel is unreleased. +- Real model sessions and other DSH versions remain unverified. Linux real DSH Web is verified separately from Node-only tests; see the release reports. ## Development setup diff --git a/CONTRIBUTING_ZH.md b/CONTRIBUTING_ZH.md index 38f81b6..bc55abc 100644 --- a/CONTRIBUTING_ZH.md +++ b/CONTRIBUTING_ZH.md @@ -22,10 +22,10 @@ ### 固定 GitHub tag ```bash -dsh plugin --profile web add github:MashedPotato817/dsh-git-plugin#v0.3.0 +dsh plugin --profile web add github:MashedPotato817/dsh-git-plugin#v0.4.0 ``` -npm 0.2.1 与此 tag 都携带编译产物与 bundle patch,无需安装期构建;新安装自动注册,重启对应 profile 即可。旧 0.2.0 profile 按迁移说明调整,不能保留重复 insert。 +npm 0.4.0 与此 tag 都携带编译产物与 bundle patch,无需安装期构建;新安装自动注册,重启对应 profile 即可。旧 0.2.0 profile 按迁移说明调整,不能保留重复 insert。 ### 从源码安装 @@ -47,7 +47,7 @@ dsh plugin --profile web add . |---|---|---| | `maxBytes` | `1048576` | 每次 Git 调用的 stdout 字节上限 | | `stderrMaxBytes` | `65536` | stderr 字节上限 | -| `timeoutMs` | `30000` | 每次命令、工具或 preCommit 调用的截止时间,单位 ms | +| `timeoutMs` | `30000` | 每次 Git、工具或 preCommit 调用的截止时间,单位 ms | | `graceMs` | `3000` | 超时或取消后等待进程终止的宽限时间,单位 ms | | `preCommit` | `[]` | 提交前执行的一个 argv 命令;失败或超时则中止提交 | @@ -82,7 +82,7 @@ dsh plugin --profile web add . | `ctx.systemPrompt` | 注入 Git 工具使用与提交规范指引 | | `ctx.subprocess` | 用纯 argv 执行 Git,落实输出上限、取消与超时终止 | -调用读取会话目录,解析目标仓库,再执行 Git 并返回文本结果。禁用插件时释放命令、工具和提示词注册;开发版还清理 Web 路由、tab、slot、样式和请求,重新启用不重复注册。 +调用读取会话目录,解析目标仓库,再执行 Git 并返回文本结果。禁用插件时释放命令、工具和提示词注册;面板还清理 Web 路由、tab、slot、样式和请求,重新启用不重复注册。 模型工具均为只读: @@ -103,7 +103,7 @@ dsh plugin --profile web add . |---|---|---| | DSH | `>=0.2.0-rc.2 <0.3.0-0` | **仅 `0.2.0-rc.2`** | | Node.js | `>=20` | Windows 24.19.0;Linux 20.20.2 / 22.23.3;GitHub Actions Node 20/22 | -| Git | 支持 `--end-of-options`,Git 2.24+ | Windows 2.53.0.windows.2;Linux 2.43.0 | +| Git | Git 2.32+,支持未跟踪 stash 预览 | Windows 2.53.0.windows.2;Linux 2.43.0 | ### 0.2.0 的实际验证 @@ -115,7 +115,7 @@ dsh plugin --profile web add . | npm / 固定 GitHub tag 安装 | 独立 profile 实装与启用通过,schema 无诊断;提供官方宿主 peer 后服务栈通过 | | 发布一致性 | npm gitHead、tag、Release 对应 `c83f332`;实际 tarball 的 `lib/` 哈希一致 | -完整证据见[发布记录](docs/release-report-0.2.0.md)与[验证报告](docs/validation-report-0.2.0.md)。**真实模型会话、Linux 完整 DSH 宿主、其他 DSH 版本仍未验证。** +完整证据见[发布记录](docs/release-report-0.2.0.md)与[验证报告](docs/validation-report-0.2.0.md)。上述为 0.2.0 历史验证;Linux 真实服务栈/Web 已在 [0.3.0 发布记录](docs/release-report-0.3.0.md)验证。真实模型会话和其他 DSH 版本仍未验证。 ### 运行限制 @@ -124,7 +124,7 @@ dsh plugin --profile web add . - **仓库发现:** 只检查会话目录与直接子目录;多个子仓库时列出候选,建议将会话切换到目标仓库。 - **耗时检查:** preCommit 也受默认 30 秒截止时间约束;较慢的测试请显式调大 `timeoutMs`。 - **平台边界:** Linux 本地测试和 CI 不等于完整 DSH 宿主验证;上游开发 peer 链的 Node 20 engine 告警见验证报告。 -- **界面范围:** 发布的 0.2.1 提供命令与工具;开发分支已实现只读 Web 面板,尚未发布。见[面板说明](docs/web-panel_ZH.md)。 +- **界面范围:** 0.2.x 提供命令与工具,0.3.0 已发布只读 Web 面板,0.4.0 增加人工确认的 Git 操作。见[面板说明](docs/web-panel_ZH.md)。 ## 准备开发环境 diff --git a/README.md b/README.md index b9f488c..5915363 100644 --- a/README.md +++ b/README.md @@ -19,20 +19,20 @@ English · [简体中文](README_ZH.md) ## Keep Git in your DSH session -Inspect changes, create branches, run pre-commit checks, and keep recoverable snapshots. **Read-only Web panel + 5 slash commands + 4 read-only model tools** give you and your agent the same view of the repository. +Inspect changes, create branches, run pre-commit checks, and keep recoverable snapshots. **Web Git panel + 5 slash commands + 4 read-only model tools** give you and your agent the same view of the repository. - **See the changes** — branch, working tree, staged / unstaged diff, and history. - **Commit with checks** — branch and commit directly in your session. - **Keep a snapshot** — stash current changes, then list or restore them. -Version: **0.3.0**. Verified host: **DSH 0.2.0-rc.2**. Requires Node.js ≥20 and Git ≥2.24. [Compatibility and validation](CONTRIBUTING.md#compatibility-and-validation). +Version: **0.4.0**. Verified host: **DSH 0.2.0-rc.2**. Requires Node.js ≥20 and Git ≥2.32. [Compatibility and validation](CONTRIBUTING.md#compatibility-and-validation). ## Install Use your active profile; replace `web` as needed: ```bash -dsh plugin --profile web add dsh-git-plugin@0.3.0 +dsh plugin --profile web add dsh-git-plugin@0.4.0 ``` New installations register the plugin automatically. Restart that profile's DSH process and refresh the browser when using Web. @@ -41,7 +41,11 @@ Upgrading a manually enabled 0.2.0 profile? Follow the [migration steps](docs/ma ## Git in the Web sidebar -Open a repository workspace/session, expand the right sidebar and select **Git**. See branch/file states, switch between staged and unstaged diffs, and browse commit history/detail. The panel is read-only; Git writes use the existing slash commands. [Panel guide](docs/web-panel.md). +Open a repository workspace/session, expand the right sidebar and select **Git**. See branch/file states, switch between staged and unstaged diffs, and browse commit history/detail. Stage selected files or all changes, commit only the index, manage branches/stashes, and restore files with a retained backup. Every write opens a preview and requires your confirmation. [Panel guide](docs/web-panel.md). + +

+ Actual DSH Web Git action panel in a disposable test repository +

## Use @@ -64,7 +68,7 @@ The model can use `git-status`, `git-diff`, `git-log`, and `git-show`. For examp > Check Git status, diff, and the last five commits. Explain what is staged and what to verify before committing. -**Commit scope:** `/commit ` stages all changes in the target repository before committing. Check the scope first. Without a message it only shows guidance and changes. +**Commit scope:** the Web commit uses only staged changes. `/commit ` stages all changes in the target repository before committing. Check the scope first. Without a message it only shows guidance and changes. **Snapshots:** `/undo` stashes working-tree changes, including untracked files. It does not revert commits. Restoring with `pop` may produce conflicts. @@ -79,7 +83,7 @@ Need longer timeouts or a test hook? See [configuration](CONTRIBUTING.md#configu - [Web panel](docs/web-panel.md) · development preview and validation - [Maintenance instructions](AGENTS.md) · engineering constraints -Version 0.3.0 adds the first read-only Web panel for Issue #1. Market inclusion is tracked separately. Presentation inspired by [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams); the SVG banner is original. +Version 0.4.0 completes the Git operation panel requested in Issue #1. Market inclusion is tracked separately. Presentation inspired by [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams); the SVG banner is original. ## License diff --git a/README_ZH.md b/README_ZH.md index 183bfc9..dfa7e0f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -19,20 +19,20 @@ ## 让 Git 工作流留在 DSH 会话里 -查看改动、创建分支、提交前检查、保存可恢复快照。**只读 Web 面板 + 5 个斜杠命令 + 4 个模型只读工具**,让你和模型看到同一份仓库状态。 +查看改动、创建分支、提交前检查、保存可恢复快照。**Web Git 面板 + 5 个斜杠命令 + 4 个模型只读工具**,让你和模型看到同一份仓库状态。 - **看清改动** — 分支、工作区状态、已暂存 / 未暂存 diff 与提交历史。 - **顺手提交** — 创建分支,配置提交前检查,在会话中完成提交。 - **留一份快照** — 用 stash 暂存工作区,随时查看与恢复。 -本页对应 **0.3.0**,实测宿主为 **DSH 0.2.0-rc.2**;需要 Node.js ≥20、Git ≥2.24。[完整兼容与验证记录](CONTRIBUTING_ZH.md#兼容性与验证记录)。 +本页对应 **0.4.0**,实测宿主为 **DSH 0.2.0-rc.2**;需要 Node.js ≥20、Git ≥2.32。[完整兼容与验证记录](CONTRIBUTING_ZH.md#兼容性与验证记录)。 ## 安装 在需要使用插件的 profile 中执行,将 `web` 替换为实际名称: ```bash -dsh plugin --profile web add dsh-git-plugin@0.3.0 +dsh plugin --profile web add dsh-git-plugin@0.4.0 ``` 安装后自动注册插件。重启对应 profile 的 DSH;使用 Web 时刷新页面。 @@ -41,7 +41,11 @@ dsh plugin --profile web add dsh-git-plugin@0.3.0 ## Web 侧栏里的 Git -进入仓库工作区/会话,展开右侧栏,点击 **Git**。查看分支/文件状态、已暂存与未暂存 diff,以及提交历史和详情。面板只读,Git 写操作继续使用现有斜杠命令。见[面板指南](docs/web-panel_ZH.md)。 +进入仓库工作区/会话,展开右侧栏,点击 **Git**。查看分支/文件状态、已暂存与未暂存 diff,以及提交历史和详情。支持单文件/批量暂存、仅提交暂存区、分支与 stash 管理,以及保留备份后还原文件。每次写操作先预览,再由你明确确认。见[面板指南](docs/web-panel_ZH.md)。 + +

+ 独立测试仓库中的真实 DSH Web Git 操作面板 +

## 使用 @@ -64,7 +68,7 @@ dsh plugin --profile web add dsh-git-plugin@0.3.0 > 先查看 Git 状态、diff 和最近 5 条提交,说明哪些改动已暂存,并指出提交前需要检查的内容。 -**提交范围:** `/commit ` 会暂存目标仓库的全部改动再提交;执行前确认范围。不带参数只显示指引与改动。 +**提交范围:** Web 提交只包含已暂存内容;`/commit ` 会暂存目标仓库的全部改动再提交;执行前确认范围。不带参数只显示指引与改动。 **快照含义:** `/undo` 保存工作区改动(含未跟踪文件),不是回退 commit;`pop` 恢复时可能遇到冲突。 @@ -79,7 +83,7 @@ dsh plugin --profile web add dsh-git-plugin@0.3.0 - [Web 面板](docs/web-panel_ZH.md) · 开发版的使用与验收 - [维护指引](AGENTS_ZH.md) · 工程约束 -0.3.0 新增 Issue #1 的只读 Web 首期面板。市场收录状态见上述进展文档。页面设计参考 [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams),横幅为原创 SVG。 +0.4.0 完成 Issue #1 的 Git 操作面板。市场收录状态见上述进展文档。页面设计参考 [dsh-agent-teams](https://github.com/NanmiCoder/dsh-agent-teams),横幅为原创 SVG。 ## License diff --git a/docs/images/git-actions-dark.png b/docs/images/git-actions-dark.png new file mode 100644 index 0000000..8f89e85 Binary files /dev/null and b/docs/images/git-actions-dark.png differ diff --git a/docs/maintenance-plan.md b/docs/maintenance-plan.md index a426387..1e950a5 100644 --- a/docs/maintenance-plan.md +++ b/docs/maintenance-plan.md @@ -1,3 +1,7 @@ + + +> 2026-10-02 当前:0.3.0 已双渠道发布,完整 Git 操作进入 0.4.0 候选。发布顺序仍为最终提交 → PR 保留历史合并 → 干净产物/CI → 单次 next publish → 双渠道实装 → latest → Issue 完成。当前基线与证据见 AGENTS.md / docs/release-report-0.4.0.md;下文旧版本表、社区数量及“尚未实现”属于日期所示历史。 + # dsh-git-plugin 维护计划 > 历史维护方案:下文的 0.2.0 版本、5 文件清单与“GUI 未实现”是当时快照。当前 0.3.0 只读 Web 发布进展见 [发布记录](release-report-0.3.0.md)。新发布复用“候选 → 干净门 → 定稿 → PR merge → 单次 publish next → 实装验证 → dist-tag latest → tag/Release”的顺序,版本号与实际文件表以本次记录为准。 diff --git a/docs/release-report-0.4.0.md b/docs/release-report-0.4.0.md new file mode 100644 index 0000000..8f78a82 --- /dev/null +++ b/docs/release-report-0.4.0.md @@ -0,0 +1,34 @@ +# 0.4.0 release record / 0.4.0 发布记录 + +Status: candidate, not published. / 当前为候选,未发布。 + +## Scope / 范围 + +Complete Issue #1's native Web workflow: selected/bulk staging and unstaging, index-only commit, local branches, stash save/apply/drop and tracked-file backup restore. Keep status, both diff sides and history, headless commands and read-only model tools. / 完整操作面板并保留首期读取及 CLI 能力。 + +Important documents are paired English/Chinese. GUI labels are Chinese. No remote push/pull, forced reset or model approval grants. / 核心文档双语、界面中文,不扩大到远端推拉/强制重置/模型写授权。 + +## Development evidence / 开发证据 + +- 8b9efc1: initial actions implementation; Windows build/check, 54 tests and 15-file pack dry run passed. Linux clean clone Node 22 build/artifact/check/54 tests and Node 20 54 tests passed. / 首次完整提交的独立矩阵通过。 +- 293ff98: actual selected untracked/stash content previews; Windows full 55 tests passed. / 先失败回归后补实际内容。 +- 9871360: actual browser exposed a native carrier mismatch. DSH HTTP bridge sets Request.url to dsh.internal while preserving the browser Host/Origin. The original URL comparison returned 403 for genuine clicks; corrected authority binding and bridge-shaped regression passed all 55 tests. / 真实宿主定位并修复,不把模拟测试当宿主证据。 +- da406ac: actual browser completed stage/unstage/index-only commit/branch creation, then exposed operation controls remounting after refresh. Controls now persist across read refresh; client/type checks passed. The installed entry passed the complete browser workflow with independent Git assertions: stage/unstage/index-only commit/branches/stash/backup restore, read regression, cancellation, anonymous/foreign/missing-Origin rejection, two lifecycle cycles, zero page errors; Edge 154.0.4258.48. / 完整实装操作、独立 Git 核对、载体边界、两次启停与零页面错误通过。 +- pnpm reused an old install when the same development tarball pathname was overwritten. Unique SHA filenames forced a genuine install; actual web-actions.js/client.js byte hashes then matched the source. / 实装哈希揭示缓存问题,未手改安装包。 +- One temporary Linux script initially used CRLF on its final command; corrected LF and reran Node 20 successfully. / 临时脚本问题如实记录。 + +- Independent review reproduced staged-rename path expansion and a stash index-only preview omission. Both new real-Git tests first failed, then passed after the selected path scope was unified and stash previews included the index parent and object-derived file list. Full Windows build/check/57 tests passed. / 独立审查的范围与暂存预览问题已通过失败回归验证并修复,完整 57 项通过。 + +## Final gates / 最终门 + +- Candidate 33b7b0b: Windows build/check/57 tests; clean Linux Node20.20.2 and22.23.3 npm ci/build/artifact/check/57 tests; official services28 checks; actual installed 0.4.0 candidate tarball and complete browser operation acceptance all passed. / 最终候选的独立矩阵、宿主及完整 Web 通过。 +- Current-candidate push CI37014384527 and PR CI37014560238: all four Windows/Linux × Node20/22 jobs passed. / 两组四平台组合 CI 通过。 +- Independent reviewer verified a44650c and reproduced the fixes; no remaining release blockers. / 独立审查无剩余发布阻断问题。 +- Actual narrow dark screenshot: [Git actions](images/git-actions-dark.png),343px panel; synthetic fixture only. / 已检查真实窄侧栏截图,不含日常仓库数据。 +- npm account check returned401; public metadata confirms0.4.0 unused and latest=next=0.3.0. Official web login will be required after release preparation. / 当前登录过期,发布前需要官方网页认证,不要求聊天发送秘密。 + +Pending: final date/SHA CI, release-point artifact gate, public npm/GitHub installs and latest readback, Issue reply/closure. / 未执行项不标 PASS。 + +DSH 0.2.0-rc.2, Windows Node 24.19.0/Git 2.53.0.windows.2 and WSL Ubuntu 24.04 Node 20.20.2/22.23.3 Git 2.43.0. Only exact tested host versions are verified. Git >=2.32 is required for untracked stash previews. Real model sessions, other host versions and reverse-proxy mount deployment remain unverified. / 实测范围与能力要求分开;不推断未测环境。 + +Use independent DSH_HOME/profile and throwaway repos; no daily profile or credentials are modified. Final publication must use one new version upload and a single consistent npm gitHead/tag/Release/tree point. / 独立环境与发布一致性约束保持。 diff --git a/docs/superpowers/plans/2026-10-02-git-actions.md b/docs/superpowers/plans/2026-10-02-git-actions.md new file mode 100644 index 0000000..6cb3061 --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-git-actions.md @@ -0,0 +1,9 @@ +# Git actions implementation plan / 实施计划 +Design: ../specs/2026-10-02-git-actions-design.md. Execute inline in ordered commits; user authorized completion, commits, publication and Issue closure. 按序实施并及时提交,完成/发布/关闭已获目标授权。 + +1. Host + contract: src/web-actions.ts, src/panel-types.ts, src/index.ts; test/web-actions.test.js. Interfaces: registerWebActions(ctx,caps,run,resolveRoot), GET operations, POST prepare/execute; browser-safe ActionInput/ActionPreview/ActionResult/OperationsView. First red test prepares stage for a.txt, asserts no index mutation, confirms token, asserts staged diff; rerun node --test test/web-actions.test.js, implement, build/check/full test and commit. +2. UI: src/client/actions.tsx + index.tsx/panel.css. Props sessionId/signal/revision/onChange; selected and group staging buttons; commit and branch/stash forms; confirmation displays server scope/preview, only Confirm sends execute. Test no mutation before click and Session change cancels confirmation; red-green DOM tests; build/check/full test and commit. +3. Real host: independent DSH_HOME/temp repos, official 0.2.0-rc.2 on Linux Node22, Playwright actual clicks; verify Git independently after each action and shutdown/tokens/cancellation. Reuse baseline optional verifier for reads. Make focused fixes with regressions; preserve daily profile/upstream/runtimes. +4. Delivery: paired README/CONTRIBUTING/AGENTS/Web guide/CHANGELOG, new acceptance evidence. Version/lock0.4.0 and dated release commit; clean clone npm ci/build/git diff --exit-code lib/check/test/pack; current-SHA CI; preserving merge; one npm publish next; exact public tarball and actual npm/GitHub installs, latest; annotated tag/Release same point; bilingual Issue completion reply and close; final independent audit then update_goal complete. + +Review focus tests: single-use replay; index/worktree/untracked changes between prepare/execute; Session cwd change; traversal/magic/rename paths; concurrent writes and disable; failing/changed preCommit; stash conflicts retain source; restore backup and index preservation. 未通过项继续修复,不因现有测试通过缩减完整目标。 diff --git a/docs/superpowers/specs/2026-10-02-git-actions-design.md b/docs/superpowers/specs/2026-10-02-git-actions-design.md new file mode 100644 index 0000000..9e584f3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-02-git-actions-design.md @@ -0,0 +1,25 @@ +# Git operation panel design / Git 操作面板设计 + +Goal: finish Issue #1 with a usable native DSH Web Git workflow, then close it only after the published package is verified. 目标:完整交付网页 Git 工作流,正式包验收后回复并关闭 Issue。 + +## Scope / 范围 +Keep status, independent index/worktree diffs, previews and history. Add selected/bulk staging and unstaging, staged-only commit, local branch creation/switching, stash save/apply/drop, and tracked-file restore with a retained stash backup. Remote push/pull and forced history rewriting are outside this request. 保留只读能力,补暂存/取消暂存、仅提交暂存区、分支及 stash 操作、带保留备份的已跟踪文件还原;不引入远端推送或强制改写历史。 + +## Human confirmation / 人工确认 +- Upstream user-approval request rejects calls outside an open agent turn (packages/interaction/user-approval/src/index.ts:215). Subprocess spawn is a capability, not a model approval answerer. GUI actions originate from the admitted operator, not a model tool; no fabricated turn, model call or API key. +- 新增人工 GUI 确认,不复用或绕过模型审批,不伪造 turn。 +- Authenticated GET operations lists branches/stashes. POST prepare validates fixed JSON keys, Session-derived canonical root and Git argv, and returns action/paths/message/branch, bounded preview, root and a random two-minute single-use token. +- POST execute accepts only sessionId/token/confirm:true. Consume token once, lock that repository against concurrent panel mutations and recheck the Session/root, HEAD/branch, refs, index/worktree diffs and relevant untracked contents. Drift returns 409 before writes; cancellation/disable aborts processes and invalidates confirmations. +- 同源 JSON、一次性两分钟确认、执行前身份与状态复核、每仓库单写锁,变化后要求重新预览。自动截止与启停清理覆盖写进程。 +- Read authentication alone does not mutate. Reject foreign/missing Origin, unknown/repeated query fields, extra JSON keys, unknown Sessions, absolute/traversal/.git paths, arbitrary cwd/argv, option-shaped refs and untracked symlink reads. + +## Behavior / 操作语义 +- stage uses git add -A -- exact listed files; an already indexed rename stages only its selected destination; an unstaged rename includes its still-indexed source. unstage retains worktree; unborn HEAD uses git rm --cached. +- commit uses only the existing index, honors configured preCommit, refuses conflicts/empty index, rechecks the reviewed snapshot after the hook and reports Git hook failures. Never git add -A during commit. +- Branch switch uses ordinary Git protections, no force. A clean workspace is required; create-and-switch preserves changes. +- Stash save includes untracked files; apply --index retains its stash even on conflicts; drop requires explicit destructive confirmation. Submodule internal edits need their own repository. +- Restore backs up tracked edits with git stash create/store before restoring selected worktree paths from the index; it never deletes untracked files or uses reset --hard. The backup remains visible in stash. +- 提交只用暂存区,分支不强制;stash 应用保留原记录;还原先保留备份,不删除未跟踪文件。 + +## Acceptance / 验收 +Real Git: prepare does not mutate; cancel/replay/foreign Session/root/index/worktree/untracked drift fails closed; stage/unstage incl unborn and rename; staged-only commit/failed hook; branches; stash and backup restore; conflicts, literal/Chinese/space paths; route and process disposal. DOM: explicit confirmation, no POST execute before confirm, stale Session cannot execute. Installed real DSH Web: click through the actual workflow, assert Git state independently, test carrier boundaries and lifecycle. CI Node20/22 and Windows/Linux, clean artifact gate, pack closure. Important documents English/Chinese. Publish new 0.4.0 once to next; verify both channels and then latest. Close Issue with installation, features, evidence and honest limits. diff --git a/docs/web-panel.md b/docs/web-panel.md index e6b5196..3e6183e 100644 --- a/docs/web-panel.md +++ b/docs/web-panel.md @@ -2,90 +2,59 @@ English · [简体中文](web-panel_ZH.md) -Version **0.3.0** implements the first read-only phase of [Issue #1](https://github.com/MashedPotato817/dsh-git-plugin/issues/1). Earlier 0.2.1 provides commands/tools without the panel. Do not overwrite any published package or move existing tags. +**0.4.0** completes the Git operation panel requested in [Issue #1](https://github.com/MashedPotato817/dsh-git-plugin/issues/1). The UI uses Chinese labels; important documentation is bilingual. -![Actual development panel in DSH Web](images/git-panel.png) - -This actual screenshot is from an independent DSH 0.2.0-rc.2 Web profile and a temporary Git repository. No private repository content is shown. - -## Install +## Install and open ```bash -dsh plugin --profile web add dsh-git-plugin@0.3.0 +dsh plugin --profile web add dsh-git-plugin@0.4.0 ``` -Replace web with the active profile. Restart that DSH process and refresh the browser. Existing bundle configuration is retained; manually inserted 0.2.0 profiles follow the migration guide. +Replace `web` with your profile. Restart DSH and refresh the browser. Select a repository workspace/session, open the right sidebar and choose **Git**. Older manual 0.2.0 inserts need the [migration](marketplace-submission.md#从手工启用的-020-迁移); do not register duplicate rows. -## Try a source checkout +## Daily workflow -Use an independent `DSH_HOME` and profile. Do not replace your daily profile while testing. From this checkout: +1. Inspect conflicted, staged, unstaged and untracked files. A file changed on both sides appears twice; each opens its own diff. History comes from Git objects, with pagination and commit details. +2. Select a file for its actions, or stage/unstage all eligible files. Review the operation, paths and diff, then **确认执行**. Cancel performs no write. +3. Enter a commit message and preview. **Web commits only the existing index.** Configured preCommit and native Git hooks still run. If the configured hook changes the reviewed snapshot, a new preview is required. The existing `/commit ` command still stages all changes before committing. +4. Expand **分支与 stash** to create/switch local branches or save/apply/drop a stash. Switching requires a clean workspace. Save includes untracked files; apply restores the index and retains the stash, including on conflicts. Delete is explicitly confirmed. +5. **备份并还原此文件** first stores all tracked edits in a retained stash, then restores the selected worktree file from the index. It preserves the index and untracked files. Apply the backup when recovery is needed; resolve conflicts manually. -```bash -npm ci -npm run build -npm run check -npm test -npm pack -``` - -Set `DSH_HOME` to a new test directory using your shell. Replace the paths below with absolute paths on your machine, and use the CLI belonging to your DSH installation: +Confirmation expires after two minutes and is single-use. Session/root, branch/HEAD, refs, index, worktree or relevant untracked content changes invalidate it. Errors require checking current Git state before retrying; do not assume a failed or cancelled write rolled back. External editors/Git processes can race the checks. Native hooks are trusted repository code. Submodule internal edits require their own repository. There are no remote push/pull or forced history operations. -```bash -dsh --profile git-panel-test --from-default-profile web --dump-config -dsh plugin --profile git-panel-test add /absolute/path/dsh-git-plugin-0.3.0.tgz -``` +## Host and client contract -Start DSH from a temporary Git repository: - -```bash -dsh --profile git-panel-test --no-open --port 17832 -``` +All routes use the official authenticated Connection carrier. The Host derives a canonical repository from an existing live/persisted Session, including single-child discovery. It rejects arbitrary cwd/root/argv, unknown Sessions, traversal/.git paths, Git option injection and unsafe untracked symlinks. -Open the official local authentication URL printed by DSH. Select a repository workspace/session, open the right sidebar, and choose **Git**. Installing or replacing a tarball requires restarting that profile's DSH process and refreshing its browser page. No model request or API key is needed to read the panel. - -## What you can see - -- Branch and repository, with a manual refresh button. -- Conflicted, staged, unstaged and untracked files. A file changed on both sides appears in both groups; each opens its own diff. -- Text diffs rendered as text, with added/deleted lines highlighted. Untracked files show a bounded current-content preview, not a fabricated HEAD diff. Binary/unavailable previews and truncation are explicit. -- Paginated commit history and commit detail from Git objects. Empty repositories and detached HEAD have explicit states. - -The UI currently uses Chinese labels. Bilingual documentation does not imply an English UI. There are no stage, commit, branch-switch, restore or stash buttons in this phase. Existing slash commands retain their behavior. - -## Host contract - -All routes are fixed, read-only `GET` requests behind DSH Connection authentication. Responses are JSON; failures are `{ "error": { "code": "...", "message": "..." } }`. Browser requests use the current DSH base URL, including a mount prefix. - -| Route | Query | Successful projection | +| Route | Request | Result | |---|---|---| -| `/api/git-panel/status` | `sessionId` | root, branch, HEAD and NUL-parsed XY file states | -| `/api/git-panel/diff` | `sessionId`, repository-relative `path`, `side=staged\|unstaged` | text, binary/untracked/truncated flags | -| `/api/git-panel/log` | `sessionId`, optional `count` (1–100, default 30), `skip` (0–10000, default 0) | entries, skip, hasMore, truncated | -| `/api/git-panel/show` | `sessionId`, full lowercase 40–64 hex `sha` | commit text and truncated flag | - -The Host derives cwd from a live or persisted Session, and reuses the plugin's single-child repository discovery. Unknown Sessions are refused. Zero/multiple child repositories cannot silently select a target. Requests cannot supply arbitrary cwd, root, argv or repeated/unknown query keys. +| status | GET sessionId | branch/root/HEAD/file states | +| diff | GET sessionId/path/side | bounded staged/worktree or untracked preview | +| log / show | GET sessionId/count/skip or full commit SHA | history / commit detail | +| operations | GET sessionId | local branches and stashes | +| prepare | POST fixed action input | review and expiring token; no mutation | +| execute | POST sessionId/token/confirm:true | one reviewed mutation | -Path traversal, absolute paths and `.git` paths are refused; literal pathspecs prevent Git magic expansion. Commit detail accepts a verified commit SHA, not arbitrary option-shaped revisions. Git runs through `ctx.subprocess` with pure argv, disabled external diff/textconv, no optional Git locks, bounded output and the configured deadline. An incomplete capped status response is an error, never a false clean state. +POST requires JSON and a browser Origin matching the carrier's trusted Host authority. The native HTTP bridge uses dsh.internal internally, so that synthetic URL is not the browser origin. DSH 0.2.0-rc.2 has one admitted operator, not tenant-specific Session ACLs. GUI confirmation is separate from model tool approval and never fabricates an agent turn. Model tools remain read-only. -DSH 0.2.0-rc.2 authenticates one admitted operator. This is not a tenant-specific Session ACL; knowing another Session ID in the same admitted operator's host is not separate tenant authorization. The plugin adds unknown-Session and path boundaries within that host contract. +Git runs through ctx.subprocess with pure argv, bounded output and per-call deadlines. Diffs disable external diff/textconv. Writes are locked against concurrent panel writes. Disable removes routes/tab/slot/style, expires tokens and aborts requests/processes. Navigation ignores stale results. Client requests respect the current base URL; reverse-proxy mount prefixes still need real deployment acceptance. -Disabling the plugin disposes the tab, slot, style and Host routes, aborts pending requests and terminates their subprocesses. Closing/navigating the tab aborts browser reads; stale responses cannot replace the new Session/file view. CLI/headless hosts keep their original commands/tools without requiring Web services. +Requires Git >=2.32 for stash previews including untracked content ([Git documentation](https://git-scm.com/docs/git-stash/2.32.0)). Actual tested versions and evidence are separate from this requirement. -## Repeat the browser check +## Development and acceptance -The optional [acceptance script](../scripts/verify-web-panel.mjs) needs Playwright installed in a separate tools directory and a supported browser (tested with Edge on Windows). It is outside npm test/CI and is not an npm runtime dependency. It enables the plugin, then runs two disable/enable cycles in the supplied test profile; never point it at a daily profile. - -Use the three-file fixture described by `--help`. Obtain its Session ID from the panel's `status?sessionId=...` request in browser DevTools; the ID is not a credential. Pass the isolated server log, not an account secret: +Use an independent DSH_HOME/profile and disposable repositories. Never use your daily profile for acceptance. ```bash -node scripts/verify-web-panel.mjs --help -node scripts/verify-web-panel.mjs --dsh-log /test/server.log --session-id --playwright-module /test/tools/node_modules/playwright --screenshots /test/screenshots +npm ci +npm run build +npm run check +npm test +npm pack ``` -The script reads the official local authentication URL into memory, closes its browser, and never exports browser storage. Keep that startup log private; it contains the temporary authentication link. The operator must stop the test server and delete the independent profile after testing. - -## Validation and next phase +Create the Web profile with `dsh --profile git-panel-test --from-default-profile web --dump-config`, install the absolute tarball path using `dsh plugin --profile git-panel-test add `, then start it from a fixture repository with `dsh --profile git-panel-test --no-open --port 17834`. -See the [validation record](web-panel-validation.md) for test levels, exact environments and remaining checks. Windows and Linux real Web reads and live enable/disable passed; unit/real-Git evidence is separate from browser evidence. The current implementation is not marked stable before actual user acceptance. +[Read acceptance](../scripts/verify-web-panel.mjs) and [operation acceptance](../scripts/verify-web-actions.mjs) run separately from CI/npm test. The latter documents its disposable actions-repo fixture and arguments at its top, uses the actual browser confirmation flow and independently checks Git results. It does not make model calls or export browser storage. Keep the startup authentication log private. Use a unique tarball filename for each development SHA: pnpm may cache a repeated pathname despite changed bytes; compare installed hashes and restart. -P3 must first resolve write approval for a sidebar click without an open model turn, define recoverability, and add focused regressions. Keep Issue #1 open with `Refs #1`; a read-only first phase does not close the entire request. +Release evidence: [0.3.0](release-report-0.3.0.md) and [0.4.0](release-report-0.4.0.md). Real model sessions and other DSH versions remain unverified. diff --git a/docs/web-panel_ZH.md b/docs/web-panel_ZH.md index 7e7bcfb..0b89b14 100644 --- a/docs/web-panel_ZH.md +++ b/docs/web-panel_ZH.md @@ -2,90 +2,59 @@ [English](web-panel.md) · 简体中文 -**0.3.0** 实现了 [Issue #1](https://github.com/MashedPotato817/dsh-git-plugin/issues/1) 的首期只读面板。旧版 0.2.1 仅有命令/工具,不含面板;不要覆盖任何已发布包或移动旧标签。 +**0.4.0** 完成 [Issue #1](https://github.com/MashedPotato817/dsh-git-plugin/issues/1) 的 Git 操作面板。界面使用中文,重要文档英中双语。 -![真实 DSH Web 开发版面板](images/git-panel.png) - -截图来自独立 DSH 0.2.0-rc.2 Web profile 与临时 Git 仓库,不含私人仓库内容。 - -## 安装 +## 安装并打开 ```bash -dsh plugin --profile web add dsh-git-plugin@0.3.0 +dsh plugin --profile web add dsh-git-plugin@0.4.0 ``` -将 web 替换为实际 profile,重启对应 DSH 并刷新页面。原 bundle 配置保留;手工 insert 的 0.2.0 profile 先按迁移说明调整。 +将 `web` 替换为你的 profile。重启 DSH 并刷新浏览器。选择仓库工作区/会话,展开右侧栏,点击 **Git**。旧版手工插入的 0.2.0 配置先按[迁移说明](marketplace-submission.md#从手工启用的-020-迁移)处理,避免重复注册。 -## 试用源码 checkout +## 日常流程 -使用独立 `DSH_HOME` 与 profile;不要替换日常配置。在当前开发 checkout 中执行: +1. 查看冲突、已暂存、未暂存和未跟踪文件。同一文件两侧都有改动时出现两行,各自打开对应 diff。历史和提交详情来自真实 Git 对象。 +2. 点击文件后使用单文件操作,或批量暂存/取消暂存。核对操作、路径及差异后点 **确认执行**;取消不写入。 +3. 输入提交说明并预览。**Web 只提交现有暂存区。** 配置的 preCommit 与 Git 原生钩子仍执行;配置钩子改变预览快照后须重新预览。原有 `/commit ` 仍会暂存全部改动后提交。 +4. 展开 **分支与 stash**,创建/切换本地分支,保存/应用/删除 stash。切换要求干净工作区;保存包含未跟踪文件,应用恢复暂存状态并保留原 stash,冲突时也保留。删除需要明确确认。 +5. **备份并还原此文件** 先将所有已跟踪改动存入保留的 stash,再把选定工作区文件还原为暂存区内容。暂存区及未跟踪文件保留;需要恢复时应用备份,冲突自行处理。 -```bash -npm ci -npm run build -npm run check -npm test -npm pack -``` - -用当前 shell 设置 `DSH_HOME` 为新的测试目录。下列路径替换为本机绝对路径,使用当前 DSH 安装对应的 CLI: +确认两分钟失效、只能使用一次。会话/仓库、HEAD/分支、引用、暂存区、工作区或相关未跟踪内容变化后须重新预览。错误后先检查当前 Git 状态,不假设失败或取消意味着回滚。外部编辑器/Git 进程可能并发修改;原生钩子是受信仓库代码。子模块内部改动须进入其仓库处理。不提供远端推拉或强制改写历史。 -```bash -dsh --profile git-panel-test --from-default-profile web --dump-config -dsh plugin --profile git-panel-test add /absolute/path/dsh-git-plugin-0.3.0.tgz -``` +## 宿主与客户端契约 -在临时 Git 仓库内启动: - -```bash -dsh --profile git-panel-test --no-open --port 17832 -``` +全部接口通过官方 Connection 认证载体。宿主从已有活动/持久化 Session 推导规范化仓库,复用唯一子仓库发现。拒绝任意 cwd/root/argv、未知会话、越界/.git 路径、选项注入及不安全的未跟踪符号链接。 -打开 DSH 输出的官方本地认证链接,选择仓库工作区/会话,打开右侧栏,点击 **Git**。安装或更换 tgz 后,重启该 profile 的 DSH 并刷新页面。查看面板不需要发起模型请求或配置 API Key。 - -## 可以查看的内容 - -- 分支、仓库与手动刷新。 -- 冲突、已暂存、未暂存、未跟踪文件。同一文件两侧均有改动时,会分别出现,打开各自 diff。 -- 以文本渲染的 diff,新增/删除行着色。未跟踪文件显示受限的当前内容预览,不伪造 HEAD diff;二进制、无法预览和截断均明确提示。 -- 从 Git 对象读取的分页历史与提交详情,明确显示空仓库及 detached HEAD。 - -当前 UI 使用中文标签;文档双语不代表 UI 已有英文。首期不提供暂存、提交、切分支、恢复或 stash 按钮,现有斜杠命令行为保持。 - -## Host 契约 - -所有接口都是 DSH Connection 认证后的固定只读 `GET`,返回 JSON。失败结构为 `{ "error": { "code": "...", "message": "..." } }`。浏览器沿用当前 DSH base URL,包括挂载前缀。 - -| 路由 | 参数 | 成功结果 | +| 接口 | 请求 | 结果 | |---|---|---| -| `/api/git-panel/status` | `sessionId` | 根目录、分支、HEAD 与按 NUL 解析的 XY 文件状态 | -| `/api/git-panel/diff` | `sessionId`、仓库相对 `path`、`side=staged\|unstaged` | 文本及 binary/untracked/truncated 标记 | -| `/api/git-panel/log` | `sessionId`、可选 `count`(1–100,默认 30)、`skip`(0–10000,默认 0) | entries、skip、hasMore、truncated | -| `/api/git-panel/show` | `sessionId`、完整小写 40–64 位十六进制 `sha` | 提交文本、truncated | - -Host 从运行中或持久化 Session 取得 cwd,并复用插件的唯一子仓库发现。未知 Session 拒绝读取;零个/多个子仓库不会擅自选定目标。请求不能传任意 cwd、root、argv,重复或未知参数拒绝。 +| status | GET sessionId | 分支/目录/HEAD/文件状态 | +| diff | GET sessionId/path/side | 受限的暂存/工作区或未跟踪预览 | +| log / show | GET sessionId/count/skip 或完整 SHA | 历史/提交详情 | +| operations | GET sessionId | 本地分支与 stash | +| prepare | POST 固定操作字段 | 预览及短期凭证,不写入 | +| execute | POST sessionId/token/confirm:true | 执行一次已确认操作 | -路径穿越、绝对路径及 `.git` 路径拒绝;literal pathspec 防止 Git magic 展开。提交详情只接受验证后的 commit SHA,不接受任意选项形式 revision。Git 继续通过 `ctx.subprocess` 与纯 argv 执行,关闭 external diff/textconv 和可选 Git 锁,受输出字节及配置超时限制。状态超限返回错误,不把缺项列表显示成干净仓库。 +POST 需要 JSON 和与受信 Host authority 匹配的浏览器 Origin;HTTP bridge 的 dsh.internal 内部 URL 不代表浏览器地址。DSH 0.2.0-rc.2 认证单一操作用户,并非租户级 Session ACL。GUI 人工确认与模型审批分开,不伪造 agent turn;模型工具仍只读。 -DSH 0.2.0-rc.2 对准入的单一 operator 认证,并没有按租户划分 Session ACL。同一宿主内已准入 operator 知道其他 Session ID,不是另一套租户授权。插件在该宿主契约内增加未知 Session 与路径边界。 +Git 通过 ctx.subprocess、纯 argv、输出上限及每次调用截止执行;禁用外部 diff/textconv。每仓库写锁防止面板操作相互并发。停用时移除接口/tab/slot/样式,清除凭证并中止请求与进程;导航不显示过期响应。请求尊重 base URL,但反向代理挂载前缀仍须真实部署验收。 -禁用插件会清理 tab、slot、样式和 Host 路由,取消请求并终止相应子进程。关闭/切换面板取消浏览器读取,旧响应不能覆盖新 Session/文件视图。CLI/headless 继续使用原命令和工具,无需 Web 服务。 +未跟踪 stash 预览要求 Git >=2.32([官方文档](https://git-scm.com/docs/git-stash/2.32.0));实际验证版本与最低要求分开记录。 -## 复跑浏览器验收 +## 开发与验收 -可选[验收脚本](../scripts/verify-web-panel.mjs)需要独立工具目录内的 Playwright 与可用浏览器(本轮使用 Windows Edge),不进入 npm test/CI,不是插件运行时依赖。它会在指定测试 profile 内启用插件并执行两轮禁用/启用;不要指向日常 profile。 - -按 `--help` 创建三文件样本。从浏览器 DevTools 的面板 `status?sessionId=...` 请求读取该测试会话 ID(它不是凭据),传入独立服务日志,不传账号秘密: +使用独立 DSH_HOME/profile 和临时仓库,不在日常配置上验收。 ```bash -node scripts/verify-web-panel.mjs --help -node scripts/verify-web-panel.mjs --dsh-log /test/server.log --session-id --playwright-module /test/tools/node_modules/playwright --screenshots /test/screenshots +npm ci +npm run build +npm run check +npm test +npm pack ``` -脚本只在内存中读取官方本地认证 URL,结束时关闭浏览器,不导出 browser storage。启动日志含临时认证链接,应留在本地;验收后由执行者停止测试服务并清理独立 profile。 - -## 验证与下一阶段 +用 `dsh --profile git-panel-test --from-default-profile web --dump-config` 建立 Web profile;`dsh plugin --profile git-panel-test add <绝对 tarball 路径>` 安装;从临时仓库运行 `dsh --profile git-panel-test --no-open --port 17834`。 -[验收记录](web-panel-validation.md) 区分测试层级、实际环境与未验证项。Windows 和 Linux 真实 Web 读取与在线启停已通过;单元/真实 Git 结果与浏览器证据分别记录。用户实际验收前,不标为“暂定稳定”。 +[读取验收](../scripts/verify-web-panel.mjs)和[操作验收](../scripts/verify-web-actions.mjs)不进入 CI/npm test。后者文件顶部列出 actions-repo 临时夹具和参数,通过真实浏览器确认流程操作,并独立核对 Git 结果。不调用模型、不导出浏览器存储;启动认证日志须保密。每个开发 SHA 使用不同 tarball 文件名:pnpm 可能缓存同一路径,必须比较实际安装哈希并重启。 -P3 首先要解决无模型 open turn 时点击按钮的写审批契约,确定可恢复性并补针对性回归。Issue #1 保持开放,使用 `Refs #1`;只读首期不等于整个 GUI 需求完成。 +发布证据:[0.3.0](release-report-0.3.0.md)、[0.4.0](release-report-0.4.0.md)。真实模型会话及其他 DSH 版本仍未验证。 diff --git a/lib/client.js b/lib/client.js index dd3b554..7f6d078 100644 --- a/lib/client.js +++ b/lib/client.js @@ -28,13 +28,404 @@ __export(index_exports, { inject: () => inject }); module.exports = __toCommonJS(index_exports); -var import_react = require("react"); +var import_react2 = require("react"); // src/client/panel.css -var panel_default = '[data-git-panel] {\n --gp-line: var(--dsw-alias-border-l3, #dadde3);\n --gp-muted: var(--dsw-alias-label-secondary, #747b88);\n --gp-accent: #c06a37;\n --gp-add: #298253;\n --gp-delete: #bc4a4a;\n --gp-hunk: #5585b0;\n --gp-warning: #aa7431;\n --gp-surface: var(--dsw-alias-bg-layer-2, #f5f6f8);\n display: flex;\n flex-direction: column;\n min-height: 0;\n height: 100%;\n color: var(--dsw-alias-label-primary, #243044);\n font-size: 13px;\n line-height: 1.5;\n font-family: var(--dsh-content-font-family, inherit);\n}\n[data-git-panel] * {\n box-sizing: border-box;\n}\n[data-git-panel] button {\n color: inherit;\n font: inherit;\n cursor: pointer;\n background: none;\n border: 0;\n}\n[data-git-panel] button:focus-visible {\n outline: 2px solid var(--gp-accent);\n outline-offset: -2px;\n}\n[data-git-panel] button:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.gp-head {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--gp-line);\n}\n.gp-brand {\n color: var(--gp-accent);\n font-weight: 800;\n font-size: 19px;\n letter-spacing: -1px;\n}\n.gp-repo {\n min-width: 0;\n flex: 1;\n}\n.gp-repo strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.gp-branch {\n color: var(--gp-muted);\n font:\n 11px/1.5 ui-monospace,\n Consolas,\n monospace;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.gp-refresh {\n padding: 6px 8px !important;\n border: 1px solid var(--gp-line) !important;\n border-radius: 5px;\n}\n.gp-nav {\n display: flex;\n gap: 16px;\n border-bottom: 1px solid var(--gp-line);\n padding: 0 14px;\n}\n.gp-nav button {\n padding: 10px 0 !important;\n border-bottom: 2px solid transparent !important;\n}\n.gp-nav button[aria-pressed="true"] {\n border-color: var(--gp-accent) !important;\n font-weight: 700;\n}\n.gp-body {\n overflow: auto;\n min-height: 0;\n flex: 1;\n}\n.gp-note {\n padding: 20px 16px;\n color: var(--gp-muted);\n}\n.gp-error {\n border-left: 3px solid #cc5656;\n margin: 12px;\n background: var(--gp-surface);\n padding: 10px;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.gp-group h3 {\n margin: 0;\n padding: 12px 14px 6px;\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--gp-muted);\n}\n.gp-count {\n float: right;\n font:\n 11px ui-monospace,\n Consolas,\n monospace;\n}\n.gp-files {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n.gp-file {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n width: 100%;\n text-align: left;\n padding: 8px 14px !important;\n}\n.gp-file:hover,\n.gp-file[aria-pressed="true"] {\n background: var(--gp-surface);\n}\n.gp-code {\n color: var(--gp-accent);\n font:\n 12px ui-monospace,\n Consolas,\n monospace;\n min-width: 16px;\n}\n.gp-file-path {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.gp-old {\n display: block;\n font-size: 11px;\n color: var(--gp-muted);\n}\n.gp-detail {\n border-top: 1px solid var(--gp-line);\n}\n.gp-detail-head {\n position: sticky;\n top: 0;\n display: flex;\n gap: 10px;\n align-items: center;\n padding: 10px 14px;\n background: var(--gp-surface);\n}\n.gp-detail-title {\n min-width: 0;\n flex: 1;\n font:\n 12px ui-monospace,\n Consolas,\n monospace;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.gp-tag {\n font-size: 10px;\n color: var(--gp-muted);\n text-transform: uppercase;\n}\n.gp-patch {\n margin: 0;\n padding: 8px 0 14px;\n font:\n 12px/1.65 ui-monospace,\n SFMono-Regular,\n Consolas,\n monospace;\n overflow: auto;\n tab-size: 4;\n}\n.gp-line {\n display: block;\n white-space: pre;\n min-width: max-content;\n padding: 0 14px;\n}\n.gp-line-add {\n background: rgba(40, 160, 93, 0.1);\n color: var(--gp-add);\n}\n.gp-line-del {\n background: rgba(201, 61, 61, 0.1);\n color: var(--gp-delete);\n}\n.gp-line-hunk {\n background: rgba(71, 130, 195, 0.08);\n color: var(--gp-hunk);\n}\n.gp-history {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n.gp-commit {\n display: block;\n text-align: left;\n width: 100%;\n padding: 12px 14px !important;\n border-bottom: 1px solid var(--gp-line) !important;\n}\n.gp-subject {\n display: block;\n overflow-wrap: anywhere;\n}\n.gp-meta {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n margin-top: 5px;\n font-size: 11px;\n color: var(--gp-muted);\n}\n.gp-sha {\n color: var(--gp-accent);\n font-family: ui-monospace, Consolas, monospace;\n}\n.gp-pager {\n display: flex;\n justify-content: space-between;\n padding: 12px 14px;\n}\n.gp-footer {\n padding: 7px 14px;\n border-top: 1px solid var(--gp-line);\n color: var(--gp-muted);\n font-size: 10px;\n letter-spacing: 0.04em;\n}\n.gp-warning {\n padding: 8px 14px;\n color: var(--gp-warning);\n background: var(--gp-surface);\n}\n\n/* The shell publishes this attribute when its active palette is dark. */\nbody[data-ds-dark-theme] [data-git-panel] {\n --gp-accent: #e09e72;\n --gp-add: #75d29b;\n --gp-delete: #f39090;\n --gp-hunk: #96c4ed;\n --gp-warning: #e4b875;\n}\n'; +var panel_default = '[data-git-panel] {\n --gp-line: var(--dsw-alias-border-l3, #dadde3);\n --gp-muted: var(--dsw-alias-label-secondary, #747b88);\n --gp-accent: #c06a37;\n --gp-add: #298253;\n --gp-delete: #bc4a4a;\n --gp-hunk: #5585b0;\n --gp-warning: #aa7431;\n --gp-surface: var(--dsw-alias-bg-layer-2, #f5f6f8);\n display: flex;\n flex-direction: column;\n min-height: 0;\n height: 100%;\n color: var(--dsw-alias-label-primary, #243044);\n font-size: 13px;\n line-height: 1.5;\n font-family: var(--dsh-content-font-family, inherit);\n}\n[data-git-panel] * {\n box-sizing: border-box;\n}\n[data-git-panel] button {\n color: inherit;\n font: inherit;\n cursor: pointer;\n background: none;\n border: 0;\n}\n[data-git-panel] button:focus-visible {\n outline: 2px solid var(--gp-accent);\n outline-offset: -2px;\n}\n[data-git-panel] button:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.gp-head {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 12px 14px;\n border-bottom: 1px solid var(--gp-line);\n}\n.gp-brand {\n color: var(--gp-accent);\n font-weight: 800;\n font-size: 19px;\n letter-spacing: -1px;\n}\n.gp-repo {\n min-width: 0;\n flex: 1;\n}\n.gp-repo strong {\n display: block;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.gp-branch {\n color: var(--gp-muted);\n font:\n 11px/1.5 ui-monospace,\n Consolas,\n monospace;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.gp-refresh {\n padding: 6px 8px !important;\n border: 1px solid var(--gp-line) !important;\n border-radius: 5px;\n}\n.gp-nav {\n display: flex;\n gap: 16px;\n border-bottom: 1px solid var(--gp-line);\n padding: 0 14px;\n}\n.gp-nav button {\n padding: 10px 0 !important;\n border-bottom: 2px solid transparent !important;\n}\n.gp-nav button[aria-pressed="true"] {\n border-color: var(--gp-accent) !important;\n font-weight: 700;\n}\n.gp-body {\n overflow: auto;\n min-height: 0;\n flex: 1;\n}\n.gp-note {\n padding: 20px 16px;\n color: var(--gp-muted);\n}\n.gp-error {\n border-left: 3px solid #cc5656;\n margin: 12px;\n background: var(--gp-surface);\n padding: 10px;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.gp-group h3 {\n margin: 0;\n padding: 12px 14px 6px;\n font-size: 11px;\n letter-spacing: 0.04em;\n color: var(--gp-muted);\n}\n.gp-count {\n float: right;\n font:\n 11px ui-monospace,\n Consolas,\n monospace;\n}\n.gp-files {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n.gp-file {\n display: flex;\n align-items: flex-start;\n gap: 10px;\n width: 100%;\n text-align: left;\n padding: 8px 14px !important;\n}\n.gp-file:hover,\n.gp-file[aria-pressed="true"] {\n background: var(--gp-surface);\n}\n.gp-code {\n color: var(--gp-accent);\n font:\n 12px ui-monospace,\n Consolas,\n monospace;\n min-width: 16px;\n}\n.gp-file-path {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.gp-old {\n display: block;\n font-size: 11px;\n color: var(--gp-muted);\n}\n.gp-detail {\n border-top: 1px solid var(--gp-line);\n}\n.gp-detail-head {\n position: sticky;\n top: 0;\n display: flex;\n gap: 10px;\n align-items: center;\n padding: 10px 14px;\n background: var(--gp-surface);\n}\n.gp-detail-title {\n min-width: 0;\n flex: 1;\n font:\n 12px ui-monospace,\n Consolas,\n monospace;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.gp-tag {\n font-size: 10px;\n color: var(--gp-muted);\n text-transform: uppercase;\n}\n.gp-patch {\n margin: 0;\n padding: 8px 0 14px;\n font:\n 12px/1.65 ui-monospace,\n SFMono-Regular,\n Consolas,\n monospace;\n overflow: auto;\n tab-size: 4;\n}\n.gp-line {\n display: block;\n white-space: pre;\n min-width: max-content;\n padding: 0 14px;\n}\n.gp-line-add {\n background: rgba(40, 160, 93, 0.1);\n color: var(--gp-add);\n}\n.gp-line-del {\n background: rgba(201, 61, 61, 0.1);\n color: var(--gp-delete);\n}\n.gp-line-hunk {\n background: rgba(71, 130, 195, 0.08);\n color: var(--gp-hunk);\n}\n.gp-history {\n list-style: none;\n margin: 0;\n padding: 0;\n}\n.gp-commit {\n display: block;\n text-align: left;\n width: 100%;\n padding: 12px 14px !important;\n border-bottom: 1px solid var(--gp-line) !important;\n}\n.gp-subject {\n display: block;\n overflow-wrap: anywhere;\n}\n.gp-meta {\n display: flex;\n gap: 8px;\n flex-wrap: wrap;\n margin-top: 5px;\n font-size: 11px;\n color: var(--gp-muted);\n}\n.gp-sha {\n color: var(--gp-accent);\n font-family: ui-monospace, Consolas, monospace;\n}\n.gp-pager {\n display: flex;\n justify-content: space-between;\n padding: 12px 14px;\n}\n.gp-footer {\n padding: 7px 14px;\n border-top: 1px solid var(--gp-line);\n color: var(--gp-muted);\n font-size: 10px;\n letter-spacing: 0.04em;\n}\n.gp-warning {\n padding: 8px 14px;\n color: var(--gp-warning);\n background: var(--gp-surface);\n}\n\n/* The shell publishes this attribute when its active palette is dark. */\nbody[data-ds-dark-theme] [data-git-panel] {\n --gp-accent: #e09e72;\n --gp-add: #75d29b;\n --gp-delete: #f39090;\n --gp-hunk: #96c4ed;\n --gp-warning: #e4b875;\n}\n.gp-actions {\n padding: 12px;\n border-top: 1px solid var(--gp-line);\n}\n.gp-actions form {\n display: flex;\n flex-wrap: wrap;\n align-items: end;\n gap: 8px;\n margin: 12px 0;\n}\n.gp-actions label {\n display: flex;\n flex-direction: column;\n gap: 4px;\n flex: 1;\n min-width: 140px;\n}\n.gp-actions input,\n.gp-actions textarea {\n width: 100%;\n color: inherit;\n background: var(--gp-surface);\n border: 1px solid var(--gp-line);\n border-radius: 5px;\n padding: 7px;\n font: inherit;\n}\n.gp-actions textarea {\n resize: vertical;\n min-height: 58px;\n}\n.gp-actions button {\n border: 1px solid var(--gp-line);\n border-radius: 5px;\n padding: 5px 8px;\n}\n.gp-action-row {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n align-items: center;\n}\n.gp-selected {\n width: 100%;\n overflow-wrap: anywhere;\n color: var(--gp-muted);\n margin: 5px 0;\n}\n.gp-operation-details summary {\n cursor: pointer;\n padding: 6px 0;\n}\n.gp-operation-details ul,\n.gp-confirm ul {\n padding: 0;\n list-style: none;\n}\n.gp-operation-details li {\n display: flex;\n flex-wrap: wrap;\n gap: 5px;\n margin: 6px 0;\n align-items: center;\n}\n.gp-operation-details li > span {\n flex: 1;\n min-width: 100px;\n overflow-wrap: anywhere;\n}\n.gp-confirm {\n border: 1px solid var(--gp-accent);\n border-radius: 7px;\n padding: 12px;\n margin-top: 12px;\n}\n.gp-confirm h3 {\n margin: 0 0 8px;\n}\n.gp-confirm p,\n.gp-confirm li,\n.gp-action-notice {\n overflow-wrap: anywhere;\n white-space: pre-wrap;\n}\n.gp-review-patch {\n max-height: 220px;\n overflow: auto;\n font:\n 11px/1.5 ui-monospace,\n Consolas,\n monospace;\n padding: 8px;\n background: var(--gp-surface);\n}\n'; -// src/client/index.tsx +// src/client/actions.tsx +var import_react = require("react"); var import_jsx_runtime = require("react/jsx-runtime"); +function Actions({ + sessionId, + signal, + revision, + files, + selected, + onChange +}) { + const [preview, setPreview] = (0, import_react.useState)(null); + const [busy, setBusy] = (0, import_react.useState)(false), [notice, setNotice] = (0, import_react.useState)(""); + const [message, setMessage] = (0, import_react.useState)(""), [branch, setBranch] = (0, import_react.useState)(""), [stashMessage, setStashMessage] = (0, import_react.useState)(""); + const [operations, setOperations] = (0, import_react.useState)({ + branches: [], + stashes: [] + }); + const controller = (0, import_react.useRef)(null), opener = (0, import_react.useRef)(null); + const generation = (0, import_react.useRef)(0); + (0, import_react.useEffect)(() => setNotice(""), [sessionId]); + function cancel() { + generation.current++; + controller.current?.abort(); + setPreview(null); + setBusy(false); + opener.current?.focus(); + } + (0, import_react.useEffect)(() => { + generation.current++; + controller.current?.abort(); + setPreview(null); + setBusy(false); + setMessage(""); + setBranch(""); + setStashMessage(""); + const abort = new AbortController(); + const requestSignal = AbortSignal.any([abort.signal, signal]); + setOperations({ branches: [], stashes: [] }); + const url = new URL( + "api/git-panel/operations", + new URL("./", document.baseURI) + ); + url.search = new URLSearchParams({ sessionId }).toString(); + void fetch(url, { + signal: requestSignal, + credentials: "same-origin", + cache: "no-store" + }).then(async (response) => { + const data = await response.json(); + if (!response.ok) + throw new Error(data.error?.message ?? "\u8BFB\u53D6\u5206\u652F\u5931\u8D25\u3002"); + if (!requestSignal.aborted) + setOperations({ + branches: data.branches ?? [], + stashes: data.stashes ?? [] + }); + }).catch((error) => { + if (!requestSignal.aborted) + setNotice(error instanceof Error ? error.message : "\u8BFB\u53D6\u5931\u8D25\u3002"); + }); + return () => { + abort.abort(); + generation.current++; + controller.current?.abort(); + }; + }, [sessionId, signal, revision]); + async function request(route, body, requestSignal) { + const url = new URL( + `api/git-panel/${route}`, + new URL("./", document.baseURI) + ); + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + credentials: "same-origin", + cache: "no-store", + signal: requestSignal + }); + const result = await response.json(); + if (!response.ok) + throw new Error(result.error?.message ?? `\u64CD\u4F5C\u5931\u8D25 (${response.status})`); + return result; + } + async function prepare(input) { + controller.current?.abort(); + const abort = new AbortController(); + controller.current = abort; + const version = ++generation.current, requestSignal = AbortSignal.any([abort.signal, signal]); + opener.current = document.activeElement instanceof document.defaultView.HTMLElement ? document.activeElement : null; + setBusy(true); + setNotice(""); + setPreview(null); + try { + const result = await request( + "prepare", + { sessionId, ...input }, + requestSignal + ); + if (!requestSignal.aborted && version === generation.current) + setPreview(result); + } catch (error) { + if (!requestSignal.aborted && version === generation.current) + setNotice(error instanceof Error ? error.message : "\u9884\u89C8\u5931\u8D25\u3002"); + } finally { + if (!requestSignal.aborted && version === generation.current) + setBusy(false); + } + } + async function execute() { + if (!preview || busy) return; + const abort = new AbortController(); + controller.current = abort; + const version = ++generation.current, requestSignal = AbortSignal.any([abort.signal, signal]); + setBusy(true); + try { + const result = await request( + "execute", + { sessionId, token: preview.token, confirm: true }, + requestSignal + ); + if (!requestSignal.aborted && version === generation.current) { + setPreview(null); + onChange(); + setNotice( + result.message + (result.backup ? ` \u5907\u4EFD\uFF1A${result.backup}` : "") + ); + } + } catch (error) { + if (!requestSignal.aborted && version === generation.current) { + setPreview(null); + onChange(); + setNotice( + (error instanceof Error ? error.message : "\u64CD\u4F5C\u5931\u8D25\u3002") + " \u8BF7\u6838\u5BF9\u5F53\u524D\u72B6\u6001\u540E\u91CD\u65B0\u9884\u89C8\uFF1B\u4E0D\u8981\u91CD\u590D\u6267\u884C\u3002" + ); + } + } finally { + if (!requestSignal.aborted && version === generation.current) + setBusy(false); + } + } + const unstaged = files.filter((f) => !f.conflict && (f.untracked || f.worktree !== " ")).map((f) => f.path); + const staged = files.filter((f) => !f.conflict && !f.untracked && f.index !== " ").map((f) => f.path); + const chosen = files.find((f) => f.path === selected); + return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "gp-actions", "aria-label": "Git \u64CD\u4F5C", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gp-action-row", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + "aria-label": "\u6682\u5B58\u5168\u90E8\u672A\u6682\u5B58\u6587\u4EF6", + disabled: busy || !!preview || !unstaged.length, + onClick: () => void prepare({ action: "stage", paths: unstaged }), + children: "\u6682\u5B58\u5168\u90E8" + } + ), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + disabled: busy || !!preview || !staged.length, + onClick: () => void prepare({ action: "unstage", paths: staged }), + children: "\u53D6\u6D88\u5168\u90E8\u6682\u5B58" + } + ) + ] }), + chosen && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gp-action-row", "aria-label": "\u9009\u5B9A\u6587\u4EF6\u64CD\u4F5C", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-selected", title: chosen.path, children: chosen.path }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + disabled: busy || !!preview, + onClick: () => void prepare({ action: "stage", paths: [chosen.path] }), + children: "\u6682\u5B58\u6B64\u6587\u4EF6" + } + ), + !chosen.untracked && chosen.index !== " " && /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + disabled: busy || !!preview, + onClick: () => void prepare({ action: "unstage", paths: [chosen.path] }), + children: "\u53D6\u6D88\u6B64\u6587\u4EF6\u6682\u5B58" + } + ), + !chosen.untracked && !chosen.conflict && chosen.worktree !== " " && /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + disabled: busy || !!preview, + onClick: () => void prepare({ action: "restore", paths: [chosen.path] }), + children: "\u5907\u4EFD\u5E76\u8FD8\u539F\u6B64\u6587\u4EF6" + } + ) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + "form", + { + onSubmit: (event) => { + event.preventDefault(); + void prepare({ action: "commit", message }); + }, + children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [ + "\u63D0\u4EA4\u8BF4\u660E", + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "textarea", + { + "aria-label": "\u63D0\u4EA4\u8BF4\u660E", + value: message, + onChange: (event) => setMessage(event.target.value), + placeholder: "\u53EA\u63D0\u4EA4\u5DF2\u6682\u5B58\u5185\u5BB9", + maxLength: 4096, + disabled: busy || !!preview + } + ) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "submit", + disabled: busy || !!preview || !message.trim() || !staged.length, + children: "\u9884\u89C8\u63D0\u4EA4" + } + ) + ] + } + ), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("details", { className: "gp-operation-details", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("summary", { children: "\u5206\u652F\u4E0E stash" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + "form", + { + onSubmit: (event) => { + event.preventDefault(); + void prepare({ action: "branch-create", branch }); + }, + children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [ + "\u65B0\u5206\u652F", + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "input", + { + "aria-label": "\u65B0\u5206\u652F\u540D\u79F0", + value: branch, + onChange: (event) => setBranch(event.target.value), + maxLength: 200, + disabled: busy || !!preview + } + ) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { disabled: busy || !!preview || !branch.trim(), type: "submit", children: "\u521B\u5EFA\u5E76\u5207\u6362" }) + ] + } + ), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { children: operations.branches.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [ + item.current ? "\u25CF " : "", + item.name + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + disabled: busy || !!preview || item.current, + onClick: () => void prepare({ action: "branch-switch", branch: item.name }), + children: "\u5207\u6362" + } + ) + ] }, item.name)) }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + "form", + { + onSubmit: (event) => { + event.preventDefault(); + void prepare({ action: "stash-save", message: stashMessage }); + }, + children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [ + "Stash \u8BF4\u660E", + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "input", + { + "aria-label": "Stash \u8BF4\u660E", + value: stashMessage, + onChange: (event) => setStashMessage(event.target.value), + maxLength: 4096, + disabled: busy || !!preview + } + ) + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "submit", + disabled: busy || !!preview || !stashMessage.trim() || !files.length, + children: "\u4FDD\u5B58\u6539\u52A8" + } + ) + ] + } + ), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { children: operations.stashes.map((item) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { title: item.sha, children: [ + item.ref, + " \xB7 ", + item.message + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + disabled: busy || !!preview, + onClick: () => void prepare({ action: "stash-apply", sha: item.sha }), + children: "\u5E94\u7528\u5E76\u4FDD\u7559" + } + ), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + disabled: busy || !!preview, + onClick: () => void prepare({ action: "stash-drop", sha: item.sha }), + children: "\u5220\u9664" + } + ) + ] }, item.sha)) }), + !operations.stashes.length && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "gp-note", children: "\u6682\u65E0 stash" }) + ] }), + busy && !preview && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { role: "status", children: "\u6B63\u5728\u51C6\u5907 Git \u64CD\u4F5C\u2026" }), + notice && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "gp-action-notice", role: "status", children: notice }), + preview && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + "section", + { + className: "gp-confirm", + role: "dialog", + "aria-label": "\u786E\u8BA4 Git \u64CD\u4F5C", + onKeyDown: (event) => { + if (event.key === "Escape" && !busy) cancel(); + }, + children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { children: "\u786E\u8BA4 Git \u64CD\u4F5C" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: preview.description }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { className: "gp-meta", children: [ + preview.root, + " \xB7 ", + preview.branch + ] }), + preview.input.message && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { children: preview.input.message }), + preview.input.branch && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { children: [ + "\u5206\u652F\uFF1A", + preview.input.branch + ] }), + preview.input.sha && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { children: [ + "Stash\uFF1A", + preview.input.sha + ] }), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { children: preview.paths.map((p) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: p }, p)) }), + preview.preview && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "gp-review-patch", children: preview.preview }), + preview.destructive && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "gp-warning", children: "\u6B64\u64CD\u4F5C\u4F1A\u4FEE\u6539\u6216\u5220\u9664\u5185\u5BB9\uFF0C\u8BF7\u6838\u5BF9\u9884\u89C8\u3002" }), + /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gp-action-row", children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + "aria-label": "\u53D6\u6D88\u64CD\u4F5C", + autoFocus: true, + disabled: busy, + onClick: cancel, + children: "\u53D6\u6D88" + } + ), + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + "button", + { + type: "button", + "aria-label": "\u786E\u8BA4\u6267\u884C\u64CD\u4F5C", + disabled: busy, + onClick: () => void execute(), + children: busy ? "\u6B63\u5728\u6267\u884C\u2026" : "\u786E\u8BA4\u6267\u884C" + } + ) + ] }) + ] + } + ) + ] }); +} + +// src/client/index.tsx +var import_jsx_runtime2 = require("react/jsx-runtime"); var inject = ["slots", "sidebarRightTabs"]; var ID = "dsh-git-plugin"; var labels = { @@ -47,10 +438,10 @@ var labels = { conflict: "\u51B2\u7A81" }; function useRead(sessionId, route, params, revision, signal) { - const [state, setState] = (0, import_react.useState)({ busy: false }); + const [state, setState] = (0, import_react2.useState)({ busy: false }); const key = JSON.stringify(params); const identity = JSON.stringify([sessionId, route, key, revision]); - (0, import_react.useEffect)(() => { + (0, import_react2.useEffect)(() => { const abort = new AbortController(); const requestSignal = AbortSignal.any([abort.signal, signal]); if (route === null) { @@ -93,13 +484,13 @@ function useRead(sessionId, route, params, revision, signal) { } function Notice({ state }) { if (state.busy) - return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-note", role: "status", children: "\u6B63\u5728\u8BFB\u53D6 Git\u2026" }); + return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-note", role: "status", children: "\u6B63\u5728\u8BFB\u53D6 Git\u2026" }); if (state.error) - return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-error", role: "alert", children: state.error }); + return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-error", role: "alert", children: state.error }); return null; } function Patch({ text }) { - return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "gp-patch", children: text.split("\n").map((line, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("pre", { className: "gp-patch", children: text.split("\n").map((line, i) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( "span", { className: `gp-line ${line.startsWith("+") && !line.startsWith("+++") ? "gp-line-add" : line.startsWith("-") && !line.startsWith("---") ? "gp-line-del" : line.startsWith("@@") ? "gp-line-hunk" : ""}`, @@ -110,11 +501,11 @@ function Patch({ text }) { } function GitPanel({ sessionId, useTabInfo }) { const { tab } = useTabInfo(); - const [mode, setMode] = (0, import_react.useState)("files"); - const [revision, setRevision] = (0, import_react.useState)(0); - const [skip, setSkip] = (0, import_react.useState)(0); - const [selection, setSelection] = (0, import_react.useState)(null); - (0, import_react.useEffect)(() => { + const [mode, setMode] = (0, import_react2.useState)("files"); + const [revision, setRevision] = (0, import_react2.useState)(0); + const [skip, setSkip] = (0, import_react2.useState)(0); + const [selection, setSelection] = (0, import_react2.useState)(null); + (0, import_react2.useEffect)(() => { setSelection(null); setSkip(0); }, [sessionId]); @@ -172,14 +563,14 @@ function GitPanel({ sessionId, useTabInfo }) { setMode(next); setSelection(null); }; - return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { "data-git-panel": "", "aria-label": "Git \u9762\u677F", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("header", { className: "gp-head", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-brand", "aria-hidden": "true", children: "git." }), - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gp-repo", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { title: status.value?.root, children: status.value?.root.split(/[\\/]/).pop() ?? "\u5F53\u524D\u4ED3\u5E93" }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-branch", title: status.value?.branch, children: status.value?.branch ?? "\u4F1A\u8BDD\u5DE5\u4F5C\u76EE\u5F55" }) + return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { "data-git-panel": "", "aria-label": "Git \u9762\u677F", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("header", { className: "gp-head", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-brand", "aria-hidden": "true", children: "git." }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "gp-repo", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("strong", { title: status.value?.root, children: status.value?.root.split(/[\\/]/).pop() ?? "\u5F53\u524D\u4ED3\u5E93" }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-branch", title: status.value?.branch, children: status.value?.branch ?? "\u4F1A\u8BDD\u5DE5\u4F5C\u76EE\u5F55" }) ] }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( "button", { type: "button", @@ -193,8 +584,8 @@ function GitPanel({ sessionId, useTabInfo }) { } ) ] }), - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("nav", { className: "gp-nav", "aria-label": "Git \u89C6\u56FE", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("nav", { className: "gp-nav", "aria-label": "Git \u89C6\u56FE", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)( "button", { type: "button", @@ -203,11 +594,11 @@ function GitPanel({ sessionId, useTabInfo }) { children: [ labels.files, " ", - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-count", children: status.value?.files.length ?? "\u2014" }) + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-count", children: status.value?.files.length ?? "\u2014" }) ] } ), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( "button", { type: "button", @@ -217,14 +608,14 @@ function GitPanel({ sessionId, useTabInfo }) { } ) ] }), - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gp-body", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, { state: status }), - mode === "files" && status.value && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children: files.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-note", children: "\u5DE5\u4F5C\u533A\u5E72\u51C0" }) : groups.filter((g) => g.files.length).map((group) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "gp-group", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("h3", { children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "gp-body", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Notice, { state: status }), + mode === "files" && status.value && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_jsx_runtime2.Fragment, { children: files.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-note", children: "\u5DE5\u4F5C\u533A\u5E72\u51C0" }) : groups.filter((g) => g.files.length).map((group) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { className: "gp-group", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("h3", { children: [ group.label, - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-count", children: group.files.length }) + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-count", children: group.files.length }) ] }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { className: "gp-files", children: group.files.map((file) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ul", { className: "gp-files", children: group.files.map((file) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)( "button", { type: "button", @@ -235,10 +626,10 @@ function GitPanel({ sessionId, useTabInfo }) { side: group.side }), children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-code", children: file.untracked ? "?" : file.conflict ? "!" : group.side === "staged" ? file.index : file.worktree }), - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "gp-file-path", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-code", children: file.untracked ? "?" : file.conflict ? "!" : group.side === "staged" ? file.index : file.worktree }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "gp-file-path", children: [ file.path, - file.oldPath && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "gp-old", children: [ + file.oldPath && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "gp-old", children: [ "\u2190 ", file.oldPath ] }) @@ -247,28 +638,28 @@ function GitPanel({ sessionId, useTabInfo }) { } ) }, file.path)) }) ] }, group.label)) }), - mode === "history" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, { state: history }), - history.value && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ - history.value.entries.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-note", children: "\u6682\u65E0\u63D0\u4EA4" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { className: "gp-history", children: history.value.entries.map((commit) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)( + mode === "history" && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Notice, { state: history }), + history.value && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [ + history.value.entries.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-note", children: "\u6682\u65E0\u63D0\u4EA4" }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("ul", { className: "gp-history", children: history.value.entries.map((commit) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("li", { children: /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)( "button", { type: "button", className: "gp-commit", onClick: () => setSelection({ sha: commit.sha }), children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-subject", children: commit.subject }), - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "gp-meta", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-sha", children: commit.short }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: commit.author }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("time", { dateTime: commit.date, children: commit.date.slice(0, 10) }) + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-subject", children: commit.subject }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("span", { className: "gp-meta", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-sha", children: commit.short }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { children: commit.author }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("time", { dateTime: commit.date, children: commit.date.slice(0, 10) }) ] }) ] } ) }, commit.sha)) }), - history.value.truncated && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-warning", children: "\u5386\u53F2\u8F93\u51FA\u5DF2\u622A\u65AD\uFF0C\u8BF7\u51CF\u5C11\u6761\u6570\u6216\u63D0\u9AD8\u8F93\u51FA\u4E0A\u9650\u3002" }), - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gp-pager", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + history.value.truncated && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-warning", children: "\u5386\u53F2\u8F93\u51FA\u5DF2\u622A\u65AD\uFF0C\u8BF7\u51CF\u5C11\u6761\u6570\u6216\u63D0\u9AD8\u8F93\u51FA\u4E0A\u9650\u3002" }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "gp-pager", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( "button", { type: "button", @@ -280,7 +671,7 @@ function GitPanel({ sessionId, useTabInfo }) { children: "\u2190 \u4E0A\u4E00\u9875" } ), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( "button", { type: "button", @@ -295,11 +686,15 @@ function GitPanel({ sessionId, useTabInfo }) { ] }) ] }) ] }), - selection && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", { className: "gp-detail", "aria-label": "Git \u8BE6\u60C5", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "gp-detail-head", children: [ - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-detail-title", children: "sha" in selection ? selection.sha.slice(0, 12) : selection.path }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "gp-tag", children: "sha" in selection ? "commit" : selection.side === "staged" ? "\u5DF2\u6682\u5B58" : "\u672A\u6682\u5B58" }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + mode === "files" && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Actions, { sessionId, signal: tab.signal, revision, files, selected: selection && "path" in selection ? selection.path : void 0, onChange: () => { + setSelection(null); + setRevision((n) => n + 1); + } }), + selection && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("section", { className: "gp-detail", "aria-label": "Git \u8BE6\u60C5", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { className: "gp-detail-head", children: [ + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-detail-title", children: "sha" in selection ? selection.sha.slice(0, 12) : selection.path }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "gp-tag", children: "sha" in selection ? "commit" : selection.side === "staged" ? "\u5DF2\u6682\u5B58" : "\u672A\u6682\u5B58" }), + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( "button", { type: "button", @@ -309,15 +704,15 @@ function GitPanel({ sessionId, useTabInfo }) { } ) ] }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Notice, { state: detail }), - detail.value && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [ - detail.value.truncated && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-warning", children: "\u8F93\u51FA\u5DF2\u622A\u65AD" }), - "untracked" in detail.value && detail.value.untracked && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-warning", children: "\u672A\u8DDF\u8E2A\u6587\u4EF6\uFF1A\u5F53\u524D\u5185\u5BB9\u9884\u89C8" }), - "binary" in detail.value && detail.value.binary ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-note", children: "\u4E8C\u8FDB\u5236\u6587\u4EF6\uFF0C\u65E0\u6CD5\u663E\u793A\u6587\u672C diff\u3002" }) : detail.value.text ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Patch, { text: detail.value.text }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "gp-note", children: "\u8BE5\u4FA7\u6682\u65E0\u5DEE\u5F02\uFF1B\u6587\u4EF6\u53EF\u80FD\u5DF2\u53D1\u751F\u53D8\u5316\uFF0C\u8BF7\u5237\u65B0\u3002" }) + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Notice, { state: detail }), + detail.value && /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [ + detail.value.truncated && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-warning", children: "\u8F93\u51FA\u5DF2\u622A\u65AD" }), + "untracked" in detail.value && detail.value.untracked && /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-warning", children: "\u672A\u8DDF\u8E2A\u6587\u4EF6\uFF1A\u5F53\u524D\u5185\u5BB9\u9884\u89C8" }), + "binary" in detail.value && detail.value.binary ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-note", children: "\u4E8C\u8FDB\u5236\u6587\u4EF6\uFF0C\u65E0\u6CD5\u663E\u793A\u6587\u672C diff\u3002" }) : detail.value.text ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Patch, { text: detail.value.text }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("div", { className: "gp-note", children: "\u8BE5\u4FA7\u6682\u65E0\u5DEE\u5F02\uFF1B\u6587\u4EF6\u53EF\u80FD\u5DF2\u53D1\u751F\u53D8\u5316\uFF0C\u8BF7\u5237\u65B0\u3002" }) ] }) ] }) ] }), - /* @__PURE__ */ (0, import_jsx_runtime.jsx)("footer", { className: "gp-footer", children: "\u53EA\u8BFB \xB7 \u6570\u636E\u6765\u81EA\u5F53\u524D\u4F1A\u8BDD\u7684 Git \u4ED3\u5E93" }) + /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("footer", { className: "gp-footer", children: "\u5F53\u524D\u4F1A\u8BDD\u4ED3\u5E93 \xB7 \u5199\u64CD\u4F5C\u987B\u5148\u9884\u89C8\u5E76\u786E\u8BA4" }) ] }); } function apply(ctx) { @@ -339,7 +734,7 @@ function apply(ctx) { id: "git", order: 30, title: () => "Git", - description: () => "\u6587\u4EF6\u72B6\u6001\u3001diff \u4E0E\u63D0\u4EA4\u5386\u53F2" + description: () => "\u72B6\u6001\u3001diff\u3001\u5386\u53F2\u4E0E Git \u64CD\u4F5C" } ] }) diff --git a/lib/index.js b/lib/index.js index 6c59183..8cffc8f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,4 +1,5 @@ import { registerWebPanel } from "./web-host.js"; +import { registerWebActions } from "./web-actions.js"; import { existsSync } from "node:fs"; import { readdir } from "node:fs/promises"; import { join } from "node:path"; @@ -532,7 +533,7 @@ async function apply(ctx, config) { assertPositiveInteger("stderrMaxBytes", caps.stderrMaxBytes); assertPositiveInteger("graceMs", caps.graceMs); assertPositiveInteger("timeoutMs", caps.timeoutMs); - ctx.inject?.(["connection", "sessions", "sessionPersistence", "fs"], (scope) => registerWebPanel(scope, caps, runProcess, resolveGitRoot)); + ctx.inject?.(["connection", "sessions", "sessionPersistence", "fs"], (scope) => { registerWebPanel(scope, caps, runProcess, resolveGitRoot); registerWebActions(scope, caps, runProcess, resolveGitRoot); }); applyCommands(ctx, caps); applyTools(ctx, caps); ctx.systemPrompt.section({ diff --git a/lib/panel-types.d.ts b/lib/panel-types.d.ts index 7359a31..4eefc96 100644 --- a/lib/panel-types.d.ts +++ b/lib/panel-types.d.ts @@ -46,3 +46,41 @@ export interface PanelFailure { message: string; }; } +/** Fixed operator actions, never arbitrary argv or a model tool grant. */ +export type ActionName = "stage" | "unstage" | "commit" | "branch-create" | "branch-switch" | "stash-save" | "stash-apply" | "stash-drop" | "restore"; +export interface ActionInput { + sessionId: string; + action: ActionName; + paths?: string[]; + message?: string; + branch?: string; + sha?: string; +} +export interface ActionPreview { + token: string; + expiresAt: string; + root: string; + branch: string; + input: ActionInput; + description: string; + preview: string; + paths: string[]; + destructive: boolean; +} +export interface ActionResult { + ok: true; + message: string; + backup?: string; +} +export interface OperationsView { + branches: Array<{ + name: string; + sha: string; + current: boolean; + }>; + stashes: Array<{ + sha: string; + ref: string; + message: string; + }>; +} diff --git a/lib/web-actions.d.ts b/lib/web-actions.d.ts new file mode 100644 index 0000000..7530760 --- /dev/null +++ b/lib/web-actions.d.ts @@ -0,0 +1,15 @@ +import type { Context } from "@deepseek-ai/cordis"; +import type { Caps, PluginContext, RunOptions, RunOutcome } from "./index.js"; +type Runner = (ctx: PluginContext, argv: string[], opts: RunOptions) => Promise; +type Resolver = (ctx: PluginContext, cwd: string, signal: AbortSignal, caps: Caps) => Promise<{ + ok: true; + root: string; +} | { + ok: false; + text: string; + aborted?: true; + timedOut?: true; +}>; +/** Authenticated operator writes; separate from model tool approval and never opens an artificial turn. */ +export declare function registerWebActions(ctx: Context, caps: Caps, run: Runner, resolveRoot: Resolver): void; +export {}; diff --git a/lib/web-actions.js b/lib/web-actions.js new file mode 100644 index 0000000..d13944c --- /dev/null +++ b/lib/web-actions.js @@ -0,0 +1,596 @@ +import { createHash, randomBytes } from "node:crypto"; +import { isAbsolute, resolve } from "node:path"; +import { parseStatus } from "./web-host.js"; +class ActionError extends Error { + code; + status; + constructor(code, message, status = 409) { + super(message); + this.code = code; + this.status = status; + } +} +const base = [ + "--no-optional-locks", + "--literal-pathspecs", + "-c", + "color.ui=false", + "-c", + "core.fsmonitor=false", + "-c", + "core.quotePath=false", +]; +const actionNames = [ + "stage", + "unstage", + "commit", + "branch-create", + "branch-switch", + "stash-save", + "stash-apply", + "stash-drop", + "restore", +]; +function fail(code, message, status = 409) { + throw new ActionError(code, message, status); +} +function filePath(value) { + if (typeof value !== "string" || + !value || + value.length > 4096 || + value.includes("\0") || + value.includes("\\") || + isAbsolute(value) || + /^[a-z]:/i.test(value) || + value + .split("/") + .some((s) => !s || s === "." || s === ".." || s.toLowerCase() === ".git")) + return fail("bad-path", "请选择仓库内的文件。", 400); + return value; +} +function exact(value, allowed) { + if (Object.keys(value).some((k) => !allowed.includes(k))) + fail("bad-request", "请求包含未知字段。", 400); +} +function validate(value) { + const sessionId = value.sessionId, action = value.action; + if (typeof sessionId !== "string" || + !sessionId || + sessionId.length > 200 || + typeof action !== "string" || + !actionNames.includes(action)) + return fail("bad-request", "操作或会话无效。", 400); + const input = { + sessionId, + action: action, + }; + if (["stage", "unstage", "restore"].includes(action)) { + exact(value, ["sessionId", "action", "paths"]); + if (!Array.isArray(value.paths) || + !value.paths.length || + value.paths.length > 200) + fail("bad-request", "请选择 1–200 个文件。", 400); + input.paths = [...new Set(value.paths.map(filePath))]; + } + else if (["commit", "stash-save"].includes(action)) { + exact(value, ["sessionId", "action", "message"]); + if (typeof value.message !== "string" || + !value.message.trim() || + Buffer.byteLength(value.message) > 4096 || + value.message.includes("\0")) + fail("bad-request", "请输入有效说明(最多 4096 字节)。", 400); + input.message = value.message.trim(); + } + else if (["branch-create", "branch-switch"].includes(action)) { + exact(value, ["sessionId", "action", "branch"]); + if (typeof value.branch !== "string" || + !value.branch || + value.branch.length > 200 || + value.branch.startsWith("-") || + value.branch.includes("\0") || + value.branch === "HEAD") + fail("bad-request", "分支名称无效。", 400); + input.branch = value.branch; + } + else { + exact(value, ["sessionId", "action", "sha"]); + if (typeof value.sha !== "string" || + !/^([a-f0-9]{40}|[a-f0-9]{64})$/.test(value.sha)) + fail("bad-request", "请选择有效 stash。", 400); + input.sha = value.sha; + } + return input; +} +async function bodyOf(request) { + // The official HTTP bridge uses dsh.internal in Request.url. Connection has + // already fenced the real Host; bind the mandatory browser Origin to it. + const origin = request.headers.get("origin"), host = request.headers.get("host"); + let trusted = false; + try { + const source = new URL(origin ?? ""), authority = new URL("http://" + (host ?? "")); + trusted = + !!host && + !!origin && + ["http:", "https:"].includes(source.protocol) && + source.origin === origin && + source.host === authority.host && + !authority.username && + !authority.password && + authority.pathname === "/"; + } + catch { } + if (!trusted) + fail("foreign-origin", "写操作必须来自当前 DSH 页面。", 403); + if (!request.headers + .get("content-type") + ?.toLowerCase() + .startsWith("application/json")) + fail("bad-request", "需要 JSON 请求。", 400); + const data = await request.arrayBuffer(); + if (data.byteLength > 65536) + fail("too-large", "请求过大。", 413); + let parsed; + try { + parsed = JSON.parse(new TextDecoder().decode(data)); + } + catch { + return fail("bad-request", "JSON 无效。", 400); + } + if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") + return fail("bad-request", "JSON 对象无效。", 400); + return parsed; +} +async function lists(git) { + const branches = (await git([ + "for-each-ref", + "--format=%(refname:short)%00%(objectname)%00%(HEAD)", + "refs/heads", + ])) + .trimEnd() + .split("\n") + .filter(Boolean) + .map((row) => { + const [name, sha, current] = row.split("\0"); + return { name: name, sha: sha, current: current === "*" }; + }); + const stashes = (await git(["stash", "list", "--format=%H%x00%gd%x00%gs"])) + .trimEnd() + .split("\n") + .filter(Boolean) + .map((row) => { + const [sha, ref, ...message] = row.split("\0"); + return { sha: sha, ref: ref, message: message.join("\0") }; + }); + return { branches, stashes }; +} +/** Use the same exact path scope for review and mutation. A staged rename's + * old name is no longer in the index; staging it could add an unrelated new file. */ +function effectivePaths(input, files) { + return [ + ...new Set((input.paths ?? []).flatMap((p) => { + const f = files.find((f) => f.path === p); + const source = f?.oldPath && + ((input.action === "unstage" && f.index === "R") || + (input.action === "stage" && + f.worktree === "R" && + f.index !== "R")); + return source ? [p, filePath(f.oldPath)] : [p]; + })), + ]; +} +/** Authenticated operator writes; separate from model tool approval and never opens an artificial turn. */ +export function registerWebActions(ctx, caps, run, resolveRoot) { + const lifetime = new AbortController(), pending = new Set(), tokens = new Map(), locks = new Set(); + ctx.effect(() => async () => { + lifetime.abort(); + tokens.clear(); + await Promise.allSettled(pending); + }); + async function repository(id, signal) { + const sid = id; + const header = ctx.sessions.get(sid)?.header ?? + (await ctx.sessionPersistence.stat(sid))?.header; + if (!header?.cwd) + fail("session-not-found", "当前宿主不存在该会话。", 404); + const found = await resolveRoot(ctx, header.cwd, signal, caps); + if (!found.ok) + fail(found.aborted + ? "cancelled" + : found.timedOut + ? "timeout" + : "repository-unavailable", found.text, found.aborted ? 499 : found.timedOut ? 504 : 409); + let root = found.root; + const git = async (args, allowMissing = false) => { + // stash internally cleans untracked files; Git 2.53 inherits literal pathspecs into that cleanup. + // Stash routes accept only fixed options and validated object IDs, never pathspecs. + const flags = args[0] === "stash" + ? base.filter((flag) => flag !== "--literal-pathspecs") + : base; + const result = await run(ctx, ["git", ...flags, ...args], { + cwd: root, + signal, + caps, + ...(allowMissing ? { acceptedExitCodes: [0, 1] } : {}), + }); + if (!result.ok) + fail(result.aborted + ? "cancelled" + : result.timedOut + ? "timeout" + : "git-failed", result.text, result.aborted ? 499 : result.timedOut ? 504 : 409); + if (result.truncated) + fail("truncated", "Git 输出超限,无法安全确认;请调整 maxBytes 后重试。", 413); + return result.rawText ?? result.text; + }; + root = (await git(["rev-parse", "--show-toplevel"])).trim(); + const target = await ctx.fs.resolve(root, { signal }); + if ((await ctx.fs.stat(target, signal))?.type !== "directory" || + resolve(ctx.fs.processPath(target)) !== resolve(root)) + fail("repository-unavailable", "仓库未映射到当前执行环境。"); + return { root, git }; + } + async function safeParents(root, name, signal) { + const parts = name.split("/"); + for (let i = 1; i < parts.length; i++) { + const p = resolve(root, ...parts.slice(0, i)), info = await ctx.fs.lstat(p, {}, signal); + if (info?.type === "symlink") + fail("bad-path", "文件的父目录是符号链接,不能在面板中操作。", 400); + } + } + async function snapshot(root, git, input, signal) { + const status = await git([ + "status", + "--porcelain=v1", + "-z", + "--branch", + "-uall", + ]); + const files = parseStatus(status).files; + const selected = effectivePaths(input, files); + for (const name of selected) + await safeParents(root, name, signal); + const head = (await git(["rev-parse", "--verify", "--quiet", "HEAD"], true)).trim() || + null; + const branch = (await git(["symbolic-ref", "--quiet", "--short", "HEAD"], true)).trim() || "(detached HEAD)"; + const index = await git([ + "diff", + "--cached", + "--binary", + "--no-ext-diff", + "--no-textconv", + ]); + const worktree = await git([ + "diff", + "--binary", + "--no-ext-diff", + "--no-textconv", + ]); + const operations = await lists(git); + const hash = createHash("sha256").update(JSON.stringify({ + root, + status, + head, + branch, + index, + worktree, + operations, + })); + const relevant = files.filter((f) => f.untracked && + (input.action === "stash-save" || selected.includes(f.path))); + let remaining = caps.maxBytes; + const untrackedPreview = []; + for (const f of relevant) { + await safeParents(root, f.path, signal); + const path = resolve(root, f.path), info = await ctx.fs.lstat(path, {}, signal); + if (info?.type !== "file") + fail("bad-path", "未跟踪文件须为普通文件;符号链接请使用 Git 命令操作。", 400); + const target = await ctx.fs.resolve(path, { signal }); + const rootTarget = await ctx.fs.resolve(root, { signal }); + if (!ctx.fs.contains(rootTarget, target)) + fail("bad-path", "文件不在仓库内。", 400); + const bytes = await ctx.fs.readBytes(target, signal, remaining); + remaining -= bytes.byteLength; + hash.update(JSON.stringify([f.path, bytes.byteLength])).update(bytes); + untrackedPreview.push(`Untracked: ${f.path}\n${bytes.includes(0) ? "[binary file]" : new TextDecoder().decode(bytes)}`); + } + return { + hash: hash.digest("hex"), + untrackedPreview: untrackedPreview.join("\n"), + status, + files, + head, + branch, + index, + worktree, + operations, + }; + } + async function check(input, state, git) { + if (input.paths) { + for (const name of input.paths) { + const f = state.files.find((f) => f.path === name); + if (!f) + fail("changed", "选定文件已变化,请刷新后重新预览。"); + if (input.action === "unstage" && (f.untracked || f.index === " ")) + fail("bad-request", "该文件没有暂存内容。", 400); + if (input.action === "restore" && + (f.untracked || f.conflict || f.worktree === " ")) + fail("bad-request", "只能还原已跟踪、未冲突的工作区改动。", 400); + } + } + if (input.action === "commit") { + if (state.files.some((f) => f.conflict)) + fail("conflict", "请先编辑并暂存解决冲突。"); + if (!state.index.trim()) + fail("empty-index", "没有已暂存的改动。"); + } + if (input.branch) { + await git(["check-ref-format", "--branch", input.branch]); + const exists = state.operations.branches.some((b) => b.name === input.branch); + if (input.action === "branch-create" && exists) + fail("branch-exists", "分支已存在。"); + if (input.action === "branch-switch" && !exists) + fail("branch-not-found", "分支已变化,请刷新。"); + if (input.action === "branch-switch" && state.files.length) + fail("dirty", "切换分支前请提交或 stash 当前改动。"); + } + if (input.action === "stash-save") { + if (!state.head) + fail("no-head", "首次提交后才可使用 stash。"); + if (!state.files.length) + fail("clean", "工作区干净,无需保存 stash。"); + if (state.files.some((f) => f.conflict)) + fail("conflict", "请先解决冲突。"); + } + if (input.sha && !state.operations.stashes.some((s) => s.sha === input.sha)) + fail("stash-not-found", "stash 已变化,请刷新。"); + } + async function presentation(input, state, root, token, expires, git) { + const descriptions = { + stage: "将选定文件的当前内容加入暂存区。", + unstage: "取消选定文件的暂存,工作区内容保留。", + commit: "仅提交当前暂存区;未暂存和未跟踪内容不会自动加入。配置的 preCommit 与 Git 原生钩子仍会执行。", + "branch-create": "创建并切换到新分支,保留当前改动。", + "branch-switch": "切换到选定本地分支;不使用强制覆盖。", + "stash-save": "保存已跟踪和未跟踪改动到 stash;子模块内部改动需要分别处理。", + "stash-apply": "应用 stash 并恢复暂存状态,保留原 stash;冲突时请在文件中解决。", + "stash-drop": "删除该 stash;此操作不可通过面板直接撤销。", + restore: "先把所有已跟踪改动备份到保留的 stash,再将选定文件工作区还原为暂存区内容;未跟踪文件保留。", + }; + let preview = input.action === "commit" + ? state.index + : [state.index, state.worktree, state.untrackedPreview] + .filter(Boolean) + .join("\n"); + if (input.paths) { + const paths = effectivePaths(input, state.files); + preview = await git([ + "diff", + ...(input.action === "unstage" ? ["--cached"] : []), + "--binary", + "--no-ext-diff", + "--no-textconv", + "--", + ...paths, + ]); + if (input.action === "stage") + preview += state.untrackedPreview; + } + let stashPaths; + if (input.sha) { + const indexArgs = [input.sha + "^1", input.sha + "^2"]; + const indexPreview = await git([ + "diff", + "--binary", + "--no-ext-diff", + "--no-textconv", + ...indexArgs, + ]); + const treePreview = await git([ + "stash", + "show", + "--include-untracked", + "--patch", + "--binary", + "--no-ext-diff", + "--no-textconv", + input.sha, + ]); + preview = [ + indexPreview ? "Stash index changes:\n" + indexPreview : "", + treePreview + ? "Stash worktree / untracked changes:\n" + treePreview + : "", + ] + .filter(Boolean) + .join("\n"); + const names = await git(["diff", "--name-only", "-z", ...indexArgs]); + const treeNames = await git([ + "stash", + "show", + "--include-untracked", + "--name-only", + "-z", + input.sha, + ]); + stashPaths = [ + ...new Set((names + treeNames).split("\0").filter(Boolean)), + ]; + } + return { + token, + expiresAt: new Date(expires).toISOString(), + root, + branch: state.branch, + input, + description: descriptions[input.action], + preview, + paths: (input.paths ? effectivePaths(input, state.files) : stashPaths) ?? + state.files + .filter((f) => input.action !== "commit" || (!f.untracked && f.index !== " ")) + .map((f) => f.path), + destructive: input.action === "stash-drop" || input.action === "restore", + }; + } + async function mutation(input, state, git, root, signal) { + const paths = effectivePaths(input, state.files); + let text = ""; + switch (input.action) { + case "stage": + text = await git(["add", "-A", "--", ...paths]); + break; + case "unstage": + text = await git(state.head + ? ["restore", "--staged", "--", ...paths] + : ["rm", "--cached", "-f", "--", ...paths]); + break; + case "commit": { + if (caps.preCommit.length) { + const hook = await run(ctx, caps.preCommit, { + cwd: root, + signal, + caps, + }); + if (!hook.ok) + fail("pre-commit-failed", hook.text); + const after = await snapshot(root, git, input, signal); + if (after.hash !== state.hash) + fail("changed", "preCommit 改变了预览范围,请重新预览。"); + } + text = await git(["commit", "-m", input.message]); + break; + } + case "branch-create": + text = await git(["switch", "-c", input.branch]); + break; + case "branch-switch": + text = await git(["switch", input.branch]); + break; + case "stash-save": + text = await git([ + "stash", + "push", + "--include-untracked", + "-m", + input.message, + ]); + break; + case "stash-apply": + text = await git(["stash", "apply", "--index", input.sha]); + break; + case "stash-drop": { + const stash = state.operations.stashes.find((s) => s.sha === input.sha); + text = await git(["stash", "drop", stash.ref]); + break; + } + case "restore": { + const backup = (await git(["stash", "create"])).trim(); + if (!backup) + fail("backup-failed", "无法建立还原备份,未执行还原。"); + await git([ + "stash", + "store", + "-m", + "dsh-git-panel restore backup", + backup, + ]); + text = await git(["restore", "--worktree", "--", ...input.paths]); + return { + ok: true, + message: text || "已还原工作区;备份保留在 stash。", + backup, + }; + } + } + return { + ok: true, + message: text.trim() || "操作完成,请刷新查看当前 Git 状态。", + }; + } + for (const route of ["operations", "prepare", "execute"]) { + ctx.connection.fetch.register({ + path: `/api/git-panel/${route}`, + methods: [route === "operations" ? "GET" : "POST"], + requestBody: "buffered", + fetch: (request) => { + const signal = AbortSignal.any([request.signal, lifetime.signal]); + const task = (async () => { + let locked; + try { + if (lifetime.signal.aborted) + fail("cancelled", "插件已停用。", 499); + if (request.method !== (route === "operations" ? "GET" : "POST")) + fail("bad-method", "请求方法无效。", 405); + const query = new URL(request.url).searchParams; + if (route === "operations") { + if ([...query.keys()].some((k) => k !== "sessionId") || + query.getAll("sessionId").length !== 1) + fail("bad-request", "请求参数无效。", 400); + const id = query.get("sessionId"); + if (!id || id.length > 200) + fail("bad-request", "会话无效。", 400); + const { git } = await repository(id, signal); + return Response.json(await lists(git), { + headers: { "cache-control": "no-store" }, + }); + } + if (query.size) + fail("bad-request", "写操作不接受 URL 参数。", 400); + const data = await bodyOf(request); + if (route === "prepare") { + const input = validate(data), { root, git } = await repository(input.sessionId, signal); + if (locks.has(root)) + fail("busy", "该仓库已有操作正在执行。"); + const state = await snapshot(root, git, input, signal); + await check(input, state, git); + const now = Date.now(); + for (const [token, c] of tokens) + if (c.expires <= now) + tokens.delete(token); + if (tokens.size >= 128) + fail("busy", "待确认操作过多,请稍后重试。", 429); + const token = randomBytes(32).toString("base64url"), expires = now + 120000; + tokens.set(token, { input, root, hash: state.hash, expires }); + return Response.json(await presentation(input, state, root, token, expires, git), { headers: { "cache-control": "no-store" } }); + } + exact(data, ["sessionId", "token", "confirm"]); + if (data.confirm !== true) + fail("confirmation-required", "请明确确认预览后再执行。", 400); + const token = typeof data.token === "string" ? data.token : "", c = tokens.get(token); + tokens.delete(token); + if (!c || + c.expires <= Date.now() || + c.input.sessionId !== data.sessionId) + fail("confirmation-expired", "确认已失效,请重新预览。"); + const { root, git } = await repository(c.input.sessionId, signal); + if (root !== c.root) + fail("changed", "会话仓库已变化,请重新预览。"); + if (locks.has(root)) + fail("busy", "该仓库已有操作正在执行。"); + locks.add(root); + locked = root; + const state = await snapshot(root, git, c.input, signal); + if (state.hash !== c.hash) + fail("changed", "仓库内容、暂存区或分支已变化,请重新预览。"); + await check(c.input, state, git); + signal.throwIfAborted(); + return Response.json(await mutation(c.input, state, git, root, signal), { headers: { "cache-control": "no-store" } }); + } + catch (error) { + const e = error instanceof ActionError + ? error + : new ActionError(signal.aborted ? "cancelled" : "unavailable", error instanceof Error ? error.message : "操作失败。", signal.aborted ? 499 : 409); + return Response.json({ error: { code: e.code, message: e.message } }, { status: e.status, headers: { "cache-control": "no-store" } }); + } + finally { + if (locked) + locks.delete(locked); + } + })(); + pending.add(task); + void task.then(() => pending.delete(task), () => pending.delete(task)); + return task; + }, + }); + } +} diff --git a/package-lock.json b/package-lock.json index 6dfa53a..c667051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dsh-git-plugin", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dsh-git-plugin", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@deepseek-ai/schemastery": "^3.18.1" diff --git a/package.json b/package.json index 2e63574..02448c1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dsh-git-plugin", - "version": "0.3.0", - "description": "Git workflow for DeepSeek Harness: read-only Web panel, slash commands and Git tools", + "version": "0.4.0", + "description": "Git workflow for DeepSeek Harness: confirmed Web Git actions, slash commands and read-only tools", "type": "module", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/screenshots.json b/screenshots.json index 5b6f6cb..4bc47ca 100644 --- a/screenshots.json +++ b/screenshots.json @@ -1,5 +1,6 @@ [ "assets/readme/hero.svg", + "docs/images/git-actions-dark.png", "docs/images/git-panel.png", "docs/images/git-panel-dark.png" ] diff --git a/scripts/verify-web-actions.mjs b/scripts/verify-web-actions.mjs new file mode 100644 index 0000000..14b0e3e --- /dev/null +++ b/scripts/verify-web-actions.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node +/** Optional installed-DSH Web acceptance. Only use a disposable repo/profile. + * --dsh-log --repo + * --playwright-module [--wsl-distro Ubuntu-24.04] [--screenshots ] + * Fixture: notes.txt committed with 'base\n', working version 'working\n', + * and untracked 中文 space.txt with 'new content\n'. No model request is made. + */ +import fs from "node:fs"; +import path from "node:path"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { randomUUID } from "node:crypto"; +const opts = new Map(); +const known = [ + "--dsh-log", + "--repo", + "--playwright-module", + "--wsl-distro", + "--screenshots", +]; +for (let i = 2; i < process.argv.length; i += 2) { + if ( + !known.includes(process.argv[i]) || + !process.argv[i + 1] || + opts.has(process.argv[i]) + ) + throw Error("Invalid arguments"); + opts.set(process.argv[i], process.argv[i + 1]); +} +for (const key of ["--dsh-log", "--repo", "--playwright-module"]) + assert.ok(opts.get(key), `Missing ${key}`); +const repo = opts.get("--repo"), + distro = opts.get("--wsl-distro"); +assert.equal( + path.posix.basename(repo.replaceAll("\\", "/")), + "actions-repo", + "Use the disposable actions-repo fixture", +); +const repoFs = distro + ? `\\\\wsl.localhost\\${distro}${repo.replaceAll("/", "\\")}` + : repo; +const git = (...args) => + distro + ? execFileSync("wsl", ["-d", distro, "--", "git", "-C", repo, ...args], { + encoding: "utf8", + }) + : execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }); +const write = (name, text) => fs.writeFileSync(path.join(repoFs, name), text); +let browser, debugPage; +try { + const { chromium, request } = await import( + pathToFileURL(path.join(opts.get("--playwright-module"), "index.mjs")).href + ); + const startup = fs + .readFileSync(opts.get("--dsh-log"), "utf8") + .match(/dsh web: (http[^\s]+)/); + assert.ok(startup, "Missing startup URL"); + const auth = new URL(startup[1]); + assert.ok(["127.0.0.1", "localhost", "[::1]"].includes(auth.hostname)); + const base = new URL("./", auth); + browser = await chromium.launch({ + headless: true, + ...(process.platform === "win32" ? { channel: "msedge" } : {}), + }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 1000 }, + }); + const page = await context.newPage(); + debugPage = page; + page.setDefaultTimeout(15000); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto(auth.href); + await page.waitForLoadState("networkidle"); + const endpoint = (route) => new URL("api/" + route, base).href; + async function rpc(method, args) { + const r = await context.request.post(endpoint(method), { + data: { + type: "client-request", + rpcId: randomUUID(), + method, + payload: { args }, + }, + }); + const b = await r.json(); + assert.ok(b.result?.ok, "Native RPC failed: " + method); + return b.result.value; + } + await rpc("pluginManager/setBundleEnabled", { + name: "dsh-git-plugin", + enabled: true, + }); + const created = await rpc("workspace/create", { request: { path: repo } }); + const title = "actions-verify-" + randomUUID().slice(0, 8); + await rpc("workspace/rename", { + request: { workspaceId: created.workspace.workspaceId, title }, + }); + await page.reload(); + await page.waitForLoadState("networkidle"); + for (const name of ["继续", "稍后配置"]) { + const b = page.getByRole("button", { name, exact: true }); + try { + await b.waitFor({ state: "visible", timeout: 2500 }); + await b.click(); + } catch {} + } + const close = page.getByRole("button", { name: "收起右侧边栏", exact: true }); + if (await close.count()) await close.click(); + await page + .getByLabel("会话", { exact: true }) + .getByText(title, { exact: true }) + .hover(); + const creation = page.waitForResponse((r) => + new URL(r.url()).pathname.endsWith("/api/session/create"), + ); + await page + .getByRole("button", { name: `在“${title}”中新建会话`, exact: true }) + .click(); + const sessionResponse = await (await creation).json(); + const sid = sessionResponse.result.value.sessionId; + await page.getByRole("button", { name: "打开右侧边栏", exact: true }).click(); + await page.getByRole("button", { name: /^Git/ }).click(); + const panel = page.locator("[data-git-panel]"); + await panel.locator(".gp-file").first().waitFor(); + async function refresh() { + const r = page.waitForResponse((x) => + new URL(x.url()).pathname.endsWith("/api/git-panel/status"), + ); + await panel + .getByRole("button", { name: "刷新 Git 面板", exact: true }) + .click(); + await r; + await page.waitForTimeout(100); + } + async function confirm(trigger) { + await trigger.click(); + const dialog = panel.getByRole("dialog", { name: "确认 Git 操作" }); + await dialog.waitFor(); + const done = page.waitForResponse((r) => + new URL(r.url()).pathname.endsWith("/api/git-panel/execute"), + ); + await dialog + .getByRole("button", { name: "确认执行操作", exact: true }) + .click(); + const response = await done; + const body = await response.json(); + assert.equal(response.status(), 200, body.error?.message); + await dialog.waitFor({ state: "hidden" }); + await page.waitForTimeout(150); + return body; + } + // Read regression and cancellation before any write. + await panel.locator(".gp-file").filter({ hasText: "notes.txt" }).click(); + await panel.locator(".gp-patch").waitFor(); + assert.match(await panel.locator(".gp-patch").innerText(), /working/); + await panel + .getByRole("button", { name: "暂存全部未暂存文件", exact: true }) + .click(); + await panel.getByRole("dialog").waitFor(); + await panel.getByRole("button", { name: "取消操作", exact: true }).click(); + assert.equal(git("diff", "--cached"), ""); + await confirm( + panel.getByRole("button", { name: "暂存全部未暂存文件", exact: true }), + ); + assert.match(git("diff", "--cached"), /new content/); + await confirm( + panel.getByRole("button", { name: "取消全部暂存", exact: true }), + ); + assert.equal(git("diff", "--cached"), ""); + await panel.locator(".gp-file").filter({ hasText: "notes.txt" }).click(); + await confirm(panel.getByRole("button", { name: "暂存此文件", exact: true })); + await panel + .getByRole("textbox", { name: "提交说明", exact: true }) + .fill("feat: GUI reviewed commit"); + await confirm(panel.getByRole("button", { name: "预览提交", exact: true })); + assert.equal(git("show", "HEAD:notes.txt"), "working\n"); + assert.equal(git("ls-files", "--", "中文 space.txt"), ""); + await panel.locator("summary").click(); + await panel + .getByRole("textbox", { name: "新分支名称", exact: true }) + .fill("feat/gui"); + await confirm(panel.getByRole("button", { name: "创建并切换", exact: true })); + assert.equal(git("branch", "--show-current").trim(), "feat/gui"); + await panel + .getByRole("textbox", { name: "Stash 说明", exact: true }) + .fill("GUI stash"); + await confirm(panel.getByRole("button", { name: "保存改动", exact: true })); + assert.equal(git("status", "--porcelain"), ""); + await confirm( + panel + .locator(".gp-operation-details li") + .filter({ hasText: "main" }) + .getByRole("button", { name: "切换", exact: true }), + ); + assert.equal(git("branch", "--show-current").trim(), "main"); + await confirm(panel.getByRole("button", { name: "应用并保留", exact: true })); + assert.equal( + fs.readFileSync(path.join(repoFs, "中文 space.txt"), "utf8"), + "new content\n", + ); + assert.match(git("stash", "list"), /GUI stash/); + await confirm(panel.getByRole("button", { name: "删除", exact: true })); + assert.equal(git("stash", "list"), ""); + write("notes.txt", "restore original\n"); + await refresh(); + await panel.locator(".gp-file").filter({ hasText: "notes.txt" }).click(); + const restore = await confirm( + panel.getByRole("button", { name: "备份并还原此文件", exact: true }), + ); + assert.ok(restore.backup); + assert.equal( + fs.readFileSync(path.join(repoFs, "notes.txt"), "utf8"), + "working\n", + ); + assert.match(git("stash", "show", "-p", restore.backup), /restore original/); + assert.ok(fs.existsSync(path.join(repoFs, "中文 space.txt"))); + await panel.getByRole("button", { name: "历史", exact: true }).click(); + await panel.getByRole("button", { name: /GUI reviewed commit/ }).click(); + await panel.locator(".gp-patch").waitFor(); + assert.match(await panel.locator(".gp-patch").innerText(), /working/); + await panel.getByRole("button", { name: /^文件/ }).click(); + const anonymous = await request.newContext(); + try { + assert.equal( + ( + await anonymous.post(endpoint("git-panel/prepare"), { + data: { sessionId: sid, action: "stage", paths: ["中文 space.txt"] }, + }) + ).status(), + 401, + ); + } finally { + await anonymous.dispose(); + } + assert.equal( + ( + await context.request.post(endpoint("git-panel/prepare"), { + headers: { Origin: "https://evil.invalid" }, + data: { sessionId: sid, action: "stage", paths: ["中文 space.txt"] }, + }) + ).status(), + 403, + ); + assert.equal( + ( + await context.request.post(endpoint("git-panel/prepare"), { + data: { sessionId: sid, action: "stage", paths: ["中文 space.txt"] }, + }) + ).status(), + 403, + ); + for (let i = 0; i < 2; i++) { + await rpc("pluginManager/setBundleEnabled", { + name: "dsh-git-plugin", + enabled: false, + }); + await page.waitForFunction( + () => !document.querySelector('style[data-plugin="dsh-git-plugin"]'), + ); + assert.equal( + ( + await context.request.get( + endpoint("git-panel/operations") + "?sessionId=" + sid, + ) + ).status(), + 404, + ); + await rpc("pluginManager/setBundleEnabled", { + name: "dsh-git-plugin", + enabled: true, + }); + await page.waitForFunction( + () => + document.querySelectorAll('style[data-plugin="dsh-git-plugin"]') + .length === 1, + ); + } + if (!(await panel.isVisible())) + await page.getByRole("button", { name: /^Git/ }).click(); + await panel.waitFor(); + await panel.locator('.gp-repo strong').filter({hasText:'actions-repo'}).waitFor(); + await panel.locator('.gp-note').filter({hasText:'正在读取 Git…'}).waitFor({state:'hidden'}); + const details=panel.locator('.gp-operation-details'); + if(await details.getAttribute('open')===null)await details.locator('summary').click(); + await page.emulateMedia({ colorScheme: "dark" }); + await page.setViewportSize({ width: 800, height: 900 }); + if (opts.get("--screenshots")) { + fs.mkdirSync(opts.get("--screenshots"), { recursive: true }); + await panel.screenshot({ + path: path.join(opts.get("--screenshots"), "git-actions-dark-narrow.png"), + }); + } + assert.deepEqual(errors, []); + console.log( + `PASS: installed GUI stage/unstage/commit/branches/stash/backup restore, read regression, confirmation cancel, carrier boundaries, two lifecycle cycles; browser ${browser.version()}`, + ); +} catch (error) { + if (debugPage && opts.get("--screenshots")) { + fs.mkdirSync(opts.get("--screenshots"), { recursive: true }); + fs.writeFileSync( + path.join(opts.get("--screenshots"), "failure.txt"), + await debugPage.locator("body").innerText(), + ); + await debugPage.screenshot({ + path: path.join(opts.get("--screenshots"), "failure.png"), + }); + } + console.error( + String(error.message).replace(/https?:\/\/[^\s]+/g, "[URL redacted]"), + ); + process.exitCode = 1; +} finally { + if (browser) await browser.close(); +} diff --git a/src/client/actions.tsx b/src/client/actions.tsx new file mode 100644 index 0000000..378d9cd --- /dev/null +++ b/src/client/actions.tsx @@ -0,0 +1,416 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import type { + ActionInput, + ActionPreview, + ActionResult, + FileState, + OperationsView, +} from "../panel-types.js"; +interface Props { + sessionId: string; + signal: AbortSignal; + revision: number; + files: FileState[]; + selected?: string; + onChange: () => void; +} +export function Actions({ + sessionId, + signal, + revision, + files, + selected, + onChange, +}: Props): ReactNode { + const [preview, setPreview] = useState(null); + const [busy, setBusy] = useState(false), + [notice, setNotice] = useState(""); + const [message, setMessage] = useState(""), + [branch, setBranch] = useState(""), + [stashMessage, setStashMessage] = useState(""); + const [operations, setOperations] = useState({ + branches: [], + stashes: [], + }); + const controller = useRef(null), + opener = useRef(null); + const generation = useRef(0); + useEffect(()=>setNotice(""),[sessionId]); + function cancel() { + generation.current++; + controller.current?.abort(); + setPreview(null); + setBusy(false); + opener.current?.focus(); + } + useEffect(() => { + generation.current++; + controller.current?.abort(); + setPreview(null); + setBusy(false); + setMessage(""); + setBranch(""); + setStashMessage(""); + const abort = new AbortController(); + const requestSignal = AbortSignal.any([abort.signal, signal]); + setOperations({ branches: [], stashes: [] }); + const url = new URL( + "api/git-panel/operations", + new URL("./", document.baseURI), + ); + url.search = new URLSearchParams({ sessionId }).toString(); + void fetch(url, { + signal: requestSignal, + credentials: "same-origin", + cache: "no-store", + }) + .then(async (response) => { + const data = (await response.json()) as OperationsView & { + error?: { message: string }; + }; + if (!response.ok) + throw new Error(data.error?.message ?? "读取分支失败。"); + if (!requestSignal.aborted) + setOperations({ + branches: data.branches ?? [], + stashes: data.stashes ?? [], + }); + }) + .catch((error: unknown) => { + if (!requestSignal.aborted) + setNotice(error instanceof Error ? error.message : "读取失败。"); + }); + return () => { + abort.abort(); + generation.current++; + controller.current?.abort(); + }; + }, [sessionId, signal, revision]); + async function request( + route: string, + body: unknown, + requestSignal: AbortSignal, + ): Promise { + const url = new URL( + `api/git-panel/${route}`, + new URL("./", document.baseURI), + ); + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + credentials: "same-origin", + cache: "no-store", + signal: requestSignal, + }); + const result = (await response.json()) as T & { + error?: { message: string }; + }; + if (!response.ok) + throw new Error(result.error?.message ?? `操作失败 (${response.status})`); + return result; + } + async function prepare(input: Omit) { + controller.current?.abort(); + const abort = new AbortController(); + controller.current = abort; + const version = ++generation.current, + requestSignal = AbortSignal.any([abort.signal, signal]); + opener.current = + document.activeElement instanceof document.defaultView!.HTMLElement + ? document.activeElement + : null; + setBusy(true); + setNotice(""); + setPreview(null); + try { + const result = await request( + "prepare", + { sessionId, ...input }, + requestSignal, + ); + if (!requestSignal.aborted && version === generation.current) + setPreview(result); + } catch (error) { + if (!requestSignal.aborted && version === generation.current) + setNotice(error instanceof Error ? error.message : "预览失败。"); + } finally { + if (!requestSignal.aborted && version === generation.current) + setBusy(false); + } + } + async function execute() { + if (!preview || busy) return; + const abort = new AbortController(); + controller.current = abort; + const version = ++generation.current, + requestSignal = AbortSignal.any([abort.signal, signal]); + setBusy(true); + try { + const result = await request( + "execute", + { sessionId, token: preview.token, confirm: true }, + requestSignal, + ); + if (!requestSignal.aborted && version === generation.current) { + setPreview(null); + onChange(); + setNotice( + result.message + (result.backup ? ` 备份:${result.backup}` : ""), + ); + } + } catch (error) { + if (!requestSignal.aborted && version === generation.current) { + setPreview(null); + onChange(); + setNotice( + (error instanceof Error ? error.message : "操作失败。") + + " 请核对当前状态后重新预览;不要重复执行。", + ); + } + } finally { + if (!requestSignal.aborted && version === generation.current) + setBusy(false); + } + } + const unstaged = files + .filter((f) => !f.conflict && (f.untracked || f.worktree !== " ")) + .map((f) => f.path); + const staged = files + .filter((f) => !f.conflict && !f.untracked && f.index !== " ") + .map((f) => f.path); + const chosen = files.find((f) => f.path === selected); + return ( +
+
+ + +
+ {chosen && ( +
+ + {chosen.path} + + + {!chosen.untracked && chosen.index !== " " && ( + + )} + {!chosen.untracked && !chosen.conflict && chosen.worktree !== " " && ( + + )} +
+ )} +
{ + event.preventDefault(); + void prepare({ action: "commit", message }); + }} + > +