Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/skills/intelli-git-debug/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<base-dir>/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' \
Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/intelli-git-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
24 changes: 16 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand All @@ -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。
Expand Down
5 changes: 5 additions & 0 deletions apps/extension/src/providers/BaseWebviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
17 changes: 16 additions & 1 deletion apps/extension/src/providers/ConflictResolverPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
});
});
4 changes: 4 additions & 0 deletions apps/extension/src/providers/ConflictResolverPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 30 additions & 11 deletions apps/extension/src/utils/webviewHtml.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
Expand Down
19 changes: 13 additions & 6 deletions apps/extension/src/utils/webviewHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -35,17 +36,23 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
window.initialState = ${initialStateJson};
</script>`;

// 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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; style-src-attr 'unsafe-inline'; font-src ${webview.cspSource}; img-src ${webview.cspSource}; script-src 'nonce-${nonce}' ${webview.cspSource}; connect-src ${webview.cspSource}; worker-src ${webview.cspSource} blob:;">
<meta http-equiv="Content-Security-Policy" content="${csp}">
<link href="${iconStyleUri}" rel="stylesheet">
<link href="${styleUri}" rel="stylesheet">
<link href="${editorStyleUri}" rel="stylesheet">
<title>${title}</title>
</head>
<body data-vscode-context='{"preventDefaultContextMenuItems": true}'>
Expand Down
17 changes: 14 additions & 3 deletions apps/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -24,7 +28,14 @@ function AppContent() {
<Routes>
<Route path="/" element={<LocalChangesView />} />
<Route path="/git-log" element={<GitLogView />} />
<Route path="/conflict-resolver" element={<ConflictResolverPage />} />
<Route
path="/conflict-resolver"
element={
<Suspense fallback={null}>
<ConflictResolverPage />
</Suspense>
}
/>
</Routes>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions apps/webview-ui/src/components/merge/monacoEnvironment.ts
Original file line number Diff line number Diff line change
@@ -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;
9 changes: 5 additions & 4 deletions apps/webview-ui/src/components/merge/monacoRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,11 @@ function getThemeBase(): Monaco.editor.BuiltinTheme {
}

export async function loadMonaco(): Promise<MonacoApi> {
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;
}
Expand Down
49 changes: 2 additions & 47 deletions apps/webview-ui/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down
Loading