diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e998fb7..c1a7060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,8 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm compat:check - run: pnpm test + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:e2e - run: pnpm docs:check - run: pnpm build - run: pnpm test:artifact diff --git a/.gitignore b/.gitignore index 17cd9b9..6d9703d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules/ .pnpm-store/ .zcode/ .cursor/ -.tmp/ \ No newline at end of file +.tmp/ +test-results/ +playwright-report/ diff --git a/.pnpmfile.cjs b/.pnpmfile.cjs index c49df01..613c50c 100644 --- a/.pnpmfile.cjs +++ b/.pnpmfile.cjs @@ -14,6 +14,10 @@ const { join } = require('node:path') const { developmentPin, pinDshDependencies } = require('./scripts/dsh-release-wave.cjs') const root = __dirname +const compatibilitySource = readFileSync(join(root, 'src/compat/dsh-version.ts'), 'utf8') +const releases = name => [...compatibilitySource.match(new RegExp('export const ' + name + ' = \\[([^\\]]*)\\]'))[1].matchAll(/'([^']+)'/g)].map(item => item[1]) +const supportedReleases = releases('SUPPORTED_DSH_RELEASES') +const credentialReleases = releases('CREDENTIALS_SEAM_DSH_RELEASES') const rootManifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) function targetVersion() { @@ -29,7 +33,13 @@ function isRootPackage(pkg, context) { } function readPackage(pkg, context) { - return pinDshDependencies(pkg, targetVersion(), { + const version = targetVersion() + if (isRootPackage(pkg, context) && supportedReleases.includes(version) && !credentialReleases.includes(version)) { + // pnpm installs a root peer as a direct edge even when optional. Omit only + // this development-lane edge; the published manifest remains unchanged. + if (pkg.peerDependencies) delete pkg.peerDependencies['@deepseek-ai/dsh-credentials'] + } + return pinDshDependencies(pkg, version, { skipPeerDependencies: isRootPackage(pkg, context), }) } diff --git a/AGENTS.md b/AGENTS.md index 72e8af1..34e61da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ The Host core depends only on standard DSH services and launches `git` through ` The Client main fiber contains every portable contribution. Native enhancement belongs in a child `ctx.inject(['desktop'], ...)` fiber and declares only the structural methods it consumes. Provider unload must remove the enhancement without removing the core UI. -Use machine-readable Git output and temporary repositories in tests. Keep GitHub and remote credential workflows outside this package until a separate decision adds them. +Use machine-readable Git output and temporary repositories in tests. GitHub and remote credential workflows are admitted by [ADR-0004](docs/decisions/ADR-0004-remote-platform-integration.md) (status Proposed) and land stage by stage per `docs/plans/active/remote-platform-integration.md`; anything not yet landed stays fail-closed behind `git/remote-unavailable`, and all portability constraints above still apply. # 文档系统总纲 diff --git a/CHANGELOG.i18n.yaml b/CHANGELOG.i18n.yaml index 59c79df..9b68262 100644 --- a/CHANGELOG.i18n.yaml +++ b/CHANGELOG.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # git hash-object CHANGELOG.md CHANGELOG.zh.md -CHANGELOG.md: 4dfd36eb3efab306ccc4bd293c12cf50bf6d944f -CHANGELOG.zh.md: f838b855a265f924b5c76f294565339975d635c1 +CHANGELOG.md: 3adb8ff5532e152ef5c541fdce70108ec58c8958 +CHANGELOG.zh.md: 0be125c1508cd68d896508a633f9b65af8ae1025 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dfd36e..3adb8ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ English | [中文](CHANGELOG.zh.md) All notable changes to this package are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — the package is pre-1.0, so a minor release may still change the API. Each published version also has a [GitHub Release](https://github.com/cherrchen/dsh-plugin-git/releases) carrying its tarball. +## [Unreleased] + +### Changed + +- GitHub Remote PR/Issue lists, detail comments, and comparison files no longer stop at a fixed pagination budget. `RemotePage` carries an opaque `next` cursor (the origin-checked `Link rel="next"`), and `remote-list-prs`, `remote-list-issues`, `remote-comments`, and `remote-files` accept `cursor`/`pages` request fields. The Client controller appends items in a serial background stream — a multi-page first request, then one page per continuation — deduplicating by number/comment id/file path, honoring generation checks, and recovering once at `resetAt` after a rate limit. The truncated banner is removed in favor of a streaming hint. +- `remote-files` continuations pin the comparison snapshot: `expectedBaseSha`/`expectedHeadSha` are verified on every continuation page, so inline-comment coordinates never drift between pages. Inline comment submission stays disabled until all files are loaded, and the bounded collections used for notification expansion and write-path coordinate validation are unchanged. +- The PR detail page no longer renders local file diffs. New `remote-commits`/`remote-reviews` endpoints project `RemoteCommit`/`RemoteReview` (review `state` normalized to approved/changes-requested/commented/dismissed/pending) and stream through the same cursor mechanism, while file changes open the GitHub Files page via a `pr.url + '/files'` link. Inline comments are read-only and nested under their owning review through `reviewId`; the inline-comment composer button is removed since there is no anchor picker. `remote-files` and `remote-inline-comment` remain guarded RPC capabilities. The detail surface also stops retrying a failed load/open in a tight loop — each attempt is keyed once per target and revision. + +- The PR/Issue detail tab is now a GitHub-style chronological timeline. `RemoteIssue`/`RemoteComment` carry `createdAt` and `RemoteCommit` carries `committedAt`, and a new `RemoteTimeline` interleaves comments, commits, reviews and inline threads by time — empty per-reply reviews are hidden and orphan threads stand alone. The opening post renders as the first card in the same gutter alignment, and the detail tab moved to 20px horizontal padding. +- GitHub bodies render readably instead of leaking raw markup: a pure `github-markdown` transform projects the HTML subset GitHub allows (details/summary, comments, images, links, emphasis, code, pre, lists, tables, blockquote alerts) into Markdown before `MarkdownText`, and `RemoteMarkdown` renders `
` folds as native disclosures without ever touching `innerHTML`. +- The platform-authentication dialog was redesigned around a status card plus "Ways to connect" method rows (GitHub account, credentials already on this computer, Git credential helper), each explaining what it does and tagging the method in use. `authAction` accepts an endpoint sequence executed in order inside one busy run; Local/Git credential reconnects prepend `remote-clear-grant` whenever a stored grant or signed-out/invalid marker would override the choice, so Disconnect (persisted signed-out) vs clearing the local grant is expressed through the flow instead of five opaque buttons. + +- Follow-up polish on the same surfaces: timeline and auth-dialog markers now render real GitHub avatars — `author.avatarUrl`/viewer `avatarUrl` are projected only from `https://avatars.githubusercontent.com` via a dedicated `avatarUrl()` gate — with an opaque initial-letter fallback that keeps the timeline gutter hidden behind them. The authentication dialog widened to 440px and simplified into a status card plus a GitHub CLI / GitHub OAuth radio group whose checked state derives purely from capability; the git-credential endpoint remains available behind `remote-use-git-credential`. + ## [0.2.7] — 2026-10-17 ### Changed diff --git a/CHANGELOG.zh.md b/CHANGELOG.zh.md index f838b85..0be125c 100644 --- a/CHANGELOG.zh.md +++ b/CHANGELOG.zh.md @@ -4,6 +4,20 @@ 本文件记录本 package 的所有重要变更。格式参照 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html) —— 本包处于 pre-1.0,minor 版本之间仍可能改变 API。每个已发布版本同时有对应的 [GitHub Release](https://github.com/cherrchen/dsh-plugin-git/releases),附该版本的 tarball。 +## [Unreleased] + +### Changed + +- GitHub Remote 的 PR/Issue 列表、详情评论与比较文件不再受固定分页预算截断。`RemotePage` 携带不透明 `next` 续页游标(经 origin 校验的 `Link rel="next"`),`remote-list-prs`、`remote-list-issues`、`remote-comments`、`remote-files` 接受 `cursor`/`pages` 请求字段。Client controller 以串行后台流追加条目——首请求多页、之后每次续取一页——按 number/评论 id/文件路径去重,逐页校验 generation,并在限流后按 `resetAt` 有界恢复一次。truncated 警示条移除,改为流式提示。 +- `remote-files` 续页钉住比较快照:每次续取校验 `expectedBaseSha`/`expectedHeadSha`,行内评论坐标不会跨页漂移。行内评论提交在文件全部加载完成前保持禁用;通知线程展开与写路径坐标校验的有界收集保持不变。 +- PR 详情页不再渲染本地文件 diff。新增 `remote-commits`/`remote-reviews` 端点,投影 `RemoteCommit`/`RemoteReview` 模型(review `state` 归一为 approved/changes-requested/commented/dismissed/pending)并沿用同一游标流式加载;文件改动经 `pr.url + '/files'` 链接跳转 GitHub Files 页查看。行内评论只读、按 `reviewId` 嵌套在对应 review 下展示;因无锚点选择器,行内评论提交按钮移除。`remote-files` 与 `remote-inline-comment` 保留为受守护 RPC 能力。详情页同时修复了 load/open 失败后紧密循环重试的问题——每个目标与 revision 只尝试一次。 + +- PR/Issue 详情页改为 GitHub 风格的时间线。`RemoteIssue`/`RemoteComment` 携带 `createdAt`,`RemoteCommit` 携带 `committedAt`;新增 `RemoteTimeline` 按时间交错排列评论、提交、review 与行内 thread——空的 per-reply review 隐藏、孤儿 thread 独立成项。正文作为开场卡与后续项同槽对齐,详情页改用 20px 横向内边距。 +- GitHub 正文不再泄漏原始标签:纯模块 `github-markdown` 先把 GitHub 允许的 HTML 子集(details/summary、注释、图片、链接、强调、code、pre、列表、表格、alert 引用)投影为 Markdown 再交给 `MarkdownText`,`RemoteMarkdown` 用原生 `
` 渲染折叠块,全程不接触 `innerHTML`。 +- 平台认证弹窗重做为状态卡 + “连接方式”方法行(GitHub 账号登录、本机已有的凭据、Git 凭据助手),每种方式附说明并对当前生效的方法标“当前使用”。`authAction` 接受端点序列、在同一 busy 周期内按序执行、首个失败即停;当已有 grant 或 signed-out/invalid marker 会覆盖所选方式时,Local/Git credential 重连自动前置 `remote-clear-grant`,用流程表达“断开连接(持久 signed-out)”与“清除本地授权”的区别,取代原先五个含义不清的按钮。 + +- 同一批界面的后续打磨:时间线与认证弹窗标记改为真实 GitHub 头像——`author.avatarUrl` 与 viewer `avatarUrl` 仅经专用 `avatarUrl()` 准入 `https://avatars.githubusercontent.com`——失败或缺失时回退为首字母圆点,并保持不透明以遮住时间线竖线。认证弹窗加宽至 440px,并简化为状态卡加 GitHub CLI / GitHub OAuth 单选组,选中态只由 capability 派生;Git 凭据端点仍保留在 `remote-use-git-credential` 之后。 + ## [0.2.7] — 2026-10-17 ### Changed diff --git a/README.i18n.yaml b/README.i18n.yaml index fb0a3c2..8edbb5b 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # git hash-object README.md README.zh.md -README.md: cbf236227463aef4ae92fb9e9a1194ddff67bb2f -README.zh.md: 342e9f3a5809c87a25339c4290b13a58b9504c2e +README.md: 47c4b50a549578ade8a81d0d225835002587fb14 +README.zh.md: 3cfa9d1db8f2283fc3e3c58f0ab6e3151bc382e0 diff --git a/README.md b/README.md index cbf2362..47c4b50 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Git joins the tab-type system through the upstream right sidebar's standard two- Dynamic client plugins may not import runtime values from another plugin package — only `import type` — so the type references erase completely from the bundle and no `external` entry is needed. -**Stage one** — `ctx.sidebarRightTabs.register(definition)` registers three tab types: `git.changes` and `git.graph` are page types (opened by kind, one tab per pane) that each contribute one guide entry (Changes `order: 10`, Graph `order: 11`), replacing the old Details launcher cards on the guide page; `git.diff` is a resource type with pattern `dsh-resource://git/diff/**`, a `canOpen` that validates the address decodes, and a title taken from the decoded file name. +**Stage one** — `ctx.sidebarRightTabs.register(definition)` registers three tab types: `git.changes` and `git.graph` are page types (opened by kind, one tab per pane) that each contribute one guide entry (Changes `order: 11`, Graph `order: 12`), replacing the old Details launcher cards on the guide page; `git.diff` is a resource type with pattern `dsh-resource://git/diff/**`, a `canOpen` that validates the address decodes, and a title taken from the decoded file name. Optional Worktrees and Remote entries use orders 13 and 14, keeping all Git entries between Workspace files (10) and New terminal (20). Graph uses commit-lane artwork; Remote uses a cloud with upload/download arrows. **Stage two** — each type's body component injects the `sidebar.right.pane.tab` seat under the definition's `id`; bodies read `{ address, params, revision }` from `useTabInfo().tab.navigation`. @@ -210,7 +210,7 @@ Commit history is read with a paged `git log` (`GIT_LOG_FORMAT`, one commit per Commit message generation is opt-in at the host: it needs an LLM runtime and a resolvable model (the session default, or a custom `provider`/`model`). A staged diff (capped by `commitMessage.maxDiffBytes`) is sent to that route and the streamed suggestion is written into the editable commit message input. The system prompt defaults to a Conventional Commit instruction and can be overridden from Plugin configuration. Generation is suggestion-only — it never stages, commits, or pushes anything. -GitHub authentication, hosting-provider workflows, credential prompts, issues, pull requests, stash, cherry-pick, and merge-conflict editing remain outside this package. Push and sync invoke `git push` / `git pull --rebase` as separate argv values and surface Git's own errors when remotes or credentials are missing. +GitHub Remote is available when the host provides the complete optional credentials seam: authentication, PR/Issue browsing, comparisons, PR comments/reviews, verified checkout, Issue branches, and guarded create/close/merge. See the [remote RPC contract](docs/reference/remote-platform-rpc.en.md) for configuration, limits, credential handling and unknown-result recovery, and the [active plan](docs/plans/active/remote-platform-integration.en.md) for acceptance evidence and unverified external scenarios. Git fetch/push uses existing Git transport credentials, independently of platform login. Stash, cherry-pick and merge-conflict editing remain outside this package. ## npm publication @@ -240,7 +240,7 @@ None. Commit-message generation is an independent Host LLM request and does not ## Known Limitations and Deferred Work -- **No credential UI** — push and sync call Git with no prompt for remotes or credentials; a missing `origin` or rejected auth fails as a Git command error. +- **Independent Git transport** — Remote platform login does not configure Git fetch/push credentials; existing helpers or SSH remain responsible for Git authentication. - **Bounded command output** — a diff larger than `maxOutputBytes` retains only the subprocess collector's tail, so deployments handling very large diffs must raise that validated setting. - **Generation needs host + model** — commit message generation requires a host LLM runtime and a resolvable model (session default or a custom route in Plugin configuration); without either, the Generate action stays disabled or reports `git/generation-unavailable`. - **Worktree entry needs a live directory service** — without the host working-directory service, or without a live agent for that session, the checkout list stays read-only. Writes then keep the previous caller-supplied repository contract. A missing directory is not repaired here; recovery belongs to the host working-directory service. diff --git a/README.zh.md b/README.zh.md index 342e9f3..3cfa9d1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -133,7 +133,7 @@ Git 通过上游右侧边栏的标准两阶段注册接入 tab-type 体系。Cli 动态 client plugin 不允许 import 其他 plugin 包的运行时值,只允许 `import type`;因此类型引用在 bundle 中被完全擦除,`external` 不再需要。 -**阶段一**——`ctx.sidebarRightTabs.register(definition)` 注册三个 tab type:`git.changes` 与 `git.graph` 是 page type(按 kind 打开,每个 pane 单例),并各贡献一个 guide 条目(Changes `order: 10`、Graph `order: 11`),在 guide 页替代旧 Details Launcher 卡片;`git.diff` 是 resource type,pattern `dsh-resource://git/diff/**`,`canOpen` 校验地址可解码,tab 标题取解码出的文件名。 +**阶段一**——`ctx.sidebarRightTabs.register(definition)` 注册三个 tab type:`git.changes` 与 `git.graph` 是 page type(按 kind 打开,每个 pane 单例),并各贡献一个 guide 条目(Changes `order: 11`、Graph `order: 12`),在 guide 页替代旧 Details Launcher 卡片;`git.diff` 是 resource type,pattern `dsh-resource://git/diff/**`,`canOpen` 校验地址可解码,tab 标题取解码出的文件名。可选 Worktrees 与 Remote 条目分别使用顺序 13、14,使所有 Git 入口统一排在“工作区文件”(10)与“新建终端”(20)之间。Graph 使用提交轨道图标;Remote 使用云端与上传/下载箭头图标。 **阶段二**——每个 tab type 的 body 组件注入 `sidebar.right.pane.tab` 座(key 即 definition 的 `id`),body 通过 `useTabInfo().tab.navigation` 读取 `{ address, params, revision }`。 @@ -210,7 +210,7 @@ Discard 通过明确的 index、working tree 与 clean 操作还原一条 staged Commit message 生成在宿主侧按需启用:需要 LLM runtime 与可解析的模型(会话默认,或自定义 `provider`/`model`)。staged diff(受 `commitMessage.maxDiffBytes` 上限约束)会发送到该路由,流式生成的建议写入可编辑的 commit message 输入框。System prompt 默认为 Conventional Commit 说明,可在插件配置中覆盖。生成只提供建议 —— 它绝不 stage、commit 或 push 任何内容。 -GitHub authentication、hosting-provider workflows、credential prompts、issues、pull requests、stash、cherry-pick 与 merge-conflict editing 不属于本 package。Push 与 sync 以独立 argv values 调用 `git push`/`git pull --rebase`,在缺少 remote 或 credentials 时展示 Git 自身的错误。 +宿主提供完整可选 credentials seam 时,Remote 提供认证、PR/Issue 浏览、比较、PR 评论/review、核验 checkout、Issue 分支及受保护 create/close/merge。配置、限制、凭据处理与未知结果恢复以[远程 RPC 契约](docs/reference/remote-platform-rpc.md)为准,验收证据及尚未验证的外部场景见[进行中计划](docs/plans/active/remote-platform-integration.md)。Git fetch/push 独立使用现有 Git 传输凭据。Stash、cherry-pick 与 merge-conflict 编辑仍不在本包范围。 ## npm 发布 @@ -240,7 +240,7 @@ pnpm pack ## Known Limitations and Deferred Work -- **无 credential UI** — push 与 sync 调用 Git 时不提示 remote 或 credentials;缺少 `origin` 或认证被拒时以 Git command error 失败。 +- **独立 Git 传输**——Remote 平台登录不配置 Git fetch/push 凭据;Git 认证仍由现有 helper 或 SSH 负责。 - **Command output 有界** — 大于 `maxOutputBytes` 的 diff 只保留 subprocess collector tail;处理超大 diff 的 deployment 必须提高这一 validated setting。 - **生成依赖 host 与模型** — commit message 生成需要 host LLM runtime 与可解析的模型(会话默认或插件配置中的自定义路由);两者缺其一时,Generate 动作保持禁用或报告 `git/generation-unavailable`。 - **进入工作树需要活的目录服务** — 没有宿主工作目录服务,或该会话没有活的 agent 时,检出列表只读。写操作此时仍沿用原先由调用方提供仓库路径的契约。目录消失后的恢复不属于本包,由宿主工作目录服务负责。 diff --git a/docs/architecture/overview.en.md b/docs/architecture/overview.en.md new file mode 100644 index 0000000..7ece306 --- /dev/null +++ b/docs/architecture/overview.en.md @@ -0,0 +1,55 @@ +[中文](overview.md) | English + +# Current architecture overview + +- **Status**: Current (verifiable against the repository and tests) + +## Components + +```text +Host (Cordis plugin, src/index.ts + src/service.ts) + requires ctx.subprocess; provides ctx.git + git executable + separate argv; never a shell + optional Connection child fiber: loopback /git RPC + | + | status / diff / log / stage / commit / branch ... + | +Client (src/client/) + main fiber: every portable contribution + composer branch picker and changed-file indicator through slots + permanent sidebar tab types: git.changes / git.diff / git.graph + capability-dependent git.worktrees + git-commit-message and git-worktrees settings cards + (compatibility: individual cards or combined bundle details) + advisory commit-message generation using the Host LLM runtime + ctx.inject(['desktop']) child: reveal / openPath / notifications +``` + +## Structural facts + +- **Host service**: Launches Git through `ctx.subprocess`, using an executable name or absolute path and separate argv values. Each command runs in the caller's repository, or the checkout containing the session's effective directory when the Host supplies directory services and the session has a live agent. It never uses process-wide `process.cwd()`. Status uses porcelain v2 with NUL-delimited paths; branches use `for-each-ref`; history uses paginated `git log` with the fixed-field `GIT_LOG_FORMAT` for progressive Graph loading. Checkout kind and `git worktree list --porcelain -z` results accompany status. NUL-delimited paths retain leading and trailing whitespace; older Git falls back to newline output. Discard uses explicit index/worktree/clean operations and requires two-step Client confirmation. +- **Session directory**: The Client uses a nonempty projected `workingDirectory`, otherwise the session header's `cwd`. It consumes the existing Host projection without a separate directory database. Directory changes cancel old reads and unsent writes. An already-sent worktree creation waits independently for its receipt so its own directory projection cannot cancel the successful result; controller disposal still cancels it. Other in-flight Git RPCs follow binding cancellation. Host writes also verify that the requested repository matches the effective directory's checkout; mismatches fail rather than applying results to another checkout. Each session has its own controller. +- **Worktree boundary**: The plugin discovers every checkout of a repository regardless of origin. With `workingDirectory.set` and a live session agent, the user can move the current session into another listed checkout. The `git.worktrees` sidebar type follows `worktree-capability`: `upstream` delegates creation to the experimental Host `ctx.worktrees.create(agent, ...)`, which changes the session directory on success; the UI follows the projection. Without that runtime, enabled `git-worktrees` settings select `local`: `GitService.createWorktree` runs `git worktree add -b`, defaulting to `/.agents/worktrees/`, then enters through session directory services. Optional `ctx.fs` only checks occupancy and maintains the pool's `.gitignore` marker. Otherwise mode is `off` and the type unregisters. Capability is read on mount and every five seconds; settings saves refresh immediately, with one trailing poll after an in-flight read. Registrations and session controllers share the capability store, allowing mode/provider changes without rebinding. Unload stops timers and subscriptions. Absolute pool paths, including Windows UNC/root paths, are preserved; relative paths resolve against the repository. Upstream probing is structural (`create` exists), with no experimental-package dependency. Deletion/pruning are outside scope. Directory changes and creation serialize, keeping entry buttons disabled while queued work remains. +- **RPC**: When the DSH Web Host exists, a Connection child fiber registers the loopback `/git` channel. Stale mutation results are discarded; filenames use literal pathspecs. +- **Client refresh merging**: `GitClientController.refresh()` shares in-flight work for the same path and `operationGeneration`, releasing the slot in `finally`. A request arriving before status starts shares that read. A request arriving after status starts schedules one trailing read; merged callers await it even if the first read failed. A burst contains at most two rounds, preventing feedback from Windows console focus events generated by the controller's own Git commands. Composer `window` focus handlers therefore avoid stacked discover/status rounds and mutually superseding results. See [troubleshooting](../troubleshooting/windows-desktop-console-flood.md). +- **Client tab caching**: Changes/Worktrees no longer force an extra refresh on mount. Surfaces share `setWorkspace`, reusing session snapshots for five seconds and retaining content while refreshing on a stale return. Explicit refresh and mutations bypass freshness gates. Graph retains loaded history until repository refresh or mutation invalidates it. Each path/side Diff caches for at most five seconds, with at most 32 entries per session. Repository snapshot replacement, workspace rebinding and `navigation.revision` changes invalidate the relevant result; errors are not cached. Remote views have a longer freshness window and conditional page updates; their sole policy reference is the [remote-platform RPC contract](../reference/remote-platform-rpc.en.md#client-caching-and-incremental-validation-current). All caches live in the current Client memory and do not survive restart. +- **Right sidebar integration**: The Client manifest declares `ctx.sidebarRight` / `ctx.sidebarRightTabs` through `inject`. Sidebar package imports are type-only: dynamic plugins must not import runtime values from other packages, so erased types require no bundle external. Phase one registers permanent Changes/Graph page types and the Diff resource type (`dsh-resource://git/diff/**`). Worktrees registers/unregisters with capability mode; fiber unload unregisters it. Remote list page and item resource types (`dsh-resource://git-remote////`) follow remote capability on/off. Phase two injects bodies into `sidebar.right.pane.tab`, keyed by definition id. Navigation uses `ctx.sidebarRight.openTab(kind)` and `openResource(address, { params })`. Changes/Graph deduplicate by kind within each pane; Diff and remote items use exact resource addresses. Reopening reveals the existing tab and increments `navigation.revision`; payload types augment `SidebarRightResourceParamsMap`. The upstream sidebar, based on ui-dockkit, owns the tab bar, guide, docking geometry and panel widths. Diff results remain panel-local: `fetchDiff` independently caches each path/staged side and shares in-flight reads, while each surface owns its request/result projection keyed by repository root and tab address. Out-of-order results cannot cross split panes, floating windows or staged/worktree tabs. Changes/Graph/Worktrees register live titles in `sidebar.right.pane.tab.title`; Diff chips use language-independent filenames and remote item chips derive `PR #n`/localized Issue labels from addresses, so neither registers that slot. Open chips track locale changes. Provider unload removes registrations while preserving tab records; upstream shows its unavailable fallback until registration returns. +- **Diff presentation**: `GitDiffSurface` handles fetching, panel state and sidebar adaptation; the official `@deepseek-ai/dsh-client-ui-primitives` `DiffBlock` owns rendering. `git-diff-adapter.ts` uses the existing `diff` package's `parsePatch` to produce old/new text fragments. Display paths use the selected literal filename rather than quoted Git headers. Copy, folding and newer Host newline controls belong to the official component. Worktree/index/HEAD comparisons never become session-turn snapshots. Binary files, metadata-only changes, empty patches and incomplete patches have separate states. Because the component normalizes line endings, the adapter tracks missing terminal newlines and shows a notice only when the two sides differ; both missing, including shared-context markers, is not a difference. Context/label compatibility lives in `src/compat/dsh-diff.ts`; see the [compatibility reference](../reference/dsh-compatibility.md#官方-diffblock-接入). +- **Remote platform (ADR-0004)**: The credentials child fiber in `src/remote/` owns auth/refresh, REST, account caches and cancel/drain. RPCs are available only with a complete seam; missing/incomplete seams turn the whole feature off. Capability performs no network/subprocess work; storage, auth, viewer and permission states remain independent. The Client main fiber dynamically registers the remote list page/body/title and shared PR/Issue resource body. The list offers filters and PR creation; item details, discussion, file comparison and guarded writes open in address-identified detail tabs. Authentication sits in a toolbar-icon dialog. Native notifications are an unloadable enhancement. Checkout uses private refs, SHA verification, tracking CAS and partial receipts; OAuth never enters Git transport. Models/coordinates remain browser-safe, and local structural types support older lanes without the optional seam package. Published optional peers use registry ranges. The [remote-platform RPC contract](../reference/remote-platform-rpc.en.md) is authoritative; stages and outstanding real OAuth/platform/matrix acceptance live in the [plan](../plans/active/remote-platform-integration.en.md). +- **Portable/native split**: The main fiber never requires `desktop`. The optional child consumes only `shell.showItemInFolder`, `shell.openPath` and `notification.show`. Core operations remain available without Desktop; native actions are hidden. +- **Graph layout engine** (`src/client/graph/`): Pure logic with no Git, React or DOM access, independently testable and separate from Canvas rendering. See [Git Graph Layout Engine](../reference/git-graph-layout.md), [ADR-0001](../decisions/ADR-0001-graph-lane-not-owned-by-branch.md) and [ADR-0002](../decisions/ADR-0002-graph-lane-cap-max-three-lanes.md). +- **Commit-message generation**: An independent Host LLM request outside session context, with no KV-cache impact. It requires an LLM runtime and resolvable model; otherwise the Client reports `git/generation-unavailable`. Configuration is canonical in the root README's [Configuration](../../README.md#configuration). +- **Loading/cancellation recovery**: Mounting the same directory does not rebind because refresh changed a result generation. Successful refresh updates session capabilities. The Host caches only successful Git executable discovery; cancellation and lookup failure do not poison subsequent requests. Changes/Graph expose discover/status errors. See [Git blank UI and persistent AbortError](../troubleshooting/git-workspace-abort.md). +- **Settings save**: The bundle page shares one Save button. Sections save sequentially and read the shared ConfigForm's new revision after each completion. Leaving during a save retains the next pending section. Rejected writes/transport errors retain drafts and restore editing/retry. Disabling local mode explicitly writes `false` to override composition defaults. +- **Creation admission**: Upstream Worktrees creation requires current session directory capability, including a live agent, both when opening and submitting the dialog. Local mode can create without a live agent and skip entry. Pool-directory hints appear only in local mode; upstream owns its creation location. + +## Validation + +- `pnpm test`: unit and Client specifications using temporary repositories and machine-readable output. +- `pnpm test:artifact`: bundle/manifest assertions against built `lib/client.js`. +- Graph topology invariants: `tests/graph-layout.client.spec.ts` and `tests/graph-history.client.spec.ts`. + +## Related documents + +- [Requirements](../requirements/scope-and-requirements.md): requirements and constraints met by this architecture. +- [Reference](../reference/README.en.md): stable references including the Graph layout engine. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 6306301..aaf64c6 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -1,3 +1,5 @@ +中文 | [English](overview.en.md) + # 当前架构总览 - **Status**: Current(以下均可用当前仓库与测试验证) @@ -31,8 +33,10 @@ - **Worktree 边界**:本包发现同一仓库的全部检出(来源不限),并在宿主 `workingDirectory.set` 可用且该会话仍有活 agent 时,让用户把**当前这个会话**切到列表中的另一个检出。专用 Worktrees 侧栏 tab(`git.worktrees`)按 `worktree-capability` RPC 上报的模式动态注册:宿主挂载上游实验性 `ctx.worktrees` 时为 `upstream`——创建委托 `ctx.worktrees.create(agent, ...)`,上游成功后自己切换会话目录,Git 界面跟随投影刷新;未挂载且 `git-worktrees` 设置的 `enabled` 为真时为 `local`——`GitService.createWorktree` 用 `git worktree add -b`(检出池默认 `<仓库根>/.agents/worktrees/`,可在设置中改,可选 `ctx.fs` 只为占用检查与池 `.gitignore` 标记),随后经会话目录服务进入新检出;两者皆无时为 `off`,tab 类型随之注销。Client 在插件挂载时及每 5 秒轮询 capability;设置保存后立即请求刷新,在途轮询后补一次刷新以避免旧响应遮蔽设置变化。tab 注册与全部会话控制器共享同一份能力状态,因此启停本地模式或挂载/卸载上游运行时无需重新绑定工作区;插件卸载时停止定时器并解除会话订阅。绝对检出池目录(包括 Windows UNC 与根路径)保持原样,相对路径按仓库根解析。上游运行时的探测是结构性的(服务提供 `create` 方法),不引入实验包依赖。删除与修剪仍不在本包内。同一次目录切换或创建尚未结束时,下一次排队执行,进入按钮保持禁用。 - **RPC**:DSH Web 宿主存在时,Connection 子 fiber 注册 loopback `/git` 通道;过期输出(stale mutation)被丢弃,文件名按字面 pathspec 处理。 - **Client 刷新合并**:`GitClientController.refresh()` 对同一工作区复用在途刷新(复用键为 `path` + `operationGeneration`,只有同 `generation` 才复用;轮次结束在 `finally` 中释放槽位)。复用只在该轮尚未发出 `status` 读取时成立;请求在该轮读取之后到达时,该轮记一次 trailing 读取,合并进来的调用方 await 到这轮 trailing 读取落地,共享轮次失败(瞬时错误)也不例外——请求的依据是「读取已发出」,与那轮成败无关。一个 burst 最多两轮往返(trailing 不再递归链接,避免控制器自身的 Host 流量——Windows 下每条 git 命令弹出的控制台窗口带来的焦点事件——自激成永久循环)。composer 控件的 `window` focus 监听因此不会在焦点抖动时叠加多轮 discover/status,也不会让重叠轮次互相判旧。背景见 [troubleshooting](../troubleshooting/windows-desktop-console-flood.md)。 -- **右侧边栏集成**:Client manifest 以 `inject` 声明运行时依赖 `ctx.sidebarRight` / `ctx.sidebarRightTabs`;对 sidebar 包只有 `import type`(动态 plugin 不得 import 其他包的运行时值),bundle 中类型被擦除,故无 `external`。阶段一向 `ctx.sidebarRightTabs.register` 注册三个常驻 tab type(changes/graph 是 page type,diff 是 resource type,pattern `dsh-resource://git/diff/**`),worktrees page type 则订阅 `worktree-capability` 轮询结果随模式动态注册/注销(fiber 卸载时一并注销);阶段二把四个 body 注入 `sidebar.right.pane.tab` 座(key 为 definition id)。导航统一走 `ctx.sidebarRight.openTab(kind)` 与 `openResource(address, { params })`:changes/graph 由侧栏按 kind 每 pane 去重,diff 以精确地址为 tab 身份,重复打开 reveal 既有 tab 并递增 `navigation.revision`;参数类型通过 `SidebarRightResourceParamsMap` 模块扩充。tab 栏、guide 页、停靠几何与面板宽度由右侧边栏(基于 ui-dockkit)拥有。Diff 结果是面板局部的:controller 只提供无状态的 `fetchDiff`,每个 Diff surface 持有自己的在途请求与结果(按仓库根 + tab 地址为键,乱序完成互相不可见),故分屏、浮动窗口与同文件的 staged/worktree 两个 tab 永不互串正文。changes/graph/worktrees 另在 `sidebar.right.pane.tab.title` 座注册 live title(diff chip 是语言无关的文件名,不注册),已打开的 chip 随语言切换更新,而非冻结在打开时刻的文本。provider 卸载只移除 tab type 注册,保留 tab 记录:上游 body 座转为其 "unavailable" 兜底,重新注册后正文恢复。 +- **Client 页签缓存**:Changes / Worktrees 挂载不再额外强刷;各 surface 共用 `setWorkspace`,同目录 5 秒内直接复用会话快照,过期重入时保留画面并刷新,手动刷新和写操作不受新鲜度限制。Graph 继续保留已取历史,仓库刷新或变更会使其失效。Diff 的每个路径/侧缓存最多保留 5 秒(每会话最多 32 项),仓库快照替换、目录重绑定与 `navigation.revision` 变化使对应结果失效;错误不缓存。远程视图另有更长的新鲜度窗口及按页条件更新,具体策略唯一参考为[远程平台 RPC](../reference/remote-platform-rpc.md#client-缓存与增量校验current)。缓存均为当前 Client 内存,不跨重启持久化。 +- **右侧边栏集成**:Client manifest 以 `inject` 声明运行时依赖 `ctx.sidebarRight` / `ctx.sidebarRightTabs`;对 sidebar 包只有 `import type`(动态 plugin 不得 import 其他包的运行时值),bundle 中类型被擦除,故无 `external`。阶段一向 `ctx.sidebarRightTabs.register` 注册三个常驻 tab type(changes/graph 是 page type,diff 是 resource type,pattern `dsh-resource://git/diff/**`),worktrees page type 则订阅 `worktree-capability` 轮询结果随模式动态注册/注销(fiber 卸载时一并注销);remote 的 `git.remote` page type 与 remote item 的 `git.remote.item` resource type(pattern `dsh-resource://git-remote////`)随 `remote-capability` 的 on/off 同步注册/注销。阶段二把各 body 注入 `sidebar.right.pane.tab` 座(key 为 definition id)。导航统一走 `ctx.sidebarRight.openTab(kind)` 与 `openResource(address, { params })`:changes/graph 由侧栏按 kind 每 pane 去重,diff 与 remote item 以精确地址为 tab 身份,重复打开 reveal 既有 tab 并递增 `navigation.revision`;参数类型通过 `SidebarRightResourceParamsMap` 模块扩充。tab 栏、guide 页、停靠几何与面板宽度由右侧边栏(基于 ui-dockkit)拥有。Diff 结果是面板局部的:controller 的 `fetchDiff` 按路径与 staged 侧独立缓存并合并在途读取,每个 Diff surface 持有自己的在途请求与结果(按仓库根 + tab 地址为键,乱序完成互相不可见),故分屏、浮动窗口与同文件的 staged/worktree 两个 tab 永不互串正文。changes/graph/worktrees 另在 `sidebar.right.pane.tab.title` 座注册 live title(diff chip 是语言无关的文件名,remote item chip 是地址解析出的 `PR #n`/`议题 #n`,均不注册),已打开的 chip 随语言切换更新,而非冻结在打开时刻的文本。provider 卸载只移除 tab type 注册,保留 tab 记录:上游 body 座转为其 "unavailable" 兜底,重新注册后正文恢复。 - **Diff 显示**:插件不再维护逐行 patch 渲染器;`GitDiffSurface` 只负责 Git 取数、面板状态与侧栏适配,正文交给 `@deepseek-ai/dsh-client-ui-primitives` 公开导出的 `DiffBlock`。`git-diff-adapter.ts` 用已有 `diff` 包的 `parsePatch` 把 Git unified patch 转成组件接受的 old/new 文本片段,显示路径取用户选择的字面路径,不从 Git 引号头部反推。复制、折叠与新宿主的换行控件由官方组件拥有;Git 的工作区/index/HEAD 比较不转换成会话回合快照。二进制、仅元数据更改、空 patch 与不完整 patch 分别显示状态;末尾换行差异另有说明,因为官方组件会归一化行终止符;适配器分别记录旧、新文本是否缺少末尾换行,仅两侧状态不同时显示提示,双方均缺少换行(包括共享上下文中的标记)不算换行差异。支持版本上的上下文与 labels 差异集中在 `src/compat/dsh-diff.ts`,见[兼容性参考](../reference/dsh-compatibility.md#官方-diffblock-接入)。 +- **远程平台(ADR-0004)**:`src/remote/` 的 credentials 子 fiber 拥有认证/刷新、REST provider、账户缓存与取消/drain;完整 seam 下 RPC 已接入,缺失/不完整时整功能 off。capability 零网络/子进程,storage/auth/viewer/权限各自独立。Client 主 fiber 按 capability 动态注册 `git.remote` page type/body/title 与 `git.remote.item` resource type/body:列表页提供 PR/Issue 列表、过滤与建 PR 入口,条目详情(描述、评论、文件比较与受保护写)打开为以 `dsh-resource://git-remote////` 地址为身份的共享 detail tab,PR 与 Issue 共用一个 body 按导航负载分派;平台认证入口收进工具栏图标按钮的 dialog。原生通知只是可卸载增强。Git checkout 用私有 ref、SHA 核验、tracking CAS 和部分回执;OAuth 不进入 Git 传输。模型/坐标是浏览器安全模块,可选 seam 使用本地结构类型以支持无包旧车道;发布依赖仍用 registry optional peer。细节唯一参考为[远程平台 RPC](../reference/remote-platform-rpc.md),阶段及尚未执行的真实 OAuth/平台/矩阵验收见[计划](../plans/active/remote-platform-integration.md)。 - **可移植 / 原生分离**:主 fiber 不 require `desktop`;子 fiber `ctx.inject(['desktop'], ...)` 只接受 `shell.showItemInFolder`、`shell.openPath`、`notification.show`。缺 Desktop 时核心操作全部可用,仅原生动作不显示。 - **Graph layout engine**(`src/client/graph/`):纯逻辑模块——不访问 Git、不依赖 React、不触碰 DOM,可独立测试;输出 geometry 与 Canvas 2D 绘制解耦。设计决策与不变量见 [Git Graph Layout Engine](../reference/git-graph-layout.md)、[ADR-0001](../decisions/ADR-0001-graph-lane-not-owned-by-branch.md)、[ADR-0002](../decisions/ADR-0002-graph-lane-cap-max-three-lanes.md)。 - **Commit message 生成**:Host 侧独立 LLM 请求(不经会话上下文,无 KV Cache 影响);需要 LLM runtime 与可解析模型,否则 Client 报告 `git/generation-unavailable`。配置结构见根 README [Configuration](../../README.md#configuration)(canonical)。 diff --git a/docs/decisions/ADR-0004-remote-platform-integration.md b/docs/decisions/ADR-0004-remote-platform-integration.md new file mode 100644 index 0000000..c1fb7d5 --- /dev/null +++ b/docs/decisions/ADR-0004-remote-platform-integration.md @@ -0,0 +1,518 @@ +# ADR-0004:Git 远程平台集成——credentials seam 门禁、REST 传输与 provider 抽象 + +- **Status**: Proposed(设计评审项已全部闭合,待冻结) +- **Date**: 2026-10-21 + +> 正文 `Decision` 为归并后的唯一实施依据,已完整吸收 R/F/G/H 各组评审修订;初始决策原文移入「初始决策(历史)」,全部评审与处理记录按时间保留于「后续修正」。 + +## Context + +本插件需要一个新右侧边栏 tab,提供托管平台远程功能:初期 GitHub,预留 GitLab;范围含 PR 列表/详情、Issue、checkout、review 提交、评论、merge、create PR——即对标准齐 VSCode `vscode-pull-request-github` 与 IDEA 内置 GitHub/GitLab 集成的能力面(编辑器内嵌 Review Mode 经决策**不实现**,本包无编辑器面可挂)。 + +这正式反转了既有非目标——根 README、[范围与需求总览](../requirements/scope-and-requirements.md)与 `AGENTS.md` 均声明 "GitHub authentication, hosting-provider workflows, credential prompts, issues, pull requests" 在本包之外,"until a separate decision adds them"。本 ADR 即是该 decision。 + +驱动约束: + +- NFR-1/`AGENTS.md`:Host core 只用标准 DSH 服务,绝不 shell/Electron; +- Windows 桌面端**每条子进程弹一个控制台窗口**(已记录的宿主行为,见 [troubleshooting](../troubleshooting/windows-desktop-console-flood.md)),高频子进程调用有 UX 成本; +- OAuth 授权产物需要**持久化与刷新安全**的存储;宿主无 VSCode SecretStorage/IDEA PasswordSafe 的同名物,但上游 `@deepseek-ai/dsh-credentials` 提供 `ctx.credentials` seam——`GrantRecord` 明文就是为 "authorization grant from a sign-in flow" 设计,`modifyRecord` 提供跨进程序列化 RMW(文档明示为 token 刷新安全),`describe*`/`record-updated` 提供不含值的 wire-safe 视图与变更事件; +- `ctx.credentials` 在锁文件中仅见于 `0.2.0-rc.2` 及以后副本——旧宿主可能没有该 seam。 + +## Decision + +以下 15 条为归并后的唯一实施依据,已完整吸收全部评审修订;实施者无需逐轮推导何者生效。 + +1. **门禁:`ctx.credentials` 缺席即整功能缺席。** 结构性探测全部实际调用方法(`readRecord`、`modifyRecord`、`deleteRecord`、`resolve`、`describe`、`describeRecord` 及 `record-updated` 事件发射面),缺一即 `remote` off 并给出 reason,不做逐方法降级。Host 不装配 remote 运行时,`/git` 通道对 `remote-*` 端点返回 `git/remote-unavailable`;Client 按 `remote-capability` 轮询结果动态注册/注销 `git.remote` tab type(沿用 worktrees 先例)。不做版本字符串判定、不做半功能降级——实际可用宿主 ≥0.2.0-rc.2,旧宿主行为零变化。 + +2. **凭据持久化 = `ctx.credentials` GrantRecord 状态机。** 记录键 `credentialKey('dsh-plugin-git', )`(如 `github-com`;自建实例的无碰撞 host-id 编码为开放项)。payload v1:`{ v, state, revision, accessToken?, refreshToken?, tokenType?, scope?, expiresAt?, userId?, login?, host, attemptId?, refreshId?, at? }`;`state ∈ authorizing | authenticated | refreshing | signed-out | reauth-required`;每次持久提交携带新的不可复用 `revision`。`modifyRecord` 是唯一写路径,一切授权状态迁移都发生在其 mutate 回调内;`at` 仅作展示。不另建文件、不写 settings、不碰 OS keychain。 + +3. **token 解析与发现链。** 后台顺序:GrantRecord(authenticated)→ `resolve(GH_TOKEN`/`GITHUB_TOKEN)` ref → `gh auth token` → 匿名;ref 分层由 provider 定义、以 `resolve().source` 如实展示,不承诺固定层级。`gh`/helper 子进程只在身份建立事件(seam mount、凭据事件、用户重试、401 失效后重解析)串行触发,每事件至多一轮,不进逐 REST 路径;capability 探测零子进程零网络。`git credential fill` 仅为用户显式动作,非交互契约:`GIT_ASKPASS=''`(空值覆盖非删除)、`GIT_TERMINAL_PROMPT=0`、`SSH_ASKPASS_REQUIRE=never`、`GCM_INTERACTIVE=never`、`-c credential.interactive=never`、≤5s 硬超时、输出上限;凭据探针的 stdout/stderr 不透传 RPC 错误与日志。凭据经 401 验证失效 → `invalid` + 修复提示,不回退换身份;匿名仅在从未存在凭据时被选择。 + +4. **凭据变化检测与 generation。** Host 维护 credentialContext `(host, source, fingerprint)`:fingerprint 为 token 的进程内不可逆摘要(SHA-256 截断),不跨 RPC、不进日志;解析结果变化即失效 viewer/permissions/ETag 缓存、递增 revision、重新 `GET /user`。`reference-updated`/`record-updated` 事件仅作失效提示,真值以解析比较为准。`generation = { instanceId, revision }`:instanceId 为 Host 启动随机值(重启不复用),revision 单调递增;Client 持有不透明句柄。 + +5. **登录与登出。** OAuth Device Flow:登录开始即在 mutate 内 CAS 写 `{ state:'authorizing', attemptId }`;完成时仅当 `attemptId` 匹配才写 authenticated grant + 新 revision。`client_id` 经 `Config.remote.clientId` 注入(未配置则隐藏登录入口,发现链照常);scope `repo`;处理 interval/`slow_down`/code 过期/`access_denied`/取消/重复登录互斥;App expiring-token 与否运行时兼容——无 `refreshToken` 的 token 到期转 `reauth-required`;组织 SSO 拒绝单独呈现。登出 = 同 key 覆写 `{ state:'signed-out', revision:新 }`(持久抑制发现链、重启有效、重新登录覆写清除)。"清除本地授权(恢复自动发现)" = `deleteRecord`,文案注明不在 GitHub 服务端撤销 token。 + +6. **token 刷新 = 两阶段持久意图协议。** prepare:mutate CAS 把到期 `authenticated` 写为 `{ state:'refreshing', refreshId, basedOnRevision }`,**提交失败不发送**(token 未被消费,下轮重试安全);execute:marker owner 在锁外发送——互斥锁不覆盖网络调用;complete:mutate CAS 校验 `refreshId + basedOnRevision` 匹配才写新 token 对 + 新 revision,状态已变则丢弃收到的新 token。旁观与恢复:读到 `refreshing` → 按记录时间有界等待(≤TTL),超时 mutate CAS 转 `reauth-required`;非 owner 绝不重发;"marker 已提交未发送即崩溃"同样按已燃烧处理。**保证:可能被服务端消费过的凭据快照绝不再次用于刷新交换**——唯一例外是传输层确认请求未发出;发送后一切非确定结果(超时、响应丢失、任意 5xx/网关错)均视为可能已消费 → `reauth-required`;确定性拒绝(`invalid_grant`/`bad_refresh_token`)→ `reauth-required`;complete 持久化失败 → 新 token 对丢弃、`refreshing` 由旁观规则收敛、如实上报"远端已更新、本地未提交"。跨进程互斥以 backing store 支持为前提,不支持时自动旋转不可用并在 capability/文档中明示,不靠重试代偿。 + +7. **capability 四层分离**(互不推导)。`credential { configured, writable, source? }` 纯存储事实;`auth { state: off | anonymous | unauthenticated | signed-out | authenticated | invalid, source? }`;`viewer { host, userId, login, name, avatarUrl }`(每 generation 至多一次 `GET /user`);`permissions` 原样透传 `/repos/{o}/{r}` 权限位;逐动作 `canComment / canReview / canMerge / canClose / canCreatePr` 三态 `true | false | 'unknown'`——`unknown` 时 UI 乐观可用、服务端裁决,`canMerge` 取 push 位,`canClose` = 作者本人或 push 位。seam 缺席时 capability 仍返回结构化 `{ state:'off', reason }`,其余 remote 操作明确拒绝。 + +8. **传输与 origin 边界。** Host 内 `fetch` 直连 REST(`api.github.com`,`X-GitHub-Api-Version` 头,AbortSignal 取消,单请求分页页数上限 + origin 校验的续页游标,无总量上限);`gh` 不作传输层。provider 分别声明 `apiOrigin` 与 `webOrigin` allowlist:带 `Authorization` 的请求(含分页 Link 与重定向跟随)仅 `apiOrigin`;`html_url`、openExternal、device-flow `verification_uri` 走 `webOrigin` HTTPS 校验且不携带 token。REST 而非 GraphQL。 + +9. **写操作一致性。** 全部 `remote-*` 写端点 payload 携带 host/owner/repo/对象号 + `expectedGeneration` + `expectedViewer`(`host + user.id` 稳定 id,login 仅展示);逐操作 resolve 的单一凭据快照同时完成身份核对与该次调用,核对后 context 已变即拒绝、Client 刷新重确认,不得换另一份 token 发写。远端身份断言:payload 的 host/owner/repo 必须等于本地 origin/upstream 聚合解析出的身份(`assertSessionRepository` 只证本地路径一致);写端点逐个进 `isRepositoryWrite`;工作区切换取消未发写。merge 必带 `expectedHeadSha` → GitHub `sha` 前置参数,head 已动 → 刷新重新确认;review 必带 `commit_id`(展示时 head SHA);merge/close 两步确认(沿用 discard 先例);permissions 预检 + protection/scope 拒绝透传;create PR 的 fork head/base 为显式模型字段。已发送写超时/取消 → "结果待确认" + 定向刷新对账;非幂等写不自动重试。 + +10. **remote 解析与聚合。** `git remote -v` 识别 provider/host/owner/repo(SSH 与 HTTPS 两种形式);origin + upstream 聚合同 provider;无 remote 或不支持 provider → 分类状态。 + +11. **Checkout PR**(无 Review Mode)。按序:① payload remote/head 属于聚合允许集合(远端身份绑定);② `git fetch +:refs/dsh-git-remote/pr/{repoKey}/{n}`(私有 ref 含 base 仓库身份,防 origin/upstream 同号 PR 共享暂存)→ `rev-parse` 核验 == API `head.sha`,不一致即上游 race 刷新重试;③ tracking 预备:`git update-ref refs/remotes// `(缺失 ref 用全零"不存在"预期值;条件失败有界重读——已相等直接用 / 已领先并入 race 重试 / 分叉失败上报;只表达"已核验快照",不代表远端最新),`remote..fetch` 不含 head 时 `git config --add` 补映射(追加保留原映射,供 single-branch clone 后续 fetch 覆盖);④ `git switch -c `,或复用同名同 SHA 分支(先经 ③ 补齐再核其 upstream 指向期望 remote/head,不一致拒绝);⑤ `git branch --set-upstream-to=/`。命名:同仓库 PR 用真实分支名 + tracking;fork PR `pr-{n}` 不带 upstream;同名异 SHA 拒绝并给手动命名入口。成功判据 = `rev-parse HEAD` == 目标 SHA。失败回执:①–③ 失败 → **不创建/切换分支、不改 index/工作文件**,报"未切换,准备部分完成" + 已完成阶段清单(`privateRefWritten`/`trackingRefPrepared`/`fetchMappingAdded`);④ 之后失败 → 实际 HEAD + 未完成步骤标记(如 `upstreamNotConfigured`);不自动删分支,不承诺跨 ref/config 原子回滚、不盲目清理。端点整体计入 `isRepositoryWrite`(含 config 写入)。Git 传输认证不桥接插件 OAuth token:私库 fetch 依赖用户既有 git 凭据,提示区分"平台认证 OK / git 传输未认证"。脏工作区拒绝并说明。 + +12. **行内评论坐标。** 使用 `line`/`side`(多行 `start_line`/`start_side`),不以已弃用 `position` 为契约;`commit_id` 与坐标取自同一比较快照。远端 diff 模型保存 path、base/head SHA、old/new 原始行号与 side 锚点,渲染层引用锚点不反推行号;删行/重命名/多 hunk/过期线程/多行范围/patch 截断须可靠定位,否则可浏览但禁用行内提交并说明。(实现现状:便携 UI 暂不渲染本地 diff,PR 页经 `pr.url + '/files'` 跳转 GitHub Files 页;坐标模型与 `remote-files`/`remote-inline-comment` 受守护能力保留。) + +13. **预算与缓存。** 匿名 = 仅手动刷新(不自动轮询);认证 ≥60s 或遵 `X-Poll-Interval`/`Retry-After`/reset;429 与 secondary-rate-limit 403 指数退避;ETag 条件请求仅适用认证流量;缓存键含 `(host, viewer, repo, 完整查询串)`;分页设单请求页数上限并以游标续取(用户可见列表无总量上限);per REST 操作只做进程内 seam resolve。 + +14. **通知。** review 请求 / 自己 PR 的 merged·closed·changes-requested / push 成功后建议建 PR,按事件 id 去重;经 `ctx.inject(['desktop'])` 子 fiber 的 `notification.show`;web 端降级为 tab 内徽标。 + +15. **首期范围与开放项。** Issue = 列表+详情+评论只读 + 一键开分支(`issue-{n}-{slug}`,复用 createBranch/switchBranch);评论 = PR 级 + 行内(行内只读、按 review 嵌套展示);review = approve/comment/request-changes、`commit_id` 绑定;create PR = 已推送远端分支(含 fork head),创建流程内不做 push(引导走现有 push RPC);PR 文件比较以 GitHub Files 页外链替代内嵌 diff。不做:Review Mode、issue 写操作、自定义查询、hover 卡片与补全、TODO→issue、suggestion 本地应用、GitLab 实现、GHES/自建实例(结构预留)。错误码集:`remote-unavailable / unauthenticated / signed-out / insufficient-scope / head-moved / rate-limited(resetAt) / write-result-unknown / remote-identity-mismatch`。 + +## 初始决策(历史) + +> 以下为评审前的原始 Decision 文本,仅作历史保留;实施以正文 `Decision` 为准。 + +### 原始 Decision + +1. **credentials seam 为整功能硬门禁**。`ctx.credentials` 不存在(旧宿主或未挂 provider)时,本次开发的全部功能不提供:Host 不装配 remote 运行时(`/git` 通道对 `remote-*` 端点返回 `git/remote-unavailable`),Client 不注册 `git.remote` tab type(按 `remote-capability` 轮询结果动态注册/注销,沿用 worktrees 先例)。探测是**结构性的**(服务存在且有 `readRecord`/`modifyRecord`),不做版本字符串判定,也不提供砍半降级——实际可用宿主 ≥0.2.0-rc.2,旧宿主行为零变化。 + +2. **OAuth grant 持久化 = `ctx.credentials` GrantRecord**。记录键 `credentialKey('dsh-plugin-git', )`(如 `github-com`,id 空间天然兼容未来 GHES/自建实例);payload 为本插件自有格式(access token、refresh token、expiry、login、scopes),seam 不解释。刷新经 `modifyRecord` 互斥 RMW;capability 的 `authState`/`authSource`/`writable` 由 `describeRecord`/`describe` 推导;订阅 `credentials/record-updated` 使外部修改即时生效。**不**另建文件、不写 settings、不碰 OS keychain。 + +3. **传输 = Host 内 `fetch` 直连 GitHub REST API**(`api.github.com`,带 `X-GitHub-Api-Version` 头;AbortSignal 取消;分页 + ETag 条件请求)。`gh` CLI **不**作传输层,仅保留为 token 发现来源之一。 + +4. **token 解析顺序**(Host 侧,逐操作解析不跨操作缓存):`ctx.credentials` GrantRecord → `resolve(GH_TOKEN / GITHUB_TOKEN refs)`(seam 分层:store > env > .env)→ `gh auth token` → `git credential fill`(stdin `{data}` 喂 protocol/host,`GIT_TERMINAL_PROMPT=0`)→ 匿名降级(公共仓库只读,me 类分组隐藏)。 + +5. **登录 = OAuth Device Flow**:`POST /login/device/code` → 展示 user_code + verification_uri → 轮询授权端点 → `modifyRecord` 落 GrantRecord。client_id 为公共值(device flow 设计上无 secret),经 `Config.remote.clientId` 注入而非内嵌;未配置时登录入口隐藏,发现链照常工作。登出 = `deleteRecord`。 + +6. **Provider 抽象 + GitHub 先行**:`RemoteProvider` 接口封装 host 识别、API 路径、认证头、refspec(GitHub `pull/{n}/head` ↔ GitLab `merge-requests/{n}/head`)、术语与 state 映射(PR↔MR、merged↔`opened`);共享中立数据模型,UI 标签按 provider 取名。GitLab 本期不实现,仅约束接口不泄漏 GitHub 私名。 + +7. **REST 而非 GraphQL**:读与写全部走 REST v3——端点直观、分页标准、限流语义可预期(authenticated 5000/h,304 不占主限额)。 + +8. **Checkout PR 无 Review Mode**:`git fetch pull/{n}/head:pr-{n}` 后走既有 `switch-branch`(同仓库 PR 直接切 head 分支;GitLab 为 `merge-requests/{n}/head`);脏工作区拒绝并说明原因(stash 仍为非目标);不记录进入前分支、无审阅横幅、无返回捷径。 + +9. **写操作安全模型**:merge/close 两步确认(沿用 discard 先例);`permissions`(仓库 push/triage 位)预检驱动 UI 启停;protection/scope 拒绝透传 API 错误;破坏性操作不自动重试;写成功后定向刷新。 + +10. **通知**:review 请求、自己 PR 关键状态变化、push 后建议建 PR——经 `ctx.inject(['desktop'])` 子 fiber 的 `notification.show` 结构方法;web 端降级为 tab 内徽标。 + +11. **类型依赖纪律**:`@deepseek-ai/dsh-credentials` 加为 optional peer,仅 `import type`;`credentialKey`/`credentialRef` 的品牌语法(`/` 小写连字符、POSIX 标识符)在本地等价构造,不引入运行时导入——seam 缺席的旧宿主上 bundle 仍能加载。 + +## Alternatives Considered + +- **`gh` CLI 全托管**(传输+认证):需要用户预装且 `gh auth login` 过;Windows 桌面端每条子进程弹控制台窗口,轮询式调用会放大成窗口风暴;`gh --json` 字段映射仍要自己维护。仅保留其 token 出口作发现链一环。 +- **`git credential approve` 写 OS helper 链**:helper 链是 git HTTPS basic 凭据位(username/password),塞 OAuth grant 语义错位;各平台 helper 行为不一、可能弹提示;上游 seam 是产品内一致设施且自带跨进程 RMW 与 wire-safe 视图。helper 链仅保留为**发现**来源(`credential fill` 读已有项),不作写入。 +- **settings `role('secret')` 存 PAT**:redact 只保证 wire 不泄密,底层仍是明文文档,且无 grant 生命周期/refresh 语义;OAuth grant 无法用该形态表达。留作未来可选输入,不进本设计。 +- **OAuth loopback redirect**(本地 HTTP server 收 code):体验更接近 VSCode,但引入本地端口生命周期与宿主打包面;device flow 无 server、易 mock、与 headless profile 相容。将来若有需要可另 ADR 增补。 +- **GraphQL API**:单请求聚合好,但 schema 复杂、限流计点模型不透明、REST 已覆盖全部 MVP 端点。性能优化留给未来。 +- **seam 缺席时降级提供发现链+匿名的只读版**:行为在两个宿主代际间不一致,匿名 60/h 限额体验差,且"半功能"状态机会显著膨胀。门禁统一更简单可预期。 +- **GitLab 同期实现**:单一 provider 先验证抽象;差异点已枚举收进接口。 + +## Consequences + +**正面**:复用上游一致凭据设施(与 LLM key 同水位、跨进程刷新安全、wire-safe describe、变更事件);零新二进制依赖;旧宿主加载路径与行为完全不变;`remote-capability` 轮询/动态 tab 注册、两步确认、`optionalService` 探测均有现成先例;GitLab 接入面收敛到一个 provider 实现。 + +**负面/成本**: + +- 功能实际生效宿主 **≥0.2.0-rc.2**——`engines.dsh` 与 peer OR 不变(插件在旧宿主仍加载,只是 Remote tab 不出现);该差异必须在 README 与 `docs/reference/dsh-compatibility.md` 写明,`compat:check` 探测面扩到 credentials seam; +- OAuth 硬前置:产品侧须注册 GitHub OAuth App、开启 Device Flow、并经 `remote.clientId` 注入;未配置时仅发现链可用; +- GrantRecord 落在 `.credentials.yaml` 文件(非 keychain)——与 LLM key 同级,但用户文档须如实说明; +- REST 请求数多于 GraphQL:靠 ETag + ≥60s 轮询 + 手动刷新控制; +- 写操作面使 GitHub provider 实现体量显著增大(review/comment/merge/create/checkout 五族端点)。 + +## Related Documents + +- [范围与需求总览](../requirements/scope-and-requirements.md)——非目标反转与 FR-15+/NFR-6+ 修订落点 +- [当前架构总览](../architecture/overview.md)——RemoteService/tab 注册模式落点 +- [DSH 兼容契约](../reference/dsh-compatibility.md)——credentials seam 探测面 +- [Windows 桌面端控制台风暴](../troubleshooting/windows-desktop-console-flood.md)——不选 `gh` 传输层的背景 +- [`AGENTS.md`](../../AGENTS.md)——便携性与 provider 分离约束的来源 + +## 后续修正 + +### 2026-10-21 技术评审 + +**结论:方向可行,建议保持 Proposed;以下 P1 项解决前不冻结设计。** Host REST、credentials grant、portable 主 fiber 与 Desktop 子 fiber 的组合符合现有边界。本轮只追加评审,不改写前述决策;以下内容是待处理意见,不代表已实现或已采纳。 + +证据范围:当前 Host/Client 实现、锁定的 `dsh-credentials@0.2.0-rc.2` 类型与 README、本地可读的 `0.2.1-rc.2` 对应契约,以及本节链接的 Git/GitHub/GitLab 官方文档。没有使用真实账号或对远端执行写操作。文中的 R 编号是评审追踪号。 + +#### R1 · P1 · 存储元数据不能推出认证状态与远端操作权限(Decision 1、2、9) + +`describeRecord` 只返回 `configured`、`kind`、`writable`;`writable` 明确定义为能否写入凭据记录。它不能验证 token 是否撤销、当前用户是谁、是否有 API 写权限。`describe` 也不知道 `gh` / Git helper 的发现结果。按正文直接推导 `authState` 与统一 `writable`,会把“可写凭据文件”与“可 merge PR”混淆。仅用仓库 `push/triage` 位禁用全部写动作也会错禁有权评论、提交 review 或关闭自己 PR 的用户;仓库角色与 token 权限是不同维度,见 [GitHub 角色权限表](https://docs.github.com/en/organizations/managing-user-access-to-your-organizations-repositories/managing-repository-roles/repository-roles-for-an-organization)。 + +建议拆开 `credentialConfigured`、`credentialWritable`、实际 `authSource`、验证后的用户身份和逐动作 capabilities(含 unknown)。认证校验及仓库/对象权限由 Host/provider 获取,服务端 API 仍作最终裁决。门禁应探测实际调用的全部必需方法,或对 `resolve` / `describe*` / `deleteRecord` 等分别降级;只检查 `readRecord/modifyRecord` 不能保证后续调用成立。 + +验收:凭据存储只读但 token 有仓库写权限;存储可写但 token 只读或已撤销;普通贡献者评论/review、作者关闭自己的 PR;结构不完整的 credentials provider 均有明确结果。 + +#### R2 · P1 · `deleteRecord` 无法实现预期的登出,且可能被在途登录重新写回(Decision 4、5) + +当环境变量或 `gh` 已登录时,删除 GrantRecord 后下一次操作会自动发现同一账号或另一个账号,界面随即重新登录;在途 Device Flow 成功后仍可能再次写入 grant。逐操作重新解析还意味着打开确认框时是账号 A,提交时可能已换成账号 B。正文没有规定缺失凭据、无效凭据与主动登出的区别。 + +建议明确“删除插件授权”和“停止使用此 host 的所有自动发现来源”的不同语义;若保留“登出”名称,应给出有明确作用域及持久性规则的抑制发现状态与重新登录动作。无效的高优先级凭据应提示修复,不能默默换身份执行写操作。登录/登出、外部凭据更新和 provider 卸载需要 auth generation:停止旧轮询、拒绝过期登录结果写回,清理账号相关缓存;确认后的写请求也要验证身份未变。RMW 只解决存储互斥,不自动表达用户取消意图。 + +验收:grant + env + `gh` 同时存在时登出;登录尚未完成时登出;弹出 merge 确认后外部换账号;旧请求在切换账号后返回,均不得恢复旧身份或复用旧账号的私有数据。 + +#### R3 · P1 · `GIT_TERMINAL_PROMPT=0` 不能保证凭据发现不交互(Decision 4、Alternatives) + +[Git 凭据文档](https://git-scm.com/docs/gitcredentials#_requesting_credentials)明确列出 askpass 路径。本轮在隔离临时目录中设置一个只返回占位值的 `GIT_ASKPASS`,清空 helper 配置,设置 `GIT_TERMINAL_PROMPT=0` 后调用 `git credential fill`,askpass 仍被执行且命令成功。这足以否定该环境变量可单独保证“只读已有凭据”的假设;helper 本身还可启动自己的交互流程。 + +建议规定经过验证的非交互调用策略:阻止 Git 的 askpass 路径、对支持的 helper 显式禁用交互(例如 [GCM 的 `credential.interactive=false`](https://github.com/git-ecosystem/git-credential-manager/blob/main/docs/configuration.md)),并设置硬超时、取消和输出上限。任意第三方 helper 未必遵守统一开关;不能保证静默时,应限制自动发现范围或改为用户显式触发。凭据输出不得经过现有通用 `GitCommandError` 的 stderr/detail 透传到 Client 或日志。 + +验收:无 helper、askpass 已配置、helper 需要重新登录、helper 挂起与取消;后台刷新均不得弹出认证 UI,也不得泄露 stdout 中的 token。 + +#### R4 · P1 · PR checkout 尚未保证检出正确提交与保护已有本地分支(Decision 8) + +当前 [`GitService.switchBranch`](../../src/service.ts) 只执行 `git switch `。同仓库 PR 直接切 head 分支,可能切到不存在或落后于远端的本地分支。固定目标 `pr-{n}` 又没有仓库身份或所有权:已有同名分支时,fetch 可能移动用户分支;PR force-push、目标分支已在另一 worktree 检出或存在本地提交时还可能失败。脏工作区检查不能覆盖这些情况。[GitHub 的 fetch 示例](https://docs.github.com/en/pull-requests/how-tos/review-pull-requests/checking-out-pull-requests-locally)是创建分支的基本操作,并非完整的重复 checkout 策略。 + +建议定义以 base repository + PR 标识隔离的私有抓取 ref,并明确本地分支冲突、重复 checkout、force-push 与 linked worktree 的策略;不得自动覆盖用户提交或强制更新正在检出的分支。同仓库与 fork PR 都需要抓取并核验目标 head SHA,再进入用户选定的 checkout 状态。另须明确 Git 传输认证:仅在 `ctx.credentials` 保存 OAuth token 并不会让现有 Git 子进程自动具备私库 fetch 权限;若 Git 认证仍由用户独立配置,应在 capability/失败提示中如实表达,若要桥接则需另行定义便携且不落盘、不进入 argv/日志的方案。 + +验收:全新 clone、落后/分叉的 head 分支、用户已有 `pr-1`、PR force-push、同一仓库多个 worktree,以及仅插件 OAuth 已登录但 Git 尚未认证的私库。 + +#### R5 · P1 · 两步确认缺少远端对象和提交的一致性约束(Decision 9) + +若用户确认期间作者又推送提交,直接 merge 会合入用户尚未确认的内容。GitHub merge API 提供 [`sha` 前置条件](https://docs.github.com/en/rest/pulls/pulls#merge-a-pull-request),仅靠两步 UI 确认无法替代它。现有 [`isRepositoryWrite`](../../src/session-directory.ts) 还是静态端点集合,新增 `remote-*` 不会自动受到现有会话仓库写入校验。 + +建议 merge 必须携带用户确认的 head SHA,冲突后刷新并重新确认;review 明确绑定展示过的 commit,评论/close/create 也应携带并校验不可混淆的 provider、host、仓库、对象身份及 auth generation。新端点逐项纳入 Host 的会话目录/仓库约束,切换工作区时取消尚未发送的写操作。已发送写请求遇到超时或取消应显示“结果待确认”并查询远端状态;尤其 create/comment/review 不可按普通网络错误自动重试,避免重复副作用。 + +验收:确认后 PR head 更新;切换工作区/账号后旧请求入队;服务端已创建评论但响应丢失。三者分别应拒绝过期 merge、阻止错对象提交、保留可核对的未知结果。 + +#### R6 · P2 · 逐操作发现链仍可能重现子进程风暴,匿名轮询也没有足够预算(Decision 3、4、7;Consequences) + +没有 grant/env token 的用户,每个 REST 操作仍可能启动 `gh` 与 Git helper;分页、详情中的多个端点及多会话轮询会放大该次数。将传输从 `gh` 换成 `fetch` 并未消除这条高频路径。匿名公共仓库即使只轮询一个端点,60 秒一次已接近 60/h 总预算,再加详情/手动刷新就会超额。且 [304 免主限额有正确认证的前提](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#use-conditional-requests),不能套用到匿名模式;搜索与 secondary rate limit 也不能按统一 5000/h 处理。 + +建议先定义“一次操作”的边界:同一用户动作/分页序列共享一次发现,credentials ref 仍逐操作解析;capability 探测不运行 CLI、不发 GitHub 请求。对自动 CLI 发现频率作明确限制,若与“不跨操作缓存”冲突,应在 ADR 中选择可验证的方案。Host 按 host/身份/仓库共享读取与轮询预算,匿名模式采用手动刷新或更低频策略,遵守 `Retry-After`、reset、`X-Poll-Interval`,设置重试与分页上限。ETag 与响应正文需一起按身份和完整查询键隔离。 + +验收:多个会话打开同一仓库、多页列表、匿名用户、凭据缺失、429/secondary 403、缓存后 304;检查 REST 次数及子进程次数,而不只检查 UI 结果。 + +#### R7 · P2 · credentials 引用优先级不是所有支持版本上的同一契约(Decision 4、11;Consequences) + +本地 `dsh-credentials@0.2.0-rc.2` README 明确启动环境变量优先且不可覆盖;`0.2.1-rc.2` 类型则明确 provider-managed store 优先、环境作为 fallback。因此正文的 `store > env > .env` 不能同时作为最低可用版与新版的统一保证。结果是同名 ref 在两个宿主版本可能解析为不同账号;本插件应尊重 `resolve` 返回的实际 source,避免自行复制或在 UI 承诺固定层级。 + +另外,当前 [`compat:check`](../../scripts/check-dsh-compat.mjs) 要求每个 DSH peer 的范围等于完整宿主清单,并拥有开发 pin;credentials 是后引入的可选能力,直接添加 optional peer 还需定义缺包旧车道、已发布版本子集与旧宿主装载验证,不能只增加 seam 探测。对跨进程刷新安全的表述也应保留上游的“backing store 支持时”条件,而非仅凭两个方法存在就保证所有 provider 都具备该能力。 + +验收:最低可用版与新版分别设置同名 store/env 值;旧宿主完全没有 credentials 包时验证安装、Host/Client bundle 装载及本地 Git;credentials mount/unmount 验证在途登录、请求、定时器均被清理,核心 UI 保留。 + +#### R8 · P2 · GitLab state 映射有误,provider 抽象还缺操作能力边界(Decision 6) + +`merged ↔ opened` 会把已合并 MR 映射成仍开放;[GitLab MR API](https://docs.gitlab.com/api/merge_requests/)分别定义 `opened`、`closed`、`merged`(以及 `locked`)。中立模型应明确 `open/closed/merged`,GitHub 需结合 PR 的 merge 信息区分 closed 与 merged,GitLab `opened` 只映射 open。 + +建议 provider 除路径/标签外,还拥有认证与 host 配置、分页/错误归一、逐动作 capability、review/讨论语义和 head/base 仓库身份;共享层按操作接口调用,避免让 UI 拼 API 路径。GitHub 首期仅接受明确允许的 host/API origin;remote URL、分页/重定向 URL 与 `html_url` 各自校验用途,不能因“预留自建实例”就把现有 token 发给任意 host。自建实例 host-id 还需无碰撞编码规则,而非简单把标点替换成连字符。 + +验收:用纯数据 fixture 验证 open/closed/merged、fork head/base、未知能力;用传输 mock 验证陌生 origin 不接收 Authorization。GitLab 实现仍留在后续范围。 + +### 冻结设计前的补充项 + +- **明确首期验收边界。** “对齐 VSCode/IDEA 能力面”不足以估算与验收。把 Issue(只读还是 CRUD)、普通评论/行内评论、review(整体 approve/request changes/comment 还是含行定位)、create PR(现有远端分支还是包含 push/fork)、通知触发与去重分别列入需求;不做 Review Mode 并不自动回答是否要做 PR 文件比较或行内评论。大型实施计划按仓库规则放入 `docs/plans/active/`,本轮评审不将其提前视为开工。 +- **补齐 OAuth 协议与 payload。** 明确申请的 scopes 与功能对应、组织授权/SSO 拒绝呈现、payload schema version、client/app/host 绑定、access/refresh expiry,以及不可刷新的 token。Device Flow 必须处理 interval、slow_down、过期、拒绝、取消与重复登录。当前 [GitHub OAuth 文档](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)已支持 OAuth App expiring tokens,且 Device Flow 产物刷新不要求 client secret;因此“公共 client_id + Device Flow + 安全 RMW”方向成立,但应明确 App 的 expiring-token 设置或运行时选择,以及刷新失败后的重新授权状态,不能假定所有 token 都会带 refresh token。 +- **冻结公开 RPC 与验收矩阵。** 给出 remote-capability 的无 seam/off、匿名、待登录、凭据失效、存储只读等状态与错误码;无 seam 时 capability 应仍能返回结构化 off,其余 remote 操作明确拒绝。分别用 mock HTTP/credentials/subprocess 测试认证与 REST,用临时 Git 仓库测试 checkout,用 Client 生命周期测试验证 provider 卸载、工作区/账号切换及过期响应。按 Related Documents 的落点在实际实现变更中同步需求、架构、README 双语与兼容契约;本 ADR 保持 Proposed,不能以它的存在单独宣称新功能已可用。 + +本轮校验:只读核查现有实现与依赖契约;临时环境复现 askpass 行为;追加本节后运行 `pnpm docs:check`。未进行真实 OAuth、GitHub 写入或完整兼容矩阵运行。 + +### 2026-10-21 评审意见处理(拟议) + +以下是对 R1–R8 与三项冻结补充的处理方案;复核通过后,相应条目视为对 Decision 正文的修订(在方括号处注明被修订项)。仍为拟议,不代表已实现。 + +#### R1 → 修订 Decision 1、2、9:capability 拆分为四个正交事实 + +`remote-capability` 响应分层返回,互不推导: + +- `credential`:`{ configured, writable, source? }`——纯存储事实(`describeRecord`/`describe`),不声称任何认证或权限; +- `auth`:`{ state: 'off' | 'anonymous' | 'unauthenticated' | 'signed-out' | 'authenticated' | 'invalid', source?: 'grant' | 'ref' | 'gh' | 'git-credential' }`——`invalid` 专指"解析到的凭据经 401 验证失效",与"没有凭据"是不同状态; +- `viewer`:经 `GET /user` 验证的 `{ login, id, name, avatarUrl }`,按 authGeneration 缓存,每个 generation 至多验证一次; +- `permissions` 与逐动作 capability:`{ pull, triage, push, maintain, admin }` 原样透传 `/repos/{o}/{r}`;`canComment / canReview / canMerge / canClose / canCreatePr` 为三态 `true | false | 'unknown'`——`canMerge` 取 push 位,`canComment`/`canReview` 在已认证且仓库可达时为 `true`、证据不足时为 `unknown`(UI 乐观可用、服务端裁决),`canClose` = 本人作者或 push 位。**绝不**用凭据 `writable` 或仓库 push 位一刀切所有写动作。 + +门禁改为探测实际调用的全部方法(`readRecord`、`modifyRecord`、`deleteRecord`、`resolve`、`describe`、`describeRecord` 及 `record-updated` 发射面),缺一即 off 并给出 reason,不做逐方法降级。 + +#### R2 → 修订 Decision 4、5:登出 = 持久抑制 + auth generation + +- 语义拆两个动词:`revoke`(`deleteRecord`,仅删授权记录)与 `sign-out`(同 key 覆写 `{ kind:'grant', payload:{ v:1, state:'signed-out', at } }`)。后者是**持久抑制态**:发现链全停、capability 报 `signed-out`、UI 给"重新登录"入口;重启后仍有效,重新登录经 `modifyRecord` 覆写清除。临时"只想用回 env"用 revoke,两个动作在 UI 分开命名。 +- `authGeneration`:Host 内存单调计数,`record-updated`、登录完成、登出、provider unload 时递增。Device Flow 完成写回前核对 generation,过期结果丢弃;capability 响应携带 generation;**写端点要求 `expectedGeneration` + `expectedViewer`**,Host 逐操作 resolve 后核对——确认框展示的是账号 A、提交时 resolve 到账号 B 即拒绝。 +- 凭据 invalid(401)→ `invalid` + 修复提示,**不回退**到下一发现源(回退即换身份)。匿名仅在从未存在凭据时被选择。 +- provider unload:停轮询、取消在途请求与 device-flow 轮询、清按账号键控的全部缓存、递增 generation。 + +#### R3 → 修订 Decision 4:`credential fill` 移出后台发现链 + +- 后台发现链收缩为:GrantRecord → `resolve(GH_TOKEN/GITHUB_TOKEN)` → `gh auth token` → 匿名。`gh auth token` 是纯本地无网络命令,但仍是子进程——只在**身份建立事件**(seam mount、`record-updated`、用户点"重新尝试"、401 失效后重解析)触发,串行、每事件至多一轮,不进逐 REST 操作路径。 +- `git credential fill` 改为**用户显式动作**("使用 Git 凭据"入口),调用契约:`GIT_TERMINAL_PROMPT=0`、`GIT_ASKPASS=`(git 以 `askpass "prompt"` 形式调用——node 把 prompt 当脚本文件加载即非零退出,无 UI、跨平台、无 shell)、`SSH_ASKPASS_REQUIRE=never`、`GCM_INTERACTIVE=never`、`-c credential.interactive=never`;硬超时(≤5s)+ AbortSignal + 输出上限。helper 仍启动自身交互则超时终止并显式报错。 +- 凭据探针走独立错误类型:stdout/stderr **不**进入 `GitCommandError` 式透传,不进日志与 RPC 错误体。 + +#### R4 → 修订 Decision 8:私有 ref 暂存 + SHA 核验 + 冲突拒绝 + +- 抓取与检出分两步:`git fetch +:refs/dsh-git-remote/pr/{n}`(私有命名空间,不在 `refs/heads` 下,不可能与用户分支同名)→ `rev-parse` 核验落地 SHA 等于 API 返回的 `head.sha`;不一致即上游 race,刷新后重试。**先核验、后进入**,不自 Git 引文反推。 +- 分支策略区分来源:同仓库 PR 以真实分支名建 tracking 分支(`git switch -c --track /`),本地同名分支 SHA 不同→拒绝并说明(不移动/覆盖用户分支);fork PR 建 `pr-{n}`(不带 upstream),同名同 SHA→直接 switch,同名异 SHA→拒绝并给手动命名入口。目标分支已在另一 worktree 检出→透传 git 错误并建议经既有 worktree 入口进入。 +- Git 传输认证边界**明确不桥接**:插件 OAuth token 不进入 git 子进程(不嵌 remote URL、不写 askpass shim——argv/日志泄露与便携性风险);私库 fetch 依赖用户既有 git 凭据(helper/SSH)。capability 与失败提示区分"平台认证 OK / git 传输未认证"两种状态。 + +#### R5 → 修订 Decision 9:写操作带对象与身份前置条件 + +- merge 必带 `expectedHeadSha`(确认框展示的 `head.sha`)→ GitHub `sha` 前置参数;409/422 head 已动 → 刷新并重新确认。review 提交带 `commit_id` = 展示时 head SHA,绑定"审阅的就是这个提交"。 +- 全部 `remote-*` 写端点 payload 携带 host/owner/repo/对象号 + `expectedGeneration` + `expectedViewer`;并**逐个加入** `isRepositoryWrite` 静态集合(受会话仓库断言保护);工作区切换取消尚未发出的写。 +- 已发送写遇超时/取消 → 返回"结果待确认"态,Client 触发定向刷新对账;create/comment/review/merge 不按网络错误自动重试(副作用不可重放)。 + +#### R6 → 修订 Decision 3、4、7:操作边界与预算 + +- "一次操作"边界:seam `resolve`/`describe*` 逐操作调用(进程内廉价读,守上游"不跨操作缓存"约定);subprocess 探针仅在身份建立事件(R3)出现;capability 探测**零子进程零网络**,只报运行时已知事实。 +- 轮询预算:匿名=仅手动刷新(不自动轮询;60/h 预算撑不起轮询);认证 ≥60s 或遵从 `X-Poll-Interval`/`Retry-After`/reset;429 与 secondary-rate-limit 403 指数退避;分页设单请求页数上限,经 origin 校验的不透明游标续取,无总量天花板。缓存键含 (host, viewer, repo, 完整查询串);正文"304 不占主限额"表述修正为**仅适用认证流量**。 + +#### R7 → 修订 Decision 4、11:不承诺固定分层 + matrix 接入 + +- 正文 `store > env > .env` 改写为:ref 解析分层由 provider 定义,本插件以 `resolve().source` 为准如实展示,不在 UI 承诺固定层级;同名 ref 在不同宿主版本解析出不同值属上游契约差异。RMW 表述加"backing store 支持时"限定。 +- optional peer 接入沿用 `0.1.7-rc.2` util-code-language 先例:仅当目标 lane 存在该精确版本才写 devDep;无包旧车道验证安装、Host/Client bundle 装载与本地 Git 回归;credentials mount/unmount 清理在途登录、请求与定时器、保留核心 UI。 + +#### R8 → 修订 Decision 6:state 映射与 provider 边界 + +- 中立模型 `state: 'open' | 'closed' | 'merged'` + `raw` 原值透传。GitHub:`closed` 且 `merged_at` 非空 → `merged`;GitLab:`opened/closed/merged` → 同名,`locked` → `closed`(`raw` 保留 `locked`)。 +- provider 接口除路径/标签外拥有:host allowlist、认证头形态、分页/错误归一、逐动作 capability、review/讨论语义、head/base 仓库身份。UI 只调操作接口,不拼 API 路径。 +- token 只发往 provider 白名单 API origin;分页 Link、重定向、`html_url` 逐一同 origin 校验后才跟随或展示。自建实例 host-id 的无碰撞编码规则留为 GitLab/GHES 落地时的开放项(记入 plan 非冻结项)。 + +#### 冻结补充项的对应 + +- **首期验收边界**(拟定,待确认):Issue = 列表+详情+评论只读+一键开分支,不含 issue 写操作;评论 = PR 级 + 行内(挂 diff,position 映射);review = approve/comment/request-changes 含行内评论、`commit_id` 绑定;create PR = 已推送远端分支(含 fork head),创建流程内不做 push(引导走现有 push RPC);通知 = review 请求 / 自己 PR 的 merged·closed·changes-requested / push 后建议建 PR,按事件 id 去重;PR 文件比较保留(不做 Review Mode ≠ 不做 diff 浏览)。 +- **OAuth 协议与 payload**:申请 `repo` scope(私库读写必需;组织 SSO 拒绝的错误单独呈现引导授权);payload `{ v:1, accessToken, refreshToken?, tokenType, scope, expiresAt?, login, host }`;App 是否开 expiring tokens 运行时兼容——无 `refreshToken` 的 token 到期转 `invalid`+重新授权;device flow 处理 interval 递增、`slow_down`、code 过期、`access_denied`、用户取消与重复登录互斥。 +- **RPC 与验收矩阵**:capability state ∈ `off | anonymous | unauthenticated | signed-out | authenticated | invalid`,附 `storageWritable`;写路径错误码含 `remote-unavailable / unauthenticated / signed-out / insufficient-scope / head-moved / rate-limited(resetAt) / write-result-unknown`;测试 = mock HTTP(含 304/429/secondary)、mock credentials seam(含只读 store)、临时 git 仓库 checkout 矩阵(全新/落后同名/已有 pr-N/force-push/多 worktree/仅插件已认证的私库失败提示)、Client 生命周期(provider 卸载、工作区与账号切换、过期响应丢弃)。 + +### 2026-10-21 处理方案复核 + +**结论:五项产品/架构取舍大体可保留,但处理方案尚未完全闭合,ADR 继续 Proposed。** 本节是复核意见,保留上节拟议文本,尚不将其自动视为通过。 + +| 取舍 | 复核意见 | +| --- | --- | +| 同 key 持久 `signed-out` GrantRecord | 保留。重启仍登出符合该动作语义;`credential.configured=true` 可以表示“存在记录”,UI 必须按 `auth.state` 呈现,不能显示成“已授权”。只读 store 的登出须明确持久化失败或显式采用会话级抑制,不能报告持久登出成功。写回互斥见 F3。 | +| `GIT_ASKPASS=process.execPath` | 建议替换为空字符串环境变量,见 F4;不能把 Host 可执行文件等同于 Node CLI。 | +| 同仓库真实分支 + tracking、fork `pr-{n}`、异 SHA 拒绝 | 保留命名及保守冲突策略,不自动移动已有分支。必须先修复 F1,确保实际检出的就是核验过的 SHA。 | +| Client 携带 `expectedGeneration + expectedViewer` | 保留接口方向,Host 仍是校验真源。generation 必须绑定实际解析出的凭据与运行时实例,见 F2。 | +| Issue 只读 + 开分支、含行内评论/review、create PR 不包含 push | 保留范围,无需扩缩。行内评论需要独立的远端 diff 坐标模型,见 F6;实现计划需计入这部分成本。 | + +#### F1 · P1 · 私有 ref 验证成功后,tracking checkout 仍可落到旧提交(处理方案 R4) + +上节只显式 fetch `pull/{n}/head` 到私有 ref,却用 `/` 创建 tracking 分支。后者未保证被此次 fetch 更新,与刚核验的私有 ref 不是同一个起点。本轮在两个临时仓库中复现:先 clone 旧 feature、切回 main 并删除本地 feature,再让远端 feature 与 PR ref 前进;按上节显式 refspec fetch 后,私有 ref 等于新 SHA,而 `git switch -c feature --track origin/feature` 仍检出旧 SHA。全新 clone 若只有单分支 refspec,还可能根本没有该 tracking ref。 + +建议分开“分支起点”和“upstream 配置”:从核验过的私有 ref/精确 SHA 创建分支,随后显式配置 tracking 所需 refspec/upstream;若选择更新 remote-tracking ref,必须同样核验其与目标 SHA 一致,再创建/切换。已有同名分支同 SHA 时还要检查 upstream 是否指向期望的 remote/head,不能静默改写另一个 tracking 关系。私有 ref 也应包含 base 仓库身份,避免同一检出配置两个远端、二者都有 PR #1 时共享暂存地址。 + +验收加入“私有 ref 新、origin/head 旧”和 single-branch clone;成功回执前实际 HEAD 必须等于目标 SHA。命名冲突仍沿用拒绝策略,不以覆盖分支来修复此问题。 + +#### F2 · P1 · 逐操作 resolve 与按 generation 缓存 viewer 之间缺少凭据变化检测(处理方案 R1、R2、R5、R6) + +目前 generation 递增源只列出 `record-updated`、登录/登出与卸载,未包括 ref 的 `credentials/reference-updated`,也没有规定对 resolve 返回值的变化进行检测。当 `GH_TOKEN` 对应 ref 从账号 A 的 token 改为 B 时,generation 可能不变,viewer 缓存仍是 A。Host 即使逐操作 resolve 到 B,再拿缓存 A 与 `expectedViewer=A` 比对,也会通过,最后以 B 发出写请求。一次运行时内的单调计数还会在 Host 重启后重用,不能独立标识旧 Client 请求。 + +建议 Host 对每次实际解析出的凭据上下文(host、source、token 值的进程内不可逆指纹)进行变化检测,并监听相关 ref/record 事件;变化即失效 viewer、权限与缓存,重新验证身份后才允许写。指纹不跨 RPC、不落日志。generation 可用“运行时随机实例标识 + 单调 revision”表示;`expectedViewer` 使用 host + 稳定用户 id,login 只展示。写请求解析一次凭据快照,用该快照完成身份核对与后续 API 调用;确认后上下文已变则拒绝,由 Client 刷新后重新确认,不能再解析另一份 token 来发送写。 + +另外,加入 `isRepositoryWrite` 只会验证本地 `repository` 与会话目录一致;当前 [`assertSessionRepository`](../../src/session-directory.ts) 不核对 payload 的 host/owner/repo。新增 Host 逻辑还必须将这些远端身份绑定到本地选定 remote(create PR 的 fork head/base 则显式建模),不能把本地路径断言当作远端身份验证。 + +验收:同 generation 下替换 ref token、同账号换权限不同的 token、重启后旧请求重放,以及本地路径正确但 payload 指向另一个远端仓库;分别验证失效/重新确认或拒绝。 + +#### F3 · P1 · 登录写回前在锁外检查内存 generation,仍可能覆盖登出 marker(处理方案 R2) + +`modifyRecord` 的安全点是 mutate 回调拿到锁内最新记录。若“写回前核对 generation”发生在调用它之前,登出可在检查后、拿锁前发生;另一个 Host 进程的 record 事件也可能尚未送达。此时旧登录仍会无条件覆盖新的 `signed-out` marker。持久化 marker 的选择合理,但仅有本进程计数不足以保护它。 + +建议给持久授权状态/登录尝试定义不可复用的 revision 或 nonce。登录开始时,在 `modifyRecord` 内建立尝试的预期状态;Device Flow 完成、刷新 token 写回也在 mutate 回调内比较最新记录与预期 revision,匹配才替换。登出生成新 revision 并清除旧凭据,显式重新登录才创建新的 attempt;旧进程/旧尝试不得靠“当前还是 signed-out”这样的枚举值判断获准写回。marker 的 `at` 仅作展示,不能单独当唯一 revision。 + +验收:受控阻塞 mutate 获取锁,在锁外检查后插入 sign-out;另用两个调用者模拟旧登录与新登出、两次显式登录交错,验证旧结果不能恢复授权。上节 `revoke=deleteRecord` 实际只清本地记录,UI 应命名为“清除本地授权/恢复自动发现”;若使用“撤销授权”,需明确并未在 GitHub 服务端撤销 token。 + +#### F4 · P1 · `process.execPath` 不具备必然立即失败的 askpass 契约(处理方案 R3) + +Host 允许运行在 Desktop;文档没有保证其 `process.execPath` 一定是标准 Node CLI。在 Electron 宿主中它可能是应用可执行文件,重新执行可能启动应用;[Electron 的 Node 模式](https://www.electronjs.org/docs/latest/api/environment-variables#electron_run_as_node)需要专门的环境变量且受 fuse 限制。因此“node 把 prompt 当不存在的脚本,立即退出”不能作为 portable Host 设计前提。本轮未启动 Electron 验证具体宿主行为,该风险来自未成立的运行时前提。 + +更直接的方案是明确传 `GIT_ASKPASS=''`(空值保留,不能删除此环境变量),同时保留 `GIT_TERMINAL_PROMPT=0`。当前 [Git `prompt.c`](https://github.com/git/git/blob/master/prompt.c) 只有环境变量不存在时才回退 `core.askPass`/`SSH_ASKPASS`,空字符串会跳过整个 askpass 执行路径。本轮在 Git 2.56.0 下设置会留下标记的 `core.askPass` 与 `SSH_ASKPASS`,并设置空 `GIT_ASKPASS`,结果 exit 128 且两个 askpass 均未被调用,不需要新可执行文件。 + +仍须验证 subprocess provider 保留空字符串覆盖,以及支持的 Git/三种 OS;helper 自己的交互策略、用户显式触发与超时约束继续保留。超时只能终止/限制在途过程,不能保证 helper 已显示的 UI 从未出现,用户文案不能承诺任意 helper 都绝不弹窗。 + +#### F5 · P2 · API URL 与网页 URL 不能共用“同 origin”判断(处理方案 R8) + +GitHub REST origin 是 `https://api.github.com`,PR/Issue 的 `html_url` 通常是 `https://github.com`。若对三类 URL 都要求与 API 同 origin,正常的“在浏览器打开”会被拒绝;若反过来为网页放宽所有 URL,Authorization 的边界又会扩大。 + +建议 provider 分别声明 API origin 与网页 origin。分页/带认证 API 重定向只按 API 策略验证;`html_url` 按 HTTPS 网页 allowlist 打开,不携带 token;Device Flow verification URL 也采用明确的授权网页策略。GitLab 映射表也应明确写成 `opened → open`,避免“同名”与中立模型的 `open` 字面量冲突。 + +#### F6 · P2 · 行内评论保留,但应使用新坐标接口并保存原始远端坐标(冻结范围) + +[GitHub review-comment API](https://docs.github.com/en/rest/pulls/comments#create-a-review-comment-for-a-pull-request)已将 `position` 标记为将退出使用,推荐 `line`/`side`,多行附 `start_line`/`start_side`。新功能不应以旧 position 为公开定位契约。现有 [`gitFileComparison`](../../src/client/git-diff-adapter.ts) 会将 hunk 转换成文本片段,未保留原始 old/new 起始行;旧宿主还会拆开上下文,因此不能用渲染片段的行号反推出 GitHub 文件行号。 + +建议远端 diff 模型保存 path、base/head SHA、old/new 原始行号与 side,渲染层只引用稳定锚点;`commit_id` 与这些锚点来自同一比较快照。删行、重命名、多 hunk、过期线程、多行范围和 patch 缺失/截断时分别验收;无法可靠定位的内容可以浏览但禁用行内提交并说明原因。该要求不改变已选首期功能范围,只明确其实现边界。 + +本轮验证:临时仓库复现 F1;Git 2.56.0 临时环境验证空 askpass;核查 credentials seam 与当前 diff/session adapter;追加后运行 `pnpm docs:check` 与空白检查。未验证 Windows/Linux、真实 Desktop askpass、真实 OAuth 或远端写入,不能将本轮视为这些环境的通过记录。 + +### 2026-10-21 复核处理(拟议) + +对 F1–F6 的处理如下;复核通过后视为对上节拟议及 Decision 正文的修订。仍为拟议,不代表已实现。 + +#### F1 → 修订 R4:分支起点必须是已核验 SHA,upstream 单独配置 + +- checkout 序列改为:`git fetch +:refs/dsh-git-remote/pr/{repoKey}/{n}`(私有 ref 命名空间**包含 base 仓库身份 repoKey**——同一检出挂 origin+upstream 双远端且同号 PR 时不共享暂存地址)→ `rev-parse` 核验 == API `head.sha` → `git branch ` 或 `git switch -c `——**起点只用已核验 SHA,不经 remote-tracking ref**。 +- tracking 是第二步独立配置:同仓库 PR 在分支建立后 `git branch --set-upstream-to=/`;该 remote-tracking ref 是否最新不参与起点判定。fork PR 不配置 upstream。 +- 复用同名同 SHA 分支前,还须核验其 upstream 指向期望的 remote/head;不一致 → 拒绝,不静默改写 tracking 关系。 +- 成功判据:checkout 完成后 `rev-parse HEAD` == 目标 SHA,方可向 Client 返回成功。命名冲突维持拒绝策略。 +- single-branch clone(远端 tracking ref 可能不存在)与"私有 ref 新、origin/head 旧"进入验收矩阵。 + +#### F2 → 修订 R1、R2、R5、R6:凭据指纹 + 实例化 generation + 远端身份绑定 + +- Host 维护 credentialContext = `(host, source, fingerprint)`:fingerprint 为进程内不可逆摘要(如 SHA-256 截断),**不跨 RPC、不进日志**。每次解析出的凭据快照与之比较;不同即失效 viewer/permissions/ETag 缓存、递增 revision,重新 `GET /user` 验证后才放行写。`credentials/reference-updated` 与 `record-updated` 都监听,但事件只作失效提示,**真值以解析结果比较为准**(事件可能缺失或迟到)。 +- generation 改为 `{ instanceId, revision }`:instanceId = Host 启动随机值(重启不复用,旧 Client 请求可识别),revision 单调递增;Client 持有的 generation 是不透明句柄。 +- `expectedViewer` 改用 `host + user.id`(稳定数字 id);login 仅展示用途,不参与校验。 +- 写路径单一快照:一次 resolve 得到凭据快照,同一份快照同时完成身份核对与该次 API 调用;核对后发现 context 已变 → 拒绝并由 Client 刷新重确认,**不得再解析另一份 token 发写**。 +- `assertSessionRepository` 只证本地路径一致;`remote-*` 写端点另加远端身份断言:payload 的 host/owner/repo 必须等于**从本地选定 remote(origin/upstream 聚合结果)解析出的远端身份**;create PR 的 fork head/base 建为显式模型字段,不接受任意字符串。 + +#### F3 → 修订 R2:revision/nonce 写进记录,锁内比较 + +- GrantRecord payload 增不可复用 `revision`(nonce):登录尝试开始即在 `modifyRecord` mutate 回调内写入 `{ v:1, state:'authorizing', attemptId }` 占坑;Device Flow 完成与刷新写回也只在 mutate 回调内比较 `record.attemptId === 本尝试` 才替换;`signed-out` marker 携带新 revision,旧 attemptId 不匹配即丢弃——检查点在锁内,不靠锁外 generation 比较。 +- `at` 仅作展示,不作 revision 依据。 +- UI 动词改名:`revoke` → "清除本地授权(恢复自动发现)",并注明不在 GitHub 服务端撤销 token;`sign-out` 保留持久抑制语义。 + +#### F4 → 修订 R3:askpass 采用 `GIT_ASKPASS=''` + +- 放弃 `process.execPath` 方案(Host 可执行文件未必是 Node CLI,Electron 宿主前提不成立)。改为显式传 `GIT_ASKPASS=''`(空值覆盖,非删除),保留 `GIT_TERMINAL_PROMPT=0`、`SSH_ASKPASS_REQUIRE=never`、`GCM_INTERACTIVE=never`、`-c credential.interactive=never`、硬超时与输出上限。依据为 Git `prompt.c` 的空值跳过路径。 +- 已在本机 Git 2.56.0 复验:`core.askPass` 与 `SSH_ASKPASS` 均设标记脚本时空 `GIT_ASKPASS` 使 `credential fill` 以 exit 128 失败且 askpass 未被调用(复核者也独立验证)。 +- 待验项:所用 subprocess provider 是否保留空字符串 env 覆盖(`SubprocessSpawnSpec.env` 语义中 string 为覆盖、undefined 为删除——空串属合法覆盖值,实现时以测试锁定);Windows/Linux 与真实 Desktop 行为列入验收矩阵。用户文案只承诺"已禁用 Git 侧提示",不承诺任意 helper 绝不弹自带 UI。 + +#### F5 → 修订 R8:API origin 与网页 origin 分别校验 + +- provider 分别声明 `apiOrigin` 与 `webOrigin` 两条 allowlist(GitHub:`https://api.github.com` 与 `https://github.com`)。携带 Authorization 的请求(含分页 Link 与重定向跟随)仅校验 `apiOrigin`;`html_url` 与"在浏览器打开"按 `webOrigin` 的 HTTPS 校验且不携带 token;Device Flow 的 `verification_uri` 按授权网页策略校验。 +- GitLab state 映射表显式写为 `opened → open`、`closed → closed`、`merged → merged`、`locked → closed`(`raw` 透传原值),避免与中立模型字面量混淆。 + +#### F6 → 修订首期范围(行内评论实现边界) + +- 行内评论坐标改用 GitHub 现行 `line`/`side`(多行 `start_line`/`start_side`),不以已弃用的 `position` 为契约。 +- 远端 diff 模型为每个片段保存 `path`、base/head SHA、old/new 原始行号与 side;渲染层引用稳定锚点而非反推行号;行内提交的 `commit_id` 与锚点取自**同一比较快照**。 +- 删行、重命名、多 hunk、过期线程、多行范围、patch 缺失/截断列入验收;无法可靠定位的内容可浏览但禁用行内提交并说明原因。 + +本轮验证:本机 Git 2.56.0 复验空 `GIT_ASKPASS` 跳过 askpass 路径;其余仍为设计层处理,未做跨平台与真实远端验证。 + +### 2026-10-21 复核处理再审 + +**结论:F2、F4、F5、F6 在设计层通过;F1 的检出起点问题已闭合,尚需补齐 tracking ref 缺失时的步骤;F3 的登录尝试隔离成立,但 token 刷新协议还需明确。ADR 继续 Proposed。** 本轮只复核前述修改,不扩展功能范围,也不将设计层通过等同于实现或跨平台验证通过。 + +#### G1 · P1 · token 刷新不能仅以登录 attemptId 作为写回前置条件(复核处理 F3) + +上节引入持久 revision,但实际写回条件只写了 `record.attemptId === 本尝试`。attemptId 适合判断某次 Device Flow 是否仍有效;同一次登录后的多次刷新却可能共享它,不能据此区分旧、新 token 版本。若两个进程都在锁外读到旧 refresh token 并调用刷新,再进入 mutate 比较同一个 attemptId,仍未保证旋转安全。[GitHub 刷新协议](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#refreshing-an-access-token-with-a-refresh-token)会让成功使用过的旧 refresh token 与旧 access token 失效,因此锁内“只比较后写回”不能代替最初要求的完整串行 RMW。 + +建议将登录和刷新明确为两条协议: + +- **登录完成**:在 mutate 中验证 `state='authorizing'` 且 attemptId/预期 revision 匹配,写入 authenticated grant,并生成新的持久 revision;后续登录、登出和清除本地授权都使旧尝试失效。 +- **刷新**:在 `modifyRecord` 的同一个互斥 mutate 回调内重新读取最新 authenticated grant,检查 revision、有效期与 refresh token;其他进程已经刷新则直接使用最新记录。仍需刷新时,使用锁内读出的最新 token,在同一互斥区间完成有界网络刷新及新 token 对的持久替换,并生成新 revision。不能先在锁外轮换 token,再仅靠写回 CAS 宣称刷新安全;若实现另选跨进程刷新租约,需把该协议完整写明。 + +验收应断言两个调用者同时发现到期时,同一旧 refresh token 只被用于一次刷新;第二个调用者读到新记录后不再使用旧 token,且旧刷新结果不能覆盖登出或新登录。这里需要明确的是正文既有“刷新经 modifyRecord 互斥 RMW”的完整含义,不新增认证功能。 + +#### G2 · P2 · single-branch clone 仍缺可执行的 upstream 配置路径(复核处理 F1) + +本轮用临时仓库独立验证:`clone --single-branch --branch main` 后,将 PR 抓到私有 ref 并从正确 SHA `switch -c feature`,HEAD 正确;但随后 `git branch --set-upstream-to=origin/feature` 以 exit 128 失败,因为该 tracking ref 不存在。它不会因为本地分支或私有 ref 已建立而自动生成。仅把此场景加入矩阵还不足以定义成功行为,且此时本地已切换分支,不能把整个操作描述成无副作用的失败。 + +建议在修改本地检出前准备 tracking:确认允许跟踪的 remote/head,补齐缺失的 remote-tracking ref,并为 single-branch clone 明确增加该 head 的 fetch 映射(保留原有映射);然后从已核验 SHA 创建分支并设置 upstream。另一种方案是明确该 clone 的 tracking 不受支持,并在切换前拒绝,但会缩小现有拟议范围。无论采用哪种方案,若分支已创建/切换而后续配置失败,回执必须报告实际 HEAD 与未完成步骤,不自动删除用户可能已开始使用的分支。 + +本轮验证:Git 临时仓库复现 tracking exit 128;核查最新处理文本及 GitHub 刷新契约;追加后运行 `pnpm docs:check`、空白检查。没有再次运行已通过的 askpass 试验,没有执行真实 token 刷新或远端写操作。 + +### 2026-10-21 再审处理(拟议) + +对 G1、G2 的处理如下;复核通过后视为对上节拟议及 Decision 正文的修订。仍为拟议,不代表已实现。 + +#### G1 → 修订 F3/R2:登录与刷新是两条不同的锁内协议 + +`attemptId` 只管"哪次登录尝试有权写回",不管 token 版本。刷新按其自身协议在**同一个** `modifyRecord` 互斥回调内完成全序列: + +- **登录完成**:mutate 内验证 `state==='authorizing' && attemptId===本尝试` → 写 authenticated grant + 生成新 revision。登出、清除本地授权、新登录都使旧 attemptId 失效。 +- **刷新**:mutate 回调内重新读取最新记录——非 authenticated 或 revision 不符 → 返回 `undefined` 原样保留(另一进程已改写,调用方读最新值即可);`expiresAt` 尚有裕量(如 60s skew)→ `undefined`;确需刷新时,**使用锁内刚读出的最新 refreshToken,在同一互斥区间内做有界网络刷新并持久替换 + 新 revision**。后到者拿锁时读到的是新记录与新 refreshToken,自然不再使用旧 token——同一旧 refresh token 至多被用于一次刷新调用。 +- 刷新失败分类:transient(网络/5xx/超时)→ 返回 `undefined` 不写,本次操作按凭据过期上报、下轮重试;`invalid_grant` 等永久失败 → 写 `{ v:1, state:'reauth-required', revision:新 }`——持久抑制发现链、UI 提示重新授权(不删记录:删了会落回发现链静默换身份;也不叫 signed-out——用户没有主动登出)。响应缺 `refresh_token` 时保留原值;无 `refreshToken` 的 token 到期同样转 `reauth-required`。 +- 边界与限度:互斥区内的网络调用带硬超时与 AbortSignal,持锁时间有界;跨进程互斥以 backing store 支持为条件(R7 限定保留),不支持时退化为"重试并上报"而非声称已串行;不自建租约协议。 + +#### G2 → 修订 F1/R4:先备齐 tracking,再改本地检出 + +检出前准备全部前置(此前不动用户可见状态),按序: + +1. 核验 payload 的 remote/head 属于本地 origin/upstream 聚合解析出的允许集合(远端身份绑定,见 F2 处理); +2. fetch → 私有 ref `refs/dsh-git-remote/pr/{repoKey}/{n}` → 核验 == API `head.sha`; +3. **tracking 预备**:`refs/remotes//` 缺失或落后 → `git update-ref refs/remotes// ` 由已核验本地对象直接补齐,不做第二次网络 fetch(消除两次 fetch 间分支移动的竞态窗口);再检测 `remote..fetch` 映射——head 不在映射内(single-branch clone)→ `git config --add remote..fetch +refs/heads/:refs/remotes//`(追加、保留原映射,使后续 `fetch ` 持续覆盖该分支); +4. `git switch -c `(或复用同名同 SHA 分支——复用前同样先经第 3 步补齐再核验其 upstream); +5. `git branch --set-upstream-to=/`——tracking ref 已在第 3 步保证存在; +6. 中途失败回执:步骤 1–3 失败 → 无任何本地改动;步骤 4 之后失败(如 `--set-upstream-to` 竞态失败或 config 写入被拒)→ 返回实际 `HEAD` SHA 与未完成步骤标记(如 `upstreamNotConfigured`),**不自动删除**用户已能看见/使用的分支。整端点计入 `isRepositoryWrite`(含 config 写入)。 + +备选且否决:single-branch clone 拒绝 tracking——缩小范围且与本协议修复成本相当。 + +### 2026-10-21 再审处理复核(中断后重新核对) + +**结论:G1 的锁内并发刷新主流程已闭合;G2 的 update-ref 方案可以保留,无需第二次 fetch。** 本轮未发现新的 P1,以下三项 P2 是新增处理中的并发保护、失败回执和保证范围修正。ADR 保持 Proposed;设计层通过不等于实现或跨平台验收通过。上次中断时本节尚未落盘,本节以重新核对后的证据为准。 + +#### H1 · P2 · 刷新“至多一次”保证需与网络失败重试规则一致(再审处理 G1) + +同一个互斥回调已解决两个调用者同时使用旧 refresh token 的正常并发路径,无需重开 G1 的主方案。但“同一旧 token 至多被用于一次调用”与“所有网络/5xx/超时原样保留、下轮重试”不能同时无条件成立:服务端可能已经完成旋转,客户端却未收到响应。[GitHub 刷新协议](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#refreshing-an-access-token-with-a-refresh-token)规定成功刷新后旧 access/refresh token 失效;响应丢失时是否成功,客户端并不知道。成功收到 token 对但持久化失败也存在远端已生效、本地未提交的边界。 + +建议显式分开“确定未发出/未生效,可有界重试”和“结果未知”。对结果未知,推荐停止自动重放旧 token,提示重新授权并保持发现链抑制;若产品选择一次有界重试后根据 `bad_refresh_token` 等错误转重新授权,也必须如实声明 best-effort,不再承诺严格至多一次。持久化失败需单独上报,不能报告其他进程已被持久抑制。不支持跨进程互斥的 provider 也不能靠重试取得该保证,应明确自动旋转的可用前提。 + +验收补充:服务端已旋转但响应丢失、旋转成功但记录写入失败;验证重试次数、最终提示及不回退其他身份。本条是失败协议与保证范围的澄清,不表示锁内刷新设计仍有原来的并发漏洞,也不声称已复现真实 GitHub 的网络故障。 + +#### H2 · P2 · update-ref 需携带预期旧 SHA,防止覆盖检查后的并发推进(再审处理 G2,第 3 步) + +“缺失或落后”的读取判断与无条件 `git update-ref ` 之间仍有窗口:另一个会话或用户后台 fetch 可先推进相同 tracking ref,随后本操作将其写回较旧快照。本轮临时仓库重现了此交错:初始值确实落后于 verifiedSha,但检查后先写入更晚提交,无条件 update-ref 仍将其覆盖;使用预期旧 SHA 后 exit 128,较新值保留。 + +建议使用 [update-ref 的 old-oid 条件](https://git-scm.com/docs/git-update-ref):已有 ref 带 observedOldSha,缺失 ref 带 Git 定义的“不存在”预期值;条件失败则有界重读判断。若 ref 已相同或领先,保留现值;分叉时不静默强改。checkout 始终从 verifiedSha 起步,tracking ref 不必为本次 checkout 回退到同一 SHA,因此无需第二次 fetch。该步骤表达“用已核验快照准备 tracking”,不代表重新查询并确认了远端 branch 的最新状态。 + +验收加入检查后后台 fetch 推进、缺失 ref 被另一个调用者创建、tracking ref 已领先或分叉;已有分支仍不得被自动移动。本轮也验证了成功路径:手工补 tracking ref、追加 head fetch 映射后,设置 upstream 成功,实际 HEAD 保持 verifiedSha。 + +#### H3 · P2 · 准备步骤失败的保证应限定为未改检出,而非无本地改动(再审处理 G2,第 6 步) + +第 2 步 fetch、第 3 步 update-ref/config 已经写入仓库对象、ref 或配置。本轮在临时仓库 update-ref 成功后,用占用的 `config.lock` 令追加映射以 exit 255 失败;先前 ref 修改仍保留。因此“步骤 1–3 失败 → 无任何本地改动”不成立。 + +建议改为“准备阶段失败不创建/切换用户分支,不修改 index/工作文件”;回执从首次持久写入开始记录已完成阶段,包括私有 ref、tracking ref、fetch 映射是否变化。ref/config 不在同一事务中,不承诺跨二者原子回滚,也不盲目清理以免覆盖并发修改。切换前失败报告“未切换,准备部分完成”;切换后继续报告实际 HEAD 与未完成步骤。 + +本轮验证:四个临时 Git 场景分别确认无条件更新覆盖并发推进、old-oid 条件拒绝覆盖、config 失败保留 ref 修改、手工 tracking ref + 映射成功配置 upstream;核对 Git/GitHub 官方契约;追加后运行 `pnpm docs:check` 和空白检查。未执行真实 OAuth、远端写入或 Windows/Linux/Desktop 验收。 + +### 2026-10-21 H 组复核处理(拟议) + +对 H1–H3 的处理如下;复核通过后视为对上节拟议及 Decision 正文的修订。仍为拟议,不代表已实现。 + +#### H1 → 修订 G1/F3:刷新失败按"是否已生效"分类,保证范围收窄为严格不重放 + +- 失败分三类处理,不再笼统"网络错误下轮重试": + - **确定未生效**(连接被拒/DNS/发送前即取消——请求未到达服务端)→ 有界重试(即时 ≤1 次 + 下轮自然重试); + - **结果未知**(超时/响应丢失发生在请求可能已到达之后)→ **停止自动重放旧 refreshToken**,在同一 mutate 内写 `{ state:'reauth-required', revision:新 }` 并提示重新授权,发现链保持抑制。服务端已旋转的极端情况下旧 token 已死,严格不重放是正确的;服务端未旋转时则需一次本可避免的重授权——为换取"同一凭据快照至多发起一次刷新交换"的严格保证接受此代价。据此正文不再承诺"所有网络错误可重试"; + - **持久化失败**(服务端已旋转、`modifyRecord` 提交被拒)→ 错误如实上报"远端已更新、本地记录未提交";本地留存的旧 token 下次使用必然被服务端拒(`bad_refresh_token`/`invalid_grant`)→ 走永久失败路径转 `reauth-required`,自然收敛,不虚构"已知远端状态"的回执。 +- 跨进程互斥以 backing store 支持为前提(R7/G1 限定保留);不支持时自动旋转不可用,该前提在 capability/文档中明示,而不是靠重试代偿。 +- 备选且否决:"结果未知时有界重试一次 + 依据 `bad_refresh_token` 判死"——必须以 best-effort 声明放弃严格至多一次,复杂度换来的只是少一次偶发重授权。 + +#### H2 → 修订 G2/F1:`update-ref` 带预期旧 SHA 条件 + +- `git update-ref refs/remotes// `:ref 已存在 → `expectedOldSha` 取读到的当前值;缺失 → 取 Git 定义的全零"不存在"预期值。 +- 条件失败(exit 128)→ 有界重读判断,分三种去向:当前值已等于 `verifiedSha` → 直接用;当前值领先于 `verifiedSha`(远端在两次读之间推进)→ 并入既有"上游 race → 刷新重试"路径而非强改;分叉(非祖先关系)→ 失败上报,不静默覆盖。 +- 语义澄清:该步骤只表达"用已核验快照准备 tracking",不代表重新确认了远端分支最新状态;checkout 起点始终是 `verifiedSha`,tracking ref 不为本次检出回退,因此仍无需第二次 fetch。 +- 验收加入:读取后后台 fetch 推进、缺失 ref 被并发创建、ref 已领先、ref 分叉四类交错;已有用户分支不被移动的承诺不变。 + +#### H3 → 修订 G2/F1:失败保证限定为"未改检出" + +- 措辞修正:准备阶段(fetch 私有 ref / `update-ref` / `config --add`)失败 → "**不创建或切换用户分支,不修改 index 与工作文件**",替代不成立的"无任何本地改动"。 +- 回执自首次持久写入起记录已完成阶段(`privateRefWritten` / `trackingRefPrepared` / `fetchMappingAdded`);ref 与 config 不在同一事务,不承诺跨二者原子回滚,也不盲目清理以免覆盖并发写入。 +- 切换前失败 → "未切换,准备部分完成" + 已完成阶段清单;切换后失败 → 实际 `HEAD` SHA + 未完成步骤标记(如 `upstreamNotConfigured`)。 + +### 2026-10-21 H 组处理复核与冻结评估 + +**结论:H2、H3 设计层通过;H1 的结果未知分类和互斥前提通过,但严格不重放的保证仍与持久化失败路径冲突。仅余原 H1 的一项 P2,不新增 P1。ADR 保持 Proposed,尚不能声明所有评审项已闭合。** + +H2 接受用 expected-old 条件保护 tracking ref,遇领先值时有界刷新重试比直接保留更保守,但属于合理取舍;H3 已正确区分检出未变与仓库元数据部分完成。既定验收保留,不需再次调整架构。 + +**H1 剩余问题:被服务端拒绝的第二次请求仍然是重放。** 上节“持久化失败后,本地旧 token 下次使用被拒,再转重新授权”可以收敛到可解释的错误状态,却不能同时满足“同一凭据快照至多发起一次刷新交换”。反例不需要并发:进程 A 用记录 revision R 的 token 成功刷新,新记录提交失败;进程 B 或重启后的 A 再读到仍为 R 的记录,再次请求刷新。第二次虽失败,同一旧快照已发起两次交换。结果未知分支写 `reauth-required` 失败时也具有相同边界;崩溃发生在远端已执行、本地未提交之间时同理。 + +冻结前应明确采用下列哪一种保证,不能混写: + +- **最小改动:有条件的不重放。** 只对刷新结果或抑制态已成功持久提交、且 backing store 提供互斥的路径保证跨进程不重放;提交失败后当前进程停止自动刷新并报告错误,明确跨进程/重启后的严格保证不成立。保留必须重新授权的产品取舍,无需新增存储协议,但不能称为覆盖持久化失败的严格至多一次。 +- **坚持跨故障严格保证:先持久化发送意图。** 在发交换请求之前,成功提交一次性刷新尝试标记;其他调用者以及重启恢复看到未完成标记时不得再次使用该 token,只能等待已知在途结果或进入重新授权。标记无法持久提交则不得发送。成功后再提交新 token 对,失败后保留不重放证据。这仍可使用同一 GrantRecord,但需要明确准备/执行/完成的恢复协议,不能用“网络刷新后同一次 modifyRecord 的最终提交”代替发送前持久化。 + +前者是更小的契约修订;后者才能支撑上节期望的跨故障严格承诺。选择属于本 ADR 的保证范围,不要求重新评审已通过的 UI、checkout 或 provider 抽象。验收需加入远端刷新成功而本地提交失败、抑制 marker 提交失败、发送后提交前重启三类交错,并分别检查当前进程与第二个调用者的交换次数。 + +本轮核查依据为最新 H 组文本及持久提交顺序的协议反例;未执行真实 token 刷新或宣称实现已有该错误。追加后运行 `pnpm docs:check` 与空白检查,跨平台与实现测试仍按既定矩阵执行。 + +### 2026-10-21 冻结评估处理(拟议) + +**选择:先持久化发送意图的跨故障严格保证(评审列出的第二方案)。** 它用同一 GrantRecord 的状态机表达,且把网络调用移出互斥锁——比 G1 的"锁内网络刷新"更轻。修订 G1/F3/R2 相应描述如下。 + +- GrantRecord payload 状态机扩为 `authorizing | authenticated | refreshing | signed-out | reauth-required`;每次持久提交携带新的不可复用 `revision`。 +- 刷新为**两阶段**协议,两次提交都经 `modifyRecord` mutate 锁内 CAS: + 1. **prepare**:mutate 内判定 `authenticated` 且到期 → 写 `{ state:'refreshing', refreshId, basedOnRevision }`;**标记成功提交后才允许发送交换请求,提交失败则不发送**(本次操作按凭据不可用上报,下轮可重试 prepare——token 未被消费过,重试安全); + 2. **execute**:marker owner 在锁外发送刷新请求(互斥锁不覆盖网络调用,持锁时间因此有界且不含网络往返); + 3. **complete**:mutate 内验证 `state==='refreshing' && refreshId===本尝试 && basedOnRevision===R` → 写 authenticated 新 token 对 + 新 revision;状态已变(signed-out/新登录/reauth-required)→ 丢弃已收到的 token,不覆盖。 +- **旁观与恢复规则**:任何调用者读到 `refreshing` 即视为"交换在途或已发生"——先按记录时间做有界等待(轮询记录,≤TTL)等 finisher 提交;超时仍未完成 → mutate CAS 转 `reauth-required`。**非 marker owner 绝不再用该快照发送**;进程在"marker 已提交、请求未发出"间崩溃同样按已燃烧处理——为严格保证接受的保守代价。 +- **修订后的保证表述**:"**一个可能被服务端消费过的凭据快照,绝不再次用于刷新交换**"。安全重试的唯一例外是**传输层能确认请求未发出**(连接未建立/发送前取消)。发送后的任何非确定性结果一律视为可能已消费:确定性拒绝(`invalid_grant`/`bad_refresh_token`)→ 转 `reauth-required`;结果未知——含超时、响应丢失与**任意 5xx**(网关 502/504 不能证明上游未执行,非 2xx 状态码不构成"未消费"依据)→ CAS 转 `reauth-required`,绝不重放。未来仅当 provider 对某类错误给出可引用的"未消费"保证时,才逐项加入例外。 +- **complete 阶段持久化失败**:收到的新 token 对有效但未落盘 → 视为丢失丢弃;记录仍为 `refreshing`,旁观规则接管至 `reauth-required`;错误如实上报"远端已更新、本地未提交",不虚构已持久化。 +- 据此,G1 中"互斥区内做有界网络刷新"作废,替换为本节两阶段协议;"至多一次"的保证范围从"已持久提交的路径"提升为**覆盖崩溃、重启与持久化失败的全路径**,唯一不构成消费的例外是"请求确认未发出"。 + +验收增补(对齐评审要求):远端已旋转而本地 complete 提交失败;`refreshing`/`reauth-required` marker 提交失败;发送成功后 complete 提交前进程重启;**上游已旋转但调用者收到 502/504**——断言不回退 `authenticated`、不重发旧快照——分别断言每个调用者的交换次数与最终状态;旁观者 `refreshing` 等待有界且不会自行重发。 + +### 2026-10-21 两阶段严格方案复核 + +**结论:prepare/execute/complete、先提交意图后发送、complete CAS、旁观恢复及 complete 提交失败路径在设计层通过。仅余原 H1 的一个 P2:5xx 被当作确定未消费缺乏契约依据。** 不新增 P1,也不重开其他已通过事项;ADR 仍为 Proposed。 + +上节“按 GitHub 契约,旋转只随成功响应发生”不能从本轮核查的 [GitHub OAuth 刷新文档](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#refreshing-an-access-token-with-a-refresh-token)推出:该文档说明成功使用 refresh token 后旧 token 失效,没有承诺所有 HTTP 5xx 都代表未旋转或已回滚。[RFC 9110 §9.2.2](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2)要求非幂等请求的自动重试有幂等语义或原请求未执行的依据;[502/504 的语义](https://www.rfc-editor.org/rfc/rfc9110.html#section-15.6.3)只说明网关未取得有效/及时的上游响应,不能证明上游没有执行。 + +因此,协议反例为:上游已旋转 → 网关返回 504 → 当前规则将 refreshing CAS 回 authenticated → 重发旧快照。它仍违反已选择的“可能消费过绝不重发”。这是失败模型分析,不是对 GitHub 实际故障行为的观测。 + +**最小补正:删除以 5xx 状态码认定未消费的例外;发送后收到 5xx 同样按结果未知处理,CAS 转 reauth-required,保留不重放证据。** 安全重试例外仅保留传输层能确认请求未发出的情形;无法证明未发送则按未知。未来若 provider 的某个明确错误有可引用的“未消费”保证,再逐项加入,而不能从非 2xx 状态推断。本补正符合已选严格策略,无需改动两阶段架构。 + +验收增加“上游已旋转但调用者收到 502/504”,断言不回退 authenticated、不重发旧快照;其余既定矩阵保留。落实该单点补正后,本轮 H1 的设计问题即可闭合,无需再增加存储协议。冻结时还需将多轮修订归并为可直接执行的最终 Decision,历史评审保留;正式 Accepted 与实现完成仍是不同状态。 + +本轮只核查协议和官方契约,未执行真实 OAuth 或跨平台试验;追加后运行 `pnpm docs:check` 与空白检查。 + +### 2026-10-21 设计评审闭合确认 + +**结论:最新拟议条款已删除 5xx 安全重试例外,并将发送后的任意 5xx、超时、响应丢失统一归为结果未知;H1 闭合。本 ADR 已登记的 R/F/G/H 各组 P1、P2 技术设计评审项全部闭合,可以进入最终 Decision 归并与冻结评估。** 本结论是对最新拟议条款的复核结果;先前各轮问题与结论保留为当时状态的历史记录。 + +本轮核对了 prepare 提交失败不发送、锁外 execute、complete CAS、结果未知不重放、complete 提交失败保留 refreshing 证据,以及 502/504 验收断言,未发现需要重开设计评审的问题。这里的“闭合”不表示功能已实现、验收已通过或 ADR 已 Accepted;状态继续保持 Proposed,真实 OAuth、跨平台与实现矩阵仍按已登记要求执行。 + +**归并方式:采用重排,不再追加第二份并列生效的 Decision。** 将最终归并文本放入正文 `Decision`,作为唯一的拟议实施依据;原始 Decision 原文移入明确标注的“初始决策(历史)”,全部评审/处理记录按时间保留。归并版应完整吸收已采纳修订,消除硬门禁方法、发现链、权限字段、认证状态机、刷新两阶段协议、checkout/tracking 与失败回执中的旧表述;不能要求实施者再逐轮推导何者生效。此次仅确认归并规则,尚未执行重排或将状态改为 Accepted。 + +本轮 `pnpm docs:check` 与空白检查通过;没有新增实现代码或执行真实远端操作。 diff --git a/docs/decisions/README.en.md b/docs/decisions/README.en.md index b245b3c..b443eda 100644 --- a/docs/decisions/README.en.md +++ b/docs/decisions/README.en.md @@ -15,3 +15,4 @@ Holds **why important design decisions were made**: why this option won among th | [ADR-0001](ADR-0001-graph-lane-not-owned-by-branch.md) | Accepted | Graph lanes are not owned by branches | | [ADR-0002](ADR-0002-graph-lane-cap-max-three-lanes.md) | Accepted | The Graph view renders at most 3 lanes | | [ADR-0003](ADR-0003-dsh-compatibility-contract.md) | Accepted | Commit to DSH support through an exact release list | +| [ADR-0004](ADR-0004-remote-platform-integration.md) | Proposed (review closed) | Git remote-platform integration: credentials-seam gate, REST transport, provider abstraction | diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 15abb4e..129b056 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -15,3 +15,4 @@ | [ADR-0001](ADR-0001-graph-lane-not-owned-by-branch.md) | Accepted | Graph lane 不归 branch 所有 | | [ADR-0002](ADR-0002-graph-lane-cap-max-three-lanes.md) | Accepted | Graph 视图最多渲染 3 条 lane | | [ADR-0003](ADR-0003-dsh-compatibility-contract.md) | Accepted | 用精确版本清单承诺 DSH 兼容性 | +| [ADR-0004](ADR-0004-remote-platform-integration.md) | Proposed(评审闭合,待冻结) | Git 远程平台集成:credentials seam 门禁、REST 传输、provider 抽象 | diff --git a/docs/development/README.en.md b/docs/development/README.en.md index 6601830..f20924c 100644 --- a/docs/development/README.en.md +++ b/docs/development/README.en.md @@ -16,7 +16,8 @@ The `prepare` script is `pnpm run build` (tsc declarations plus both bundle face | Purpose | Command | | --- | --- | | Install (strict lockfile) | `pnpm install --frozen-lockfile` | -| Full test run | `pnpm test` | +| Unit and Client specifications | `pnpm test` | +| Isolated browser E2E | `pnpm test:e2e` ([instructions](browser-e2e.en.md)) | | Build-artifact bundle/manifest assertions | `pnpm test:artifact` | | Build (types + bundle) | `pnpm build` | | DSH compatibility contract check | `pnpm compat:check` | diff --git a/docs/development/README.md b/docs/development/README.md index c93b3cf..6b330a5 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -16,7 +16,8 @@ | 目的 | 命令 | | --- | --- | | 安装(锁文件严格模式) | `pnpm install --frozen-lockfile` | -| 全量测试 | `pnpm test` | +| 单元与客户端规格 | `pnpm test` | +| 隔离浏览器 E2E | `pnpm test:e2e`(见[运行说明](browser-e2e.md)) | | 构建产物 bundle/manifest 断言 | `pnpm test:artifact` | | 构建(类型 + bundle) | `pnpm build` | | DSH 版本契约检查 | `pnpm compat:check` | diff --git a/docs/development/browser-e2e.en.md b/docs/development/browser-e2e.en.md new file mode 100644 index 0000000..d00d0ff --- /dev/null +++ b/docs/development/browser-e2e.en.md @@ -0,0 +1,35 @@ +[中文](browser-e2e.md) | English + +# Isolated browser E2E + +- **Status**: Current + +## Running + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install chromium +pnpm test:e2e +``` + +Linux CI installs browsers with `pnpm exec playwright install --with-deps chromium`; the PR/main CI runs this suite. To use an existing local Chrome installation, run `DSH_E2E_BROWSER_CHANNEL=chrome pnpm test:e2e`. Playwright still uses a separate temporary user directory, never the everyday browser profile. On Windows, set the equivalent environment variable in your shell before running the tests. + +Entry points are `playwright.config.ts` and `tests/e2e/auth.e2e.ts`. Playwright starts a dedicated Vite fixture server bound only to `127.0.0.1:4179`. An occupied port fails startup rather than reusing another service. Tests run serially, with an independent in-memory credential store and temporary Git repository for each scenario. Failure screenshots, traces and error context remain in `test-results/e2e/`; that directory and Playwright reports are ignored by Git. + +## Isolation and validation boundaries + +The browser runs the repository's actual `RemoteAuthControl` / `RemoteAuthSection`, Client controller and capability store. A loopback HTTP adapter calls the real `GitRemoteRuntime`, authentication engine and GitHub provider. Repository reads execute real Git using temporary HOME / USERPROFILE / XDG_CONFIG_HOME / GH_CONFIG_DIR values and isolated global/system Git configuration. User helper settings and token environment variables are never read. + +Deterministic fixtures replace external GitHub OAuth/REST services and gh/credential-helper probes, issuing only synthetic sentinel tokens. The backend intercepts outbound requests; the browser rejects non-loopback requests. Tests never contact real GitHub, alter existing user grants, open real authorization pages or send remote writes. Fixture control endpoints are registered only by the test Vite configuration and are excluded from the published package. Server shutdown cancels runtime work and removes temporary repositories. + +The current seven browser cases cover: + +- Chinese/English labels for gh, OAuth and credential references; +- Disconnecting and reconnecting GitHub CLI through the method radios (the signed-out marker is cleared first inside the same run), plus a dialog-width assertion and screenshots; +- The complete internal anonymous-to-OAuth device-login flow and cancellation; +- Absence of token bodies from browser RPC responses and page content; +- Authentication isolation between separate browser contexts and credential stores. + +Missing/unknown sources and invalid authentication states are covered separately by `tests/remote-auth-section.client.spec.tsx`. The regression for authentication changes overlapping an older capability read lives in `tests/remote-client.spec.ts`. + +These are browser-to-plugin-runtime E2E tests with substituted external service boundaries. They do not replace real GitHub OAuth/SSO, system credential-helper compatibility or complete DSH Host integration acceptance. Real-service acceptance remains tracked in the [remote-platform plan](../plans/active/remote-platform-integration.en.md); source semantics belong to the [remote-platform RPC contract](../reference/remote-platform-rpc.en.md). diff --git a/docs/development/browser-e2e.md b/docs/development/browser-e2e.md new file mode 100644 index 0000000..caf3102 --- /dev/null +++ b/docs/development/browser-e2e.md @@ -0,0 +1,35 @@ +中文 | [English](browser-e2e.en.md) + +# 隔离浏览器 E2E + +- **Status**: Current + +## 运行 + +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install chromium +pnpm test:e2e +``` + +Linux CI 安装浏览器时使用 `pnpm exec playwright install --with-deps chromium`;PR / main 的 CI 已执行此套件。本机已有 Chrome 时可用 `DSH_E2E_BROWSER_CHANNEL=chrome pnpm test:e2e`,Playwright 仍使用独立临时用户目录,不连接日常浏览器 profile。Windows 可先在 shell 中设置同名环境变量,再执行测试。 + +测试入口为 `playwright.config.ts` 与 `tests/e2e/auth.e2e.ts`。Playwright 启动只监听 `127.0.0.1:4179` 的专用 Vite fixture 服务;端口已占用时拒绝启动,不复用其他服务。测试串行执行,每个场景创建独立的内存凭据存储与临时 Git 仓库。失败保留截图、trace 和错误上下文到 `test-results/e2e/`;该目录与 Playwright 报告均不纳入版本控制。 + +## 隔离与验证边界 + +浏览器实际运行仓库的 `RemoteAuthControl` / `RemoteAuthSection`、Client 控制器与 capability store,经 loopback HTTP 适配器调用真实 `GitRemoteRuntime`、认证引擎和 GitHub Provider。Git 仓库读操作执行真实 `git`,使用临时 HOME / USERPROFILE / XDG_CONFIG_HOME / GH_CONFIG_DIR,并隔离全局与系统 Git 配置,不读取用户的 helper 配置或令牌环境变量。 + +外部 GitHub OAuth / REST 服务和 gh / credential helper 探针由确定性 fixture 替代,只发放 synthetic sentinel 令牌;后端拦截出站请求,浏览器拒绝非 loopback 请求。测试不访问真实 GitHub,不修改用户现有授权、不打开真实授权页,也不发送远端写操作。fixture 控制端点只在测试 Vite 配置中注册,不进入发布包;关闭服务后取消运行时工作并清理临时仓库。 + +当前 7 个浏览器用例覆盖: + +- gh、OAuth 和凭据引用的中英文渠道展示; +- 通过“连接方式”单选项断开连接并重连 GitHub CLI(signed-out marker 时自动先清除本地 grant/marker)、对话框宽度断言与截图; +- 设备码登录从匿名到 OAuth 的完整内部流程,以及取消授权; +- 浏览器 RPC 响应与页面不出现令牌正文; +- 独立浏览器上下文和凭据存储之间的身份隔离。 + +缺失 / 未知渠道和无效认证状态的展示另由 `tests/remote-auth-section.client.spec.tsx` 覆盖;认证变更遇到旧 capability 读取在途的回归位于 `tests/remote-client.spec.ts`。 + +这是在替代外部服务边界下的浏览器到插件运行时 E2E,不替代真实 GitHub OAuth/SSO、系统凭据助手兼容性或完整 DSH 宿主集成验收。真实服务验收状态继续以[远程平台计划](../plans/active/remote-platform-integration.md)为准;认证渠道契约见[远程平台 RPC](../reference/remote-platform-rpc.md)。 diff --git a/docs/plans/README.en.md b/docs/plans/README.en.md index 3a0f6ac..9d20c03 100644 --- a/docs/plans/README.en.md +++ b/docs/plans/README.en.md @@ -10,5 +10,5 @@ Holds **large, multi-commit implementation tasks**: goals, approach, task lists, ## Index -- Active: none. +- Active: [remote-platform-integration](active/remote-platform-integration.en.md) — staged implementation of ADR-0004 remote platform integration (In progress; stage status and evidence live in the plan body). - Completed: [npm-first-release](completed/npm-first-release.md) — first npm release and the release pipeline. diff --git a/docs/plans/README.md b/docs/plans/README.md index c582e42..792237e 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -10,5 +10,5 @@ ## 索引 -- 进行中:暂无。 +- 进行中:[remote-platform-integration](active/remote-platform-integration.md) —— ADR-0004 远程平台集成分阶段实施(In progress;阶段进度与验收证据以计划正文为准)。 - 已完成:[npm-first-release](completed/npm-first-release.md) —— npm 首次发布与 Release 流水线。 diff --git a/docs/plans/active/README.en.md b/docs/plans/active/README.en.md index 5db7d3a..c3a9017 100644 --- a/docs/plans/active/README.en.md +++ b/docs/plans/active/README.en.md @@ -6,4 +6,4 @@ Only **currently executing** large plans live here. Every plan states its status ## Entries -None. +- [remote-platform-integration](remote-platform-integration.en.md) — ADR-0004 remote platform integration; In progress, started 2026-10-22. Covers S0–S7; stage status and acceptance evidence live in the plan body. diff --git a/docs/plans/active/README.md b/docs/plans/active/README.md index 4a0d4ee..cb0f784 100644 --- a/docs/plans/active/README.md +++ b/docs/plans/active/README.md @@ -6,4 +6,4 @@ ## 条目 -暂无。 +- [remote-platform-integration](remote-platform-integration.md) —— ADR-0004 远程平台集成;In progress,2026-10-22 开始。覆盖 S0–S7;阶段进度与验收证据以计划正文为准。 diff --git a/docs/plans/active/remote-platform-integration.en.md b/docs/plans/active/remote-platform-integration.en.md new file mode 100644 index 0000000..de6668a --- /dev/null +++ b/docs/plans/active/remote-platform-integration.en.md @@ -0,0 +1,209 @@ +[中文](remote-platform-integration.md) | English + +# Plan: Remote platform integration (ADR-0004) + +- **Status**: In progress +- **Started**: 2026-10-22 +- **Completed**: + +## Background + +**Current**: The remote-platform implementation is present; full external/platform acceptance is pending. Existing local Git behavior and old-host support remain the baseline. The [final Decision in ADR-0004](../../decisions/ADR-0004-remote-platform-integration.md#decision) is the sole design authority; clause numbers below refer to that section, not historical review rounds. Technical review items are closed, but the ADR remains **Proposed (pending freezing)**. Authorization to start implementation does not automatically promote it to Accepted. + +**Planned / In progress**: Implement the complete ADR in dependency order, S0→S7. S0 analysis/documentation and the S1 safe contracts/Host boundary have landed; S1 delivers only the fail-closed boundary (structured `off` answers and `git/remote-unavailable` rejections) and does not make the remote feature available. S2–S6 authentication, REST, Git integration, guarded writes and Client are wired; S7 notifications are implemented. Complete seams enable Remote and old hosts remain fail-closed. Automated tests, full-release-matrix evidence and real-platform acceptance are recorded separately; this plan remains active until all acceptance is complete. + +## Goals + +### Requirement analysis + +| Requirement | Observable acceptance | Stages | +| --- | --- | --- | +| Portable remote entry | Main-fiber right-sidebar `git.remote`; GitHub first, with a provider-neutral model rather than GitHub-bound UI; old hosts still load and run local Git | S1, S3, S6, S7 | +| PR workflows | List, detail, file comparison, PR comments, inline comments and approve/comment/request-changes reviews, checkout, create, close, merge | S3–S6 | +| Limited Issue scope | Read-only list, detail, comments; create `issue-{n}-{slug}` through existing branch operations | S3, S4, S6 | +| Identity and permission safety | All-or-nothing credentials seam; writable storage is neither authentication nor repository/action permission; every write binds a repository, stable viewer, and one credential snapshot | S1–S3, S5 | +| Honest failures and concurrency | Persist refresh send intent and never replay possibly consumed refresh credentials; verify checkout SHA, protect concurrent tracking updates, report partial effects; do not misreport unknown remote writes | S2, S4, S5 | +| Lifecycle and budgets | Stop admission, cancel/await owned work on unload; isolate account caches, back off rate limits, deduplicate notifications; Desktop is removable enhancement only | S2, S3, S6, S7 | + +Acceptance must extend beyond happy paths: prove no token reaches RPC, logs, or argv; enforce separate API/browser allowlists; prevent workspace/account/generation changes from causing cross-account writes or stale UI. + +## Non-goals + +- No Review Mode, editor embedding, GitLab, GHES, or self-hosted implementation; retain structure for later providers only. +- No Issue mutations, custom queries, hover cards, completion, TODO→issue, or local application of suggestions. +- Create PR accepts already-pushed remote branches, including forks. It does not push or create forks; direct users to the existing push RPC. +- Do not take over Git transport authentication or bridge OAuth tokens into `git fetch/push`; no automatic branch deletion or promise of atomic rollback across refs/config. +- Implementation never touches real credentials, real OAuth, remote writes, or destructive actions; dependency installation/network access needs prior approval, and no commits, pushes, or publication happen without instruction. + +## Design + +### Boundaries and implementation locations (Planned) + +Extend the [current architecture](../../architecture/overview.md): Host owns capability gating, credential runtime, REST providers, Git integration, and `/git` RPC. Client receives only wire-safe state/results; its main fiber owns all portable UI. Git continues through `ctx.subprocess` with separate argv values, without shell, Electron, or Desktop runtime dependencies. Optional Desktop notifications belong in a child `ctx.inject(['desktop'], ...)` fiber declaring only consumed structural methods. + +Implement alongside existing Host entry/service, shared types, `src/compat/`, Client controller/sidebar, and their tests. Keep remote state machines and provider modules separate from local Git paths. Establish `docs/reference/remote-platform-rpc.md` as the canonical stable RPC reference when contracts land; architecture links to it rather than duplicating schemas. + +### Stages and acceptance gates + +| Stage | Dependencies | ADR clauses | Exit criteria | +| --- | --- | --- | --- | +| S0 Analysis and plan | None | 1–15 | Bilingual plan/indexes and documentation checks complete; ADR status unchanged | +| S1 Safe contracts and Host boundary | S0 | 1, 4, 7, 9, 15 | Incomplete seam is off; complete seam is also off while implementation is pending; no local Git regression | +| S2 Credential/auth runtime | S1; dependency lanes and store guarantees | 2–7 | Deterministic discovery, sign-in/out, refresh, and unload-failure tests | +| S3 Neutral provider and REST reads | S2 | 7, 8, 10, 13, 15 | Bounded, cancellable, account-isolated PR/Issue reads and permission facts | +| S4 Repository identity and Git integration | S3 identity/snapshots | 9–11, 15 | Checkout SHA/CAS/partial receipts and Issue branch temporary-repository tests pass | +| S5 Guarded writes | S2–S4 | 9, 12, 15 | Single-snapshot identity checks, SHA preconditions, unknown-outcome reconciliation; every mutator gated | +| S6 Portable Client | S1–S5 | 1, 5, 7, 9, 12, 15 | Complete PR/Issue/auth UI, two-step confirmations, safe inline anchors; unload preserves local UI | +| S7 Notifications and integration acceptance | S1–S6 | 1, 13–15 | Removable notification enhancement, full compatibility matrix and final acceptance evidence; only then complete the plan | + +Passing a stage completes only that gate. Missing later capabilities remain Planned, without a placeholder Remote UI. In particular, S1 must not equate “seam detected” with “remote feature enabled.” + +## Checklist + +### S0 — Analysis and bilingual plan + +- [x] Complete requirements, security boundaries, staged acceptance, bilingual plans, and both index levels; `pnpm docs:check` and `git diff --check` passed, with evidence in the log below. Preserve the user's ADR/decision indexes verbatim without status promotion. + +### S1 — Safe contracts and Host boundary + +- [x] Define secret-free capability, opaque generation, viewer, permission, action, structured error/receipt contracts. Keep local credential key/ref grammar equivalent to upstream, with branded constructors and no runtime credentials import. Landed in `src/remote/types.ts` (published via `src/types.ts`/`./types`) and `src/remote/credentials.ts`; the key/ref grammar matches upstream `KEY_SEGMENT_PATTERN`/`REF_PATTERN` byte for byte, and `gitRemoteCredentialKey('dsh-plugin-git','github-com')` yields `dsh-plugin-git/github-com`. +- [x] Structurally probe `readRecord`, `modifyRecord`, `deleteRecord`, `resolve`, `describe`, `describeRecord`, and the actual `record-updated` emission surface (`notifyRecordUpdated`, prototype-inherited members accepted, inspected but never invoked). A generic event API is not evidence that the event exists. Any missing member disables the whole feature, without per-method degradation or version-string gating. Landed in `probeCredentialsService`. +- [x] `/git` `remote-capability` always returns structured off/reason; other unavailable `remote-*` operations return `git/remote-unavailable`. Even a complete seam reports implementation-pending/off until auth/provider runtime exists; do not register placeholder Remote UI. Landed in `src/remote/feature.ts` plus the `src/index.ts` boundary dispatch (remote endpoints are handled before `record()`/repository assertions). +- [x] Capability probes use no network or subprocesses and infer no authenticated identity from `describe*`; preserve old-host loading and Changes/Diff/Graph/Worktrees. Counter assertions show zero spawns/fetches. +- [x] Focused tests cover each missing method/event, secret leakage, zero-network/subprocess probing, dynamic seam loss, and local Git regression; document the landed RPC/architecture boundary. See `tests/remote-feature.spec.ts`, `tests/remote-endpoints.spec.ts`, and [remote-platform RPC](../../reference/remote-platform-rpc.md). + +### S2 — Credential/auth runtime + +- [x] Before consuming upstream types, add `@deepseek-ai/dsh-credentials` as an optional peer/type-only development dependency with lane-aware install/compatibility handling. Declare ranges only for lanes where the package was published; do not force nonexistent packages into old lanes. Local branded constructors retain no runtime import; use registry semver, never `workspace:`. The peer OR covers only `CREDENTIALS_SEAM_DSH_RELEASES` (from `0.2.0-rc.2`) and is marked `optional`; dev pin `0.2.0-rc.2`; the compat gate checks that subset and older lanes may install without the package. +- [x] Validate GrantRecord v1, record key/ref, and host/client/app binding. Perform every state transition through `modifyRecord` mutate/CAS with a fresh, non-reusable persisted revision; no extra files/settings/keychain. Distinguish persisted revision from Host opaque `{ instanceId, revision }`; token fingerprint is an irreversible process-local digest, never wire/log data. Landed in `src/remote/grant.ts` (`parseGrantRecord` and the state union, `dsh-plugin-git/github-com`) and `auth.ts` (`authBase` mints a `uuid` revision per commit, sha256 in-process digest). +- [x] Resolve per operation; discover authenticated grant→`GH_TOKEN`/`GITHUB_TOKEN` refs→`gh auth token`→anonymous, reporting the actual source. Run `gh` serially only for identity-establishment events: seam mount, credential events, explicit retry, and rebuilding identity after 401; at most one round per event. A 401 becomes invalid with repair guidance, not silent account fallback; anonymous applies only when no credentials ever existed. `establish()`/`resolveContext()` check grant→refs→cached `ghToken`→anonymous in order; `markInvalid` records the fingerprint to block the same token; malformed/unsupported records report `invalid` without falling back to discovery. +- [x] Allow `git credential fill` only as an explicit user action. Credential probes set `GIT_ASKPASS=''`, `GIT_TERMINAL_PROMPT=0`, `SSH_ASKPASS_REQUIRE=never`, `GCM_INTERACTIVE=never`, and `-c credential.interactive=never`, with ≤5s hard timeout, bounded output, redacted errors, no stdout/stderr forwarding, and no token in argv. Landed in `src/remote/probe.ts`: `git credential fill` runs only via the explicit `useGitCredential()`; protocol/host travel over stdin; timeout/cancellation compose through an internal AbortController that terminates the child and awaits its exit on abort; output is capped at 4096 bytes and failures become redacted `GitRemoteError`s. +- [x] Device Flow uses public client_id from `Config.remote.clientId` and `repo` scope. Persist authorizing/attemptId by CAS first, then commit only the matching attempt. Handle interval, slow_down, expiry, denial, cancellation, and repeated-login exclusion. Hide login without client_id but retain discovery; expiring credentials without refresh tokens become reauth-required. Landed in `startLogin`/`pollLogin`/`cancelLogin`; without `clientId`, `startLogin` rejects and `capability().loginAvailable === false`. +- [x] Sign-out persists signed-out and suppresses discovery across restart. “Clear local authorization” separately calls `deleteRecord` to resume discovery and explicitly does not revoke the token server-side. Landed in `signOut()` (CAS write of signed-out) and `clearLocalGrant()` (`deleteRecord`). +- [x] Refresh prepare must **commit** refreshing/refreshId/basedOnRevision before the owner sends outside the lock. Complete writes new tokens only on matching refreshId+basedOnRevision CAS. Onlookers wait a bounded interval, then require reauthentication; they never resend. Landed in `refresh()`: a lost marker CAS moves to a bounded `waitRefresh` (`refreshPollMs`/`refreshTtlMs`/`refreshExpiresAt`) and adopts the settled result; a failed prepare commit throws before any send. +- [x] Never replay after sent-request timeout, response loss, any 5xx including 502/504, deterministic rejection, complete-commit failure, crash, or restart. A crash after marker commit but before send also burns the attempt. Only transport-confirmed **not sent** permits safe retry; failed prepare commit sends nothing. Failed completion discards new tokens and reports remote-updated/local-uncommitted. Only `GitRemoteTransportNotSent`/`kind: 'not-sent'` takes the restore path permitting a later safe retry; other failures CAS to `reauth-required`; a complete-commit exception/CAS miss throws `{ stage: 'complete', remoteUpdated: true, localCommitted: false }` and keeps the marker. +- [x] Enable automatic rotation only with a **positive backing-store cross-process mutex guarantee**, not an inferred local lock or an invented upstream method. Otherwise explicitly expose/document automatic rotation as unavailable. `refreshStoreExclusive` defaults to `false`; without the attestation refresh moves to `reauth-required` without sending, and `automaticRefreshAvailable` honestly reports `false`. +- [x] Seam unload stops admission, cancels and awaits all owned work including login/refresh/probes, and clears account viewer/permission/ETag caches. Fake store/clock/transport/subprocess tests cover concurrency, CAS, crash recovery, confirmed-not-sent exception, redaction, and unload drain; no real OAuth calls. `dispose()` stops admission, aborts every controller, and `allSettled`s in-flight work; queued work re-checks disposed/aborted before starting. See `tests/remote-auth.spec.ts` (25 tests), `tests/remote-http.spec.ts` (12 tests), and `tests/remote-probe.spec.ts` (5 tests). The engine is now wired through S3–S6; old hosts retain the S1 gate. + +### S3 — Neutral provider and REST reads + +- [x] Define neutral PR/Issue/viewer/permission/error models: open/closed/merged PR state plus raw-state, explicit fork head/base identity; read PR lists/details/file comparisons/comments/threads and Issue lists/details/comments. +- [x] GitHub REST uses a version header, AbortSignal, and a per-request page cap with origin-checked cursor continuation (no total-item ceiling; the Client streams pages progressively). Separate API/web origins: authenticated requests, redirects, and pagination `Link` stay inside the API allowlist; browser `html_url`, openExternal, and device verification URLs use the HTTPS web allowlist without tokens. `gh` is not the REST transport. +- [x] Reuse successful `GET /user` results per generation to establish stable user.id; see the [RPC read contract](../../reference/remote-platform-rpc.en.md#repository-reads) for failure recovery. Expose storage, auth, viewer, and repository permissions separately. Action checks are true/false/unknown (unknown permits an attempt subject to server judgment); `canMerge` follows push, `canClose` follows authorship or push, never storage writability. +- [x] Authenticated ETag caches key by host+viewer+repo+full query string and invalidate on account changes. Anonymous refresh is manual-only; authenticated polling is ≥60s or stricter response-header limits. Honor X-Poll-Interval/Retry-After/reset, primary/secondary limits, exponential backoff, and actionable SSO/scope/auth failures. +- [x] Mock-transport tests cover hostile redirects/Link/URLs, pagination truncation, cancellation, rate limits, SSO, unknown permissions, cross-account caches, and generation invalidation; no real GitHub access. + +### S4 — Repository identity and Git integration + +- [x] Parse SSH/HTTPS remotes using machine-readable Git output; aggregate origin/upstream provider/host/owner/repo. Classify missing/unsupported remotes and establish the local allowed identity set for all target-write assertions. +- [x] Before checkout, verify payload remote/head against that set and refuse dirty workspaces. Fetch into a private PR ref containing the **base repository key**, then verify fetched SHA equals API head SHA; report head races and refresh on mismatch. +- [x] Same-repository tracking uses expected-old `update-ref` CAS, with all-zero expectation for missing refs. Bound equal/ahead-retry/divergent-refusal handling to prevent backward movement from background fetch or overwriting concurrent creation. Append missing fetch mappings, preserving single-branch clone mappings; tracking proves a verified snapshot, not remote freshness. +- [x] Switch/create from the explicit verified SHA, then configure upstream. Before reusing a same-name/same-SHA branch, prepare tracking and verify existing upstream matches. Refuse same-name/different-SHA with manual naming guidance; forks use `pr-N` without upstream. Preserve multi-worktree errors; success requires actual HEAD == verified SHA. +- [x] Receipts distinguish no checkout change with partial privateRef/trackingRef/fetchMapping preparation from a switched actual HEAD with unfinished upstream. No fake rollback, automatic branch deletion, or blind cleanup. Keep OAuth out of Git transport and distinguish platform-authenticated from Git-fetch-unauthenticated. +- [x] Reuse `createBranch`/`switchBranch` for Issue branches. Temporary-repository tests cover same-number origin/upstream PRs, dirty state, API SHA races, background-fetch progress, concurrent missing-ref creation, equal/ahead/divergence, single-branch mappings, branch reuse, multi-worktree, config errors, and all checkout failure/partial-receipt phases. + +### S5 — Guarded remote writes + +- [x] Enumerate every remote mutator in `isRepositoryWrite`: PR comments, inline comments, reviews, create/close/merge, PR checkout, Issue branches, and any other ref/config/branch writes. Do not classify reads/capability as writes. +- [x] Apply both `assertSessionRepository` and the local allowed remote host/owner/repo assertion. Payloads carry object identity, `expectedGeneration`, and `expectedViewer(host + stable user.id)`. Use **one immutable per-operation resolved credential snapshot** for identity checks and sending, never a replacement token after checking. Reject changed contexts with refresh/reconfirmation; cancel unsent writes on workspace changes. +- [x] Map merge `expectedHeadSha` to GitHub `sha`; send the viewed snapshot's SHA as review `commit_id`. Create PR models fork head/base explicitly and accepts only already-pushed branches. Preserve permission prechecks and protection/scope denials; wire close/merge two-step confirmation in S6. +- [x] Do not automatically retry non-idempotent comments, reviews, create/close/merge. Sent timeout/cancellation returns `write-result-unknown` with directed refresh/reconciliation for the affected object, not “not sent” or automatic resubmission. +- [x] Mock tests cover account changes including same login/different id, generation/repository changes, credential changes after checks, unsent cancellation versus sent unknown outcome, head-moved, and duplicate-submission prevention. Assert the actual request uses the exact checked snapshot's token. + +### S6 — Portable Client + +- [x] Main fiber polls capability and dynamically registers/removes `git.remote` type plus portable body/title. Discard/cancel replies mismatching workspace/account/generation. Remote-provider unload preserves local Changes/Diff/Graph/Worktrees without Desktop dependency. +- [x] Deliver PR/Issue browsing, file comparison, comments/reviews, checkout, create PR from pushed branches, Issue branches, and auth repair. Hide login without client_id; distinguish sign-out from clearing local authorization and platform auth from Git transport auth. Supply Chinese/English product copy through existing locale mechanisms. +- [x] Comparison snapshots retain path, base/head SHA, original old/new line numbers, and side anchors. Submit `line/side`, multiline `start_line/start_side`, and same-snapshot `commit_id`; never use deprecated position or infer original coordinates from rendered numbers. +- [x] For deletions, renames, multiple hunks, multiline ranges, outdated threads, truncation, or ambiguity without reliable anchors, keep read browsing available but visibly disable unsafe inline submission with an explanation. Do not introduce Review Mode. +- [x] Enforce two-step close/merge confirmation using the discard pattern; show tri-state permissions, renewed confirmation after head changes, and directed reconciliation for unknown outcomes instead of blind retries. +- [x] Client controller/component/lifecycle tests cover complete read/write flows, anchors, out-of-order replies, account/directory changes, dynamic seam loss, timer/subscription/request teardown, and retained local UI. + +### S7 — Notifications, compatibility, and integration acceptance + +- [x] Deduplicate review requests, own PR merged/closed/changes-requested events, and post-push create-PR suggestions by event id. Web uses an in-tab badge. Desktop enhancement calls structural `notification.show` only in its child fiber; removing it preserves core UI and badges. +- [x] Drive **every supported exact release lane** from the [compatibility contract](../../reference/dsh-compatibility.md) and its linked canonical version list, not just minimum/latest. Retain lane-aware optional credentials installation. Prove old-host install, bundle load, and local Git with a genuinely package-absent installation tree, not merely a mocked missing seam. +- [x] Record seam mount/unmount and abort/drain on both minimum seam host `0.2.0-rc.2` and current supported upper release `0.2.1-rc.2`; do not confuse development pin with latest support. Obtain user permission before any matrix installation needing network access. +- [x] Update requirements, architecture, RPC reference, compatibility contract, and relevant bilingual navigation as each stage lands. Synchronize root `README.md`/`README.zh.md` and refresh both hashes in `README.i18n.yaml`; adjust development constraints where needed so Planned/Current reflects reality. Do not defer all documentation to S7. +- [ ] Run final gates and collect manual acceptance evidence. Only after all implementation and acceptance completes, mark Completed, move **both** plans to `completed/`, and repair indexes/links. Do not archive after S0/S1 or automatically accept the ADR. + +## Validation + +### Layered evidence + +| Scope | Method and recording requirements | +| --- | --- | +| This S0 documentation task | Run **only** `pnpm docs:check` and `git diff --check`; save logs at an absolute temporary path outside the repository and report it in handoff. No build/unit/artifact checks or network installation | +| S1–S6 | Focused Host/Client tests, mock transport/store/clock, and temporary repositories per stage; record exact commands, exit codes, gates covered, and gaps | +| S7 final checkpoint | Run `pnpm compat:check`, `pnpm test`, `pnpm docs:check`, `pnpm build`, `pnpm test:artifact` once in full; preserve equivalent per-lane evidence across the full release matrix, not just a default-lane result | +| Manual/external | Real Device Flow/SSO/remote reads and writes, Windows/Linux/Web/Desktop acceptance require environment and user approval. Mark unexecuted checks **Not verified**, never substitute mocks or a successful build | + +Use redacted logs and simulated credentials throughout automated tests; never operate user credentials, real remote writes, or destructive actions. Real OAuth/remote-write acceptance needs separate authorization, isolation, and records, not an automatic consequence of checking a plan item. + +### Risks and external prerequisites + +| Item | Handling/blocking scope | +| --- | --- | +| Public OAuth client_id and App Device Flow setting | Deployment supplies/enables them; absent configuration hides login, never implies authentication is available | +| Positive backing-store cross-process mutex guarantee | Verify actual provider contract/implementation; without it automatic rotation is unavailable and documented, not enabled through an invented API | +| GitHub primary/secondary limits, organization SSO and permission policies | Mock normalized errors/budgets first; real behavior needs an approved environment. Writable storage never substitutes for auth or permission | +| Git fetch authentication and local concurrency | Use existing user Git transport; SHA/CAS and partial receipts protect concurrent changes, with actionable recovery guidance | +| Optional dependencies and old-lane installation | Update dependency handling before consumption; network installation requires approval, and old-host package-absent evidence is mandatory | +| GHES/self-hosted host-id encoding | Explicitly deferred, not a GitHub.com freezing blocker or an excuse to implement self-hosted support | + +Keep implementation on current `feat/git-remote`. If later authorized to commit, use the user's normal Git identity with absolutely no Generator or Co-author attribution; no push/publication unless requested. + +### Progress and verification log + +| Date | Stage | Record | +| --- | --- | --- | +| 2026-10-22 | S0 | Read repository rules, documentation skill/template, branch/mirror note, final 15 ADR clauses, and plan indexes. Organized the settled requirements/design and wrote bilingual plans/indexes. `pnpm docs:check` passed (55 Markdown files, 569 links, 188 filenames, exit 0); `git diff --check` passed (no output, exit 0). Log saved outside the repository, with its absolute temporary path supplied in handoff | +| 2026-10-22 | S1 | Landed `src/remote/{types,credentials,feature}.ts`, the `src/types.ts` re-export, and the `src/index.ts` remote boundary dispatch plus error adapter; added `tests/remote-feature.spec.ts` and `tests/remote-endpoints.spec.ts`. `pnpm exec vitest run tests/remote-feature.spec.ts tests/remote-endpoints.spec.ts tests/rpc-channel.spec.ts tests/host-entry.spec.ts`: 4 files, 63 tests passed; `pnpm build:types:standalone` exit 0; `pnpm docs:check` and `git diff --check` passed. Logs kept in a temporary directory outside the repository (path in handoff report) | +| 2026-10-22 | S2 | Landed `src/remote/{settings,errors,grant,http,probe,auth}.ts`, the `Config.remote` schema, and the `@deepseek-ai/dsh-credentials` optional peer (the `CREDENTIALS_SEAM_DSH_RELEASES` subset); added `tests/remote-{auth,http,probe}.spec.ts`. `pnpm exec vitest run tests/remote-auth.spec.ts tests/remote-http.spec.ts tests/remote-probe.spec.ts tests/remote-feature.spec.ts tests/remote-endpoints.spec.ts tests/rpc-channel.spec.ts tests/host-entry.spec.ts`: 7 files, 98 tests passed; `pnpm build:types:standalone`, `pnpm compat:check`, `pnpm docs:check`, and `git diff --check` all exit 0. At that stage checkpoint the engine was internal and capability remained off; subsequent work is recorded below. No real OAuth. Logs kept outside the repository at `/tmp/dsh-git-s2/` | +| 2026-10-22 | S2–S7 continuation | Fixed authentication cancellation/slow_down/URL/body deadlines; added provider/github-model/github/git/runtime/diff, RPC wiring, Client remote surface/controller and notifications. Focused tests and types passed; the main lane passed 442 tests in 43 files, compat/docs/build and four artifact tests; all 17 exact release lanes passed final gates, recorded below. Real OAuth, remote writes and cross-platform manual acceptance remain **Not verified** | +| 2026-10-22 | S3/S6 continuation | Remote list/comment/file reads moved from "fetch everything within one request budget and flag `truncated`" to cursor-driven progressive streaming: `RemotePage` exposes an opaque `next`; `remote-list-*`/`remote-comments`/`remote-files` accept `cursor`/`pages`; the provider re-checks cursor origins and keeps a per-request page bound. The Client controller now appends items with a serial background stream, deduplicates by number/id/path, and retries once at `resetAt` after a rate limit; `remote-files` continuations pin the comparison snapshot via expected SHAs. The truncated banner is gone and inline comments stay disabled until files are fully loaded; bounded collection for notification expansion and write-path coordinate checks is unchanged. `pnpm test` passed 448 tests in 43 files; `pnpm build` and `pnpm docs:check` exit 0. Real remote acceptance remains **Not verified** | +| 2026-10-22 | S3/S6 continuation | PR detail no longer renders file diffs proactively: added `remote-commits`/`remote-reviews` read endpoints (`RemoteCommit`/`RemoteReview` models, five normalized `state` values); file changes are reached through a GitHub Files link (`pr.url + '/files'`). Commit and review history reuse cursor streaming; inline comments are read-only and nested under their review via `reviewId`; the composer drops the inline submission entry (no anchor picker). `remote-files`/`remote-inline-comment` remain guarded capabilities but the Client no longer calls them. Also fixed an infinite load/open retry loop on detail-page failures by recording attempted keys. `pnpm test` passed 451 tests in 43 files; `pnpm build` and `pnpm docs:check` exit 0. Real remote acceptance remains **Not verified** | +| 2026-10-24 | S3/S6 continuation | Detail page rebuilt around a timeline and normalized body rendering: `RemoteIssue`/`RemoteComment` gained `createdAt` and `RemoteCommit` gained `committedAt` (from `commit.committer.date`); new pure `github-markdown.ts` projects the GitHub-allowed HTML subset (details/summary, comments, img/a/br/code/pre/table/lists/alerts, etc.) into readable Markdown, and `RemoteMarkdown` renders folds as native `
`; new `timeline.ts` + `RemoteTimeline` replace `RemotePullHistory` and the comments section, interleaving comments, commits, reviews and inline threads by time (empty per-reply reviews are hidden, orphan threads stand alone); the opening post renders as the first card in the same gutter alignment and the detail tab moved to 20px horizontal padding. The auth dialog is now a status card plus "Ways to connect" method rows: `authAction` takes an endpoint sequence executed in order inside one busy run, stopping at the first failure; Local/Git credential reconnects prepend `remote-clear-grant` whenever signed-out/invalid/source=grant, so the old sign-out vs clear-local-grant distinction is expressed as Disconnect vs per-method reconnect and the standalone retry/clear buttons are gone. First round: `pnpm test` passed 510 tests in 47 files and `pnpm test:e2e` passed all 7 cases. A same-day follow-up from screenshot feedback switched timeline markers to real GitHub avatars — `github-model.ts` gained `avatarUrl()` admitting only credential-free `https://avatars.githubusercontent.com`, used for the viewer and every `author.avatarUrl`; new `RemoteAvatar` (`s=size*2`, `loading=lazy`, `no-referrer`, initial-letter fallback on error/missing) with opaque markers so the gutter line never shows through. The auth dialog widened to 440px and simplified to a status card (avatar/StateDot + headline + source subline + Disconnect on the right) plus a GitHub CLI / GitHub OAuth radio group whose checked state derives from capability only; method descriptions, the disconnect hint and the transport note were removed (the `remote-use-git-credential` endpoint and git-credential source display remain). Verification results are unchanged: all gates exit 0. Real remote acceptance remains **Not verified** | + +## 2026-10-22 automated acceptance snapshot + +All gates exited 0: `pnpm compat:check`, `pnpm test --maxWorkers=4`, `pnpm docs:check`, `pnpm build`, `pnpm test:artifact`. Worker limits isolate resource contention across concurrent lanes without reducing coverage. Earlier unbounded concurrent runs hit existing UI specifications' five-second timeouts; all final lanes were rechecked successfully. + +The user authorized network installation in isolated temporary copies. The original development pin remains 0.2.0-rc.2; the working directory's original locked versions were restored solely to refresh installation metadata. For old lanes, .pnpmfile.cjs omits the root optional credentials peer development edge while preserving the published manifest, and upgrade-dsh.mjs removes its dev pin. Registry inspection found credentials packages published for some older versions; package absence is an explicit test condition, not an inference about publication dates. + +remote-endpoints exercises seam mount/unmount and preservation of local Git using each real Cordis version. Mock store/HTTP auth/provider tests exercise abort/drain, immutable snapshots and unknown writes. Temporary repositories cover checkout/CAS/partial receipts; Client/controller/component specifications cover cancellation, stale replies, two-step confirmation and optional notification unload. Minimum seam and supported upper release execute the same specifications. This does not prove real credential backing-store exclusion, OAuth/SSO or remote writes. + +| Exact release snapshot | Business/contract tests | Credentials installation | All gates | +| --- | --- | --- | --- | +| `0.1.5-rc.2` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.1.5-rc.3` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.1.6-alpha.1` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.1.6-alpha.2` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.1.7-alpha.1` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.1.7-alpha.2` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.1.7-rc.1` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.1.7-rc.2` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.2.0-rc.1` | 441 passed, 1 absent-package differential skipped | Absent, asserted | Passed | +| `0.2.0-rc.2` | 442 passed | Matching exact release | Passed | +| `0.2.1-alpha.1` | 442 passed | Matching exact release | Passed | +| `0.2.1-alpha.2` | 442 passed | Matching exact release | Passed | +| `0.2.1-alpha.3` | 442 passed | Matching exact release | Passed | +| `0.2.1-alpha.4` | 442 passed | Matching exact release | Passed | +| `0.2.1-alpha.5` | 442 passed | Matching exact release | Passed | +| `0.2.1-rc.1` | 442 passed | Matching exact release | Passed | +| `0.2.1-rc.2` | 442 passed | Matching exact release | Passed | + +Old lanes skip only the constructor differential requiring the absent optional package; all business tests execute. Each lane passes four artifact tests. No-secret-on-RPC/argv and no credentials runtime import invariants remain enforced. Public neutral models are exported through ./types, with per-lane type/artifact rechecks. + +**Not verified**: real Device Flow/SSO, real remote reads/writes, and Windows/Linux/Web/Desktop manual scenarios. Missing public client_id hides login; absent cross-process store exclusion disables rotation. The plan remains In progress, is not archived, and does not automatically accept the ADR. Changes are saved in one complete commit; no push or publication. External temporary log run identifiers are dsh-git-remote-final and dsh-git-remote-matrix-20261022. + +## Related Documents + +- [ADR-0004: final Decision (sole design authority)](../../decisions/ADR-0004-remote-platform-integration.md#decision) +- [Scope and requirements (Current baseline)](../../requirements/scope-and-requirements.md) +- [Current architecture](../../architecture/overview.md) +- [DSH compatibility contract](../../reference/dsh-compatibility.md) +- [Root README (existing features and installation)](../../../README.md) +- [Documentation maintenance rules](../../../.agent/skills/documentation/SKILL.md) +- [Branch and mirror layout](../../../.agent/note/branch-and-mirror-layout.en.md) +- [Plan index](../README.en.md) / [Active index](README.en.md) diff --git a/docs/plans/active/remote-platform-integration.md b/docs/plans/active/remote-platform-integration.md new file mode 100644 index 0000000..5961a22 --- /dev/null +++ b/docs/plans/active/remote-platform-integration.md @@ -0,0 +1,209 @@ +中文 | [English](remote-platform-integration.en.md) + +# Plan: 远程平台集成(ADR-0004) + +- **Status**: In progress +- **Started**: 2026-10-22 +- **Completed**: + +## 背景 + +**Current**:远程平台实现已落地,完整外部/平台验收仍待完成;现有本地 Git 功能及旧宿主支持仍是基线。[ADR-0004 的最终 Decision](../../decisions/ADR-0004-remote-platform-integration.md#decision) 是本计划唯一的设计依据(下文条款号均指该节),不从历史评审讨论重建决策。技术评审项已经闭合,但 ADR 状态仍是 **Proposed(待冻结)**;用户授权开始实施不等于自动晋级为 Accepted。 + +**Planned / In progress**:按 S0→S7 的依赖顺序实现完整 ADR。S0 分析/文档与 S1 安全契约/Host 边界已落地;S1 只交付 fail-closed 边界(结构化 `off` 应答与 `git/remote-unavailable` 拒绝),不能据此宣称远程功能完成。S2–S6 的认证、REST、Git 集成、受保护写与 Client 已接入,S7 通知实现已接入。完整 seam 下提供远程入口;旧宿主仍 fail-closed。阶段自动验证与完整矩阵/真实平台验收分别记录,尚未完成全部验收,不归档本计划。 + +## 目标 + +### 需求分析 + +| 需求 | 可验收结果 | 阶段 | +| --- | --- | --- | +| 可移植远程入口 | 主 Client fiber 的右侧栏 `git.remote`;GitHub 为首个 provider,中立模型不绑定 GitHub UI;旧宿主仍可加载并使用本地 Git | S1、S3、S6、S7 | +| PR 全流程 | 列表、详情、文件比较、PR 评论、行内评论和 approve/comment/request-changes review、checkout、创建、关闭、合并 | S3–S6 | +| Issue 有限范围 | 列表、详情、评论只读;通过现有分支能力创建 `issue-{n}-{slug}` | S3、S4、S6 | +| 身份与权限安全 | credentials seam 全有或全无;存储可写不等于认证,更不等于仓库/动作权限;所有写操作绑定仓库、稳定 viewer 和单一凭据快照 | S1–S3、S5 | +| 失败与并发诚实 | 刷新发送意图持久化、可能已消费的刷新不重放;checkout 核验 SHA、保护 tracking 并发、回报部分完成;远端写未知结果不冒充失败/成功 | S2、S4、S5 | +| 生命周期与预算 | 卸载停止准入并取消/等待所属工作;账户缓存隔离、限流退避、通知去重;Desktop 只作可卸载增强 | S2、S3、S6、S7 | + +验收不能只覆盖 happy path:还需证明无 token 进入 RPC、日志或 argv;API 与浏览器 URL 分别受独立 allowlist 限制;workspace/account/generation 变化不会串账号写入或污染 UI。 + +## 非目标 + +- 不实现 Review Mode、编辑器嵌入、GitLab、GHES 或自建实例;仅为后续 provider 保留结构。 +- 不做 Issue 写操作、自定义查询、hover 卡片、补全、TODO→issue、suggestion 本地应用。 +- 创建 PR 只接受已推送的远端分支(含 fork);不在创建流程中 push,也不创建 fork,引导用户走现有 push RPC。 +- 不接管 Git 传输认证,不把 OAuth token 桥接到 `git fetch/push`;不自动删除分支或承诺跨 ref/config 的原子回滚。 +- 实施全程不操作真实凭据、真实 OAuth、远端写入或破坏性动作;依赖安装/网络访问先获批准,未获指示不提交、不推送、不发布。 + +## 方案 + +### 边界与实施落点(Planned) + +沿用 [当前架构](../../architecture/overview.md):Host 负责安全门禁、凭据运行时、REST provider、Git 集成和 `/git` RPC;Client 只接收 wire-safe 状态/结果,主 fiber 拥有全部可移植 UI。Git 继续由 `ctx.subprocess` 以分离 argv 调用,不引入 shell、Electron 或 Desktop 运行时依赖。可选 Desktop 通知在子 `ctx.inject(['desktop'], ...)` fiber 中,仅声明实际消费的结构方法。 + +按现有 Host 入口/服务、共享类型、`src/compat/`、Client controller/侧栏及对应测试组织实现;远程状态机与 provider 模块应独立于本地 Git 路径。稳定 RPC 契约届时在 `docs/reference/remote-platform-rpc.md` 建立唯一参考,架构文档链接而不重复其 schema。 + +### 阶段与验收门 + +| 阶段 | 前置依赖 | ADR 条款 | 退出条件 | +| --- | --- | --- | --- | +| S0 分析与计划 | 无 | 1–15 | 双语计划/索引与文档检查完成;不改变 ADR 状态 | +| S1 安全契约与 Host 边界 | S0 | 1、4、7、9、15 | 不完整 seam 明确 off;实现尚缺时完整 seam 也 off;本地 Git 零回归 | +| S2 凭据与认证运行时 | S1;依赖车道及存储保证 | 2–7 | 发现、登录/登出、刷新及卸载故障路径有确定性测试 | +| S3 中立 provider 与 REST 读取 | S2 | 7、8、10、13、15 | 有界、可取消、隔离账户的 PR/Issue 读取及权限事实 | +| S4 仓库身份与 Git 集成 | S3 的身份/快照 | 9–11、15 | checkout SHA/CAS/部分回执与 Issue 分支临时仓库测试通过 | +| S5 受保护写操作 | S2–S4 | 9、12、15 | 单快照身份核对、SHA 前置与未知结果收敛;全部写端点纳入门禁 | +| S6 可移植 Client | S1–S5 | 1、5、7、9、12、15 | PR/Issue/认证完整界面、两步确认及安全行内锚点;卸载无本地 UI 损失 | +| S7 通知与集成验收 | S1–S6 | 1、13–15 | 通知增强可卸载、完整兼容矩阵和最终验收证据;才可完成计划 | + +阶段通过只表示该门完成;缺失的后续能力仍标为 Planned,不对外暴露占位 Remote UI。尤其 S1 不能将“探测到 seam”冒充“远程功能已启用”。 + +## 任务清单 + +### S0 — 分析与双语计划 + +- [x] 完成需求/安全边界/阶段验收分析、双语计划及两级索引;`pnpm docs:check`、`git diff --check` 均通过,证据见下方日志。保持用户已有 ADR/决策索引原样,不晋级状态。 + +### S1 — 安全契约与 Host 边界 + +- [x] 定义无 secret 字段的 capability、opaque generation、viewer、permission、action、结构化错误/回执契约;本地 credential key/ref 的语法与上游等价,构造器保留品牌类型且不引入运行时 credentials import。落在 `src/remote/types.ts`(经 `src/types.ts`/`./types` 发布)与 `src/remote/credentials.ts`;key/ref 语法与上游 `KEY_SEGMENT_PATTERN`/`REF_PATTERN` 逐字一致,`gitRemoteCredentialKey('dsh-plugin-git','github-com')` 产出 `dsh-plugin-git/github-com`。 +- [x] 结构性探测 `readRecord`、`modifyRecord`、`deleteRecord`、`resolve`、`describe`、`describeRecord` 及实际 `record-updated` 事件发射面(`notifyRecordUpdated`,允许原型继承、只读不调用);不能用通用事件 API 存在冒充该事件受支持。缺任一项整功能 off,不逐方法降级,不按版本字符串判断。落在 `probeCredentialsService`。 +- [x] `/git` 的 `remote-capability` 始终返回结构化 off/reason;不可用时其他 `remote-*` 返回 `git/remote-unavailable`。即使 seam 完整,在认证/provider 运行时尚未完成时也返回 implementation-pending/off,不注册占位 Remote UI。落在 `src/remote/feature.ts` + `src/index.ts` 边界分发(remote 端点在 `record()`/仓库断言之前处理)。 +- [x] capability 探测不发网络、不启动子进程,不从 `describe*` 推断已认证身份;保持旧 Host 加载路径和 Changes/Diff/Graph/Worktrees 不变。计数断言 spawn/fetch 均为零。 +- [x] 定向测试逐个缺失方法/事件、secret 不泄露、探测零网络/子进程、动态 seam 消失及本地 Git 回归;同步已落地的 RPC/架构边界文档。见 `tests/remote-feature.spec.ts`、`tests/remote-endpoints.spec.ts` 与 [远程平台 RPC](../../reference/remote-platform-rpc.md)。 + +### S2 — 凭据与认证运行时 + +- [x] 使用上游类型前先补齐 `@deepseek-ai/dsh-credentials` optional peer/type-only 开发依赖及 lane-aware 安装/兼容规则;只为确实发布该包的车道声明相应范围,不强迫旧车道安装不存在的包。本地 branded 构造器保持零运行时导入;依赖一律 registry semver,禁止 `workspace:`。peer OR 只覆盖 `CREDENTIALS_SEAM_DSH_RELEASES`(`0.2.0-rc.2` 起)并标 `optional`,开发 pin `0.2.0-rc.2`;兼容门禁按该子集校验,旧车道可无该包安装。 +- [x] 校验 GrantRecord v1、记录 key/ref 与 host/client/app 绑定;所有状态迁移经 `modifyRecord` mutate/CAS,每次持久提交使用不可复用 revision,不另写文件/settings/keychain。区分持久 revision 与 Host opaque `{ instanceId, revision }`;token fingerprint 仅为进程内不可逆摘要,不上 wire/日志。落在 `src/remote/grant.ts`(`parseGrantRecord`/状态 union,`dsh-plugin-git/github-com`)与 `auth.ts`(`authBase` 每次提交换 `uuid` revision、sha256 进程内 digest)。 +- [x] 每次操作 resolve;发现链为 authenticated grant→`GH_TOKEN`/`GITHUB_TOKEN` refs→`gh auth token`→匿名,并如实报告 source。`gh` 仅在 seam mount、凭据事件、显式重试、401 后身份重建事件中串行执行,每事件至多一轮;401 置 invalid 并要求修复,不静默换账号,匿名仅用于从未有凭据的情形。`establish()`/`resolveContext()` 按序检查 grant→refs→缓存的 `ghToken`→anonymous;`markInvalid` 记 fingerprint 阻断同 token 复入;malformed/unsupported 记录直接 `invalid` 不回落发现。 +- [x] `git credential fill` 只允许用户显式触发;凭据探针设置 `GIT_ASKPASS=''`、`GIT_TERMINAL_PROMPT=0`、`SSH_ASKPASS_REQUIRE=never`、`GCM_INTERACTIVE=never`、`-c credential.interactive=never`,硬超时 ≤5s、限制输出、错误脱敏;stdout/stderr 不透传,token 不进 argv。落在 `src/remote/probe.ts`:`git credential fill` 仅由 `useGitCredential()` 显式调用;protocol/host 经 stdin 传入;超时/取消组合经内部 AbortController,abort 时 terminate 并等待退出;输出上限 4096,失败统一为脱敏 `GitRemoteError`。 +- [x] Device Flow 由 `Config.remote.clientId` 提供公共 client_id、scope 为 `repo`;先 CAS 持久化 authorizing/attemptId,再完成匹配 attemptId 的提交。覆盖 interval、slow_down、过期、拒绝、取消、重复登录互斥;缺 client_id 隐藏登录入口但保留发现。无 refresh token 的到期凭据转 reauth-required。落在 `startLogin`/`pollLogin`/`cancelLogin`;缺 `clientId` 时 `startLogin` 拒绝且 `capability().loginAvailable === false`。 +- [x] sign-out 持久化 signed-out 并在重启后继续抑制发现;“清除本地授权”独立调用 `deleteRecord` 恢复发现,明确不是服务端撤销。落在 `signOut()`(CAS 写 signed-out)与 `clearLocalGrant()`(`deleteRecord`)。 +- [x] 刷新 prepare 必须**先提交** refreshing/refreshId/basedOnRevision,再由 owner 在锁外发送;complete 仅在 refreshId+basedOnRevision CAS 匹配时写入新 token。旁观者有界等待后转 reauth-required,绝不补发。落在 `refresh()`:marker CAS 未命中即转 `waitRefresh` 有界等待(`refreshPollMs`/`refreshTtlMs`/`refreshExpiresAt`),settle 后采用已提交结果;prepare 提交失败在发送前抛出。 +- [x] 刷新发送后超时/响应丢失/任意 5xx(含 502/504)、确定性拒绝、complete 提交失败、崩溃/重启均不重放;marker 提交后发送前崩溃也按可能已消费处理。只有传输层**确认未发送**才允许安全重试;prepare 提交失败不发送。complete 失败丢弃新 token 并报告远端已更新/本地未提交。`GitRemoteTransportNotSent`/`kind: 'not-sent'` 才走 restore(后续操作可安全重试);其他失败 CAS 写 `reauth-required`;complete CAS/提交异常抛 `{ stage: 'complete', remoteUpdated: true, localCommitted: false }` 并保留 marker。 +- [x] 自动旋转只在 backing store **正向保证跨进程互斥**时启用;不凭本地锁猜测、不发明上游方法。不支持时 capability/文档明确自动旋转不可用。`refreshStoreExclusive` 默认 false;未声明时 refresh 直接转 `reauth-required` 不发送,`automaticRefreshAvailable` 如实为 `false`。 +- [x] seam 卸载停止新工作、取消并等待所有所属工作(含登录、刷新、探针),清空 viewer/权限/ETag 等账户缓存。用 fake store/clock/transport/subprocess 覆盖并发、CAS、崩溃恢复、未发送例外、secret 脱敏与卸载 drain;不调用真实 OAuth。`dispose()` 停止准入、abort 全部 controller 并 `allSettled` 在途工作;queued 工作启动前复查 disposed/aborted。见 `tests/remote-auth.spec.ts`(25 例)、`tests/remote-http.spec.ts`(12 例)、`tests/remote-probe.spec.ts`(5 例)。该引擎已随 S3–S6 接入 RPC/Client;旧宿主继续由 S1 门禁保护。 + +### S3 — 中立 provider 与 REST 读取 + +- [x] 提供中立 PR/Issue/viewer/permission/error 模型:PR 状态 open/closed/merged 并保留 raw-state,fork head/base 显式区分;支持 PR 列表/详情/文件比较/评论/线程与 Issue 列表/详情/评论读取。 +- [x] GitHub REST 使用版本头、AbortSignal、单请求分页页数上限 + origin 校验游标续取(列表无总量天花板,Client 渐进流式补页);API 与 web origins 独立。认证请求、重定向及分页 `Link` 只准入 API allowlist;浏览器 `html_url`、openExternal 与 device verification URL 只准入 HTTPS web allowlist且不带 token;`gh` 不作 REST 传输。 +- [x] 每 generation 复用成功的 `GET /user`,以稳定 user.id 核验身份;失败恢复规则见 [RPC 读取契约](../../reference/remote-platform-rpc.md#仓库与读取)。分别呈现 storage、auth、viewer、仓库 permissions。动作检查为 true/false/unknown(unknown 允许尝试、服务端裁决);`canMerge` 用 push,`canClose` 用作者本人或 push,不从存储可写推导权限。 +- [x] 认证 ETag 缓存按 host+viewer+repo+完整查询串隔离;账户变化全部失效。匿名只手动刷新;认证轮询 ≥60s 或遵守响应头的更严限制,处理 X-Poll-Interval/Retry-After/reset、primary/secondary rate limit 和指数退避;SSO/scope/认证错误分类可修复。 +- [x] mock transport 验证恶意 redirect/Link/URL、分页截断、取消、限流、SSO、权限 unknown、跨账户缓存与 generation 失效;不访问真实 GitHub。 + +### S4 — 仓库身份与 Git 集成 + +- [x] 用机器可读 Git 输出解析 SSH/HTTPS remote,聚合 origin/upstream 的 provider/host/owner/repo;无 remote/不支持 provider 分类呈现,建立本地允许身份集合供所有目标写入断言。 +- [x] checkout 前核对 payload remote/head 属于允许集合并拒绝脏工作区;fetch 到含 **base repo key** 的私有 PR ref,再核验 fetched SHA 等于 API head SHA,不符报告 head race 并刷新。 +- [x] 同仓库 tracking 用 `update-ref` expected-old CAS(缺失 ref 用全零预期),有界处理已相等/已领先重试/分叉拒绝,防后台 fetch 倒退及并发创建覆盖。补 fetch 映射用追加,保留 single-branch clone 原映射;tracking 只证明核验快照而非远端最新。 +- [x] 从显式 verified SHA switch/create 后配置 upstream;复用同名同 SHA 分支前补 tracking 并核验已有 upstream 匹配。同名异 SHA 拒绝并提供手动命名;fork 用 `pr-N` 且无 upstream;保留 multi-worktree 错误。成功以实际 HEAD == verified SHA 为准。 +- [x] 回执区分未切换但 privateRef/trackingRef/fetchMapping 部分准备完成,与已切换的实际 HEAD/未完成 upstream;不假回滚、不自动删分支、不盲目清理。OAuth token 不进入 Git 传输;区分平台认证成功与 Git fetch 未认证。 +- [x] Issue 分支复用 `createBranch`/`switchBranch`。临时仓库测试覆盖 origin/upstream 同号 PR、dirty、API SHA race、后台 fetch 前进、缺 ref 并发创建、equal/ahead/divergence、single-branch 映射、复用分支、multi-worktree、config 失败及 checkout 各阶段失败/部分回执。 + +### S5 — 受保护远端写入 + +- [x] 逐个枚举并将 PR 评论、行内评论、review、create/close/merge、PR checkout、Issue 分支等所有 remote mutator 纳入 `isRepositoryWrite`(包括 ref/config/分支写入);列表/读取和 capability 不误标写。 +- [x] 同时 `assertSessionRepository` 与断言本地允许的 remote host/owner/repo;payload 包含对象身份、`expectedGeneration`、`expectedViewer(host + stable user.id)`。逐操作 resolve 的**一份不可变凭据快照**同时用于身份检查和发送,不在检查后换 token;context 变化即拒绝并要求刷新重确认,workspace 变化取消未发送写入。 +- [x] merge 将 `expectedHeadSha` 映射到 GitHub `sha`,review 将展示快照的 SHA 传为 `commit_id`;create PR 明确 fork head/base 且仅接受已推送分支。权限预检与 protection/scope 拒绝如实呈现;S6 才接入 close/merge 两步确认。 +- [x] 评论、review、create/close/merge 不自动重试非幂等请求;发送后超时/取消返回 `write-result-unknown`,提供对具体对象的定向刷新对账,不能当作未发送或自动重发。 +- [x] mock 测试账号切换(同 login 不同 id 亦然)、generation/仓库变化、检查后凭据变化、未发取消/已发未知、head-moved 和重复提交防护;断言实际请求 token 始终来自核对过的那份快照。 + +### S6 — 可移植 Client + +- [x] 主 fiber 轮询 capability,动态注册/注销 `git.remote` type 和可移植 body/title;workspace/account/generation 不匹配的回复丢弃/取消。remote provider 卸载不移除本地 Changes/Diff/Graph/Worktrees,不依赖 Desktop。 +- [x] 交付 PR/Issue 浏览、文件比较、评论/review、checkout、已推送分支建 PR、Issue 分支与认证修复界面;隐藏缺 client_id 的登录入口,区分 sign-out/删除本地授权以及平台/Git 传输认证。产品文案按现有 locale 机制提供中英版本。 +- [x] 比较快照保留 path、base/head SHA、原始 old/new 行号及 side 锚点;行内提交用 `line/side`、多行 `start_line/start_side` 与同快照 `commit_id`,不使用 deprecated position 或从渲染行号反推。 +- [x] 删除行、rename、多 hunk、多行、outdated、patch 截断或歧义无法可靠定位时,可读浏览仍可用,但可见地禁用不安全行内提交并解释原因;不扩展为 Review Mode。 +- [x] close/merge 强制两步确认(沿用 discard 模式);体现权限三态、head 变化重新确认及未知写结果的定向对账,阻止盲目重试。 +- [x] Client controller/component/lifecycle 测试覆盖完整读写流、锚点、乱序回复、账号/目录变化、seam 动态卸载、定时器/订阅/请求 teardown,以及卸载后本地 UI 保留。 + +### S7 — 通知、兼容与集成验收 + +- [x] review request、自己 PR 的 merged/closed/changes-requested、push 后建 PR 建议按事件 id 去重;Web 为 tab 内徽标。Desktop 增强仅在子 fiber 调结构性 `notification.show`,移除增强时 core UI 与徽标继续工作。 +- [x] 以 [兼容契约](../../reference/dsh-compatibility.md) 及其链接的唯一版本清单驱动**全部受支持精确 release 车道**,不只测最小/最新版本。保留 lane-aware optional credentials 安装;旧宿主用真实无该包安装树证明 install、bundle load 与本地 Git 均正常,而非只 mock seam 缺失。 +- [x] 最小可用 seam 宿主 `0.2.0-rc.2` 与当前支持上沿 `0.2.1-rc.2` 均记录 mount/unmount、abort/drain 证据;开发 pin 与“当前上沿”不混淆。矩阵安装若需网络,先取得用户许可,不自动执行。 +- [x] 每阶段落地时同步需求、架构、RPC reference、兼容契约及相应中英导航;根 `README.md`/`README.zh.md` 同步后刷新 `README.i18n.yaml` 双方 hash,必要时调整开发约束,使 Planned/Current 与真实能力一致,不能全部拖到 S7。 +- [ ] 最终检查点运行完整门禁并汇总手工验收;所有实现及验收通过后才标 Completed,同时把**两份**计划移到 `completed/` 并修复索引和链接。不因 S0/S1 完成而归档,不自动接受 ADR。 + +## 验证 + +### 分层证据 + +| 范围 | 方法与记录要求 | +| --- | --- | +| 本次 S0 文档任务 | **只运行** `pnpm docs:check` 与 `git diff --check`,日志保存在仓库外绝对临时路径并在交接中给出;不运行 build/unit/artifact,不联网安装 | +| S1–S6 | 每阶段使用相应窄范围 Host/Client 测试、mock transport/store/clock 和临时仓库;记录具体命令、退出码、覆盖的验收门与未覆盖项 | +| S7 最终检查点 | 一次完整运行 `pnpm compat:check`、`pnpm test`、`pnpm docs:check`、`pnpm build`、`pnpm test:artifact`;完整 release 矩阵逐车道保存相同门禁证据,不用一个默认车道代替矩阵 | +| 手工/外部 | 真实 Device Flow/SSO/远端读写、Windows/Linux/Web/Desktop 验收须另有环境和用户许可;未执行写 **Not verified**,不得以 mock 或构建成功替代 | + +全流程使用脱敏日志和模拟凭据;测试不操作用户凭据、真实远端写入或破坏性动作。真实 OAuth/远端写验收另行授权、隔离并记录,不能由计划勾选自动触发。 + +### 风险与外部前置条件 + +| 项目 | 处理/阻塞范围 | +| --- | --- | +| 公共 OAuth client_id 与 App Device Flow 设置 | 部署方提供并开启;未提供时隐藏登录,不能伪造可用认证 | +| backing store 跨进程互斥正向保证 | 核实实际 provider 契约/实现;无保证则自动旋转不可用且明确说明,不创造上游 API | +| GitHub rate limit、secondary limit、组织 SSO 与权限策略 | mock 先覆盖规范化错误/预算;真实行为需许可环境验证,不把 storage writable 当 auth 或权限 | +| Git fetch 认证及本地并发 | 使用用户既有 Git transport;SHA/CAS 与部分回执防并发覆盖,失败需人工可理解的修复入口 | +| 可选依赖与旧车道安装 | 更新依赖规则先于消费;网络安装必须获准,旧宿主无包证据是验收必需项 | +| GHES/自建 host-id 编码 | 明确延后,不阻塞 GitHub.com 冻结,也不顺带实现自建实例 | + +实施保留在当前 `feat/git-remote` 分支;若之后获准提交,使用用户正常 Git identity,绝不加 Generator 或 Co-author 归属;未请求不 push/publish。 + +### 进度与验证日志 + +| 日期 | 阶段 | 记录 | +| --- | --- | --- | +| 2026-10-22 | S0 | 已阅读仓库规则、文档 skill/template、branch/mirror note、ADR 最终 15 条及计划索引;需求与设计按已定结论整理,双语计划/索引已写。`pnpm docs:check` 通过(55 个 Markdown、569 个链接、188 个文件名,exit 0);`git diff --check` 通过(无输出,exit 0)。日志位于仓库外临时文件,绝对路径随交接报告提供 | +| 2026-10-22 | S1 | 落地 `src/remote/{types,credentials,feature}.ts`、`src/types.ts` 重导出、`src/index.ts` remote 边界分发与错误适配;`tests/remote-feature.spec.ts`、`tests/remote-endpoints.spec.ts` 新增。`pnpm exec vitest run tests/remote-feature.spec.ts tests/remote-endpoints.spec.ts tests/rpc-channel.spec.ts tests/host-entry.spec.ts`:4 文件 63 例全过;`pnpm build:types:standalone` exit 0;`pnpm docs:check` 与 `git diff --check` 通过;日志存仓库外临时目录(路径见交接报告) | +| 2026-10-22 | S2 | 落地 `src/remote/{settings,errors,grant,http,probe,auth}.ts`、`Config.remote` schema 与 `@deepseek-ai/dsh-credentials` optional peer(`CREDENTIALS_SEAM_DSH_RELEASES` 子集);`tests/remote-{auth,http,probe}.spec.ts` 新增。`pnpm exec vitest run tests/remote-auth.spec.ts tests/remote-http.spec.ts tests/remote-probe.spec.ts tests/remote-feature.spec.ts tests/remote-endpoints.spec.ts tests/rpc-channel.spec.ts tests/host-entry.spec.ts`:7 文件 98 例全过;`pnpm build:types:standalone`、`pnpm compat:check`、`pnpm docs:check` 与 `git diff --check` 均 exit 0。该次阶段检查时认证引擎仍未接 RPC,capability 为 `off`;后续进展见下方续作日志。无真实 OAuth。日志存仓库外 `/tmp/dsh-git-s2/` | +| 2026-10-22 | S2–S7 续作 | 认证取消/slow_down/URL/正文超时修复;落地 `provider/github-model/github/git/runtime/diff`、RPC 接线与 Client 远程 surface/controller/通知。定向测试与类型检查已通过,主车道 43 文件 442 例通过;`compat:check`、`docs:check`、`build`、`test:artifact`(4 例)均 exit 0;17 个精确版本最终门禁全过,逐车道见下方。真实 OAuth、远端写入及跨平台手工验收 **Not verified** | +| 2026-10-22 | S3/S6 续作 | 远程列表/评论/文件读取从"预算内一次取尽 + truncated 警示"改为游标渐进流式补页:`RemotePage` 增 opaque `next`,`remote-list-*`/`remote-comments`/`remote-files` 接受 `cursor`/`pages`,Provider 校验游标 origin 且单请求页数有界;Client controller 改追加式状态、后台串行续取、按 number/id/path 去重、限流按 resetAt 有界恢复一次;`remote-files` 续页以 expected SHA 钉住比较快照;truncated 警示条移除,files 未取尽前行内评论保持禁用。通知线程展开与写路径坐标校验的有界收集不变。`pnpm test` 43 文件 448 例、`pnpm build`、`pnpm docs:check` 均 exit 0。真实远端验收仍为 **Not verified** | +| 2026-10-22 | S3/S6 续作 | PR 详情页不再主动渲染文件改动:新增 `remote-commits`/`remote-reviews` 读端点(`RemoteCommit`/`RemoteReview` 模型,`state` 归一五种),文件改动改为 `pr.url + '/files'` 的 GitHub Files 页外链按钮;commit/review 历史沿用游标流式加载,行内评论按 `reviewId` 嵌套进对应 review 只读展示;composer 移除行内提交入口(无锚点选择器),`remote-files`/`remote-inline-comment` 保留为受守护能力但 Client 不再调用。附带修复详情页 load/open 失败后的无限重试循环(按 key 记已尝试项)。`pnpm test` 43 文件 451 例、`pnpm build`、`pnpm docs:check` 均 exit 0。真实远端验收仍为 **Not verified** | +| 2026-10-24 | S3/S6 续作 | 详情页重做时间线与正文渲染:`RemoteIssue`/`RemoteComment` 增 `createdAt`、`RemoteCommit` 增 `committedAt`(取自 `commit.committer.date`);新增纯模块 `github-markdown.ts` 把 GitHub 允许的 HTML 子集(details/summary、注释、img/a/br/code/pre/table/列表/alert 等)投影为可读 Markdown,`RemoteMarkdown` 以原生 `
` 渲染折叠块;新增 `timeline.ts` + `RemoteTimeline` 取代 `RemotePullHistory` 与评论分区,按时间交错排列评论、提交、review 与行内 thread(空的 per-reply review 隐藏、孤儿 thread 独立成项);详情页正文作为开场卡与后续项同槽对齐,详情页改用 20px 横向内边距。认证弹窗改为状态卡 + “连接方式”方法行:`authAction` 接受端点序列在同一 busy 周期内按序执行、首个失败即停;Local/Git credential 重连在 signed-out/invalid/source=grant 时自动前置 `remote-clear-grant`,sign-out 与清除本地授权的区别改由“断开连接”与按方式重连表达,独立的重试/清授权按钮移除。首轮 `pnpm test` 47 文件 510 例、`pnpm test:e2e` 7 例通过。第二轮(同日期)按截图反馈修订:timeline 头像改为真实 GitHub 头像——`github-model.ts` 新增 `avatarUrl()` 仅放行 `https://avatars.githubusercontent.com`(无凭据),viewer 与各 `author.avatarUrl` 经它投影;新增 `RemoteAvatar`(`s=size*2`、`loading=lazy`、`no-referrer`,onError/缺失回退首字母圆点),头像与 marker 均不透明以遮住时间线。认证弹窗加宽至 440px,并简化为状态卡(头像/StateDot + 标题 + 来源副行 + 右侧断开连接)加 GitHub CLI / GitHub OAuth 单选组,选中态仅由 capability 派生;方法说明、断开提示与传输提示文案移除(`remote-use-git-credential` 端点与 Git credential 来源展示保留)。验证结果同上,均为 exit 0。真实远端验收仍为 **Not verified** | + +## 2026-10-22 自动验收快照 + +本次完整门禁为 `pnpm compat:check`、`pnpm test --maxWorkers=4`、`pnpm docs:check`、`pnpm build`、`pnpm test:artifact`,全部 exit 0。限制 worker 是为隔离多个矩阵车道的资源争用,不减少测试。此前并行不限 worker 曾触发已有 UI 规格的 5s 超时;最终车道全部复核通过。 + +矩阵安装由用户明确授权,在仓库外临时副本执行,原开发 pin 仍为 `0.2.0-rc.2`。本工作区仅按冻结锁文件恢复原版本安装元数据,没有升级依赖。旧车道通过 `.pnpmfile.cjs` 在安装时省略根包 optional credentials peer 的开发边,发布 manifest 保持不变;`upgrade-dsh.mjs` 同时省略相应 dev pin。registry 中部分旧版 credentials 包确实已发布,本矩阵的“无包”是明确测试条件,不是对发布时间的推断。 + +`remote-endpoints` 在实际各版 Cordis 下验证 seam 挂载/卸载及本地 Git 保留;auth/provider 的 mock HTTP/store 检查 abort/drain、单快照、未知写结果;临时仓库覆盖 checkout/CAS/部分回执;Client/controller/component 规格覆盖取消、旧回复、两步确认与通知增强卸载。最小 seam 及支持上沿都执行同一组规格。它们不等于真实 credentials backing store、OAuth/SSO 或远端写入验收。 + +| 精确 release(本次快照) | 业务/契约测试 | credentials 安装树 | 所有门禁 | +| --- | --- | --- | --- | +| `0.1.5-rc.2` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.1.5-rc.3` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.1.6-alpha.1` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.1.6-alpha.2` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.1.7-alpha.1` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.1.7-alpha.2` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.1.7-rc.1` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.1.7-rc.2` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.2.0-rc.1` | 441 通过、1 条缺席包差分跳过 | 缺席(已断言) | 通过 | +| `0.2.0-rc.2` | 442 通过 | 对应精确版本 | 通过 | +| `0.2.1-alpha.1` | 442 通过 | 对应精确版本 | 通过 | +| `0.2.1-alpha.2` | 442 通过 | 对应精确版本 | 通过 | +| `0.2.1-alpha.3` | 442 通过 | 对应精确版本 | 通过 | +| `0.2.1-alpha.4` | 442 通过 | 对应精确版本 | 通过 | +| `0.2.1-alpha.5` | 442 通过 | 对应精确版本 | 通过 | +| `0.2.1-rc.1` | 442 通过 | 对应精确版本 | 通过 | +| `0.2.1-rc.2` | 442 通过 | 对应精确版本 | 通过 | + +旧车道仅跳过需要真实可选包的构造器差分测试,其余业务测试全部执行。产物检查每车道 4 例通过;无 secret 进入 RPC/argv、无 credentials 运行时导入等不变量继续生效。最终公共中立模型经 `./types` 导出,并对各车道复核类型/产物构建。 + +**仍未验证**:真实 Device Flow/SSO、真实远端读写、Windows/Linux/Web/Desktop 手工场景。未配置公共 client_id 时登录入口隐藏;没有 store 跨进程互斥保证时不自动旋转。计划保持 In progress,不归档、不自动接受 ADR;本轮作为一个完整提交保存;未 push、未发布。自动验收日志以 `dsh-git-remote-final` 与 `dsh-git-remote-matrix-20261022` 为临时运行标识保存于仓库外。 + +## 相关文档 + +- [ADR-0004:最终 Decision(唯一设计依据)](../../decisions/ADR-0004-remote-platform-integration.md#decision) +- [范围与需求总览(Current 基线)](../../requirements/scope-and-requirements.md) +- [当前架构总览](../../architecture/overview.md) +- [DSH 兼容契约](../../reference/dsh-compatibility.md) +- [根 README(现有能力及安装)](../../../README.zh.md) +- [文档维护规则](../../../.agent/skills/documentation/SKILL.md) +- [分支与镜像关系说明](../../../.agent/note/branch-and-mirror-layout.md) +- [计划总索引](../README.md) / [进行中索引](README.md) diff --git a/docs/reference/README.en.md b/docs/reference/README.en.md index b131c55..215383a 100644 --- a/docs/reference/README.en.md +++ b/docs/reference/README.en.md @@ -12,3 +12,4 @@ Holds **stable, look-up-when-needed technical facts**: internal APIs, schemas, p - [dsh-compatibility.md](dsh-compatibility.md) — Exact supported DSH releases, the static consistency gate, and candidate upgrade promotion flow. - [git-graph-layout.md](git-graph-layout.md) — Git Graph Layout Engine: the DAG → Layout → Renderer data flow, lane semantics, pagination continuation, renderer contract, and topology invariants. +- [remote-platform-rpc.md](remote-platform-rpc.md) — Wire contract of the `/git` channel `remote-*` endpoints: the structured capability answer, fail-closed rejection, and the error-code set. diff --git a/docs/reference/README.md b/docs/reference/README.md index 9ac7e2b..92aa530 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -12,3 +12,4 @@ - [dsh-compatibility.md](dsh-compatibility.md) —— DSH 精确支持版本、静态一致性门禁与升级候选晋级流程。 - [git-graph-layout.md](git-graph-layout.md) —— Git Graph Layout Engine:DAG → Layout → Renderer 数据流、lane 语义、分页 continuation、renderer 契约与拓扑不变量。 +- [remote-platform-rpc.md](remote-platform-rpc.md) —— `/git` 通道 `remote-*` 端点的线契约:结构化 capability 应答、fail-closed 拒绝与错误码集。 diff --git a/docs/reference/dsh-compatibility.md b/docs/reference/dsh-compatibility.md index 4d93d45..918fe53 100644 --- a/docs/reference/dsh-compatibility.md +++ b/docs/reference/dsh-compatibility.md @@ -34,6 +34,8 @@ `dsh-client-ui-primitives` 的发布 bundle 导入 `diff`,但上游没有将其声明为运行时依赖。本插件将 `diff`(`>=9 <10`)声明为 peer。alpha.2 的 bundle 还导入 `simple-icons@16.31.0`,同样未声明运行时依赖;本插件将 `simple-icons`(`>=16.31.0 <17`)声明为 peer,并以 `16.31.0` 做开发依赖 pin。这样严格依赖解析器也能满足上游 bundle 的导入。 +`@deepseek-ai/dsh-credentials` 是唯一不随全量清单取范围的 peer:本功能完整 `ctx.credentials` seam 的已验证子集自 `0.2.0-rc.2` 起,其 peer OR 只覆盖 [`src/compat/dsh-version.ts`](../../src/compat/dsh-version.ts) 中 `CREDENTIALS_SEAM_DSH_RELEASES` 声明的 canonical 子集(`0.2.0-rc.2` 至 `0.2.1-rc.2`),并标记 `peerDependenciesMeta.optional`;`pnpm compat:check` 按该子集校验。更早开发车道通过安装 hook 省略根包的 optional peer 开发依赖,构建/测试安装树没有该包:宿主仍能安装、加载本插件并运行本地 Git,远程平台边界把缺失 seam 结构性探测为 `credentials-missing` 的 `off`(见[远程平台 RPC](remote-platform-rpc.md)),不会要求安装不存在的版本。 + ## 0.1.5-rc.3 接口差异评估 晋级 `0.1.5-rc.3` 前,对比了本插件直接使用的十七个上游包在 `0.1.5-rc.2` 与 `0.1.5-rc.3` 的发布 manifest、`lib/` 运行时 bundle 与类型声明。这是一次仅含 manifest 修订的重发: @@ -202,3 +204,9 @@ Git Client 从 primitives 的公开入口复用 `DiffBlock`,不导入官方 `u ## 相关决策 - [ADR-0003:以精确版本清单承诺 DSH 兼容性](../decisions/ADR-0003-dsh-compatibility-contract.md) + +## 远程平台续作验收 + +Remote 的可选 seam 使用本地结构契约(`src/remote/credentials-types.ts`),生产源码/发布声明不要求旧宿主解析不存在的 credentials 包。新车道通过 optional peer/dev pin 与已发布构造器差分测试核对 key/ref 语法;旧车道不要求此 peer 拥有开发 pin,矩阵需检查真实安装树中该包缺失。主车道与完整矩阵的本次结果记录在[远程集成计划](../plans/active/remote-platform-integration.md),不以开发 pin 的通过替代逐车道证据。 + +远程正文与评论通过 `src/compat/dsh-markdown.tsx` 使用宿主的 `MarkdownText`。适配层只要求所有车道共有的 `text` / `labels` 类型,并单独传递可选的 `variant: 'compact'` 展示提示:支持该参数的宿主保留紧凑排版,较早实现忽略提示、使用默认排版,不影响内容和本地化代码控件。`tests/dsh-markdown.client.spec.tsx` 在每个车道渲染实际安装的官方组件;远程页面不再内嵌 diff,相关遗留样式也不应进入产物,原有“使用宿主 DiffBlock、不打包私有 renderer”的 artifact 断言保持有效。 diff --git a/docs/reference/remote-platform-rpc.en.md b/docs/reference/remote-platform-rpc.en.md new file mode 100644 index 0000000..fe72ff4 --- /dev/null +++ b/docs/reference/remote-platform-rpc.en.md @@ -0,0 +1,114 @@ +[中文](remote-platform-rpc.md) | English + +# Remote-platform RPC contract (remote-*) + +- **Status**: Current implementation, covered by mocks and temporary repositories; real OAuth, remote writes and platform manual acceptance remain Not verified. +- **Authority**: [ADR-0004 Decision](../decisions/ADR-0004-remote-platform-integration.md#decision) (Proposed) and the [implementation plan](../plans/active/remote-platform-integration.en.md). + +## Gate and lifecycle + +The `/git` channel disables the entire feature without a complete credentials seam. Structural checks require readRecord, modifyRecord, deleteRecord, resolve, describe, describeRecord and notifyRecordUpdated, including inherited methods; event emitters are never called by the probe. Missing names come from an allowlist; provider/getter/process/network diagnostics and secrets never propagate. + +A credentials child fiber owns authentication, REST, caches and subprocess work. Unload stops admission, cancels and drains work, preserving the local Git service and Changes/Diff/Graph/Worktrees. The Client main fiber polls capability every five seconds and registers/removes `git.remote` type/body/title. Session/workspace changes cancel old requests; account/generation changes discard stale data. Desktop notification enhancement lives in an optional child fiber. + +The seam uses local structural types without production or published-declaration imports of the optional package; older hosts can build and load without it. Release lanes are governed by the [compatibility contract](dsh-compatibility.md). + +## remote-capability + +Payload is ignored, including null. Missing seam returns `{state:'off',reason:'credentials-missing',auth:{state:'off'}}`; incomplete seams return credentials-incomplete and missing names. implementation-pending remains a fail-closed fallback when the runtime has not assembled. + +With a complete seam the result is `{state:'on',generation,credential,auth,actions,loginAvailable,automaticRefreshAvailable}`. Capability launches no subprocess or network activity; it reads storage descriptions and known in-memory authentication state. Storage configured/writable does not prove authentication or remote permissions. Initial auth is unauthenticated and actions are unknown. Repository viewer, permissions and actions are returned separately by remote-repository. + +`generation={instanceId,revision}` is opaque to clients; runtime replacement never reuses instanceId. Viewer identity is stable host/userId; login is presentation only. GET /user runs at most once per generation. Explicit retry creates a fresh generation; rejected tokens do not silently fall back to another account. + +## Configuration and authentication + +Configuration parsing/validation lives in src/remote/settings.ts and its schema in src/index.ts. All fields are optional volatile values: + +| Field | Default | Meaning | +| --- | --- | --- | +| clientId | absent | Public Device Flow client id; hide login when absent, retaining discovery | +| requestTimeoutMs | 15000 | Total HTTP deadline, including redirects and body reading | +| probeTimeoutMs | 5000 | Non-interactive probe deadline, 1–5000 ms | +| refreshTtlMs | 30000 | Persistent refresh intent and observer wait TTL | +| refreshPollMs | 250 | Positive observer interval, no greater than TTL | +| refreshStoreExclusive | false | Deployment attestation of cross-process modifyRecord exclusion; otherwise no automatic rotation | +| maxResponseBytes | 4194304 | Per-response byte cap; cancel oversized streams immediately | + +GrantRecord v1 uses key dsh-plugin-git/github-com. Transitions use modifyRecord CAS and a fresh revision. Discovery is authenticated grant → GH_TOKEN/GITHUB_TOKEN refs → gh auth token → anonymous. Ref source is provider-defined and resolved per operation; gh runs only for identity establishment/events/explicit retry, never per REST page. A rejected token becomes invalid without switching identity; loss of a previously present credential never silently becomes anonymous. Git credential fill is explicit, non-interactive and receives fixed host/protocol over stdin. Probe output never enters RPC, logs or argv; probes have bounded collection, ≤5s deadlines and exit drain. + +| Authentication endpoint | Payload / result | +| --- | --- | +| remote-login | {} → attemptId,userCode,verificationUri,expiresAt,intervalMs; no device_code/token on wire | +| remote-poll-login | attemptId → authenticated; shares one poll per attempt, accumulates slow_down, rejects denied/expired/cancelled authorization | +| remote-cancel-login | attemptId; cancel polling and CAS-finish that attempt | +| remote-sign-out | {}; persist signed-out to suppress discovery | +| remote-clear-grant | {}; delete only the local grant, resume discovery, no GitHub server revocation | +| remote-retry-auth | {}; explicitly rebuild identity, return capability | +| remote-use-git-credential | {}; explicit non-interactive helper probe, return capability | + +Device Flow verification is restricted to https://github.com/login/device. Refresh first CAS-commits an intent marker, exchanges outside the lock, then commits new tokens only for matching refreshId/basedOnRevision. Failed prepare sends nothing. Only confirmed-not-sent permits restoring the old snapshot. Timeout, cancellation, 5xx, response loss or crash never replay the exchange and converge to reauth-required. Failed completion discards new tokens and reports complete/remoteUpdated/localCommitted facts. + +## Repository reads + +remote-repositories accepts repository/sessionId? and returns `{state:'supported'|'no-remotes'|'unsupported',remotes:[{name,identity}]}`. Only supported GitHub HTTPS/SSH origin/upstream identities enter the allowlist; URL credentials and other hosts are rejected. + +Other repository endpoints carry repository/sessionId?/host:'github.com'/owner/repo. The Host verifies local remote membership. Reads return `{generation,value}`; lists use `{items,truncated,next?}`: `truncated` reports that upstream still has more beyond the fetched pages, and `next` is an opaque continuation cursor (the upstream `Link rel="next"`, origin-checked against the API allowlist and free of credentials). List endpoints additionally accept `{cursor?,pages?}`: `cursor` resumes from a previous `next` (the provider re-validates its origin and rejects foreign origins), `pages` bounds how many upstream pages one request may read (default 1, clamped to the per-request page cap). Each page holds at most 100 items (notifications 50) and a single request reads at most the page cap, but there is no total-item ceiling — the Client streams pages with the cursor until exhaustion; cache size is capped at 128 entries. + +| Endpoint | Additional fields / value | +| --- | --- | +| remote-repository | Viewer, known original permission bits, five tri-state actions; storage writability proves no remote permission | +| remote-list-prs / remote-list-issues | state?:open/closed/all, cursor?, pages?; Issue lists exclude PR entries | +| remote-pr / remote-issue | number; explicit PR head/base repository/ref/SHA, open/closed/merged plus rawState; `createdAt` keeps upstream `created_at` | +| remote-comments | number,inline?, cursor?, pages?; replyTo, commitSha, original line/side, outdated state and `reviewId` (owning review id for nested display); `createdAt` keeps upstream `created_at` | +| remote-commits | number, cursor?, pages?; `{items,truncated,next?}` of `{sha,subject,author,date?,committedAt?,url?}`, `committedAt` from `commit.committer.date` | +| remote-reviews | number, cursor?, pages?; `{items,truncated,next?}` of `{id,author,state,rawState,body,url?,submittedAt?,commitSha?}` with `state` normalized to `approved/changes-requested/commented/dismissed/pending` | +| remote-files | number, cursor?, pages?, expectedBaseSha?, expectedHeadSha?; files:{items,truncated,next?},baseSha,headSha; verify PR snapshot before/after the first page, then pin head/base on each continuation. PR detail no longer calls it proactively: file changes open the GitHub Files page via `pr.url + '/files'`; the capability remains for guarded write paths that need local coordinates | +| remote-notifications | automatic?; review requests and own PR merged/closed/changes-requested events with stable ids; expand at most ten threads, explicitly mark truncation | + +Tokens go only to https://api.github.com with X-GitHub-Api-Version:2022-11-28. Pagination Link and GET redirects obey the same allowlist; browser links independently require https://github.com, and avatar URLs (the viewer and every `author.avatarUrl`) further require credential-free `https://avatars.githubusercontent.com` or are omitted. Explicit projections prevent raw response leakage. Successful `GET /user` results are reused within a generation; a failed lookup clears the cached viewer promise so later operations can query again, subject to authentication and rate-limit gates. Authenticated ETag/body caches are isolated by origin/viewer/generation/full path and query, and cleared when identity changes. Anonymous reads are manual only. Authenticated polling waits at least 60s and honors stricter X-Poll-Interval. Rate limits honor Retry-After/reset and bounded exponential backoff. Writes never retry automatically. + +### Client loading feedback (Current) + +`github-markdown.ts` normalizes the HTML subset in remote bodies into Markdown and disclosure blocks. Numeric entities are converted only within `0..0x10FFFF`; out-of-range entities retain their original text so malformed PR/Issue bodies or comments cannot interrupt detail rendering. + +The Remote list, detail view and authentication dialog show a status banner distinguishing remote discovery, GitHub connection, list/discussion reads, authentication connection, waiting for user authorization and action submission. Network waits longer than eight seconds show a slow-response hint; waiting for Device Flow user authorization is not treated as a slow network. The initial list uses skeleton rows without prematurely reporting missing remotes or an empty filter result. Lists and detail collections request only one page initially, then fill the rest through the existing cursor stream with background-loading feedback. Refreshing or changing filters preserves existing rows and their original PR/Issue kind so their details remain accessible; writes retain busy/unknown guards. Failed requests clear loading feedback, show the error and restore the explicit refresh control. All copy is bilingual and animations respect reduced-motion preferences. + +The authentication dialog opens with a status card keyed by `auth.state`: connected (with the viewer avatar and `@login`), not connected, disconnected, and credential-invalid headlines; non-connected states carry their hints. When authenticated with a `ref`, `git-credential`, or unrecognized source, a small localized source subline appears (enum `gh` → GitHub CLI (gh), `grant` → GitHub OAuth (device login), `ref` → Credential reference (GH_TOKEN / GITHUB_TOKEN), `git-credential` → Git credential helper; missing/unrecognized sources show “Source not reported” and raw strings are never rendered). A ghost “Disconnect” on the card's right shows only while authenticated or invalid and sends `remote-sign-out` (persisted signed-out suppresses all discovery without revoking server-side tokens). Below it, “Ways to connect” is a radio group offering GitHub CLI (`remote-retry-auth`, with `remote.ghLoginHint` while unauthenticated) and GitHub OAuth (only when `loginAvailable`, starts device login); the checked option derives from capability only, so a failed switch reverts. When a grant or marker record would override the choice (signed-out, invalid, or source `grant`), the client sends `remote-clear-grant` first inside the same busy run — `authAction` executes the endpoint sequence in order and stops at the first failure. All copy is bilingual and never displays tokens, fingerprints or device codes. Authentication actions immediately refresh capability; if an older capability read is in flight, one trailing read prevents the source from remaining stale until the next timer tick. Isolation boundaries and browser regression commands are documented in [Browser E2E](../development/browser-e2e.en.md). + +### Client caching and incremental validation (Current) + +`GitRemoteFeature` owns controllers per session and view, separating the list from individual PR/Issue details. Unmounting a tab retains its state and in-flight pagination; unloading the feature cancels all work. Caches live only in the current Client memory, never on disk, and are cold after restart. New views evict idle controllers in least-recently-used order once 64 slots are occupied. Mounted controllers, active pagination/retries and unknown write outcomes are protected, so protected entries can temporarily exceed 64. Each controller also retains up to 12 list snapshots keyed by remote identity, pulls/issues and open/closed/all filters. Returning within the same workspace restores the selected filter, loaded pages and details. + +Returning to a view or filter reuses data younger than 60 seconds; stale data stays visible during validation. Explicit refresh and detail `navigation.revision` changes bypass the relevant freshness gate. Five-second capability polling alone does not revalidate lists or introduce anonymous automatic network polling. Failures retain content and errors without render-driven retry loops; explicit refresh remains available. + +Refresh validates pages from the beginning using the Host's existing ETag / `If-None-Match` support: unchanged pages reuse their bodies on 304, and only changed pages transfer new bodies. This is conditional updating per page, rather than item-level delta synchronization through an upstream change cursor. The Client merges by stable id and retains unchanged row references. Old tail pages remain visible while validation continues; the final page removes records absent from the new result, covering closures, deletions and changes in ordering or page boundaries. + +Workspace rebinding, capability off and identity/generation changes clear view/filter caches and cancel old requests. Remote writes, including failures and unknown outcomes, invalidate sibling caches for the same local repository. Tab switches, cache hits and automatic validation never clear an unknown-write gate: explicit targeted refresh is still required, and writes are never replayed automatically. + +## Guarded writes + +Every repository mutator is enumerated in isRepositoryWrite. In addition to repository identity, payloads require expectedGeneration, expectedViewer:{host,userId}, and requestId. Submission ids contain 8–200 alphanumeric/hyphen characters. A repeated process-local submission returns its original result, including unknown outcomes; retain at most 512 submissions and refuse further admission rather than evicting and replaying effects. Host checks local session directory, remote identity, stable viewer and the same token snapshot at admission and before sending; final resolution compares identity without replacing the checked sending token. + +| Endpoint | Additional fields | +| --- | --- | +| remote-comment | number,body; PR-level comment | +| remote-inline-comment | number,body,expectedHeadSha,path,line,side,startLine?,startSide? | +| remote-review | number,body,expectedHeadSha,event:APPROVE/COMMENT/REQUEST_CHANGES | +| remote-create-pr | title,body,head:{host,owner,repo},headRef,baseRef,draft?; verify already pushed branches, no push/fork | +| remote-close-pr | number,expectedHeadSha; two-step Client confirmation | +| remote-merge-pr | number,expectedHeadSha,method?:merge/squash/rebase; two-step confirmation and GitHub sha precondition | +| remote-checkout-pr | number,expectedHeadSha,branch?; optional nonconflicting branch name | +| remote-issue-branch | number; existing create/switch operations create issue-N-slug | + +src/remote/diff.ts derives anchors from original hunk headers and validates them on the Host: file/commit/side/hunk plus ordered multiline ranges. Payloads use line/side, start_line/start_side and commit_id, never deprecated position. The current portable UI renders no local diff and has no anchor picker, so `remote-inline-comment` stays unexposed; inline review comments are read-only and nested under their review via `reviewId`. + +Checkout fetches a private ref keyed by base repository hash and verifies API head SHA. Tracking updates use expected-old CAS, zero SHA for absence, head-moved for an advanced ref and refusal for divergence. Missing fetch mappings are appended. Branches start at the verified SHA; existing SHA/upstream must match. User branches and other worktrees are never forced. Receipts report switched/privateRefWritten/trackingRefPrepared/fetchMappingAdded/upstreamNotConfigured/branch, actual head when readable, and error. False means completion was not confirmed, not a promise of cross-ref/config rollback. Prepared state is not automatically deleted. Git transport uses existing Git credentials, without OAuth bridging. + +Sent-request response loss/timeout/cancellation/5xx yield git/write-result-unknown. Client retains the warning and disables further writes until explicit targeted refresh and inspection. No automatic resend occurs. Successful responses project only id/number/url/merged. Permission/scope/SSO errors and moved heads require repair or renewed confirmation. + +## Errors and evidence + +Codes: git/remote-unavailable, git/unauthenticated, git/signed-out, git/insufficient-scope, git/head-moved, git/rate-limited with resetAt, git/write-result-unknown, git/remote-identity-mismatch. Without the seam all other known/unknown remote endpoints fail closed before payload parsing. + +Automated evidence lives in tests/remote-*.spec.ts and tests/remote-surface.client.spec.tsx. The [plan log](../plans/active/remote-platform-integration.en.md#progress-and-validation-log) records real OAuth/SSO, remote-write, platform-manual and full-release-matrix status. Mocks and the development pin cannot substitute for those checks. diff --git a/docs/reference/remote-platform-rpc.md b/docs/reference/remote-platform-rpc.md new file mode 100644 index 0000000..3b84c37 --- /dev/null +++ b/docs/reference/remote-platform-rpc.md @@ -0,0 +1,114 @@ +中文 | [English](remote-platform-rpc.en.md) + +# 远程平台 RPC 契约(remote-*) + +- **Status**: Current(实现与 mock/临时仓库验证;真实 OAuth、远端写入及平台手工验收仍 Not verified) +- **设计权威**:[ADR-0004 Decision](../decisions/ADR-0004-remote-platform-integration.md#decision)(Proposed)与[实施计划](../plans/active/remote-platform-integration.md)。 + +## 门禁与生命周期 + +`/git` 通道在缺少完整 credentials seam 时整功能关闭:结构性检查 `readRecord`、`modifyRecord`、`deleteRecord`、`resolve`、`describe`、`describeRecord`、`notifyRecordUpdated`,允许原型方法,绝不调用事件发射方法。缺失成员只回传允许名单名称,getter/存储/进程/网络错误不透传 secret 或原始诊断。 + +Host 的 credentials 子 fiber 拥有认证、REST、缓存与子进程工作。卸载先停止准入并取消、等待在途工作;本地 Git service 和 Client Changes/Diff/Graph/Worktrees 保留。Client 主 fiber 每 5 秒探测 capability,按 on/off 注册、注销 `git.remote` type/body/title;请求按会话与仓库绑定,目录变化取消旧请求,账户/generation 变化丢弃旧数据。Desktop 子 fiber 仅增强通知,不拥有核心 UI。 + +credentials 使用本地结构契约,无运行时或发布声明包导入,旧宿主无此包时仍能构建和加载。兼容车道见 [DSH 兼容契约](dsh-compatibility.md)。 + +## `remote-capability` + +payload 忽略(含 `null`)。无 seam 返回 `{ state:'off', reason:'credentials-missing', auth:{state:'off'} }`;不完整 seam 返回 `credentials-incomplete` 与 `missing`。`implementation-pending` 保留为运行时未装配时的安全兜底。 + +完整 seam 的应答为 `{ state:'on', generation, credential, auth, actions, loginAvailable, automaticRefreshAvailable }`。capability 不启动子进程或网络,只读取存储描述与已知的内存认证状态。`credential.configured/writable` 不表示已认证或远端写权限;初始 `auth.state` 为 `unauthenticated`,动作是 `unknown`。仓库 viewer、permissions 与动作事实由 `remote-repository` 单独返回。 + +`generation = { instanceId:string, revision:number }` 对 Client 不透明;Host 重建时 instanceId 不复用。viewer 的稳定身份为 `{host,userId}`,login 仅展示。同一 generation 至多一次 `GET /user`;显式重试会创建新 generation,失败 token 不会因重试自动回退到其他账号。 + +## 配置与认证 + +`src/remote/settings.ts` 是配置解析/验证真源,schema 位于 `src/index.ts`。字段均为可选 volatile: + +| 字段 | 默认 | 语义 | +| --- | --- | --- | +| `clientId` | 无 | Device Flow 公共 client_id;缺少时隐藏登录,发现链仍可用 | +| `requestTimeoutMs` | `15000` | HTTP 请求总超时,含全部重定向和正文读取 | +| `probeTimeoutMs` | `5000` | 非交互凭据探针超时,范围 1–5000 ms | +| `refreshTtlMs` | `30000` | 刷新持久意图及旁观等待 TTL | +| `refreshPollMs` | `250` | 旁观间隔,正值且不大于 TTL | +| `refreshStoreExclusive` | `false` | 部署方对 store 跨进程 `modifyRecord` 互斥的正向保证;缺少时不自动旋转 | +| `maxResponseBytes` | `4194304` | 单响应正文上限,流式读取超限立即取消 | + +GrantRecord v1 的键为 `dsh-plugin-git/github-com`;迁移通过 `modifyRecord` CAS,提交使用新的 revision。发现链为 authenticated grant → `GH_TOKEN`/`GITHUB_TOKEN` ref → `gh auth token` → 匿名,ref source 以 provider 为准。ref 每操作重解析;gh 只在身份建立/事件/显式重试中运行,不能随 REST 分页反复启动。401 把该 token 标为 invalid,不自动换账号;曾有凭据后丢失不会静默变成匿名。`git credential fill` 仅用户显式触发,固定 hostname 经 stdin 输入,输出不进入 RPC、日志或 argv;所有探针非交互、≤5s、有界收集并等待退出。 + +| 认证端点 | payload / 结果 | +| --- | --- | +| `remote-login` | `{}` → `{attemptId,userCode,verificationUri,expiresAt,intervalMs}`;只发射用户码,不发射 device_code/token | +| `remote-poll-login` | `{attemptId}` → `{state:'authenticated'}`;共享同 attempt 的轮询,累计 slow_down,取消/拒绝/过期不提交授权 | +| `remote-cancel-login` | `{attemptId}`;取消轮询并 CAS 结束该 attempt | +| `remote-sign-out` | `{}`;持久 signed-out 抑制全部发现 | +| `remote-clear-grant` | `{}`;仅删除本地 grant,恢复发现,不撤销 GitHub 服务端 token | +| `remote-retry-auth` | `{}`;显式重建身份,返回 capability | +| `remote-use-git-credential` | `{}`;显式非交互 helper 探测,返回 capability | + +Device Flow 只允许 `https://github.com/login/device`。刷新先 CAS 提交 refreshing marker,再在锁外交换,最后核对 refreshId/basedOnRevision 提交新 token。prepare 失败不发送;仅传输确认未发送时可恢复旧快照;超时、取消、5xx、丢失响应或崩溃一律不重放,由 reauth-required 收敛。complete 失败丢弃新 token 并报告 `{stage:'complete',remoteUpdated:true,localCommitted:false}`。 + +## 仓库与读取 + +`remote-repositories` 接收 `{repository,sessionId?}`,返回 `{state:'supported'|'no-remotes'|'unsupported',remotes:[{name,identity}]}`。仅 origin/upstream 中受支持的 GitHub HTTPS/SSH URL 进入允许集合;带 URL 凭据或其他 host 不准入。 + +其余仓库端点都带 `{repository,sessionId?,host:'github.com',owner,repo}`,Host 核对目标属于该本地允许集合。读取返回 `{generation,value}`,列表 value 为 `{items,truncated,next?}`:`truncated` 表示上游在已取页之外还有更多内容,`next` 为不透明续页游标(上游 `Link rel="next"`,经 API origin 校验、不含 token)。列表端点额外接受 `{cursor?,pages?}`:`cursor` 从游标处继续(Provider 重新校验 origin,越界 origin 拒绝),`pages` 为本次请求最多读取的上游页数(默认 1,钳制在分页上限内)。每页 ≤100 条、单请求页数有上限(通知每页 50),列表无总条数天花板——Client 以游标渐进流式续取直至取尽;响应缓存最多 128 项。 + +| 端点 | 附加字段 / value | +| --- | --- | +| `remote-repository` | viewer、原始已知权限位、五项动作三态;存储可写不能推导远端权限 | +| `remote-list-prs` / `remote-list-issues` | `state?:open|closed|all, cursor?, pages?`;Issue 列表排除 PR | +| `remote-pr` / `remote-issue` | `number`;PR 显式保存 head/base 仓库、ref、SHA;state 为 open/closed/merged,保留 rawState;`createdAt` 保留上游 `created_at` | +| `remote-comments` | `number,inline?:boolean, cursor?, pages?`;inline 保存 replyTo、commitSha、原始行/side 与 `reviewId`(归属的 review id,供按 review 分组展示),outdated 不冒充当前坐标;`createdAt` 保留上游 `created_at` | +| `remote-commits` | `number, cursor?, pages?`;返回 `{items,truncated,next?}`,commit 为 `{sha,subject,author,date?,committedAt?,url?}`,`committedAt` 取自 `commit.committer.date` | +| `remote-reviews` | `number, cursor?, pages?`;返回 `{items,truncated,next?}`,review 为 `{id,author,state,rawState,body,url?,submittedAt?,commitSha?}`,`state` 归一为 `approved|changes-requested|commented|dismissed|pending` | +| `remote-files` | `number, cursor?, pages?, expectedBaseSha?, expectedHeadSha?`;返回 `{files:{items,truncated,next?},baseSha,headSha}`;首页读取前后核对 PR 快照,续页携带 expected SHA 并逐页核对 head/base 未动。PR 详情页不主动调用:文件改动经 `pr.url + '/files'` 跳转 GitHub Files 页查看,该能力保留给需要本地坐标的受守护写路径 | +| `remote-notifications` | `automatic?:boolean`;review request、自己 PR merged/closed/changes-requested,稳定事件 id;最多展开 10 个通知线程,超限标 truncated | + +API token 仅发往 `https://api.github.com`,带 `X-GitHub-Api-Version:2022-11-28`;Link 与 GET 重定向也校验该 origin。浏览器 URL 独立只允许 `https://github.com`;头像 URL(viewer 与各 `author.avatarUrl`)进一步只允许 `https://avatars.githubusercontent.com` 且不带凭据,其余来源一律省略。响应显式投影成中立模型,不透传 raw 对象。成功的 `GET /user` 结果在同一 generation 内复用;查询失败即清除 viewer Promise 缓存,让后续操作重新查询,仍遵守认证状态与限流门禁。认证 ETag 正文按 origin/viewer/generation/完整路径与查询隔离;身份变化清空。匿名不自动轮询,认证轮询最短 60 秒且遵守更长的 X-Poll-Interval;429、primary/secondary 403 遵守 Retry-After/reset 与有界指数退避,不自动重试写请求。 + +### Client 加载反馈(Current) + +远端正文的 HTML 子集由 `github-markdown.ts` 归一为 Markdown 与折叠块;数字实体仅在 `0..0x10FFFF` 范围内转换,越界实体保留原文,避免畸形 PR/Issue 正文或评论中断详情渲染。 + +Remote 列表、详情与认证弹窗通过顶部状态条区分查找远端、连接 GitHub、读取列表/讨论、认证连接、等待用户授权及提交操作。网络等待超过 8 秒显示慢响应提示;等待 Device Flow 用户授权不标为慢网。首次列表加载使用骨架占位,不提前显示“没有远端”或筛选结果为空。列表及详情关联集合首批仅请求一页,其余由现有游标机制后台补齐,并显示补页状态。刷新或切换筛选时保留已有条目及其原始 PR/Issue 类型,可继续打开详情;写入仍受 busy/unknown 门禁保护。请求失败后移除加载反馈、显示错误,并恢复显式刷新入口。所有文案提供中英文,动画遵守 reduced-motion 偏好。 + +认证弹窗顶部为状态卡:按 `auth.state` 显示已连接(含 viewer 头像与 `@login`)、未连接、已断开连接、凭据已失效四种标题;非已连接状态附对应提示。已连接且来源为 `ref`、`git-credential` 或未知时,附一行本地化的来源说明(枚举 `gh` → GitHub CLI(gh),`grant` → GitHub OAuth(设备码登录),`ref` → 凭据引用(GH_TOKEN / GITHUB_TOKEN),`git-credential` → Git 凭据助手;缺失或无法识别时显示“未提供来源”,永不原样渲染未知来源)。卡右侧的“断开连接”仅在已认证或失效时显示,发送 `remote-sign-out`(持久 signed-out 抑制全部发现,不撤销 GitHub 服务端令牌)。下方“连接方式”为 radio 选择:GitHub CLI(`remote-retry-auth`,未认证时附 `remote.ghLoginHint`)与 GitHub OAuth(仅 `loginAvailable`,触发设备登录),选中态只由 capability 派生,切换失败自动回退;当存在会覆盖所选方式的 grant/marker 记录(signed-out、invalid 或 source 为 grant)时先发送 `remote-clear-grant`。Client 的 `authAction` 在单个 busy 周期内按序执行多个端点、首个失败即停。所有文案有中英版本,不展示令牌、fingerprint 或 device code。认证操作完成后立即刷新 capability;旧 capability 读取在途时追加一次读取,避免渠道显示延迟到下一轮定时探测。隔离浏览器回归的边界和运行方法见[浏览器 E2E](../development/browser-e2e.md)。 + +### Client 缓存与增量校验(Current) + +`GitRemoteFeature` 按会话和视图持有控制器(列表与各 PR/Issue 详情独立),tab 卸载不再销毁状态或中断在途分页;卸载整个 feature 才统一取消。缓存仅存在当前 Client 内存,不写磁盘,重启后重新加载。最多保留 64 个控制器槽位,新增视图时按最近使用顺序淘汰空闲项;已挂载、在途分页/重试及写入结果未知的控制器不参与淘汰,因此被保护项较多时可临时超过 64。每个控制器另保留最多 12 组按远端身份、PR/Issue 类型和 open/closed/all 筛选隔离的列表快照;同工作区回到页签时恢复已选筛选、已取分页与详情。 + +切回视图或筛选时,60 秒内的数据直接复用;过期则在保留原内容的同时校验。显式刷新和详情 `navigation.revision` 变化绕过对应的新鲜度门禁。静态 capability 的 5 秒轮询本身不触发列表重新校验,也不引入匿名自动网络轮询。失败保留旧内容与错误,避免渲染触发重试循环,仍可显式刷新。 + +刷新仍从首页起逐页校验,复用 Host 已有的 ETag / `If-None-Match`:未变化页以 304 复用正文,仅变化页重新传输正文;这是分页级条件更新,并非依赖上游变更游标的逐条增量同步。Client 按稳定 id 合并并保留未变化行的对象引用;刷新期间保留尚未校验的旧尾页,最后一页完成后移除本轮已不存在的记录,覆盖关闭、删除及排序/分页位置变化。 + +仓库重绑定、功能 off、登录身份/generation 变化会清空视图和筛选缓存并取消旧请求;远端写入(含失败或未知结果)使同本地仓库的其他缓存过期。未知写结果门禁不会因切 tab、缓存命中或自动校验而解除,仍须显式定向刷新;不自动重放写请求。 + +## 受保护写操作 + +所有仓库 mutator 逐项进入 `isRepositoryWrite`。payload 在仓库字段之外,必须有 `{expectedGeneration,expectedViewer:{host,userId},requestId}`。requestId 为 8–200 位字母/数字/连字符,同一进程重复提交只返回第一次的结果,包括未知结果;最多保留 512 个提交,满后拒绝,不能用淘汰重放副作用。Host 在准入和发送前核对会话仓库、远端身份、stable viewer 与单份 token 快照;最后解析只用来比较,发送仍用被核对的原 token。 + +| 端点 | 附加字段 | +| --- | --- | +| `remote-comment` | `number,body`(PR 级评论) | +| `remote-inline-comment` | `number,body,expectedHeadSha,path,line,side,startLine?,startSide?` | +| `remote-review` | `number,body,expectedHeadSha,event:APPROVE|COMMENT|REQUEST_CHANGES` | +| `remote-create-pr` | `title,body,head:{host,owner,repo},headRef,baseRef,draft?`;检查已推送的 head/base 分支,不 push/fork | +| `remote-close-pr` | `number,expectedHeadSha`;Client 两步确认 | +| `remote-merge-pr` | `number,expectedHeadSha,method?:merge|squash|rebase`;Client 两步确认,映射到 GitHub sha 前置条件 | +| `remote-checkout-pr` | `number,expectedHeadSha,branch?`;可指定无冲突分支名 | +| `remote-issue-branch` | `number`;复用 create/switch 创建 `issue-N-slug` | + +行内坐标由 `src/remote/diff.ts` 从原始 hunk 解析并在 Host 校验:必须属于同文件、commit、side、hunk;多行范围还须有序。提交用 line/side、start_line/start_side、commit_id,不用 deprecated position。当前便携 UI 不渲染本地 diff、没有锚点选择器,因此不暴露 `remote-inline-comment` 入口;行内 review 评论只读展示并按 `reviewId` 嵌套在对应 review 下。 + +checkout 抓取到含 base repo hash 的私有 ref,核验 API head SHA;同仓库 tracking 用 expected-old CAS(不存在用零 SHA),已领先则 head-moved、分叉拒绝,缺映射仅追加。分支从核验 SHA 创建/复用,核对已有 upstream;不覆盖用户分支,不强制切换其他 worktree。回执包含 switched、privateRefWritten、trackingRefPrepared、fetchMappingAdded、upstreamNotConfigured、branch、实际 head(可得时)与 error。false 表示该阶段未确认完成,不承诺跨 ref/config 原子回滚;出错不自动删除准备结果。Git 传输使用用户既有 Git 凭据,不桥接平台 OAuth。 + +发送后响应丢失、超时、取消、5xx 返回 `git/write-result-unknown`;Client 保留待确认提示并禁用后续写,须显式定向刷新目标后检查,不自动重发。成功回复只投影 id/number/url/merged。权限/scope/SSO 拒绝与 head-moved 要求修复或重新确认。 + +## 错误与验证边界 + +错误码为 `git/remote-unavailable`、`git/unauthenticated`、`git/signed-out`、`git/insufficient-scope`、`git/head-moved`、`git/rate-limited`(resetAt)、`git/write-result-unknown`、`git/remote-identity-mismatch`。无 seam 时其余已知/未知 remote 端点均在解析 payload 前 fail-closed。 + +自动化证据分布在 `tests/remote-*.spec.ts` 与 `tests/remote-surface.client.spec.tsx`。真实 OAuth/SSO、远端写入、平台手工验收和完整 release 矩阵的执行状态以[计划日志](../plans/active/remote-platform-integration.md#进度与验证日志)为准;mock 与开发 pin 通过不能代替这些验收。 diff --git a/docs/requirements/scope-and-requirements.md b/docs/requirements/scope-and-requirements.md index cf62a90..497537a 100644 --- a/docs/requirements/scope-and-requirements.md +++ b/docs/requirements/scope-and-requirements.md @@ -25,6 +25,7 @@ | FR-11 | 生成 system message 可在 Plugin configuration 编辑(`git-commit-message` 命名空间设置卡) | | FR-12 | Desktop 存在时提供 reveal-in-folder / open-path 原生动作;不存在时核心功能完整可用 | | FR-13 | 跟随会话有效目录:宿主投影 `workingDirectory` 为非空字符串时用它,否则用会话头 `cwd`。目录变化后 Git 显示新检出,且不得再对旧检出执行写操作 | +| FR-15 | GitHub PR/Issue、认证、比较、PR 评论/review、已推送分支创建 PR、close/merge、SHA 核验 checkout 与 Issue 分支;credentials 硬门禁、身份前置条件、未知写结果与安全行内坐标见远程 RPC 参考 | | FR-14 | 识别主检出与 linked worktree;专用 Worktrees 侧栏 tab 列出同一仓库的全部检出(来源不限)。进入另一个检出只作用于当前会话,并复用宿主工作目录切换;该会话没有活 agent 时不提供进入。创建 worktree:宿主挂载上游 `worktrees` 服务时委托其创建;未挂载时经 `git-worktrees` 设置的 `enabled` 开关启用本插件的本地实现(检出池默认 `<仓库根>/.agents/worktrees/`,可在设置中改)。不删除或修剪 worktree | ## 非功能需求 @@ -41,11 +42,11 @@ - DSH 兼容版本采用精确清单,唯一真源为 [`src/compat/dsh-version.ts`](../../src/compat/dsh-version.ts);支持声明、升级候选验证与 CI 门禁见 [DSH 兼容契约](../reference/dsh-compatibility.md)。 - Client 主 fiber 包含全部 portable 贡献;原生增强只能放在 `ctx.inject(['desktop'], ...)` 子 fiber,且只声明其消费的结构化方法;provider unload 必须移除增强而保留核心 UI。 -- GitHub 与远端凭据工作流不在本包内(见下"非目标"),直到另行决策加入。 +- GitHub 与远端凭据工作流已由 [ADR-0004](../decisions/ADR-0004-remote-platform-integration.md)(Proposed,评审闭合待冻结)决议纳入,按 [remote-platform-integration](../plans/active/remote-platform-integration.md) 分阶段实施。Current:完整 seam 下已接入认证、REST 读写、PR checkout、Issue 分支与可移植 Remote UI;无 seam/不完整 seam 仍整功能 off,其他 remote 端点 fail-closed。真实 OAuth、远端写入、平台手工与完整版本矩阵的验收状态见计划日志。契约细节 canonical 于 [远程平台 RPC](../reference/remote-platform-rpc.md)。 ## 非目标 -以下明确排除(canonical 于根 README [Git operations](../../README.md#git-operations) 末段):GitHub 认证、hosting provider 工作流、凭据提示、issues、pull requests、stash、cherry-pick、merge-conflict 编辑。删除和修剪 Git worktree 也排除:创建委托宿主上游实验性 `worktrees` 服务(存在时),或由 `git-worktrees` 设置开启的本地实现;本包只发现检出并在宿主目录服务可用且该会话有活 agent 时进入检出。 +以下明确排除(canonical 于根 README [Git operations](../../README.md#git-operations) 末段):stash、cherry-pick、merge-conflict 编辑。GitHub 认证、hosting provider 工作流、凭据提示、issues、pull requests 已经 ADR-0004 授权进入分阶段实施,不再是永久排除项;当前可用范围与未验证项见远程平台 RPC 和实施计划,不把 mock 验证冒充真实平台验收。删除和修剪 Git worktree 也排除:创建委托宿主上游实验性 `worktrees` 服务(存在时),或由 `git-worktrees` 设置开启的本地实现;本包只发现检出并在宿主目录服务可用且该会话有活 agent 时进入检出。 ## 相关文档 diff --git a/package.json b/package.json index 8eb4f2e..dc0fd2f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "bundle": "tsdown", "prepare": "pnpm run build", "test": "vitest run", + "test:e2e": "playwright test", "test:artifact": "vitest run --config vitest.artifact.config.ts", "compat:check": "node scripts/check-dsh-compat.mjs", "docs:check": "node scripts/check-docs.mjs", @@ -67,6 +68,7 @@ }, "peerDependencies": { "@deepseek-ai/cordis": ">=4.0.2 <5.0.0 || ~4.0.5-alpha.1 || ~4.0.5-alpha.2", + "@deepseek-ai/dsh-credentials": "0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2", "@deepseek-ai/dsh-agent-default-model": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2", "@deepseek-ai/dsh-api-session-controller": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2", "@deepseek-ai/dsh-client-connection": "0.1.5-rc.2 || 0.1.5-rc.3 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2", @@ -109,11 +111,13 @@ "@deepseek-ai/dsh-client-ui-settings-plugins": "0.2.0-rc.2", "@deepseek-ai/dsh-client-ui-sidebar-right": "0.2.0-rc.2", "@deepseek-ai/dsh-client-ui-slots": "0.2.0-rc.2", + "@deepseek-ai/dsh-credentials": "0.2.0-rc.2", "@deepseek-ai/dsh-llm": "0.2.0-rc.2", "@deepseek-ai/dsh-settings": "0.2.0-rc.2", "@deepseek-ai/dsh-subprocess": "0.2.0-rc.2", "@deepseek-ai/dsh-subprocess-local": "0.2.0-rc.2", "@deepseek-ai/dsh-util-code-language": "0.2.0-rc.2", + "@playwright/test": "^1.64.1", "@shikijs/langs": "^4.3.1", "@testing-library/react": "^16.3.2", "@types/node": "^22.0.0", @@ -144,6 +148,7 @@ "simple-icons": "16.31.0", "tsdown": "^0.22.0", "typescript": "^6.0.3", + "vite": "^8.3.5", "vitest": "^4.1.8", "zustand": "~4.4.7" }, @@ -181,6 +186,9 @@ }, "@deepseek-ai/dsh-client-ui-settings-plugins": { "optional": true + }, + "@deepseek-ai/dsh-credentials": { + "optional": true } } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..2b477aa --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.e2e.ts', + fullyParallel: false, + workers: 1, + timeout: 30_000, + reporter: 'list', + outputDir: 'test-results/e2e', + use: { + baseURL: 'http://127.0.0.1:4179', + viewport: { width: 960, height: 760 }, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + ...(process.env['DSH_E2E_BROWSER_CHANNEL'] ? { channel: process.env['DSH_E2E_BROWSER_CHANNEL'] } : {}), + }, + webServer: { + command: 'node node_modules/vite/bin/vite.js --config tests/e2e/vite.config.ts', + url: 'http://127.0.0.1:4179', + reuseExistingServer: false, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d6a56e..f3345fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: overrides: '@deepseek-ai/schemastery': 3.18.4 -pnpmfileChecksum: sha256-FvmIf+qDUC2CFKGDWIkpQ5xJtlC1UhG+LSjFTI4T+lU= +pnpmfileChecksum: sha256-UVwodBinq3P1SWa+G6VwmR711JEbEYScdnWRKZk8HHk= importers: @@ -71,6 +71,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: 0.2.0-rc.2 version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@deepseek-ai/dsh-credentials': + specifier: 0.2.0-rc.2 + version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)(@deepseek-ai/dsh-invariants@0.2.0-rc.2(@deepseek-ai/cordis@4.0.4)) '@deepseek-ai/dsh-llm': specifier: 0.2.0-rc.2 version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) @@ -86,6 +89,9 @@ importers: '@deepseek-ai/dsh-util-code-language': specifier: 0.2.0-rc.2 version: 0.2.0-rc.2(@deepseek-ai/cordis@4.0.4) + '@playwright/test': + specifier: ^1.64.1 + version: 1.64.1 '@shikijs/langs': specifier: ^4.3.1 version: 4.4.3 @@ -176,9 +182,12 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vite: + specifier: ^8.3.5 + version: 8.3.5(@types/node@22.20.1)(yaml@2.9.1) vitest: specifier: ^4.1.8 - version: 4.1.11(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.1)) + version: 4.1.11(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.3.5(@types/node@22.20.1)(yaml@2.9.1)) zustand: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) @@ -999,45 +1008,96 @@ packages: '@oxc-project/types@0.149.0': resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + '@oxc-project/types@0.155.0': + resolution: {integrity: sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==} + + '@playwright/test@1.64.1': + resolution: {integrity: sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==} + engines: {node: '>=20'} + hasBin: true + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@rolldown/binding-android-arm-eabi@1.2.15': + resolution: {integrity: sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + '@rolldown/binding-android-arm-eabi@1.2.8': resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] + '@rolldown/binding-android-arm64@1.2.15': + resolution: {integrity: sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.2.8': resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.2.15': + resolution: {integrity: sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.2.8': resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.2.15': + resolution: {integrity: sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.2.8': resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.2.15': + resolution: {integrity: sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.2.8': resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + resolution: {integrity: sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.2.15': + resolution: {integrity: sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.2.8': resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1045,6 +1105,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.2.15': + resolution: {integrity: sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.2.8': resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1052,6 +1119,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + resolution: {integrity: sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.2.8': resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1059,6 +1133,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.15': + resolution: {integrity: sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.2.8': resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1066,6 +1147,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.15': + resolution: {integrity: sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.2.8': resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1073,6 +1161,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.2.15': + resolution: {integrity: sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-x64-musl@1.2.8': resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1080,18 +1175,36 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-openharmony-arm64@1.2.15': + resolution: {integrity: sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.2.8': resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-win32-arm64-msvc@1.2.15': + resolution: {integrity: sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.2.8': resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.15': + resolution: {integrity: sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.2.8': resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1819,8 +1932,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.19: - resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1905,8 +2018,18 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + playwright-core@1.64.1: + resolution: {integrity: sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.64.1: + resolution: {integrity: sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} engines: {node: ^10 || ^12 || >=14} pretty-format@27.5.1: @@ -1974,6 +2097,11 @@ packages: vue-tsc: optional: true + rolldown@1.2.15: + resolution: {integrity: sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.2.8: resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2006,6 +2134,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -2144,13 +2276,13 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@8.2.2: - resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + vite@8.3.5: + resolution: {integrity: sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.4.0 || ^0.5.0 + '@vitejs/devtools': ^0.7.1 || ^0.8.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -3083,52 +3215,103 @@ snapshots: '@oxc-project/types@0.149.0': {} + '@oxc-project/types@0.155.0': {} + + '@playwright/test@1.64.1': + dependencies: + playwright: 1.64.1 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 + '@rolldown/binding-android-arm-eabi@1.2.15': + optional: true + '@rolldown/binding-android-arm-eabi@1.2.8': optional: true + '@rolldown/binding-android-arm64@1.2.15': + optional: true + '@rolldown/binding-android-arm64@1.2.8': optional: true + '@rolldown/binding-darwin-arm64@1.2.15': + optional: true + '@rolldown/binding-darwin-arm64@1.2.8': optional: true + '@rolldown/binding-darwin-x64@1.2.15': + optional: true + '@rolldown/binding-darwin-x64@1.2.8': optional: true + '@rolldown/binding-freebsd-x64@1.2.15': + optional: true + '@rolldown/binding-freebsd-x64@1.2.8': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.15': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.15': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.2.8': optional: true + '@rolldown/binding-linux-arm64-musl@1.2.15': + optional: true + '@rolldown/binding-linux-arm64-musl@1.2.8': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.15': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.2.8': optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.15': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.2.8': optional: true + '@rolldown/binding-linux-x64-gnu@1.2.15': + optional: true + '@rolldown/binding-linux-x64-gnu@1.2.8': optional: true + '@rolldown/binding-linux-x64-musl@1.2.15': + optional: true + '@rolldown/binding-linux-x64-musl@1.2.8': optional: true + '@rolldown/binding-openharmony-arm64@1.2.15': + optional: true + '@rolldown/binding-openharmony-arm64@1.2.8': optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.15': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.2.8': optional: true + '@rolldown/binding-win32-x64-msvc@1.2.15': + optional: true + '@rolldown/binding-win32-x64-msvc@1.2.8': optional: true @@ -3252,13 +3435,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.11(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.1))': + '@vitest/mocker@4.1.11(vite@8.3.5(@types/node@22.20.1)(yaml@2.9.1))': dependencies: '@vitest/spy': 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.2(@types/node@22.20.1)(yaml@2.9.1) + vite: 8.3.5(@types/node@22.20.1)(yaml@2.9.1) '@vitest/pretty-format@4.1.11': dependencies: @@ -3966,7 +4149,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.19: {} + nanoid@3.3.20: {} node-addon-api@7.1.1: {} @@ -4043,11 +4226,17 @@ snapshots: picomatch@4.0.7: {} - postcss@8.5.28: + playwright-core@1.64.1: {} + + playwright@1.64.1: + dependencies: + playwright-core: 1.64.1 + + postcss@8.5.31: dependencies: - nanoid: 3.3.19 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 pretty-format@27.5.1: dependencies: @@ -4103,6 +4292,27 @@ snapshots: transitivePeerDependencies: - oxc-resolver + rolldown@1.2.15: + dependencies: + '@oxc-project/types': 0.155.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.15 + '@rolldown/binding-android-arm64': 1.2.15 + '@rolldown/binding-darwin-arm64': 1.2.15 + '@rolldown/binding-darwin-x64': 1.2.15 + '@rolldown/binding-freebsd-x64': 1.2.15 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.15 + '@rolldown/binding-linux-arm64-gnu': 1.2.15 + '@rolldown/binding-linux-arm64-musl': 1.2.15 + '@rolldown/binding-linux-ppc64-gnu': 1.2.15 + '@rolldown/binding-linux-s390x-gnu': 1.2.15 + '@rolldown/binding-linux-x64-gnu': 1.2.15 + '@rolldown/binding-linux-x64-musl': 1.2.15 + '@rolldown/binding-openharmony-arm64': 1.2.15 + '@rolldown/binding-win32-arm64-msvc': 1.2.15 + '@rolldown/binding-win32-x64-msvc': 1.2.15 + rolldown@1.2.8: dependencies: '@oxc-project/types': 0.149.0 @@ -4151,6 +4361,8 @@ snapshots: source-map-js@1.2.1: {} + source-map-js@1.2.2: {} + space-separated-tokens@2.0.2: {} stackback@0.0.2: {} @@ -4275,22 +4487,22 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.2.2(@types/node@22.20.1)(yaml@2.9.1): + vite@8.3.5(@types/node@22.20.1)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.2.8 + postcss: 8.5.31 + rolldown: 1.2.15 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 fsevents: 2.3.3 yaml: 2.9.1 - vitest@4.1.11(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.1)): + vitest@4.1.11(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.3.5(@types/node@22.20.1)(yaml@2.9.1)): dependencies: '@vitest/expect': 4.1.11 - '@vitest/mocker': 4.1.11(vite@8.2.2(@types/node@22.20.1)(yaml@2.9.1)) + '@vitest/mocker': 4.1.11(vite@8.3.5(@types/node@22.20.1)(yaml@2.9.1)) '@vitest/pretty-format': 4.1.11 '@vitest/runner': 4.1.11 '@vitest/snapshot': 4.1.11 @@ -4307,7 +4519,7 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.2(@types/node@22.20.1)(yaml@2.9.1) + vite: 8.3.5(@types/node@22.20.1)(yaml@2.9.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 diff --git a/scripts/check-dsh-compat.mjs b/scripts/check-dsh-compat.mjs index 5582db8..cab156e 100644 --- a/scripts/check-dsh-compat.mjs +++ b/scripts/check-dsh-compat.mjs @@ -65,6 +65,12 @@ const contract = readFileSync(join(root, 'src/compat/dsh-version.ts'), 'utf8') const match = /export const SUPPORTED_DSH_RELEASES = \[([^\]]*)\]/u.exec(contract) if (!match) throw new Error('SUPPORTED_DSH_RELEASES must be an array literal in src/compat/dsh-version.ts') const supported = [...match[1].matchAll(/'([^']+)'/gu)].map((item) => item[1]) +const credentialsMatch = /export const CREDENTIALS_SEAM_DSH_RELEASES = \[([^\]]*)\]/u.exec(contract) +if (!credentialsMatch) throw new Error('CREDENTIALS_SEAM_DSH_RELEASES must be an array literal in src/compat/dsh-version.ts') +const credentialsSeam = [...credentialsMatch[1].matchAll(/'([^']+)'/gu)].map((item) => item[1]) +if (credentialsSeam.length === 0 || !credentialsSeam.every(version => supported.includes(version))) { + throw new Error('CREDENTIALS_SEAM_DSH_RELEASES must be a non-empty subset of SUPPORTED_DSH_RELEASES') +} if (supported.length === 0) throw new Error('SUPPORTED_DSH_RELEASES must not be empty') if (process.argv.includes('--list')) { console.log(JSON.stringify(supported)) @@ -85,6 +91,8 @@ if (process.argv.includes('--list-non-default')) { const peers = Object.fromEntries(Object.entries(manifest.peerDependencies ?? {}).filter(([name]) => name.startsWith('@deepseek-ai/dsh-'))) const devs = Object.fromEntries(Object.entries(manifest.devDependencies ?? {}).filter(([name]) => name.startsWith('@deepseek-ai/dsh-'))) const expectedPeerRange = supported.join(' || ') +const credentialsPeerRange = credentialsSeam.join(' || ') +const CREDENTIALS_PEER = '@deepseek-ai/dsh-credentials' const failures = [] const notes = [] const candidateMode = process.env.DSH_COMPAT_MODE === 'warn' @@ -93,8 +101,9 @@ const candidateVersion = process.env.DSH_COMPAT_VERSION console.log(`[compat] supported DSH releases: ${supported.join(', ')}`) for (const [name, range] of Object.entries(peers)) { - if (range !== expectedPeerRange && !candidateMode) failures.push(`peerDependencies[${name}] is ${range}; expected ${expectedPeerRange}`) - if (!(name in devs)) failures.push(`${name} is a peer dependency but has no development pin`) + const expected = name === CREDENTIALS_PEER ? credentialsPeerRange : expectedPeerRange + if (range !== expected && !candidateMode) failures.push(`peerDependencies[${name}] is ${range}; expected ${expected}`) + if (!(name in devs) && !(name === CREDENTIALS_PEER && !credentialsSeam.includes(devs['@deepseek-ai/dsh-settings']))) failures.push(`${name} is a peer dependency but has no development pin`) } // Hosts since 0.2.1-alpha.4 admit a plugin by `engines.dsh` alone when it is @@ -128,6 +137,7 @@ for (const name of packages) { } } for (const [name, version] of installedVersions) { + if (version === undefined && name === CREDENTIALS_PEER && !credentialsSeam.includes(pin)) continue if (version === undefined) failures.push(`${name} is declared but not installed; run pnpm install`) else if (version !== pin) failures.push(`${name} resolves to ${version}, while the development pin is ${pin}`) } diff --git a/scripts/upgrade-dsh.mjs b/scripts/upgrade-dsh.mjs index 70cf8ea..4246549 100644 --- a/scripts/upgrade-dsh.mjs +++ b/scripts/upgrade-dsh.mjs @@ -63,9 +63,16 @@ const schemasteryVersion = caretFloor( const laneOnlyPackages = [ '@deepseek-ai/dsh-util-code-language', '@deepseek-ai/dsh-client-shortcuts', + '@deepseek-ai/dsh-credentials', ] +const compatibilitySource = readFileSync(join(root, 'src/compat/dsh-version.ts'), 'utf8') +const supportedReleases = [...compatibilitySource.match(/export const SUPPORTED_DSH_RELEASES = \[([^\]]*)\]/u)[1].matchAll(/'([^']+)'/gu)].map(item => item[1]) +const credentialReleases = [...compatibilitySource.match(/export const CREDENTIALS_SEAM_DSH_RELEASES = \[([^\]]*)\]/u)[1].matchAll(/'([^']+)'/gu)].map(item => item[1]) + function publishedAt(name) { + // Supported pre-seam lanes intentionally prove a complete install/build/load with no optional package. + if (name === '@deepseek-ai/dsh-credentials' && supportedReleases.includes(version) && !credentialReleases.includes(version)) return false try { execFileSync('npm', ['view', `${name}@${version}`, 'version'], { encoding: 'utf8', @@ -112,7 +119,7 @@ const overrideLine = /^(\s*'@deepseek-ai\/schemastery': )\S+$/m if (!overrideLine.test(workspace)) { throw new Error('[upgrade] pnpm-workspace.yaml has no @deepseek-ai/schemastery override to repoint') } -writeFileSync(workspacePath, workspace.replace(overrideLine, `$1${schemasteryVersion}`)) +writeFileSync(workspacePath, workspace.replace(overrideLine, `$1${schemasteryVersion}`).replace(/^autoInstallPeers:.*\n?/m, '')) console.log(`[upgrade] DSH packages set to ${version} (${changed} manifest entries changed)`) console.log(`[upgrade] cordis pin: ${cordisVersion}`) console.log(`[upgrade] schemastery pin: ${schemasteryVersion}`) diff --git a/src/client/GitRemoteSurface.module.css b/src/client/GitRemoteSurface.module.css new file mode 100644 index 0000000..a4ab976 --- /dev/null +++ b/src/client/GitRemoteSurface.module.css @@ -0,0 +1,924 @@ +/* + * Git Remote surface: mode pills, context row, sections, and banner styles. + * Self-contained like GitGraphSurface.module.css — shared class names are + * redeclared here rather than imported across modules. + */ + +.root { + display: flex; + min-width: 0; + min-height: 0; + height: 100%; + flex-direction: column; +} + +/* Pulls/issues switch plus the compact refresh control. */ +.modeBar { + display: flex; + flex: none; + align-items: center; + gap: 8px; + padding: 10px 12px 8px; +} + +.modePills { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 4px; +} + +/* Detail tab keeps the same row but aligns it with the wider content inset. */ +.modeBarDetail { + padding-right: 20px; + padding-left: 20px; +} + +/* Repository identity text in a detail tab's toolbar. */ +.toolbarContext { + min-width: 0; + flex: 1; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + font-size: 11px; + font-weight: 400; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.body { + flex: 1; + min-height: 0; + overflow: auto; +} + +.pad { + padding: 0 12px 18px; +} + +/* Wider, GitHub-like inset on the PR/issue detail tab only. */ +.detailPad { + padding: 8px 20px 24px; +} + +.notice { + margin: 0; + padding: 6px 12px; + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); +} + +.section { + margin-bottom: 12px; +} + +.sectionHead { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 0 0 4px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + font-weight: 600; +} + +.sectionTitle { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.sectionCount { + display: inline-grid; + min-width: 18px; + height: 18px; + place-items: center; + border-radius: 9px; + background: var(--dsw-alias-interactive-bg-hover); + font-size: 10px; + font-weight: 600; +} + +.sectionActions { + display: inline-flex; + flex: none; + align-items: center; + gap: 2px; +} + +.note { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 11px; + font-weight: 400; + line-height: 16px; +} + +/* Platform authentication: status line, transport hint, and action row. */ +.authSection { + display: grid; + gap: 6px; + margin-bottom: 12px; + padding: 8px; + border-radius: 8px; + background: var(--dsw-alias-interactive-bg-hover); +} + +.authStatus { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.authIdentity { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 8px; +} + +.authIdentityText { + display: flex; + min-width: 0; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 6px; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; +} + +.authLogin { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Device-flow prompt while a login attempt is pending. */ +.loginCard { + display: grid; + gap: 6px; + padding: 8px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.loginCode { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 15px; + font-weight: 600; + letter-spacing: 0.08em; + color: var(--dsw-alias-label-primary); +} + +.loginActions { + display: flex; + align-items: center; + gap: 8px; +} + +.loginLink { + color: var(--dsw-alias-brand-primary); + font-size: 12px; +} + +/* Remote target selector plus the open/closed/all filter. */ +.contextBar { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.repoChip { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + gap: 4px; + height: 24px; + padding: 0 4px 0 8px; + border: 0; + border-radius: 6px; + color: var(--dsw-alias-label-primary); + background: transparent; + cursor: pointer; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + text-align: left; +} + +.repoChip:hover:not([aria-disabled='true']) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.repoChip:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + +.repoChip[aria-disabled='true'] { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +.repoChipText { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.repoChevron { + display: grid; + flex: none; + place-items: center; + color: var(--dsw-alias-label-secondary); +} + +.statePills { + display: flex; + flex: none; + align-items: center; + gap: 4px; +} + +/* PR/issue rows: the hover band bleeds to the pane edges, past the pad inset. */ +.itemList { + display: grid; + gap: 2px; + margin: 0 -12px; + padding: 0; + list-style: none; +} + +.itemRow { + display: flex; + box-sizing: border-box; + width: 100%; + min-width: 0; + align-items: center; + gap: 6px; + padding: 4px 12px; + border: 0; + color: inherit; + background: transparent; + cursor: pointer; + text-align: left; +} + +.itemRow:hover:not([aria-disabled='true']) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.itemRow:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: -2px; +} + +.itemRow[aria-disabled='true'] { + cursor: default; +} + +.itemMain { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; + gap: 1px; +} + +.itemTitle { + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.itemMeta { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + line-height: 14px; +} + +.itemNumber { + flex: none; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.itemAuthor { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Detail head: number, title, state tags, and the browser shortcut. */ +.detailHeader { + display: flex; + align-items: flex-start; + gap: 6px; + margin-bottom: 4px; +} + +.detailTitle { + min-width: 0; + flex: 1; + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: 13px; + font-weight: 600; + line-height: 18px; + overflow-wrap: anywhere; +} + +.detailTags { + display: inline-flex; + flex: none; + align-items: center; + gap: 4px; +} + +.iconLink { + display: grid; + flex: none; + place-items: center; + width: 24px; + height: 24px; + border-radius: 6px; + color: var(--dsw-alias-label-secondary); +} + +.iconLink:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.iconLink:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + +.detailMeta { + margin: 0 0 8px; + color: var(--dsw-alias-label-tertiary); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 16px; + overflow-wrap: anywhere; +} + +/* Outlined link styled like the neighboring outline buttons. */ +.linkButton { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 6px; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; + text-decoration: none; +} + +.linkButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.linkButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} + +/* One pull-request commit row: sha chip, subject, author/date, browser link. */ +.commitRow { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 8px; + border-radius: 6px; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; +} + +.commitRow:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.commitSha { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} + +.commitSubject { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Write actions and the two-step confirmation card. */ +.actionsRow { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.checkoutRow { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.checkoutRow > :first-child { + min-width: 0; + flex: 1; +} + +.confirmCard { + display: grid; + gap: 8px; + margin-bottom: 8px; + padding: 8px; + border: 0.5px solid var(--dsw-alias-state-error-primary); + border-radius: 8px; +} + +.confirmText { + margin: 0; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 16px; + overflow-wrap: anywhere; +} + +.confirmActions { + display: flex; + gap: 6px; +} + +/* Monospace secondary references inside timeline cards (path:line, author · date). */ +.commentRef { + color: var(--dsw-alias-label-tertiary); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-weight: 400; +} + + +/* Chronological activity timeline: gutter line, markers and GitHub-style cards. */ +.timeline { + position: relative; + margin-bottom: 12px; +} + +.tlItem { + position: relative; + padding-bottom: 10px; + padding-left: 26px; +} + +.tlItem::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 9px; + border-left: 0.5px solid var(--dsw-alias-border-l2); +} + +.tlItem:last-child::before { + bottom: auto; + height: 12px; +} + +.tlAvatar { + position: absolute; + z-index: 1; + top: 2px; + left: 0; +} + +/* Opaque avatar circle: the gutter line must never show through it. */ +.avatar { + display: grid; + box-sizing: border-box; + place-items: center; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 50%; + color: var(--dsw-alias-label-secondary); + background: linear-gradient(var(--dsw-alias-interactive-bg-hover), var(--dsw-alias-interactive-bg-hover)), var(--dsw-alias-bg-base); + font-weight: 600; +} + +.avatarImg { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.tlDot { + position: absolute; + z-index: 1; + top: 6px; + left: 4px; + width: 11px; + height: 11px; + box-sizing: border-box; + border: 1.5px solid var(--dsw-alias-border-l2); + border-radius: 50%; + background: var(--dsw-alias-bg-base); +} + +.tlCard { + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.tlItem > .tlCard, +.tlItem > .tlReviewHead + .tlCard { + margin-bottom: 6px; +} + +.tlCardHead { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-bottom: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px 8px 0 0; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); + font-size: 11px; + line-height: 18px; +} + +.tlCardHead .iconLink { + width: 18px; + height: 18px; + margin-left: auto; +} + +.tlCardBody { + padding: 8px 10px; +} + +.tlCardBody > :global(*:first-child) { + margin-top: 0; +} + +.tlCardBody > :global(*:last-child) { + margin-bottom: 0; +} + +.tlMeta { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.tlReviewHead { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + margin-bottom: 6px; + color: var(--dsw-alias-label-primary); + font-size: 11px; + line-height: 18px; +} + +.tlReply { + padding: 8px 10px; +} + +.tlReplyNext { + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.tlReplyHead { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 2px; + color: var(--dsw-alias-label-primary); + font-size: 11px; + line-height: 16px; +} + +.tlEmpty { + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + font-style: italic; +} + +/* Native
rendered from GitHub bodies. */ +.mdDetails { + margin: 8px 0; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 6px; +} + +.mdSummary { + padding: 5px 10px; + border-radius: 6px; + color: var(--dsw-alias-label-primary); + cursor: pointer; + font-size: 12px; + line-height: 18px; +} + +.mdSummary:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.mdDetails[open] > .mdSummary { + border-bottom: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 6px 6px 0 0; +} + +/* Markdown inside the summary renders inline beside the disclosure chevron. */ +.mdSummary > :global(*) { + display: inline; +} + +.mdSummary :global(p) { + display: inline; + margin: 0; +} + +.mdDetailsBody { + padding: 8px 10px; +} + +/* Authentication dialog: status card and per-method reconnect rows. */ +.authCard { + display: grid; + gap: 4px; + padding: 8px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-base); +} + +.authMethods { + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + font-weight: 600; +} + +.authChoices { + display: grid; + gap: 6px; +} + +.radioRow { + display: flex; + align-items: center; + gap: 8px; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; +} + +.radioHint { + padding-left: 24px; + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; +} + +.methodHint { + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; +} + +/* Wider auth dialog than the primitive's default. */ +.authDialog.authDialog { + width: min(440px, 100%); +} + +/* Comment composer and its write actions. */ +.composer { + display: grid; + gap: 6px; + margin-bottom: 8px; +} + +.field { + position: relative; + display: grid; + align-items: start; + gap: 5px; + margin: 0; + color: var(--dsw-alias-label-secondary); + font-size: 11px; +} + +.field textarea { + width: 100%; + min-height: 36px; + box-sizing: border-box; + padding: 8px 12px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; + outline: none; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-base); + font-family: inherit; + font-size: 13px; + line-height: 20px; + overflow: hidden; + resize: vertical; +} + +.field textarea:focus { + border-color: var(--dsw-alias-brand-primary); +} + +.composerActions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Create-PR modal fields. */ +.createBody { + display: grid; + gap: 10px; +} + +.createField { + display: grid; + gap: 5px; + color: var(--dsw-alias-label-secondary); + font-size: 11px; +} + +.createField textarea { + width: 100%; + min-height: 60px; + box-sizing: border-box; + padding: 8px 12px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; + outline: none; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-base); + font-family: inherit; + font-size: 13px; + line-height: 20px; + resize: vertical; +} + +.createField textarea:focus { + border-color: var(--dsw-alias-brand-primary); +} + +/* Checkout receipt and write banners. */ +.receipt { + margin: 0 0 8px; + padding: 6px 8px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 16px; +} + +.receipt strong { + color: var(--dsw-alias-label-primary); + font-weight: 600; +} + +.error { + margin: 0; + padding: 0 0 8px; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + white-space: pre-line; +} + +.empty { + padding: 24px 8px; + color: var(--dsw-alias-label-tertiary); + text-align: center; + font-size: 12px; +} + +.loadingArea { + flex: none; + padding: 0 12px; +} + +.loadingAreaDetail { + padding-right: 20px; + padding-left: 20px; +} + +.loadingStatus { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 4px 0 12px; + padding: 10px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 8px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.loadingText { + display: grid; + gap: 4px; + min-width: 0; +} + +.loadingSlow { + color: var(--dsw-alias-label-tertiary); + font-size: 11px; +} + +.spinner { + flex: none; + width: 12px; + height: 12px; + margin-top: 2px; + border: 1.5px solid var(--dsw-alias-border-l2); + border-top-color: var(--dsw-alias-brand-primary); + border-radius: 50%; + animation: remote-spin 0.8s linear infinite; +} + +.skeletonList { + display: grid; + gap: 16px; + padding: 8px 0; +} + +.skeletonRow { + display: grid; + gap: 7px; +} + +.skeletonTitle, +.skeletonMeta { + height: 12px; + width: 82%; + border-radius: 4px; + background: var(--dsw-alias-interactive-bg-hover); + animation: remote-pulse 1.5s ease-in-out infinite alternate; +} + +.skeletonMeta { + width: 38%; + height: 9px; +} + +.skeletonRow:nth-child(even) .skeletonTitle { + width: 64%; +} + +@keyframes remote-spin { + to { transform: rotate(360deg); } +} + +@keyframes remote-pulse { + to { opacity: 0.4; } +} + +@media (prefers-reduced-motion: reduce) { + .spinner, + .skeletonTitle, + .skeletonMeta { + animation: none; + } +} diff --git a/src/client/LauncherIcons.tsx b/src/client/LauncherIcons.tsx index 8005f35..757cbe8 100644 --- a/src/client/LauncherIcons.tsx +++ b/src/client/LauncherIcons.tsx @@ -12,14 +12,29 @@ export function BranchGlyph() { ) } -/** History glyph for the Git Graph launcher card. */ +/** Commit lanes and a curved merge distinguish history from the branch control. */ export function GraphGlyph() { return ( + ) +} + +/** Cloud plus upload/download arrows for the remote platform launcher. */ +export function RemoteGlyph() { + return ( + ) } diff --git a/src/client/contract.ts b/src/client/contract.ts index 8027a60..8b5fea6 100644 --- a/src/client/contract.ts +++ b/src/client/contract.ts @@ -1,5 +1,6 @@ /** Stable Git right-sidebar tab identities shared by registration and `ctx.sidebarRight`. */ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { RemoteRepository } from '../remote/provider.ts' /** Implementation id (body-seat key) of the Git Changes tab type. */ export const GIT_CHANGES_ID = '@dsh-electron/dsh-plugin-git/changes' as const @@ -25,6 +26,12 @@ export const GIT_GRAPH_KIND = 'git.graph' as const /** Tab kind (`openTab` discriminator) of the Git Worktrees page. */ export const GIT_WORKTREES_KIND = 'git.worktrees' as const +/** Implementation id (body-seat key) of the remote pull/issue detail tab type. */ +export const GIT_REMOTE_ITEM_ID = '@dsh-electron/dsh-plugin-git/remote-item' as const + +/** Tab kind claimed by remote item resource addresses. */ +export const GIT_REMOTE_ITEM_KIND = 'git.remote.item' as const + /** Which sides of the comparison a Diff tab shows. */ export type GitDiffMode = 'worktree' | 'staged' @@ -91,3 +98,66 @@ declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { [GIT_DIFF_RESOURCE_PROTOCOL]: GitDiffPayload } } + +/** The resource type remote pull/issue detail tabs are addressed under. */ +export const GIT_REMOTE_ITEM_RESOURCE_PROTOCOL = 'git-remote' as const + +/** Open arguments for one remote pull/issue detail tab. */ +export interface GitRemoteItemPayload { + /** Remote owner login; the repository follows the workspace binding. */ + readonly owner: string + /** Remote repository name. */ + readonly repo: string + /** List kind the item was opened from. */ + readonly kind: 'pulls' | 'issues' + /** Pull/issue number on the remote. */ + readonly number: number +} + +const REMOTE_ITEM_PREFIX = `dsh-resource://${GIT_REMOTE_ITEM_RESOURCE_PROTOCOL}/` + +/** + * Address of one remote detail tab: remote identity plus item number. The exact + * address is the tab identity, so re-opening the same item reveals its tab. + * @param target - Remote repository identity owning the item. + * @param kind - `pulls` or `issues`. + * @param number - Remote pull/issue number. + * @returns The `dsh-resource://git-remote/...` address. + */ +export function gitRemoteItemAddress(target: RemoteRepository, kind: 'pulls' | 'issues', number: number): string { + return `${REMOTE_ITEM_PREFIX}${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/${kind}/${number}` +} + +/** + * Decode a remote item address back into its open payload. + * @param address - The tab's navigation address. + * @returns The payload, or `undefined` when the address is not a remote item. + */ +export function parseGitRemoteItemAddress(address: string): GitRemoteItemPayload | undefined { + if (!address.startsWith(REMOTE_ITEM_PREFIX)) return undefined + const parts = address.slice(REMOTE_ITEM_PREFIX.length).split('/') + if (parts.length !== 4) return undefined + const rawOwner = parts[0] + const rawRepo = parts[1] + const kind = parts[2] + const rawNumber = parts[3] + if (rawOwner === undefined || rawRepo === undefined || rawNumber === undefined || (kind !== 'pulls' && kind !== 'issues')) return undefined + const number = Number(rawNumber) + if (!Number.isInteger(number) || number <= 0) return undefined + let owner: string + let repo: string + try { + owner = decodeURIComponent(rawOwner) + repo = decodeURIComponent(rawRepo) + } catch { + return undefined + } + if (owner === '' || repo === '') return undefined + return { owner, repo, kind, number } +} + +declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' { + interface SidebarRightResourceParamsMap { + [GIT_REMOTE_ITEM_RESOURCE_PROTOCOL]: GitRemoteItemPayload + } +} diff --git a/src/client/controller.ts b/src/client/controller.ts index 29721cc..9cd9770 100644 --- a/src/client/controller.ts +++ b/src/client/controller.ts @@ -140,6 +140,8 @@ export class GitClientController { private directoryEntries = 0 /** Workspace binding identity; refreshing the same path does not replace it. */ private boundWorkspace: { path: string | undefined } | undefined + private workspaceCheckedAt = Number.NEGATIVE_INFINITY + private readonly diffs = new Map }>() private diffNavigator: ((path: string, staged: boolean) => void) | undefined /** Monotonic request identity for graph pages; stale responses are discarded. */ private graphRequestGeneration = 0 @@ -228,13 +230,18 @@ export class GitClientController { const binding = this.boundWorkspace if (binding !== undefined && this.state.workspacePath === workspacePath - && binding.path === workspacePath) return + && binding.path === workspacePath) { + if (workspacePath !== undefined && !this.state.loading && Date.now() - this.workspaceCheckedAt >= 5000) await this.refresh() + return + } // Stop reads and writes that still name the previous checkout. this.operations.abort() this.operations = new AbortController() const signal = this.operations.signal const generation = ++this.operationGeneration this.boundWorkspace = { path: workspacePath } + this.workspaceCheckedAt = Date.now() + this.diffs.clear() this.patch({ workspacePath, repository: undefined, @@ -270,6 +277,7 @@ export class GitClientController { * Drop in-flight Git RPC. Called when the owning session controller is released. */ dispose(): void { + this.diffs.clear() this.unsubscribeWorktreeFeature?.() this.operations.abort() this.worktreeCreation?.abort() @@ -290,6 +298,7 @@ export class GitClientController { * @returns Completion after the burst's last Host discovery and status calls settle. */ async refresh(workspacePath = this.state.workspacePath): Promise { + this.workspaceCheckedAt = Date.now() const signal = this.operations.signal if (workspacePath === undefined) { this.patch({ workspacePath, repository: undefined, loading: false, error: undefined }) @@ -349,16 +358,25 @@ export class GitClientController { } /** - * Read the diff for one changed path without retaining it: each Diff surface - * owns its result, so multiple tabs never overwrite one another. + * Cache independent path/side diffs for five seconds, sharing in-flight reads. + * Repository snapshot changes and explicit tab navigation bypass the cache. * @param path - Repository-relative changed path. * @param staged - Whether to read the staged diff. * @returns The decoded diff. */ - async fetchDiff(path: string, staged: boolean): Promise { + async fetchDiff(path: string, staged: boolean, revision?: number): Promise { const signal = this.operations.signal const repository = this.requireRepository() - return decodeDiff(await this.call('diff', { repository: repository.root, path, staged }, signal)) + const key = JSON.stringify([path, staged]) + const cached = this.diffs.get(key) + if (cached?.snapshot === repository && cached.revision === revision && (cached.pending || Date.now() - cached.at < 5000)) return cached.promise + const promise = this.call('diff', { repository: repository.root, path, staged }, signal).then(decodeDiff) + const entry = { snapshot: repository, revision, at: Date.now(), pending: true, promise } + if (this.diffs.size >= 32) this.diffs.delete(this.diffs.keys().next().value ?? '') + this.diffs.set(key, entry) + try { return await promise } + catch (error) { if (this.diffs.get(key) === entry) this.diffs.delete(key); throw error } + finally { entry.pending = false } } /** @@ -724,6 +742,9 @@ export class GitClientController { * @param fields - Endpoint fields other than `repository`. * @returns `true` when this binding still owns the published snapshot. */ + private pushObserver: ((repository: string, head: string) => void) | undefined + setPushObserver(observer: ((repository: string, head: string) => void) | undefined): void { this.pushObserver = observer } + private async mutate(endpoint: string, fields: Record): Promise { const signal = this.operations.signal const generation = this.operationGeneration @@ -738,6 +759,7 @@ export class GitClientController { // the loaded graph so the Graph surface reloads instead of rendering the // pre-mutation log. this.patch({ repository: next, loading: false, graphLoaded: false }) + if (endpoint === 'push' || endpoint === 'sync') this.pushObserver?.(repositoryRoot, next.head ?? next.branch ?? '') return true } catch (error) { if (signal.aborted || !this.isBindingCurrent(generation, repositoryRoot)) return false diff --git a/src/client/index.ts b/src/client/index.ts index 1334e65..73253d4 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -10,6 +10,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import { GitRemoteFeature, remoteNoticeKeys } from './remote.ts' +import { GitRemoteSurface, GitRemoteTitle } from './surfaces/GitRemoteSurface.tsx' +import { GitRemoteItemSurface } from './surfaces/GitRemoteItemSurface.tsx' import { GitBranchControl } from './GitBranchControl.tsx' import { GitDetailsHeaderActions } from './GitDetailsHeaderActions.tsx' import { GitChangesSurface } from './surfaces/GitChangesSurface.tsx' @@ -45,9 +48,12 @@ import { GIT_GRAPH_KIND, GIT_WORKTREES_ID, GIT_WORKTREES_KIND, + GIT_REMOTE_ITEM_ID, gitDiffAddress, + gitRemoteItemAddress, } from './contract.ts' -import { changesDefinition, diffDefinition, graphDefinition, worktreesDefinition } from './tab-definitions.ts' +import type { RemoteRepository } from '../remote/provider.ts' +import { changesDefinition, diffDefinition, graphDefinition, worktreesDefinition, remoteDefinition, remoteItemDefinition } from './tab-definitions.ts' import { en, NS, zh, type GitLocaleKey } from './locales.ts' import { injectDshGitSettings } from '../compat/dsh-client-settings.ts' @@ -108,9 +114,31 @@ export function apply(ctx: ClientContext): void { ctx.sidebarRight.openResource(gitDiffAddress(path, staged), { params: { path, staged } }) } gitSessions.setDiffNavigator(openDiff) + // A pull/issue opens as a resource tab whose address is the remote identity; + // one shared body renders either kind from the navigation payload. + const openRemoteItem = (target: RemoteRepository, kind: 'pulls' | 'issues', number: number): void => { + ctx.sidebarRight.openResource(gitRemoteItemAddress(target, kind, number), { params: { owner: target.owner, repo: target.repo, kind, number } }) + } ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'git: dictionaries') const t = ctx.locale.bind(NS) + const remoteFeature = new GitRemoteFeature(connection.rpc) + gitSessions.setPushObserver((repository, head) => remoteFeature.ingest([{ id: `push/${repository}/${head}`, kind: 'create-pr', title: t('remote.pushedSuggestion') }])) + let remoteTabDispose: (() => void) | undefined + const remoteSurfaces: (() => void)[] = [] + ctx.effect(() => { + const unsubscribe = remoteFeature.subscribe(() => { + if (remoteFeature.getSnapshot().state === 'on' && remoteTabDispose === undefined) { + remoteSurfaces.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: 'git.remote', locale: NS, inject: () => ({ gitSessions, remoteFeature, openRemoteItem }) }, GitRemoteSurface))) + remoteSurfaces.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: GIT_REMOTE_ITEM_ID, locale: NS, inject: () => ({ gitSessions, remoteFeature }) }, GitRemoteItemSurface))) + remoteSurfaces.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'git.remote', locale: NS, inject: () => ({ remoteFeature }) }, GitRemoteTitle))) + remoteTabDispose = ctx.sidebarRightTabs.register(remoteDefinition(t)) + remoteSurfaces.push(ctx.sidebarRightTabs.register(remoteItemDefinition(t))) + } else if (remoteFeature.getSnapshot().state === 'off') { remoteTabDispose?.(); remoteTabDispose = undefined; remoteSurfaces.splice(0).forEach(dispose => dispose()) } + }) + remoteFeature.start() + return () => { unsubscribe(); remoteTabDispose?.(); remoteSurfaces.splice(0).forEach(dispose => dispose()); remoteFeature.dispose() } + }, 'git: remote lifecycle') ctx.effect(() => ctx.sidebarRightTabs.register(changesDefinition(t)), 'git: changes tab type') ctx.effect(() => ctx.sidebarRightTabs.register(diffDefinition()), 'git: diff tab type') ctx.effect(() => ctx.sidebarRightTabs.register(graphDefinition(t)), 'git: graph tab type') @@ -179,7 +207,8 @@ export function apply(ctx: ClientContext): void { ctx.inject(['desktop'], (desktopCtx) => { gitSessions.setDesktop(desktopCtx.desktop) - return () => { gitSessions.setDesktop(undefined) } + remoteFeature.setNotify(async notice => { await desktopCtx.desktop.notification.show({ title: t('remote.title'), body: `${t(remoteNoticeKeys[notice.kind])} · ${notice.title}` }) }) + return () => { gitSessions.setDesktop(undefined); remoteFeature.setNotify(undefined) } }) const mountCommitMessageSettings = ( diff --git a/src/client/locales.ts b/src/client/locales.ts index a5a9d8b..90de9a2 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -3,6 +3,99 @@ export const NS = 'git' export const en = { + 'remote.noticeReview': "Review requested", + 'remote.noticeMerged': "Your pull request was merged", + 'remote.noticeClosed': "Your pull request was closed", + 'remote.noticeChanges': "Changes requested on your pull request", + 'remote.noticeCreate': "Create a pull request", + + 'remote.state': "State", + 'remote.stateOpen': "Open", + 'remote.stateClosed': "Closed", + 'remote.stateAll': "All", + 'remote.checkoutName': "Checkout branch name (optional)", + 'remote.checkoutDone': "Checkout completed", + 'remote.checkoutPartial': "Checkout did not complete; inspect the prepared stages before retrying", + 'remote.privateRefPrepared': "Private ref prepared", + 'remote.trackingPrepared': "Tracking ref prepared", + 'remote.mappingPrepared': "Fetch mapping added", + 'remote.upstreamPending': "Upstream configuration incomplete", + + 'remote.pushedSuggestion': 'Branch pushed. You can create a pull request from an existing remote branch.', + 'remote.title': "Remote", + 'remote.description': "Pull requests and issues", + 'remote.loadingRemotes': 'Finding remote repositories…', + 'remote.connecting': 'Connecting to GitHub…', + 'remote.loadingItems': 'Loading pull requests and issues…', + 'remote.loadingDetail': 'Loading discussion and history…', + 'remote.loadingAuth': 'Connecting to GitHub for authentication…', + 'remote.waitingAuth': 'Waiting for you to authorize in GitHub…', + 'remote.saving': 'Processing your action…', + 'remote.loadingMore': 'Loading more items…', + 'remote.slowConnection': 'GitHub is taking longer to respond. You can keep browsing while you wait.', + 'remote.noItems': 'No items match this filter.', + 'remote.auth': "Platform authentication", + 'remote.authSourceOAuth': 'GitHub OAuth (device login)', + 'remote.authSourceRef': 'Credential reference (GH_TOKEN / GITHUB_TOKEN)', + 'remote.authSourceGh': 'GitHub CLI (gh)', + 'remote.authSourceGitCredential': 'Git credential helper', + 'remote.authSourceUnknown': 'Source not reported', + 'remote.timeline': "Activity", + 'remote.commented': "commented", + 'remote.opened': "opened", + 'remote.details': "Details", + 'remote.noDescription': "No description provided.", + 'remote.authStatusConnected': "Connected to GitHub", + 'remote.authStatusAnonymous': "Not connected", + 'remote.authStatusAnonymousHint': "Browsing public data anonymously. GitHub limits the request rate, and commenting, reviewing and merging are unavailable.", + 'remote.authStatusSignedOut': "Disconnected", + 'remote.authStatusSignedOutHint': "The plugin won't use any GitHub credential on this computer until you connect again.", + 'remote.authStatusInvalid': "Credential no longer works", + 'remote.authStatusInvalidHint': "GitHub rejected the current credential or it has expired. Choose a way to connect below.", + 'remote.authMethods': "Ways to connect", + 'remote.disconnect': "Disconnect", + 'remote.loginCodeHint': "Enter this code on GitHub:", + 'remote.ghLoginHint': "Run gh auth login in a terminal first, then choose this again.", + 'remote.verify': "Open GitHub and enter this code", + 'remote.repository': "Repository", + 'remote.pulls': "Pull requests", + 'remote.issues': "Issues", + 'remote.tabPull': "PR #{number}", + 'remote.tabIssue': "Issue #{number}", + 'remote.noRemote': "No supported origin/upstream remote.", + 'remote.createPr': "Create pull request", + 'remote.pushedOnly': "Use an already pushed branch. This action does not push or create a fork.", + 'remote.prTitle': "Title", + 'remote.headOwner': "Head owner (optional)", + 'remote.headRepo': "Head repository (optional)", + 'remote.headRef': "Pushed head branch", + 'remote.baseRef': "Base branch", + 'remote.openBrowser': "Open in browser", + 'remote.filesChanged': "Files changed", + 'remote.reviewApproved': "Approved", + 'remote.reviewChanges': "Changes requested", + 'remote.reviewCommented': "Commented", + 'remote.reviewDismissed': "Dismissed", + 'remote.reviewPending': "Pending", + 'remote.stateMerged': "Merged", + 'remote.draft': "Draft", + 'remote.copy': "Copy", + 'remote.copied': "Copied", + 'remote.footnotes': "Footnotes", + 'remote.issueBranch': "Create and switch to issue branch", + 'remote.checkout': "Checkout verified PR head", + 'remote.close': "Close pull request", + 'remote.merge': "Merge pull request", + 'remote.confirm': "Confirm action for this PR, commit and account", + 'remote.outdated': "Outdated", + 'remote.body': "Comment / description", + 'remote.comment': "Post comment", + 'remote.approve': "Approve", + 'remote.reviewComment': "Submit review comment", + 'remote.requestChanges': "Request changes", + 'remote.unknown': "Write result is unknown. Refresh the affected object and inspect it before attempting another action.", + 'remote.unavailable': "Remote integration is unavailable.", + 'branch.label': 'Branches', 'branch.create': 'Create new branch', 'branch.createDialogTitle': 'Create branch', @@ -141,6 +234,99 @@ export const en = { } as const export const zh: Record = { + 'remote.noticeReview': "收到审阅请求", + 'remote.noticeMerged': "你的拉取请求已合并", + 'remote.noticeClosed': "你的拉取请求已关闭", + 'remote.noticeChanges': "你的拉取请求收到修改请求", + 'remote.noticeCreate': "创建拉取请求", + + 'remote.state': "状态", + 'remote.stateOpen': "开放", + 'remote.stateClosed': "关闭", + 'remote.stateAll': "全部", + 'remote.checkoutName': "检出分支名称(可选)", + 'remote.checkoutDone': "检出完成", + 'remote.checkoutPartial': "检出未完成,请核对准备阶段后重试", + 'remote.privateRefPrepared': "临时 ref 已准备", + 'remote.trackingPrepared': "tracking ref 已准备", + 'remote.mappingPrepared': "已追加 fetch 映射", + 'remote.upstreamPending': "upstream 配置尚未完成", + + 'remote.pushedSuggestion': '分支已推送,可使用现有远程分支创建拉取请求。', + 'remote.title': "远程", + 'remote.description': "拉取请求与议题", + 'remote.loadingRemotes': '正在查找远程仓库…', + 'remote.connecting': '正在连接 GitHub…', + 'remote.loadingItems': '正在加载拉取请求与议题…', + 'remote.loadingDetail': '正在加载讨论与历史记录…', + 'remote.loadingAuth': '正在连接 GitHub 进行认证…', + 'remote.waitingAuth': '等待你在 GitHub 完成授权…', + 'remote.saving': '正在处理操作…', + 'remote.loadingMore': '正在加载更多条目…', + 'remote.slowConnection': 'GitHub 响应较慢,等待期间可以继续浏览。', + 'remote.noItems': '没有符合当前筛选条件的条目。', + 'remote.auth': "平台认证", + 'remote.authSourceOAuth': 'GitHub OAuth(设备码登录)', + 'remote.authSourceRef': '凭据引用(GH_TOKEN / GITHUB_TOKEN)', + 'remote.authSourceGh': 'GitHub CLI(gh)', + 'remote.authSourceGitCredential': 'Git 凭据助手', + 'remote.authSourceUnknown': '未提供来源', + 'remote.timeline': "动态", + 'remote.commented': "发表了评论", + 'remote.opened': "创建", + 'remote.details': "详情", + 'remote.noDescription': "未提供描述。", + 'remote.authStatusConnected': "已连接 GitHub", + 'remote.authStatusAnonymous': "未连接", + 'remote.authStatusAnonymousHint': "正在以匿名方式浏览公开数据。GitHub 会限制请求频率,且无法评论、评审或合并。", + 'remote.authStatusSignedOut': "已断开连接", + 'remote.authStatusSignedOutHint': "在你重新连接之前,插件不会使用这台电脑上的任何 GitHub 凭据。", + 'remote.authStatusInvalid': "凭据已失效", + 'remote.authStatusInvalidHint': "GitHub 拒绝了当前凭据或凭据已过期,请在下方选择一种方式重新连接。", + 'remote.authMethods': "连接方式", + 'remote.disconnect': "断开连接", + 'remote.loginCodeHint': "在 GitHub 页面输入此代码:", + 'remote.ghLoginHint': "请先在终端运行 gh auth login,然后再选择此项。", + 'remote.verify': "打开 GitHub 并输入代码", + 'remote.repository': "仓库", + 'remote.pulls': "拉取请求", + 'remote.issues': "议题", + 'remote.tabPull': "PR #{number}", + 'remote.tabIssue': "议题 #{number}", + 'remote.noRemote': "没有受支持的 origin/upstream 远端。", + 'remote.createPr': "创建拉取请求", + 'remote.pushedOnly': "请使用已推送分支。此操作不推送或创建 fork。", + 'remote.prTitle': "标题", + 'remote.headOwner': "源仓库所有者(可选)", + 'remote.headRepo': "源仓库名称(可选)", + 'remote.headRef': "已推送的源分支", + 'remote.baseRef': "目标分支", + 'remote.openBrowser': "在浏览器中打开", + 'remote.filesChanged': "更改的文件", + 'remote.reviewApproved': "已批准", + 'remote.reviewChanges': "请求修改", + 'remote.reviewCommented': "已评论", + 'remote.reviewDismissed': "已忽略", + 'remote.reviewPending': "待提交", + 'remote.stateMerged': "已合并", + 'remote.draft': "草稿", + 'remote.copy': "复制", + 'remote.copied': "已复制", + 'remote.footnotes': "脚注", + 'remote.issueBranch': "创建并切换到议题分支", + 'remote.checkout': "检出已核验的 PR 提交", + 'remote.close': "关闭拉取请求", + 'remote.merge': "合并拉取请求", + 'remote.confirm': "确认此 PR、提交和账号的操作", + 'remote.outdated': "已过时", + 'remote.body': "评论/描述", + 'remote.comment': "发表评论", + 'remote.approve': "批准", + 'remote.reviewComment': "提交审阅评论", + 'remote.requestChanges': "请求修改", + 'remote.unknown': "写入结果待确认。请刷新并检查目标对象后再操作。", + 'remote.unavailable': "远程集成不可用。", + 'branch.label': '分支', 'branch.create': '创建新分支', 'branch.createDialogTitle': '创建分支', diff --git a/src/client/remote.ts b/src/client/remote.ts new file mode 100644 index 0000000..fbe0e66 --- /dev/null +++ b/src/client/remote.ts @@ -0,0 +1,390 @@ +/** Portable remote capability lifecycle and session/workspace-scoped controller. */ +import type { GitRpcClient } from './controller.ts' +import type { GitRemoteCapability, GitRemoteGeneration, GitRemoteWriteContext } from '../remote/types.ts' +import type { BoundRemote, RemoteRepositorySet, RemoteCheckoutReceipt } from '../remote/git.ts' +import type { RemoteComment, RemoteCommit, RemoteIssue, RemotePage, RemotePull, RemoteRepository, RemoteRepositoryFacts, RemoteResult, RemoteNotice, RemoteReview } from '../remote/provider.ts' + +const OFF: GitRemoteCapability = { state: 'off', reason: 'implementation-pending', auth: { state: 'off' } } +const equal = (a: GitRemoteGeneration | undefined, b: GitRemoteGeneration | undefined) => a !== undefined && b !== undefined && a.instanceId === b.instanceId && a.revision === b.revision +/** Upstream pages fetched by the first request of a progressive list; continuations take one page each. */ +const FIRST_PAGES = 1 +/** Re-entering a view within this window needs no network validation. */ +const CACHE_TTL_MS = 60_000 +/** Longest reset wait for one automatic stream resume after `git/rate-limited`. */ +const STREAM_RESUME_MAX_MS = 600_000 +export class RemoteRpcError extends Error { + constructor(readonly code: string, message: string, readonly details: unknown) { super(message) } +} +export class GitRemoteFeature { + private readonly controllers = new Map() + /** Controllers belong to the feature, so tab unmounts retain data and in-flight pages. */ + controllerFor(sessionId: string, view = 'list'): GitRemoteController { + const key = JSON.stringify([sessionId, view]) + const existing = this.controllers.get(key) + if (existing !== undefined) { this.controllers.delete(key); this.controllers.set(key, existing); return existing } + // Bound retained idle views without evicting mounted or working controllers. + for (const [oldKey, controller] of this.controllers) { + if (this.controllers.size < 64) break + if (controller.idle) { controller.dispose(); this.controllers.delete(oldKey) } + } + const controller = new GitRemoteController(this, sessionId) + this.controllers.set(key, controller) + return controller + } + invalidate(repository: string | undefined): void { + for (const controller of this.controllers.values()) { + if (controller.getSnapshot().repository === repository) controller.invalidate() + } + } + private state = OFF + private notices: readonly RemoteNotice[] = [] + private readonly seen = new Set() + private notify: ((notice: RemoteNotice) => void | Promise) | undefined + readonly getNotices = () => this.notices + setNotify(notify: ((notice: RemoteNotice) => void | Promise) | undefined): void { this.notify = notify } + ingest(notices: readonly RemoteNotice[]): void { + if (this.owner.signal.aborted || this.state.state !== 'on') return + for (const notice of notices) { + if (this.seen.has(notice.id)) continue + if (this.seen.size >= 1024) return + this.seen.add(notice.id); this.notices = [...this.notices.slice(-99), notice] + try { void Promise.resolve(this.notify?.(notice)).catch(() => {}) } catch { /* Native enhancement never interrupts the portable badge. */ } + } + for (const listener of this.listeners) listener() + } + private readonly listeners = new Set<() => void>() + private readonly owner = new AbortController() + private pending: Promise | undefined + private refreshAgain = false + private timer: ReturnType | undefined + constructor(readonly rpc: GitRpcClient) {} + readonly getSnapshot = () => this.state + readonly subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener) } } + start(): void { this.refresh(); this.timer ??= setInterval(() => this.refresh(), 5000) } + refresh(): void { + if (this.owner.signal.aborted) return + if (this.pending !== undefined) { this.refreshAgain = true; return } + this.pending = (async () => { + try { + const reply = await this.rpc.call('/git', 'remote-capability', {}, this.owner.signal) + if (this.owner.signal.aborted) return + const cap = reply.ok ? reply.value as GitRemoteCapability : OFF + if (cap?.state !== 'on' || (this.state.state === 'on' && !equal(cap.generation, this.state.generation))) { this.notices = []; this.seen.clear() } + this.state = cap?.state === 'on' || cap?.state === 'off' ? cap : OFF + } catch { this.state = OFF } + if (!this.owner.signal.aborted) for (const listener of this.listeners) listener() + })().finally(() => { + this.pending = undefined + if (this.refreshAgain) { this.refreshAgain = false; this.refresh() } + }) + } + dispose(): void { this.owner.abort(); clearInterval(this.timer); for (const controller of this.controllers.values()) controller.dispose(); this.controllers.clear(); this.listeners.clear() } +} +export type RemotePending = 'repositories' | 'repository' | 'items' | 'detail' | 'auth' | 'authorization' | 'write' +export interface RemoteClientState { + readonly capability: GitRemoteCapability + readonly repository?: string | undefined + readonly remotes: readonly BoundRemote[] + readonly target?: RemoteRepository | undefined + readonly facts?: RemoteRepositoryFacts | undefined + readonly generation?: GitRemoteGeneration | undefined + readonly items: readonly (RemotePull | RemoteIssue)[] + readonly listKind?: 'pulls' | 'issues' | undefined + readonly listState?: 'open' | 'closed' | 'all' | undefined + /** Opaque list continuation cursor; absent once the list is fully loaded. */ + readonly itemsNext?: string | undefined + readonly detail?: RemotePull | RemoteIssue | undefined + readonly comments: readonly RemoteComment[] + readonly commentsNext?: string | undefined + readonly inline: readonly RemoteComment[] + readonly inlineNext?: string | undefined + readonly commits: readonly RemoteCommit[] + readonly commitsNext?: string | undefined + readonly reviews: readonly RemoteReview[] + readonly reviewsNext?: string | undefined + readonly checkout?: RemoteCheckoutReceipt | undefined + readonly busy: boolean + readonly pending?: RemotePending | undefined + /** True while a background page pull appends to an already-rendered list. */ + readonly streaming: boolean + /** Set when the background stream stopped on an error or an overlong rate-limit reset. */ + readonly streamError?: string | undefined + readonly error?: string | undefined + readonly unknown: boolean +} +export class GitRemoteController { + private state: RemoteClientState + private readonly listeners = new Set<() => void>() + private epoch = 0 + private active = new AbortController() + private disposed = false + private streamEpoch: number | undefined + private streamResume: ReturnType | undefined + private listKind: 'pulls' | 'issues' = 'pulls' + private listState: 'open' | 'closed' | 'all' = 'open' + private listAttempt: { key: string; at: number } | undefined + private detailAttempt: { key: string; at: number } | undefined + private listValidatedAt = 0 + private readonly lists = new Map() + private readonly collections = new Map() + private readonly unsubscribe: () => void + constructor(private readonly feature: GitRemoteFeature, private readonly sessionId?: string) { + this.state = { capability: feature.getSnapshot(), remotes: [], items: [], comments: [], inline: [], commits: [], reviews: [], busy: false, streaming: false, unknown: false } + this.unsubscribe = feature.subscribe(() => { + const capability = feature.getSnapshot() + if (capability.state === 'off' || (this.state.generation !== undefined && capability.state === 'on' && !equal(this.state.generation, capability.generation))) this.clear(this.state.repository) + this.patch({ capability }) + }) + } + readonly getSnapshot = () => this.state + get idle(): boolean { return this.listeners.size === 0 && !this.state.busy && !this.state.streaming && !this.state.unknown && this.streamResume === undefined } + invalidate(): void { this.listAttempt = undefined; this.detailAttempt = undefined; this.lists.clear() } + readonly subscribe = (listener: () => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener) } } + private patch(value: Partial): void { if (this.disposed) return; this.state = { ...this.state, ...value }; for (const listener of this.listeners) listener() } + private clear(repository?: string): void { + this.invalidate(); this.collections.clear() + this.listValidatedAt = 0 + this.epoch += 1; this.active.abort(); this.active = new AbortController() + if (this.streamResume !== undefined) { clearTimeout(this.streamResume); this.streamResume = undefined } + this.state = { capability: this.feature.getSnapshot(), ...(repository === undefined ? {} : { repository }), remotes: [], items: [], comments: [], inline: [], commits: [], reviews: [], busy: false, streaming: false, unknown: false } + } + setRepository(repository: string | undefined, autoload = true): void { if (repository === this.state.repository) return; this.clear(repository); this.patch({}); if (repository !== undefined && autoload) void this.load() } + /** Restore a filter immediately, validating stale data while it remains visible. */ + async ensureList(kind = this.listKind, selected = this.state.target, state = this.listState): Promise { + if (this.state.busy || this.state.unknown || this.state.repository === undefined || this.state.capability.state !== 'on') return + const key = this.listKey(kind, selected, state) + if (this.listAttempt?.key === key && (this.state.streaming || Date.now() - this.listAttempt.at < CACHE_TTL_MS)) return + const cached = this.lists.get(key) + if (cached !== undefined) { + this.stopStream() + this.listKind = kind; this.listState = state + this.listValidatedAt = cached.at + if (cached.state.itemsNext !== undefined) this.collections.set('items', cached.collected ?? cached.state.items) + this.patch({ ...cached.state, capability: this.feature.getSnapshot(), busy: false, pending: undefined, streaming: false }) + this.listAttempt = { key, at: cached.at } + if (Date.now() - cached.at < CACHE_TTL_MS) { this.stream(this.epoch); return } + } + await this.load(kind, selected, state) + } + async ensureOpen(n: number, kind: 'pulls' | 'issues', revision: number): Promise { + if (this.state.unknown) return + const key = `${kind}/${n}:${revision}` + if (this.detailAttempt?.key === key && Date.now() - this.detailAttempt.at < CACHE_TTL_MS) return + if (this.state.busy) return + this.detailAttempt = { key, at: Date.now() } + await this.open(n, kind) + } + private listKey(kind: string, target: RemoteRepository | undefined, state: string): string { + return JSON.stringify([target?.host, target?.owner.toLowerCase(), target?.repo.toLowerCase(), kind, state]) + } + private rememberList(): void { + if (this.state.listKind === undefined || this.state.detail !== undefined || this.listAttempt === undefined) return + const key = this.listKey(this.listKind, this.state.target, this.listState) + if (this.lists.size >= 12) this.lists.delete(this.lists.keys().next().value ?? '') + this.lists.set(key, { state: this.state, at: this.listValidatedAt, collected: this.collections.get('items') }) + } + private stopStream(): void { + this.epoch += 1; this.active.abort(); this.active = new AbortController() + if (this.streamResume !== undefined) { clearTimeout(this.streamResume); this.streamResume = undefined } + this.collections.clear() + this.patch({ streaming: false }) + } + /** Keep old rows during validation; remove missing rows only after the final page. */ + private page(name: string, current: readonly T[], page: RemotePage, key: (item: T) => string | number, first = false): readonly T[] { + const accumulated = appendUnique(first ? [] : (this.collections.get(name) ?? []) as readonly T[], page.items, key) + if (page.next === undefined) this.collections.delete(name) + else this.collections.set(name, accumulated) + const ids = new Set(accumulated.map(key)) + return reconcile(current, page.next === undefined ? accumulated : [...accumulated, ...current.filter(item => !ids.has(key(item)))], key) + } + private async call(endpoint: string, payload: Record = {}, signal = this.active.signal): Promise { + const reply = await this.feature.rpc.call('/git', endpoint, { ...payload, ...(this.state.repository === undefined ? {} : { repository: this.state.repository }), ...(this.sessionId === undefined ? {} : { sessionId: this.sessionId }) }, signal) + if (!reply.ok) { const error = reply.error as { code?: string; message?: string; details?: unknown }; throw new RemoteRpcError(error.code ?? 'git/remote-unavailable', error.message ?? 'Git remote request failed', error.details) } + return reply.value as T + } + private async run(work: (epoch: number) => Promise, pending: RemotePending = 'write'): Promise { + if (this.disposed || this.state.busy) return + const epoch = this.epoch; this.patch({ busy: true, pending, error: undefined }) + try { await work(epoch) } catch (error) { if (epoch === this.epoch) this.patch({ error: error instanceof Error ? error.message : String(error), unknown: error instanceof RemoteRpcError && error.code === 'git/write-result-unknown' || this.state.unknown }) } + finally { if (epoch === this.epoch) { this.patch({ busy: false, pending: undefined }); this.feature.refresh() } } + } + async load(kind: 'pulls' | 'issues' = 'pulls', selected?: RemoteRepository, state: 'open' | 'closed' | 'all' = 'open'): Promise { + if (this.disposed || this.state.busy) return + this.rememberList() + this.stopStream() + this.listAttempt = { key: this.listKey(kind, selected ?? this.state.target, state), at: Date.now() } + await this.run(async epoch => { + const repositories = await this.call('remote-repositories') + const target = selected ?? this.state.target ?? repositories.remotes[0]?.identity + if (epoch !== this.epoch) return + this.patch({ remotes: repositories.remotes, pending: 'repository' }) + if (target === undefined) return + const facts = await this.call>('remote-repository', target as unknown as Record) + if (epoch !== this.epoch) return + this.patch({ pending: 'items' }) + const list = await this.call>>(kind === 'pulls' ? 'remote-list-prs' : 'remote-list-issues', { ...target, state, pages: FIRST_PAGES }) + if (epoch !== this.epoch || !equal(facts.generation, list.generation)) return + this.listKind = kind; this.listState = state + this.listAttempt = { key: this.listKey(kind, target, state), at: Date.now() } + this.listValidatedAt = Date.now() + const sameList = this.state.listKind === kind && this.state.listState === state && this.listKey(kind, this.state.target, state) === this.listKey(kind, target, state) + this.patch({ target, generation: facts.generation, facts: facts.value, listKind: kind, listState: state, items: this.page('items', sameList ? this.state.items : [], list.value, item => item.number, true), itemsNext: list.value.next, detail: undefined, comments: [], commentsNext: undefined, inline: [], inlineNext: undefined, commits: [], commitsNext: undefined, reviews: [], reviewsNext: undefined, streamError: undefined, unknown: false }) + this.rememberList() + this.stream(epoch) + }, 'repositories') + } + async open(n: number, kind: 'pulls' | 'issues'): Promise { + if (this.disposed || this.state.busy) return + this.stopStream() + await this.run(async epoch => { + const target = this.state.target; if (target === undefined) return + const detail = await this.call>(kind === 'pulls' ? 'remote-pr' : 'remote-issue', { ...target, number: n }) + const comments = await this.call>>('remote-comments', { ...target, number: n, pages: FIRST_PAGES }) + const inline = kind === 'pulls' ? await this.call>>('remote-comments', { ...target, number: n, inline: true, pages: FIRST_PAGES }) : undefined + const commits = kind === 'pulls' ? await this.call>>('remote-commits', { ...target, number: n, pages: FIRST_PAGES }) : undefined + const reviews = kind === 'pulls' ? await this.call>>('remote-reviews', { ...target, number: n, pages: FIRST_PAGES }) : undefined + if (epoch !== this.epoch || !equal(detail.generation, comments.generation) || (inline !== undefined && !equal(detail.generation, inline.generation)) || (commits !== undefined && !equal(detail.generation, commits.generation)) || (reviews !== undefined && !equal(detail.generation, reviews.generation))) return + const sameDetail = this.state.detail?.number === n && ('head' in this.state.detail) === (kind === 'pulls') + this.patch({ facts: this.state.facts === undefined ? undefined : { ...this.state.facts, actions: { ...this.state.facts.actions, canClose: detail.value.author?.userId === this.state.facts.viewer?.userId || this.state.facts.permissions.push === true ? true : this.state.facts.permissions.push === false ? false : 'unknown' } }, itemsNext: undefined, detail: detail.value, generation: detail.generation, comments: this.page('comments', sameDetail ? this.state.comments : [], comments.value, entry => entry.id, true), commentsNext: comments.value.next, inline: inline === undefined ? [] : this.page('inline', sameDetail ? this.state.inline : [], inline.value, entry => entry.id, true), inlineNext: inline?.value.next, commits: commits === undefined ? [] : this.page('commits', sameDetail ? this.state.commits : [], commits.value, entry => entry.sha, true), commitsNext: commits?.value.next, reviews: reviews === undefined ? [] : this.page('reviews', sameDetail ? this.state.reviews : [], reviews.value, entry => entry.id, true), reviewsNext: reviews?.value.next, streamError: undefined, unknown: false }) + this.stream(epoch) + }, 'detail') + } + /** Continue any pending page cursor in the background, one upstream page per call. */ + private stream(epoch: number): void { + if (this.disposed || this.streamEpoch === epoch || this.active.signal.aborted) return + this.streamEpoch = epoch + void this.streamLoop(epoch).then(drained => { + if (this.streamEpoch !== epoch) return + this.streamEpoch = undefined + // A kick landing between the loop's last cursor check and this teardown is honored now. + if (drained && epoch === this.epoch && this.hasPendingCursor()) this.stream(epoch) + }) + } + private hasPendingCursor(): boolean { + const s = this.state + return s.itemsNext !== undefined || s.commentsNext !== undefined || s.inlineNext !== undefined || s.commitsNext !== undefined || s.reviewsNext !== undefined + } + /** @returns `true` only when the loop drained every cursor; errors and superseded state return `false`. */ + private async streamLoop(epoch: number): Promise { + this.patch({ streaming: true, streamError: undefined }) + try { + for (;;) { + if (this.disposed || epoch !== this.epoch || this.active.signal.aborted) return false + const s = this.state + const target = s.target + const generation = s.generation + if (target === undefined || generation === undefined) return false + if (s.itemsNext !== undefined) { + const cursor = s.itemsNext + const page = await this.call>>(this.listKind === 'issues' ? 'remote-list-issues' : 'remote-list-prs', { ...target, state: this.listState, cursor }) + if (epoch !== this.epoch || !equal(generation, page.generation)) return false + if (this.state.itemsNext !== cursor) continue + this.patch({ items: this.page('items', this.state.items, page.value, item => item.number), itemsNext: page.value.next }); this.rememberList() + continue + } + const number = s.detail?.number + if (s.commentsNext !== undefined && number !== undefined) { + const cursor = s.commentsNext + const page = await this.call>>('remote-comments', { ...target, number, cursor }) + if (epoch !== this.epoch || !equal(generation, page.generation)) return false + if (this.state.commentsNext !== cursor) continue + this.patch({ comments: this.page('comments', this.state.comments, page.value, item => item.id), commentsNext: page.value.next }) + continue + } + if (s.inlineNext !== undefined && number !== undefined) { + const cursor = s.inlineNext + const page = await this.call>>('remote-comments', { ...target, number, inline: true, cursor }) + if (epoch !== this.epoch || !equal(generation, page.generation)) return false + if (this.state.inlineNext !== cursor) continue + this.patch({ inline: this.page('inline', this.state.inline, page.value, item => item.id), inlineNext: page.value.next }) + continue + } + if (s.commitsNext !== undefined && number !== undefined) { + const cursor = s.commitsNext + const page = await this.call>>('remote-commits', { ...target, number, cursor }) + if (epoch !== this.epoch || !equal(generation, page.generation)) return false + if (this.state.commitsNext !== cursor) continue + this.patch({ commits: this.page('commits', this.state.commits, page.value, item => item.sha), commitsNext: page.value.next }) + continue + } + if (s.reviewsNext !== undefined && number !== undefined) { + const cursor = s.reviewsNext + const page = await this.call>>('remote-reviews', { ...target, number, cursor }) + if (epoch !== this.epoch || !equal(generation, page.generation)) return false + if (this.state.reviewsNext !== cursor) continue + this.patch({ reviews: this.page('reviews', this.state.reviews, page.value, item => item.id), reviewsNext: page.value.next }) + continue + } + return true + } + } catch (error) { + if (this.disposed || epoch !== this.epoch || this.active.signal.aborted) return false + this.patch({ streamError: error instanceof Error ? error.message : String(error) }) + if (error instanceof RemoteRpcError && error.code === 'git/rate-limited') { + const resetAt = (error.details as { resetAt?: unknown } | undefined)?.resetAt + const delay = typeof resetAt === 'number' ? resetAt - Date.now() : Number.NaN + if (Number.isFinite(delay) && delay > 0 && delay <= STREAM_RESUME_MAX_MS) { + this.streamResume = setTimeout(() => { this.streamResume = undefined; this.stream(epoch) }, delay) + } + } + return false + } finally { + if (!this.disposed && epoch === this.epoch) this.patch({ streaming: false }) + } + } + async pollNotices(): Promise { + const { target, generation, capability } = this.state + if (this.disposed || this.state.busy || target === undefined || generation === undefined || capability.state !== 'on' || capability.auth.state !== 'authenticated') return + const epoch = this.epoch + try { const result = await this.call>>('remote-notifications', { ...target, automatic: true }); if (epoch === this.epoch && equal(generation, result.generation)) this.feature.ingest(result.value.items) } catch { /* Shared Host budgets decide when polling can resume. */ } + } + async authAction(...endpoints: string[]): Promise { + if (endpoints[0] === 'remote-sign-out' && this.state.busy) { this.clear(this.state.repository); this.patch({}) } + await this.run(async epoch => { + for (const endpoint of endpoints) await this.call(endpoint) + if (epoch === this.epoch) { this.clear(this.state.repository); this.patch({}); this.feature.refresh() } + }, 'auth') + } + async login(show: (value: { userCode: string; verificationUri: string; attemptId: string }) => void): Promise { + await this.run(async epoch => { + const login = await this.call<{ userCode: string; verificationUri: string; attemptId: string }>('remote-login') + if (epoch !== this.epoch) return + show(login) + this.patch({ pending: 'authorization' }) + await this.call('remote-poll-login', { attemptId: login.attemptId }) + }, 'auth') + } + async cancelLogin(attemptId: string): Promise { await this.call('remote-cancel-login', { attemptId }); this.feature.refresh() } + async write(endpoint: string, values: Record): Promise { + if (this.state.unknown) return + await this.run(async epoch => { + const { target, generation, facts, repository } = this.state + if (target === undefined || generation === undefined || facts?.viewer === undefined) return + const context: GitRemoteWriteContext = { ...target, expectedGeneration: generation, expectedViewer: facts.viewer } + let result: unknown + try { result = await this.call(endpoint, { ...context, ...values, requestId: crypto.randomUUID() }) } + finally { this.invalidate(); this.feature.invalidate(repository) } + if (epoch !== this.epoch) return + if (endpoint === 'remote-checkout-pr' && typeof result === 'object' && result !== null) this.patch({ checkout: result as RemoteCheckoutReceipt, ...('error' in result ? { error: 'Git checkout needs attention' } : {}) }) + }) + } + dispose(): void { this.disposed = true; this.epoch += 1; this.active.abort(); if (this.streamResume !== undefined) clearTimeout(this.streamResume); this.unsubscribe(); this.listeners.clear() } +} + +function appendUnique(items: readonly T[], page: readonly T[], key: (item: T) => string | number): readonly T[] { + const merged = new Map(items.map(item => [key(item), item])) + for (const item of page) merged.set(key(item), item) + return [...merged.values()] +} + +/** Preserve object/array identity for unchanged rows after conditional validation. */ +function reconcile(previous: readonly T[], next: readonly T[], key: (item: T) => string | number): readonly T[] { + const old = new Map(previous.map(item => [key(item), item])) + const rows = next.map(item => { + const retained = old.get(key(item)) + return retained !== undefined && JSON.stringify(retained) === JSON.stringify(item) ? retained : item + }) + return rows.length === previous.length && rows.every((item, index) => item === previous[index]) ? previous : rows +} + +export const remoteNoticeKeys = { 'review-requested': 'remote.noticeReview', merged: 'remote.noticeMerged', closed: 'remote.noticeClosed', 'changes-requested': 'remote.noticeChanges', 'create-pr': 'remote.noticeCreate' } as const diff --git a/src/client/remote/RemoteAuthControl.tsx b/src/client/remote/RemoteAuthControl.tsx new file mode 100644 index 0000000..c470305 --- /dev/null +++ b/src/client/remote/RemoteAuthControl.tsx @@ -0,0 +1,52 @@ +import { useState, useSyncExternalStore } from 'react' +import type { ReactNode } from 'react' +import { Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconUserOutline16 } from '../../compat/dsh-icons.tsx' +import { GitIconButton } from '../details/GitIconButton.tsx' +import { RemoteAuthSection, type RemoteLoginAttempt } from './RemoteAuthSection.tsx' +import { RemoteLoading } from './RemoteLoading.tsx' +import css from '../GitRemoteSurface.module.css' +import type { GitRemoteController } from '../remote.ts' +import type { GitRemoteCapabilityOn } from '../../remote/types.ts' +import type { GitRemoteViewer } from '../../remote/types.ts' +import type { GitLocaleKey } from '../locales.ts' + +/** Toolbar entry to the platform-authentication dialog; owns the login-attempt state. */ +export function RemoteAuthControl({ capability, viewer, busy, controller, t }: { + readonly capability: GitRemoteCapabilityOn + readonly viewer: GitRemoteViewer | undefined + readonly busy: boolean + readonly controller: GitRemoteController + readonly t: (key: GitLocaleKey) => string +}): ReactNode { + const [open, setOpen] = useState(false) + const [login, setLogin] = useState() + const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot) + return ( + <> + { setOpen(true) }}> + + + { setOpen(false) }} + title={t('remote.auth')} + closeLabel={t('branch.cancel')} + className={`${css.authDialog}`} + > + + {state.error !== undefined &&

{state.error}

} + { void controller.authAction(...endpoints) }} + onLogin={() => { void controller.login(setLogin).finally(() => { setLogin(undefined) }) }} + onCancelLogin={attemptId => { void controller.cancelLogin(attemptId).finally(() => { setLogin(undefined) }) }} + t={t} + /> +
+ + ) +} diff --git a/src/client/remote/RemoteAuthSection.tsx b/src/client/remote/RemoteAuthSection.tsx new file mode 100644 index 0000000..a67601f --- /dev/null +++ b/src/client/remote/RemoteAuthSection.tsx @@ -0,0 +1,121 @@ +import { useId } from 'react' +import type { ReactNode } from 'react' +import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { GitRemoteCapabilityOn, GitRemoteAuthSource, GitRemoteAuthState } from '../../remote/types.ts' +import type { GitRemoteViewer } from '../../remote/types.ts' +import type { GitLocaleKey } from '../locales.ts' +import { RemoteAvatar } from './RemoteAvatar.tsx' +import css from '../GitRemoteSurface.module.css' + +/** Pending device-flow attempt shown until the poll settles or the user cancels. */ +export interface RemoteLoginAttempt { + readonly userCode: string + readonly verificationUri: string + readonly attemptId: string +} + +function authDot(state: GitRemoteAuthState): 'done' | 'warning' | 'error' | 'idle' { + if (state === 'authenticated') return 'done' + if (state === 'invalid') return 'error' + if (state === 'unauthenticated') return 'warning' + return 'idle' +} + +const sourceLabels: Record = { + grant: 'remote.authSourceOAuth', + ref: 'remote.authSourceRef', + gh: 'remote.authSourceGh', + 'git-credential': 'remote.authSourceGitCredential', +} + +function statusCopy(state: GitRemoteAuthState): { readonly headline: GitLocaleKey; readonly hint?: GitLocaleKey } { + if (state === 'authenticated') return { headline: 'remote.authStatusConnected' } + if (state === 'signed-out') return { headline: 'remote.authStatusSignedOut', hint: 'remote.authStatusSignedOutHint' } + if (state === 'invalid') return { headline: 'remote.authStatusInvalid', hint: 'remote.authStatusInvalidHint' } + return { headline: 'remote.authStatusAnonymous', hint: 'remote.authStatusAnonymousHint' } +} + +/** Authentication status card plus a radio choice of connect methods; checked state derives from capability only. */ +export function RemoteAuthSection({ capability, viewer, busy, login, onAuth, onLogin, onCancelLogin, t }: { + readonly capability: GitRemoteCapabilityOn + readonly viewer: GitRemoteViewer | undefined + readonly busy: boolean + readonly login: RemoteLoginAttempt | undefined + readonly onAuth: (...endpoints: string[]) => void + readonly onLogin: () => void + readonly onCancelLogin: (attemptId: string) => void + readonly t: (key: GitLocaleKey) => string +}): ReactNode { + const group = useId() + const { auth } = capability + const authenticated = auth.state === 'authenticated' + // A stored grant or signed-out marker would override the chosen method; drop it first. + const reset = auth.state === 'signed-out' || auth.state === 'invalid' || auth.source === 'grant' + const status = statusCopy(auth.state) + const source = authenticated && auth.source !== undefined ? sourceLabels[auth.source] ?? 'remote.authSourceUnknown' : 'remote.authSourceUnknown' + const subline = authenticated && auth.source !== 'gh' && auth.source !== 'grant' ? source : undefined + return ( +
+
+
+
+ {authenticated && viewer !== undefined + ? + : } +
+ {t(status.headline)} + {authenticated && viewer !== undefined && @{viewer.login}} + {status.hint !== undefined && {t(status.hint)}} + {subline !== undefined && {t(subline)}} +
+
+ {(authenticated || auth.state === 'invalid') && ( + + )} +
+
+
+ {t('remote.authMethods')} + + {!authenticated && {t('remote.ghLoginHint')}} + {capability.loginAvailable && ( + + )} + {capability.loginAvailable && login !== undefined && ( +
+ {t('remote.loginCodeHint')} + {login.userCode} +
+ + {t('remote.verify')} + + +
+
+ )} +
+
+ ) +} diff --git a/src/client/remote/RemoteAvatar.tsx b/src/client/remote/RemoteAvatar.tsx new file mode 100644 index 0000000..f70ed3f --- /dev/null +++ b/src/client/remote/RemoteAvatar.tsx @@ -0,0 +1,27 @@ +/** GitHub avatar with an initial-letter fallback; opaque so the timeline gutter never shows through. */ +import { useState } from 'react' +import type { ReactNode } from 'react' +import css from '../GitRemoteSurface.module.css' + +export function RemoteAvatar({ login, url, size }: { + readonly login: string | undefined + readonly url: string | undefined + readonly size: number +}): ReactNode { + const [failed, setFailed] = useState(false) + let src: string | undefined + if (url !== undefined) { + try { + const parsed = new URL(url) + parsed.searchParams.set('s', String(size * 2)) + src = parsed.href + } catch { src = undefined } + } + return ( + + ) +} diff --git a/src/client/remote/RemoteComposer.tsx b/src/client/remote/RemoteComposer.tsx new file mode 100644 index 0000000..7a24ccd --- /dev/null +++ b/src/client/remote/RemoteComposer.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' +import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import type { GitLocaleKey } from '../locales.ts' +import css from '../GitRemoteSurface.module.css' + +const REVIEW_EVENTS = ['APPROVE', 'COMMENT', 'REQUEST_CHANGES'] as const +export type RemoteReviewEvent = typeof REVIEW_EVENTS[number] + +function reviewKey(event: RemoteReviewEvent): GitLocaleKey { + if (event === 'APPROVE') return 'remote.approve' + if (event === 'COMMENT') return 'remote.reviewComment' + return 'remote.requestChanges' +} + +/** Comment body plus the comment / review write actions. */ +export function RemoteComposer({ writable, canComment, canReview, body, onBody, onComment, onReview, t }: { + readonly writable: boolean + readonly canComment: boolean | 'unknown' | undefined + readonly canReview: boolean | 'unknown' | undefined + readonly body: string + readonly onBody: (value: string) => void + readonly onComment: () => void + readonly onReview: (event: RemoteReviewEvent) => void + readonly t: (key: GitLocaleKey) => string +}): ReactNode { + return ( +
+