From 44620603d5862fe3201b92f887643938c30844b7 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 9 Sep 2026 21:17:53 +0800 Subject: [PATCH 01/13] =?UTF-8?q?fix(Webview):=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E9=80=9A=E9=81=93=E5=A4=8D=E7=94=A8=E3=80=81reword=20=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E7=8E=AF=E5=A2=83=E4=B8=8E=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=BA=8F=E5=88=97=E5=8C=96=E6=81=A2=E5=A4=8D;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - merge/rebase 面板脚本回调内重复调用 acquireVsCodeApi()(每 webview 仅允许一次)改为顶部获取后全程复用,修复 merge「保存→拒绝确认→取消」路径取消失效的问题; - reword 的 GIT_EDITOR 经 process.execPath(扩展宿主内为 Electron 二进制)运行,补 ELECTRON_RUN_AS_NODE=1 防止拉起新编辑器进程; - rebase/merge 两个 retainContextWhenHidden 面板补 registerWebviewPanelSerializer:窗口 reload 后 rebase 按 webview state(base + 用户编辑后的 todo 行序/action/subject)恢复,merge 按 filePath 重拉三阶段冲突数据恢复,无有效 state 时渲染过期占位页; - 面板 onDidReceiveMessage 订阅随 onDidDispose 释放; - 抽公共 getNonce(randomUUID hex)替代各 webview 内联 nonce 生成,本批迁移 merge/rebase。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/adapter/webview/merge-editor.ts | 80 +++++++++++++++- src/adapter/webview/nonce.ts | 9 ++ src/adapter/webview/rebase-webview.ts | 126 +++++++++++++++++++++----- src/extension.ts | 5 +- 4 files changed, 192 insertions(+), 28 deletions(-) create mode 100644 src/adapter/webview/nonce.ts diff --git a/src/adapter/webview/merge-editor.ts b/src/adapter/webview/merge-editor.ts index 9eff82c..d608b11 100644 --- a/src/adapter/webview/merge-editor.ts +++ b/src/adapter/webview/merge-editor.ts @@ -1,4 +1,3 @@ -import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as vscode from 'vscode'; @@ -6,6 +5,7 @@ import type { GitRepositoryService } from '../git-repository-service'; import { diff3, type MergeHunk } from '../../engine/merge/diff3'; import { parseConflictState } from '../../engine/git-state/conflict-detector'; import { getBaseStyles } from './shared-styles'; +import { getNonce } from './nonce'; const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); @@ -53,8 +53,19 @@ export class MergeEditorWebview { vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true }, ); + MergeEditorWebview.attachPanel(service, panel, filePath, hunks, conflicts); + } + + /** 面板装配:渲染 HTML + 消息接线(订阅随 panel dispose 释放)。 */ + private static attachPanel( + service: GitRepositoryService, + panel: vscode.WebviewPanel, + filePath: string, + hunks: readonly MergeHunk[], + conflicts: number, + ): void { panel.webview.html = MergeEditorWebview.renderHtml(filePath, hunks, conflicts); - panel.webview.onDidReceiveMessage(async (msg) => { + const msgSub = panel.webview.onDidReceiveMessage(async (msg) => { if (msg?.type === 'save' && typeof msg.content === 'string') { await MergeEditorWebview.saveResult(service, filePath, msg.content); panel.dispose(); @@ -62,6 +73,61 @@ export class MergeEditorWebview { panel.dispose(); } }); + panel.onDidDispose(() => msgSub.dispose()); + } + + /** + * 窗口 reload 后的面板恢复:按 state 中的 filePath 重拉三阶段冲突数据重渲染。 + * 已编辑未保存的 Result 不随 state 保留(known limitation);若冲突已在他处解决则直接关闭恢复的面板。 + */ + static registerSerializer(service: GitRepositoryService): vscode.Disposable { + return vscode.window.registerWebviewPanelSerializer('hyperGit.mergeEditor', { + async deserializeWebviewPanel(panel, state) { + const filePath = (state as { filePath?: unknown } | undefined)?.filePath; + if (typeof filePath !== 'string' || !service.repo) { + panel.webview.html = MergeEditorWebview.renderStaleHtml(); + return; + } + try { + const [base, ours, theirs] = await Promise.all([ + service.execGit(['show', `:1:${filePath}`]), + service.execGit(['show', `:2:${filePath}`]), + service.execGit(['show', `:3:${filePath}`]), + ]); + const hunks = diff3(splitLines(base), splitLines(ours), splitLines(theirs)); + const conflicts = hunks.filter((h) => h.kind === 'conflict').length; + if (conflicts === 0) { + // reload 前冲突已被解决:恢复无意义,直接关闭。 + void vscode.window.showInformationMessage(`"${filePath}" has no unresolved conflicts; the restored merge editor was closed.`); + panel.dispose(); + return; + } + MergeEditorWebview.attachPanel(service, panel, filePath, hunks, conflicts); + } catch { + panel.webview.html = MergeEditorWebview.renderStaleHtml(); + } + }, + }); + } + + /** 面板过期(无有效 state / 冲突数据已不可读)时的占位页。 */ + private static renderStaleHtml(): string { + return ` + + + + + + + +

Merge

+

This merge session is no longer available. Close this tab and re-open it from Resolve Conflicts… if needed.

+ +`; } private static async saveResult(service: GitRepositoryService, filePath: string, content: string): Promise { @@ -90,7 +156,7 @@ export class MergeEditorWebview { } private static renderHtml(filePath: string, hunks: readonly MergeHunk[], conflicts: number): string { - const nonce = crypto.randomBytes(16).toString('base64'); + const nonce = getNonce(); // JSON 注入 破出。 // 不可用 escapeHtml——其产出 " 在 `; diff --git a/src/adapter/webview/nonce.ts b/src/adapter/webview/nonce.ts new file mode 100644 index 0000000..6ca575b --- /dev/null +++ b/src/adapter/webview/nonce.ts @@ -0,0 +1,9 @@ +import * as crypto from 'crypto'; + +/** + * CSP nonce(单一实现,替代各 webview 内联的 randomBytes 拼接)。 + * randomUUID 产 hex(无 base64 的 +/= 字符),对 CSP 属性值更稳。 + */ +export function getNonce(): string { + return crypto.randomUUID().replace(/-/g, ''); +} diff --git a/src/adapter/webview/rebase-webview.ts b/src/adapter/webview/rebase-webview.ts index c8f8566..d17e99e 100644 --- a/src/adapter/webview/rebase-webview.ts +++ b/src/adapter/webview/rebase-webview.ts @@ -7,12 +7,20 @@ import type { GitRepositoryService } from '../git-repository-service'; import { handleGitConflict } from '../conflict-ui'; import { type RebaseTodoItem, isValidAction, serializeTodo } from '../../engine/rebase/todo'; import { getBaseStyles } from './shared-styles'; +import { getNonce } from './nonce'; interface RebaseCommit { readonly hash: string; readonly subject: string; } +/** 渲染行(初始 = 原始提交默认 pick;恢复 = 序列化保存的 action/subject/DOM 顺序)。 */ +interface RebaseRow { + readonly hash: string; + readonly subject: string; + readonly action?: string; +} + const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e)); /** @@ -93,9 +101,18 @@ export class RebaseWebview { enableScripts: true, retainContextWhenHidden: true, }); - panel.webview.html = RebaseWebview.renderHtml(rebaseCommits); + RebaseWebview.attachPanel(service, panel, base, rebaseCommits.map((c) => ({ hash: c.hash, subject: c.subject }))); + } - panel.webview.onDidReceiveMessage(async (msg) => { + /** 面板装配:渲染 HTML + 消息接线(订阅随 panel dispose 释放,避免悬挂监听)。 */ + private static attachPanel( + service: GitRepositoryService, + panel: vscode.WebviewPanel, + base: string, + rows: readonly RebaseRow[], + ): void { + panel.webview.html = RebaseWebview.renderHtml(base, rows); + const msgSub = panel.webview.onDidReceiveMessage(async (msg) => { if (msg.type === 'rebase') { await RebaseWebview.executeRebase( service, @@ -107,6 +124,53 @@ export class RebaseWebview { panel.dispose(); } }); + panel.onDidDispose(() => msgSub.dispose()); + } + + /** + * 窗口 reload 后的面板恢复:webview state(base + 用户编辑后的 actions,按 DOM 顺序)重渲染 todo 列表。 + * 恢复的 panel 是纯 todo 编辑器——若 reload 前 rebase 已执行完毕,重放需用户再次确认 Start Rebase(可 Cancel)。 + */ + static registerSerializer(service: GitRepositoryService): vscode.Disposable { + return vscode.window.registerWebviewPanelSerializer('hyperGit.rebase', { + async deserializeWebviewPanel(panel, state) { + const actions = (state as { base?: unknown; actions?: unknown } | undefined)?.actions; + const base = (state as { base?: unknown } | undefined)?.base; + if (typeof base !== 'string' || !Array.isArray(actions) || actions.length === 0) { + panel.webview.html = RebaseWebview.renderStaleHtml(); + return; + } + const rows: RebaseRow[] = []; + for (const a of actions) { + if (typeof a?.hash !== 'string' || typeof a?.subject !== 'string') { + panel.webview.html = RebaseWebview.renderStaleHtml(); + return; + } + rows.push({ hash: a.hash, subject: a.subject, action: typeof a.action === 'string' ? a.action : undefined }); + } + RebaseWebview.attachPanel(service, panel, base, rows); + }, + }); + } + + /** 面板过期(无有效 state / 数据不完整)时的占位页。 */ + private static renderStaleHtml(): string { + return ` + + + + + + + +

Interactive Rebase

+

This rebase session is no longer available. Close this tab and run Interactive Rebase… to start a new one.

+ +`; } private static async executeRebase( @@ -143,6 +207,9 @@ export class RebaseWebview { tmpState = path.join(os.tmpdir(), `hg-reword-state-${tag}.json`); fs.writeFileSync(tmpEditor, REWORD_EDITOR_JS); fs.writeFileSync(tmpState, JSON.stringify({ counter: 0, subjects: rewordSubjects })); + // 扩展宿主内 process.execPath 是 Electron 二进制而非独立 node:必须以 node 模式运行 helper + // (否则 git 每次调用 GIT_EDITOR 会拉起新的编辑器进程)。 + env.ELECTRON_RUN_AS_NODE = '1'; env.GIT_EDITOR = `"${process.execPath}" "${tmpEditor}"`; env.HYPERGIT_REWORD_STATE = tmpState; } else { @@ -179,24 +246,23 @@ export class RebaseWebview { } } - private static renderHtml(commits: RebaseCommit[]): string { - const nonce = crypto.randomBytes(16).toString('base64'); - const rows = commits - .map( - (c) => ` + private static renderHtml(base: string, rows: readonly RebaseRow[]): string { + const nonce = getNonce(); + // base 注入 破出)。 + const baseJson = JSON.stringify(base).replace(/ { + const action = isValidAction(r.action ?? 'pick') ? (r.action ?? 'pick') : 'pick'; + const options = ['pick', 'reword', 'edit', 'squash', 'fixup', 'drop'] + .map((a) => ``) + .join(''); + return ` - -${escapeHtml(c.hash.slice(0, 7))} - -`, - ) + +${escapeHtml(r.hash.slice(0, 7))} + +`; + }) .join('\n'); return ` @@ -240,7 +306,7 @@ input.subject:not(:disabled) { border-color: var(--vscode-focusBorder, #007fd4);

reword — edit the message inline  ·  edit/squash — pauses rebase (continue in terminal)  ·  drop — remove the commit  ·  drag the handle to reorder.

-${rows}
ActionHashSubject
+${trs}
@@ -257,6 +323,9 @@ input.subject:not(:disabled) { border-color: var(--vscode-focusBorder, #007fd4);
diff --git a/src/extension.ts b/src/extension.ts index e1a8174..09c401a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,7 +25,7 @@ import { InlineCommitCodeLensProvider, registerInlineCommitCommand } from './ada import { BlameAnnotationController } from './adapter/editor/blame-annotation'; import { ShelfService, ShelfTreeProvider, registerShelfCommands } from './adapter/shelf'; import { RebaseWebview } from './adapter/webview/rebase-webview'; -import { registerMergeCommands } from './adapter/webview/merge-editor'; +import { MergeEditorWebview, registerMergeCommands } from './adapter/webview/merge-editor'; import { registerMiscCommands } from './adapter/misc-commands'; import { registerClaudeCommands } from './adapter/claude-commands'; import { registerRepositorySelectionCommand } from './adapter/repository-selection'; @@ -173,6 +173,9 @@ export async function activate( ...registerAdvancedCommands(service, branchesTree), ...registerRemoteCommands(service, branchesTree, logTree), ...registerMergeCommands(service), + // 窗口 reload 后 rebase/merge 面板恢复(retainContextWhenHidden 仅保活不跨 reload,serializer 补齐)。 + RebaseWebview.registerSerializer(service), + MergeEditorWebview.registerSerializer(service), ...registerMiscCommands(service, branchesTree, logTree), ...registerClaudeCommands(), registerRepositorySelectionCommand(service), From 3da75147b788751f18af07e0ed5c1226cef7e894 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 9 Sep 2026 21:20:34 +0800 Subject: [PATCH 02/13] =?UTF-8?q?fix(Commit):=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E4=B8=8E=E9=80=89=E9=A1=B9=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Webview state v2 升 v3:byRepo 分区新增 draft(message + amend/signoff/skipHooks),视图隐藏销毁或窗口 reload 后草稿可恢复,对齐内置 SCM 输入框草稿语义; - 草稿在输入/勾选变更时即时落盘(saveState 从 DOM 现值取数),彻底消除 200ms debounce 尾丢与模板重灌覆盖问题; - 草稿回灌仅两时机(webview 重建首帧 / 活跃仓库切换),且先于 reconcileChecked 执行,避免 state 推送往返中覆盖当前编辑内容; - 提交成功清空草稿(失败保留供重试);最近消息 chip 应用与模板注入同步入草稿; - 旧版 state(v2 byRepo / v1 平铺)无感升级;nonce 生成迁移至公共 getNonce。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- src/adapter/webview/commit-webview.ts | 50 +++++++++++++++++++++------ 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/src/adapter/webview/commit-webview.ts b/src/adapter/webview/commit-webview.ts index db5f974..51d451b 100644 --- a/src/adapter/webview/commit-webview.ts +++ b/src/adapter/webview/commit-webview.ts @@ -1,4 +1,3 @@ -import * as crypto from 'crypto'; import * as path from 'path'; import * as vscode from 'vscode'; import { getDecoration } from '../../engine/scm-mapping/status-decoration'; @@ -15,6 +14,7 @@ import type { } from '../../shared/protocol'; import type { CommitService } from '../commit/commit-service'; import { getBaseStyles } from './shared-styles'; +import { getNonce } from './nonce'; /** * Commit 提交窗口(WebviewView,自绘提交面板)。 @@ -275,12 +275,14 @@ details.advanced[open] summary { margin-bottom: 4px; } diff --git a/src/adapter/webview/shared-styles.ts b/src/adapter/webview/shared-styles.ts index 2dce225..d855553 100644 --- a/src/adapter/webview/shared-styles.ts +++ b/src/adapter/webview/shared-styles.ts @@ -24,6 +24,18 @@ export const GRAPH_LANE_W = 14; /** 按钮视觉变体。 */ export type ButtonVariant = 'primary' | 'secondary' | 'sm'; +// ── 共享内联 SVG 图标(fill/stroke currentColor 随主题前景色;webview 不引 codicon 字体的替代方案) ── + +/** 12px 折叠 chevron(向下;折叠态由 CSS rotate(-90deg) 旋转)。 */ +export const ICON_CHEVRON_DOWN = + ''; +/** 11px 关闭 X。 */ +export const ICON_CLOSE = + ''; +/** 12px 水平省略号(⋯ 菜单)。 */ +export const ICON_ELLIPSIS = + ''; + /** * 基础样式:`:root` Token + 通用组件类 + 全局控件基线 + 统一交互态。 * 返回纯 CSS 字符串,供 Webview 在 `