From dc7263860ba8bfbe6d5e8fb4b5c1e9fb80f635a4 Mon Sep 17 00:00:00 2001 From: MashedPotato817 Date: Fri, 2 Oct 2026 14:13:42 +0800 Subject: [PATCH 1/9] =?UTF-8?q?docs(web):=20=E5=9B=BA=E5=AE=9A=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=20Git=20=E6=93=8D=E4=BD=9C=E9=9D=A2=E6=9D=BF=E4=B8=8E?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 ++--- AGENTS_ZH.md | 6 ++++- .../plans/2026-10-02-git-actions.md | 9 +++++++ .../specs/2026-10-02-git-actions-design.md | 25 +++++++++++++++++++ 4 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-02-git-actions.md create mode 100644 docs/superpowers/specs/2026-10-02-git-actions-design.md diff --git a/AGENTS.md b/AGENTS.md index 429c4d1..ed08600 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,7 +52,7 @@ 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. +- P3 / current authorized work: complete stage/unstage, staged-only commit, branches, stash and backup restore. 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 is write approval/recovery design. 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. diff --git a/AGENTS_ZH.md b/AGENTS_ZH.md index 1a3cdc9..98ef6ad 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。 @@ -83,3 +83,7 @@ - 贡献指南见 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,不把只读首期当作完整交付。 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..6706bd5 --- /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; rename includes its 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. From 8b9efc1d0ea8642299b1d814f1a06954f811e466 Mon Sep 17 00:00:00 2001 From: MashedPotato817 Date: Fri, 2 Oct 2026 14:35:55 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(web):=20=E6=B7=BB=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E7=9A=84=20Git=20=E6=93=8D=E4=BD=9C=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/client.js | 510 ++++++++++++++++++++++++--- lib/index.js | 3 +- lib/panel-types.d.ts | 38 ++ lib/web-actions.d.ts | 15 + lib/web-actions.js | 505 ++++++++++++++++++++++++++ src/client/actions.tsx | 415 ++++++++++++++++++++++ src/client/index.tsx | 6 +- src/client/panel.css | 443 ++++++++++++++--------- src/index.ts | 3 +- src/panel-types.ts | 100 ++++-- src/web-actions.ts | 643 ++++++++++++++++++++++++++++++++++ test/client.test.js | 382 +++++++++++--------- test/panel.test.js | 144 +------- test/support/panel-fixture.js | 161 +++++++++ test/web-actions.test.js | 330 +++++++++++++++++ 15 files changed, 3124 insertions(+), 574 deletions(-) create mode 100644 lib/web-actions.d.ts create mode 100644 lib/web-actions.js create mode 100644 src/client/actions.tsx create mode 100644 src/web-actions.ts create mode 100644 test/support/panel-fixture.js create mode 100644 test/web-actions.test.js diff --git a/lib/client.js b/lib/client.js index dd3b554..1f7fa9b 100644 --- a/lib/client.js +++ b/lib/client.js @@ -28,13 +28,403 @@ __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); + 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 +437,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 +483,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 +500,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 +562,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 +583,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 +593,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 +607,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 +625,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 +637,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 +670,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 +685,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" && status.value && /* @__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 +703,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 +733,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..dfb6a30 --- /dev/null +++ b/lib/web-actions.js @@ -0,0 +1,505 @@ +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) { + if (request.headers.get("origin") !== new URL(request.url).origin) + 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 }; +} +/** 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; + for (const name of input.paths ?? []) + 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" || input.paths?.includes(f.path))); + let remaining = caps.maxBytes; + 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(f.path).update(bytes); + } + return { + hash: hash.digest("hex"), + 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 已变化,请刷新。"); + } + function presentation(input, state, root, token, expires) { + const descriptions = { + stage: "将选定文件的当前内容加入暂存区。", + unstage: "取消选定文件的暂存,工作区内容保留。", + commit: "仅提交当前暂存区;未暂存和未跟踪内容不会自动加入。配置的 preCommit 与 Git 原生钩子仍会执行。", + "branch-create": "创建并切换到新分支,保留当前改动。", + "branch-switch": "切换到选定本地分支;不使用强制覆盖。", + "stash-save": "保存已跟踪和未跟踪改动到 stash;子模块内部改动需要分别处理。", + "stash-apply": "应用 stash 并恢复暂存状态,保留原 stash;冲突时请在文件中解决。", + "stash-drop": "删除该 stash;此操作不可通过面板直接撤销。", + restore: "先把所有已跟踪改动备份到保留的 stash,再将选定文件工作区还原为暂存区内容;未跟踪文件保留。", + }; + return { + token, + expiresAt: new Date(expires).toISOString(), + root, + branch: state.branch, + input, + description: descriptions[input.action], + preview: input.action === "commit" + ? state.index + : [state.index, state.worktree].filter(Boolean).join("\n"), + paths: input.paths ?? + 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 = input.paths?.flatMap((p) => { + const f = state.files.find((f) => f.path === p); + return f?.oldPath ? [p, f.oldPath] : [p]; + }) ?? []; + 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(presentation(input, state, root, token, expires), { 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/src/client/actions.tsx b/src/client/actions.tsx new file mode 100644 index 0000000..f532732 --- /dev/null +++ b/src/client/actions.tsx @@ -0,0 +1,415 @@ +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); + 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 }); + }} + > +