From 942c95f12c0bd126d6614ce49bafad204f5553c6 Mon Sep 17 00:00:00 2001 From: tranphucbol Date: Tue, 20 Oct 2026 23:48:44 +0700 Subject: [PATCH 1/3] Load Monaco only in the opt-in merge editor webview - Keep the Commit, Git Log and Push webviews on a strict CSP; only ConflictResolverPanel adds style-src-attr 'unsafe-inline' and worker-src blob:. - Lazy-load the conflict resolver route and Monaco worker setup so the webview.js entry bundle no longer includes Monaco (1,075 kB -> 695 kB). - No behavior change with intelli-git.mergeEditor.enabled off (default). - Docs: describe both setting states in AGENTS.md and the debug/test skills. Refs boyan01/intelli-git#6 --- .agents/skills/intelli-git-debug/SKILL.md | 4 +- .agents/skills/intelli-git-test/SKILL.md | 2 +- AGENTS.md | 24 ++++++--- .../src/providers/BaseWebviewProvider.ts | 5 ++ .../providers/ConflictResolverPanel.test.ts | 17 ++++++- .../src/providers/ConflictResolverPanel.ts | 4 ++ apps/extension/src/utils/webviewHtml.test.ts | 41 +++++++++++----- apps/extension/src/utils/webviewHtml.ts | 21 +++++--- apps/webview-ui/src/App.tsx | 17 +++++-- .../src/components/merge/monacoEnvironment.ts | 11 +++++ .../src/components/merge/monacoRuntime.ts | 9 ++-- apps/webview-ui/vite.config.ts | 49 +------------------ 12 files changed, 122 insertions(+), 82 deletions(-) create mode 100644 apps/webview-ui/src/components/merge/monacoEnvironment.ts diff --git a/.agents/skills/intelli-git-debug/SKILL.md b/.agents/skills/intelli-git-debug/SKILL.md index 0f7f05a..e3c2390 100644 --- a/.agents/skills/intelli-git-debug/SKILL.md +++ b/.agents/skills/intelli-git-debug/SKILL.md @@ -102,7 +102,9 @@ node "$CONTROL" workbench-eval --state "$STATE" \ node "$CONTROL" webview-targets --state "$STATE" ``` -Open the standard merge-conflict resolver deterministically: +The Intelli Git merge editor is opt-in. Before opening it, set `"intelli-git.mergeEditor.enabled": true` in the Dev Host user settings (`/vscode-user-data/User/settings.json`). The setting is read on click, so no reload is needed. With the setting off, the same clicks open the file in the standard VS Code editor, which is the default behavior to verify. + +Open the merge-conflict resolver deterministically: ```bash node "$CONTROL" webview-eval --state "$STATE" --contains 'Conflicting Changes' \ diff --git a/.agents/skills/intelli-git-test/SKILL.md b/.agents/skills/intelli-git-test/SKILL.md index b8c73fb..c64a677 100644 --- a/.agents/skills/intelli-git-test/SKILL.md +++ b/.agents/skills/intelli-git-test/SKILL.md @@ -33,7 +33,7 @@ bash .agents/skills/intelli-git-test/scripts/open-checkout-scenario.sh --list Prefer these scenarios for conflict testing: - `rebase-conflict`: starts clean on `feature/smart-checkout`; trigger rebase onto `main` in Intelli Git, then abort and switch branches. -- `merge-conflict`: opens a repo already in real `UU`, `UD`, `DU`, and `AA` conflict states, including one rich 500-line file with normal, asymmetric, whitespace-only, and auto-merged changes. +- `merge-conflict`: opens a repo already in real `UU`, `UD`, `DU`, and `AA` conflict states, including one rich 500-line file with normal, asymmetric, whitespace-only, and auto-merged changes. Conflict files open in the standard editor by default; set `"intelli-git.mergeEditor.enabled": true` in the test user settings to open the Intelli Git merge editor instead. - `stash-pop-conflict`: opens a repo after `stash pop` has created a real conflict. - `conflict`, `untracked`, `staged`, `mixed`: checkout overwrite conflicts, not merge conflicts. diff --git a/AGENTS.md b/AGENTS.md index 455cce8..206ced9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,13 +267,20 @@ Improve AI provider setup flow - File items 不得显示 hunk child nodes。 - 如果已支持,changelist roots 之间的 drag and drop 文件重分配行为应继续可用。 -### Conflict Resolver Behavior -- 点击或双击 conflict file 时,必须在 VS Code editor 区域打开 Intelli Git 自己的 conflict resolver webview,不应走普通 open file 路径,也不得把 merge editor 嵌在 commit view 侧边栏里。 -- Conflict resolver 通过 shared RPC 从 Git index stages 读取 base/current/incoming,通过 worktree 读取 result;webview 不得直接推断 Git stages。 -- `Accept Current Change` / `Accept Incoming Change` 必须保留 `repoPath` 并路由到对应 repository 的 Git service。 -- `Mark as Resolved` 必须写入 result content、执行 `git add`、刷新 commit view;失败时 resolver 不能关闭。 -- 二进制 conflict files 可以显示在 `Conflicting Changes` root 下,但 inline editing 可以禁用并给出明确反馈。 -- Rebase continue 只能在没有 unresolved conflict files 时可用;只有存在、非二进制、无 conflict markers 的文本文件才能作为 `resolvedCandidate` 自动放行。 +### Conflict File Behavior +- Intelli Git 的 three-way merge editor(conflict resolver)是 opt-in 功能,由配置项 `intelli-git.mergeEditor.enabled` 控制,默认关闭。点击时读取该配置,修改后无需 reload。 +- 配置关闭(默认)时:点击或双击 conflict file 在 VS Code 标准 editor 中打开该文件,由 VS Code 自带的 conflict 能力处理内容编辑;commit view 的 `Mark as Resolved` 只对 `resolvedCandidate` 文件执行 `git add` 并刷新 commit view。 +- 配置开启时:点击或双击 conflict file 必须在 VS Code editor 区域打开 Intelli Git conflict resolver webview,不得把 merge editor 嵌在 commit view 侧边栏里。 + - Conflict resolver 通过 shared RPC 从 Git index stages 读取 base/current/incoming,通过 worktree 读取 result;webview 不得直接推断 Git stages。 + - Resolver 的 `Apply` 必须写入 result content、执行 `git add`、刷新 commit view;失败时 resolver 不能关闭。 + - 二进制 conflict files 在 resolver 中可以禁用 inline editing,但必须给出明确反馈并支持 whole-side accept。 +- 两种配置共用:commit view 的 `Accept Current Change` / `Accept Incoming Change` 必须保留 `repoPath` 并路由到对应 repository 的 Git service;二进制 conflict files 显示在 `Conflicting Changes` root 下,可通过 whole-side accept 解决。 +- Rebase continue 只能在没有 unresolved conflict files 时可用;只有存在、非二进制、无 conflict markers 的文本文件才能作为 `resolvedCandidate` 自动放行。该规则与 merge editor 配置无关。 + +### Webview CSP and Monaco Isolation +- Monaco 只允许出现在 conflict resolver 自己的 editor-area webview(`ConflictResolverPanel`)中。 +- Commit view、log view、push panel 等其他 webview 必须使用 strict CSP:不允许 `style-src-attr 'unsafe-inline'`,不允许 `worker-src blob:`。只有 `ConflictResolverPanel` 通过 `usesMonacoEditor()` 打开 `getWebviewHtml({ monacoEditor: true })` 的放宽策略;任何情况下 script 都只能通过 nonce 和 `cspSource` 加载。 +- Conflict resolver route 必须通过 `React.lazy` 加载;Monaco、`MonacoEnvironment` worker setup 和 Monaco CSS 只能出现在 lazy chunks 中,不得进入 `webview.js` entry bundle。 ### Changes Mode Context Menu Rules - 右键 changelist root 时,必须打开原生 VS Code webview context menu。 @@ -296,7 +303,8 @@ Improve AI provider setup flow - 在 `staged` 和 `changes` mode 之间切换时,toolbar 和 context menu 行为必须正确。 - `changes` mode 绝不能显示 staged/unstaged split groups。 - Conflict files 必须只出现在 `Conflicting Changes` root 下。 -- 点击 conflict file 必须在 editor 区域打开 Intelli Git conflict resolver。 +- `intelli-git.mergeEditor.enabled` 关闭时,点击 conflict file 必须在 VS Code 标准 editor 中打开该文件;开启时必须在 editor 区域打开 Intelli Git conflict resolver。 +- Commit view 和 log view 必须保持 strict CSP,且 `webview.js` entry bundle 中不得包含 Monaco 或 `MonacoEnvironment`。 - `changes` mode 绝不能在 changelist root 上显示 active badge。 - `changes` mode 下 root hover 绝不能显示 inline operation icons。 - File items 绝不能显示 child hunk nodes。 diff --git a/apps/extension/src/providers/BaseWebviewProvider.ts b/apps/extension/src/providers/BaseWebviewProvider.ts index 8d18912..5578990 100644 --- a/apps/extension/src/providers/BaseWebviewProvider.ts +++ b/apps/extension/src/providers/BaseWebviewProvider.ts @@ -85,9 +85,14 @@ export abstract class BaseWebviewProvider { title: this.getTitle(), initialRoute: this.getInitialRoute(), initialState: this.getInitialState(), + monacoEditor: this.usesMonacoEditor(), }); } + protected usesMonacoEditor(): boolean { + return false; + } + protected getOnDispose(): (() => void) | undefined { return undefined; } diff --git a/apps/extension/src/providers/ConflictResolverPanel.test.ts b/apps/extension/src/providers/ConflictResolverPanel.test.ts index c83b617..5da4e0b 100644 --- a/apps/extension/src/providers/ConflictResolverPanel.test.ts +++ b/apps/extension/src/providers/ConflictResolverPanel.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it } from 'vitest'; import type * as vscode from 'vscode'; import * as vscodeMock from 'vscode'; import type { RepositoryManager } from '../services/RepositoryManager'; -import { openConflictFile } from './ConflictResolverPanel'; +import { ConflictResolverPanel, openConflictFile } from './ConflictResolverPanel'; +import { CommitViewProvider } from './CommitViewProvider'; +import { GitLogViewProvider } from './GitLogViewProvider'; +import { PushPanel } from './PushPanel'; import type { WebviewProviderOptions } from './BaseWebviewProvider'; const testVscode = vscodeMock as unknown as { @@ -32,3 +35,15 @@ describe('openConflictFile', () => { expect((command.args[0] as vscode.Uri).fsPath).toBe('/workspace/repository/src/conflict.ts'); }); }); + +describe('ConflictResolverPanel', () => { + it('is the only webview that requests the relaxed Monaco CSP', () => { + const usesMonacoEditor = (prototype: object) => + (prototype as { usesMonacoEditor(): boolean }).usesMonacoEditor(); + + expect(usesMonacoEditor(ConflictResolverPanel.prototype)).toBe(true); + expect(usesMonacoEditor(CommitViewProvider.prototype)).toBe(false); + expect(usesMonacoEditor(GitLogViewProvider.prototype)).toBe(false); + expect(usesMonacoEditor(PushPanel.prototype)).toBe(false); + }); +}); diff --git a/apps/extension/src/providers/ConflictResolverPanel.ts b/apps/extension/src/providers/ConflictResolverPanel.ts index 5e9c457..4487526 100644 --- a/apps/extension/src/providers/ConflictResolverPanel.ts +++ b/apps/extension/src/providers/ConflictResolverPanel.ts @@ -58,6 +58,10 @@ export class ConflictResolverPanel extends BaseWebviewProvider { return '/conflict-resolver'; } + protected usesMonacoEditor(): boolean { + return true; + } + protected getInitialState(): unknown { const conflictFile: RepositoryFileReference = { path: this.file.path, diff --git a/apps/extension/src/utils/webviewHtml.test.ts b/apps/extension/src/utils/webviewHtml.test.ts index 57c5d1c..e4b7d50 100644 --- a/apps/extension/src/utils/webviewHtml.test.ts +++ b/apps/extension/src/utils/webviewHtml.test.ts @@ -1,24 +1,43 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import * as vscode from 'vscode'; import { getWebviewHtml } from './webviewHtml'; +function renderHtml(monacoEditor?: boolean): string { + return getWebviewHtml({ + webview: { + cspSource: 'https://webview.test', + asWebviewUri: (value) => value, + } as vscode.Webview, + extensionUri: vscode.Uri.parse('file:///extension'), + title: 'Test', + monacoEditor, + }); +} + describe('webview HTML', () => { - it('allows Monaco row layout styles without allowing inline scripts', () => { + beforeAll(() => { const uri = vscode.Uri as unknown as { joinPath(base: vscode.Uri, ...segments: string[]): vscode.Uri; }; uri.joinPath = (base, ...segments) => vscode.Uri.parse(`${base.toString()}/${segments.join('/')}`); + }); + + it('restricts styles and scripts to the webview source and nonce by default', () => { + const html = renderHtml(); + + expect(html).toContain('style-src https://webview.test;'); + expect(html).not.toContain('unsafe-inline'); + expect(html).not.toContain('worker-src'); + expect(html).not.toContain('blob:'); + expect(html).not.toContain('editor.css'); + expect(html).toMatch(/script-src 'nonce-[A-Za-z0-9]{32}' https:\/\/webview\.test;/); + }); - const html = getWebviewHtml({ - webview: { - cspSource: 'https://webview.test', - asWebviewUri: (value) => value, - } as vscode.Webview, - extensionUri: vscode.Uri.parse('file:///extension'), - title: 'Test', - }); + it('allows Monaco row layout styles and workers only when requested, without allowing inline scripts', () => { + const html = renderHtml(true); - expect(html).toContain("style-src https://webview.test; style-src-attr 'unsafe-inline';"); + expect(html).toContain("style-src-attr 'unsafe-inline';"); + expect(html).toContain('worker-src https://webview.test blob:;'); expect(html).toMatch(/script-src 'nonce-[A-Za-z0-9]{32}' https:\/\/webview\.test;/); expect(html).not.toContain("script-src 'unsafe-inline'"); }); diff --git a/apps/extension/src/utils/webviewHtml.ts b/apps/extension/src/utils/webviewHtml.ts index 9bb3d3f..c9fafd8 100644 --- a/apps/extension/src/utils/webviewHtml.ts +++ b/apps/extension/src/utils/webviewHtml.ts @@ -6,6 +6,8 @@ export interface WebviewHtmlOptions { title: string; initialRoute?: string; initialState?: unknown; + /** Relax the CSP for Monaco. Only the conflict resolver panel should enable this. */ + monacoEditor?: boolean; } export function generateNonce(): string { @@ -18,12 +20,11 @@ export function generateNonce(): string { } export function getWebviewHtml(options: WebviewHtmlOptions): string { - const { webview, extensionUri, title, initialRoute, initialState } = options; + const { webview, extensionUri, title, initialRoute, initialState, monacoEditor = false } = options; const nonce = generateNonce(); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'out', 'webview', 'webview.js')); const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'out', 'webview', 'index.css')); - const editorStyleUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'out', 'webview', 'editor.css')); const iconStyleUri = webview.asWebviewUri(vscode.Uri.joinPath(extensionUri, 'media', 'intelli-git-icons.css')); const language = vscode.env.language; @@ -35,17 +36,25 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { window.initialState = ${initialStateJson}; `; - // Monaco replaces visible rows with HTML that contains inline layout attributes. - // Allow style attributes while keeping every script nonce-restricted. + // Monaco replaces visible rows with HTML that contains inline layout attributes and + // runs its worker from a blob URL. Only the Monaco webview gets those relaxations; + // every script stays nonce-restricted in both policies. + const monacoPolicy = monacoEditor + ? ` style-src-attr 'unsafe-inline'; worker-src ${webview.cspSource} blob:;` + : ''; + const csp = + `default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; ` + + `img-src ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource}; ` + + `connect-src ${webview.cspSource};${monacoPolicy}`; + return ` - + - ${title} diff --git a/apps/webview-ui/src/App.tsx b/apps/webview-ui/src/App.tsx index 0b24425..4a1f9af 100644 --- a/apps/webview-ui/src/App.tsx +++ b/apps/webview-ui/src/App.tsx @@ -1,10 +1,14 @@ import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom'; -import { useEffect } from 'react'; +import { lazy, Suspense, useEffect } from 'react'; import { LocalChangesView } from './components/local-changes/LocalChangesView'; import { GitLogView } from './components/git-log/GitLogView'; -import { ConflictResolverPage } from './components/merge/ConflictResolver'; import './index.css'; +// Keep the merge editor and Monaco out of the commit and log view bundle. +const ConflictResolverPage = lazy(() => + import('./components/merge/ConflictResolver').then((module) => ({ default: module.ConflictResolverPage })) +); + function AppContent() { const navigate = useNavigate(); const location = useLocation(); @@ -24,7 +28,14 @@ function AppContent() { } /> } /> - } /> + + + + } + /> ); } diff --git a/apps/webview-ui/src/components/merge/monacoEnvironment.ts b/apps/webview-ui/src/components/merge/monacoEnvironment.ts new file mode 100644 index 0000000..4e21273 --- /dev/null +++ b/apps/webview-ui/src/components/merge/monacoEnvironment.ts @@ -0,0 +1,11 @@ +import type * as Monaco from 'monaco-editor/esm/vs/editor/editor.api'; +import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline'; + +// Loaded lazily from monacoRuntime so the worker source stays out of the main webview bundle. +const environment: Monaco.Environment = { + getWorker() { + return new EditorWorker(); + }, +}; + +self.MonacoEnvironment = environment; diff --git a/apps/webview-ui/src/components/merge/monacoRuntime.ts b/apps/webview-ui/src/components/merge/monacoRuntime.ts index af889ba..5491aee 100644 --- a/apps/webview-ui/src/components/merge/monacoRuntime.ts +++ b/apps/webview-ui/src/components/merge/monacoRuntime.ts @@ -111,10 +111,11 @@ function getThemeBase(): Monaco.editor.BuiltinTheme { } export async function loadMonaco(): Promise { - monacoLoadPromise ??= import('monaco-editor/esm/vs/editor/editor.main').then(async () => { - const monaco = await import('monaco-editor/esm/vs/editor/editor.api'); - return monaco; - }); + monacoLoadPromise ??= (async () => { + await import('./monacoEnvironment'); + await import('monaco-editor/esm/vs/editor/editor.main'); + return import('monaco-editor/esm/vs/editor/editor.api'); + })(); return monacoLoadPromise; } diff --git a/apps/webview-ui/vite.config.ts b/apps/webview-ui/vite.config.ts index e102727..7e8e6ec 100644 --- a/apps/webview-ui/vite.config.ts +++ b/apps/webview-ui/vite.config.ts @@ -1,56 +1,11 @@ -import { defineConfig, type Plugin } from 'vite'; +import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; -const MONACO_ENV_MODULE_ID = 'virtual:intelli-git-monaco-env'; -const RESOLVED_MONACO_ENV_MODULE_ID = `\0${MONACO_ENV_MODULE_ID}`; - -function monacoWorkers(): Plugin { - let entryId = ''; - - return { - name: 'intelli-git-monaco-workers', - enforce: 'pre', - configResolved(config) { - entryId = path.resolve(config.root, 'src/main.tsx').replace(/\\/g, '/'); - }, - resolveId(id) { - return id === MONACO_ENV_MODULE_ID ? RESOLVED_MONACO_ENV_MODULE_ID : null; - }, - load(id) { - if (id !== RESOLVED_MONACO_ENV_MODULE_ID) { - return null; - } - - return [ - "import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline';", - '', - 'self.MonacoEnvironment = {', - ' getWorker() {', - ' return new EditorWorker();', - ' },', - '};', - '', - ].join('\n'); - }, - transform(code, id) { - const normalizedId = id.replace(/\\/g, '/').split('?')[0]; - if (normalizedId !== entryId || code.includes(MONACO_ENV_MODULE_ID)) { - return null; - } - - return { - code: `import ${JSON.stringify(MONACO_ENV_MODULE_ID)};\n${code}`, - map: null, - }; - }, - }; -} - // https://vite.dev/config/ export default defineConfig({ envDir: path.resolve(__dirname, '../..'), - plugins: [monacoWorkers(), react()], + plugins: [react()], base: './', resolve: { alias: { From a221056a089e2eaca73ae2a05c485733eb951627 Mon Sep 17 00:00:00 2001 From: tranphucbol Date: Wed, 21 Oct 2026 00:00:54 +0700 Subject: [PATCH 2/3] Remove the empty icon gap before branch folders in the Git Log panel - Hide the BasicTreeView icon slot when a custom icon renders nothing, such as a folder row under a file icon theme without folder icons (Seti). - Branch group rows in the Git Log branch panel now align their label right after the chevron, matching the VS Code Explorer. - Themes with folder icons (for example Material Icon Theme) still show the icon, and codicon rows and the loading placeholder are unchanged. --- .../src/components/common/BasicTreeView.module.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/webview-ui/src/components/common/BasicTreeView.module.css b/apps/webview-ui/src/components/common/BasicTreeView.module.css index aab7417..2fce4a3 100644 --- a/apps/webview-ui/src/components/common/BasicTreeView.module.css +++ b/apps/webview-ui/src/components/common/BasicTreeView.module.css @@ -142,6 +142,11 @@ color: var(--vscode-icon-foreground); } +/* A custom icon that renders nothing (for example a folder under Seti) takes no space, as in VS Code. */ +.icon:empty { + display: none; +} + .icon .codicon { display: flex; align-items: center; From 4e9fca7476828bc1659d25ee2ec1b764c981d00b Mon Sep 17 00:00:00 2001 From: tranphucbol Date: Wed, 21 Oct 2026 00:26:18 +0700 Subject: [PATCH 3/3] Format the webview CSP builder with Prettier - Internal: collapse the Monaco CSP ternary onto one line so format:check passes in CI. --- apps/extension/src/utils/webviewHtml.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/extension/src/utils/webviewHtml.ts b/apps/extension/src/utils/webviewHtml.ts index c9fafd8..336eef4 100644 --- a/apps/extension/src/utils/webviewHtml.ts +++ b/apps/extension/src/utils/webviewHtml.ts @@ -39,9 +39,7 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { // Monaco replaces visible rows with HTML that contains inline layout attributes and // runs its worker from a blob URL. Only the Monaco webview gets those relaxations; // every script stays nonce-restricted in both policies. - const monacoPolicy = monacoEditor - ? ` style-src-attr 'unsafe-inline'; worker-src ${webview.cspSource} blob:;` - : ''; + const monacoPolicy = monacoEditor ? ` style-src-attr 'unsafe-inline'; worker-src ${webview.cspSource} blob:;` : ''; const csp = `default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; ` + `img-src ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource}; ` +