From dd931795e9ed766296cf6b9e7180065233e46929 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:53 -0300 Subject: [PATCH] feat(code-editor): add a CodeMirror-based code and merge editor Layer 1 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 209 +++++++++++++++++- package.json | 12 + .../agents/config/__tests__/codeEditorMock.ts | 30 +++ src/components/code-editor/CodeEditor.vue | 208 +++++++++++++++++ .../code-editor/CodeEditorError.vue | 14 ++ .../code-editor/CodeEditorFallback.vue | 12 + src/components/code-editor/CodeMergeView.vue | 96 ++++++++ .../code-editor/__tests__/CodeEditor.spec.ts | 145 ++++++++++++ .../__tests__/CodeMergeView.spec.ts | 39 ++++ .../code-editor/__tests__/diagnostics.spec.ts | 34 +++ .../code-editor/__tests__/index.spec.ts | 27 +++ src/components/code-editor/diagnostics.ts | 30 +++ src/components/code-editor/index.ts | 35 +++ src/components/code-editor/languages/index.ts | 20 ++ src/components/code-editor/theme.ts | 114 ++++++++++ vitest.setup.ts | 38 ++++ 16 files changed, 1062 insertions(+), 1 deletion(-) create mode 100644 src/components/agents/config/__tests__/codeEditorMock.ts create mode 100644 src/components/code-editor/CodeEditor.vue create mode 100644 src/components/code-editor/CodeEditorError.vue create mode 100644 src/components/code-editor/CodeEditorFallback.vue create mode 100644 src/components/code-editor/CodeMergeView.vue create mode 100644 src/components/code-editor/__tests__/CodeEditor.spec.ts create mode 100644 src/components/code-editor/__tests__/CodeMergeView.spec.ts create mode 100644 src/components/code-editor/__tests__/diagnostics.spec.ts create mode 100644 src/components/code-editor/__tests__/index.spec.ts create mode 100644 src/components/code-editor/diagnostics.ts create mode 100644 src/components/code-editor/index.ts create mode 100644 src/components/code-editor/languages/index.ts create mode 100644 src/components/code-editor/theme.ts diff --git a/package-lock.json b/package-lock.json index 806b54e1..f9c7d0b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,17 @@ "name": "continuous-compliance", "version": "0.0.0", "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.4", + "@codemirror/lint": "^6.9.7", + "@codemirror/merge": "^6.12.2", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", "@heroicons/vue": "^2.2.0", + "@lezer/highlight": "^1.2.5", "@tailwindcss/vite": "^4.1.14", "@types/chart.js": "^2.9.41", "@vueuse/core": "^13.5.0", @@ -20,6 +30,7 @@ "chartjs-adapter-luxon": "^1.3.1", "date-fns": "^4.1.0", "decamelize-keys": "^2.0.1", + "js-yaml": "^4.3.2", "luxon": "^3.7.2", "pinia": "^2.3.1", "pinia-plugin-persistedstate": "^3.2.3", @@ -37,6 +48,7 @@ "devDependencies": { "@tailwindcss/typography": "^0.5.19", "@tsconfig/node20": "^20.1.6", + "@types/js-yaml": "^4.0.9", "@types/jsdom": "^21.1.7", "@types/node": "^20.19.19", "@vitejs/plugin-vue": "^5.2.4", @@ -553,6 +565,125 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/merge": { + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz", + "integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1402,6 +1533,58 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2321,6 +2504,13 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jsdom": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", @@ -3936,6 +4126,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5679,7 +5875,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -7686,6 +7881,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, "node_modules/superjson": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", @@ -8659,6 +8860,12 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index e5aa8b4a..6e7759c7 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,17 @@ "node": "^20" }, "dependencies": { + "@codemirror/commands": "^6.11.1", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/lang-yaml": "^6.1.3", + "@codemirror/language": "^6.12.4", + "@codemirror/lint": "^6.9.7", + "@codemirror/merge": "^6.12.2", + "@codemirror/search": "^6.7.2", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", "@heroicons/vue": "^2.2.0", + "@lezer/highlight": "^1.2.5", "@tailwindcss/vite": "^4.1.14", "@types/chart.js": "^2.9.41", "@vueuse/core": "^13.5.0", @@ -41,6 +51,7 @@ "chartjs-adapter-luxon": "^1.3.1", "date-fns": "^4.1.0", "decamelize-keys": "^2.0.1", + "js-yaml": "^4.3.2", "luxon": "^3.7.2", "pinia": "^2.3.1", "pinia-plugin-persistedstate": "^3.2.3", @@ -58,6 +69,7 @@ "devDependencies": { "@tailwindcss/typography": "^0.5.19", "@tsconfig/node20": "^20.1.6", + "@types/js-yaml": "^4.0.9", "@types/jsdom": "^21.1.7", "@types/node": "^20.19.19", "@vitejs/plugin-vue": "^5.2.4", diff --git a/src/components/agents/config/__tests__/codeEditorMock.ts b/src/components/agents/config/__tests__/codeEditorMock.ts new file mode 100644 index 00000000..d5e22b8f --- /dev/null +++ b/src/components/agents/config/__tests__/codeEditorMock.ts @@ -0,0 +1,30 @@ +// Synchronous stand-ins for the async CodeMirror components (@/components/code-editor). +// Usage in a spec: vi.mock('@/components/code-editor', () => import('./codeEditorMock')); +import { defineComponent, h } from 'vue'; + +export const CodeEditor = defineComponent({ + name: 'CodeEditor', + props: ['modelValue', 'readonly', 'language', 'diagnostics', 'label'], + emits: ['update:modelValue'], + setup(props, { emit }) { + return () => + h('textarea', { + class: 'code-editor-stub', + 'data-language': props.language, + 'data-diagnostics': JSON.stringify(props.diagnostics ?? []), + readonly: props.readonly, + value: props.modelValue, + onInput: (e: Event) => + emit('update:modelValue', (e.target as HTMLTextAreaElement).value), + }); + }, +}); + +export const CodeMergeView = defineComponent({ + name: 'CodeMergeView', + props: ['original', 'modified', 'language', 'mode'], + setup(props) { + return () => + h('div', { class: 'merge-stub' }, `${props.original}|${props.modified}`); + }, +}); diff --git a/src/components/code-editor/CodeEditor.vue b/src/components/code-editor/CodeEditor.vue new file mode 100644 index 00000000..91f596b6 --- /dev/null +++ b/src/components/code-editor/CodeEditor.vue @@ -0,0 +1,208 @@ + + + diff --git a/src/components/code-editor/CodeEditorError.vue b/src/components/code-editor/CodeEditorError.vue new file mode 100644 index 00000000..afdf0841 --- /dev/null +++ b/src/components/code-editor/CodeEditorError.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/components/code-editor/CodeEditorFallback.vue b/src/components/code-editor/CodeEditorFallback.vue new file mode 100644 index 00000000..1cfe9023 --- /dev/null +++ b/src/components/code-editor/CodeEditorFallback.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/code-editor/CodeMergeView.vue b/src/components/code-editor/CodeMergeView.vue new file mode 100644 index 00000000..fdd656f7 --- /dev/null +++ b/src/components/code-editor/CodeMergeView.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/components/code-editor/__tests__/CodeEditor.spec.ts b/src/components/code-editor/__tests__/CodeEditor.spec.ts new file mode 100644 index 00000000..b5a73227 --- /dev/null +++ b/src/components/code-editor/__tests__/CodeEditor.spec.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import { defineComponent } from 'vue'; +import CodeEditor from '../CodeEditor.vue'; + +function mountEditor(props: Record = {}) { + return mount(CodeEditor, { + props: { + modelValue: 'a: 1\n', + language: 'yaml', + label: 'Overlay YAML', + ...props, + }, + attachTo: document.body, + }); +} + +describe('CodeEditor (CodeMirror 6)', () => { + it('renders the model value and emits edits (v-model both ways)', async () => { + const wrapper = mountEditor(); + const view = ( + wrapper.vm as unknown as { + view: { + state: { doc: { toString(): string } }; + dispatch: (t: unknown) => void; + }; + } + ).view; + expect(view.state.doc.toString()).toBe('a: 1\n'); + expect(wrapper.find('.cm-content').attributes('aria-label')).toBe( + 'Overlay YAML', + ); + + view.dispatch({ changes: { from: 0, insert: 'b: 2\n' } }); + expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['b: 2\na: 1\n']); + + await wrapper.setProps({ modelValue: 'c: 3\n' }); + expect(view.state.doc.toString()).toBe('c: 3\n'); + // External changes do not echo back. + expect(wrapper.emitted('update:modelValue')).toHaveLength(1); + wrapper.unmount(); + }); + + it('is read-only when asked', async () => { + const wrapper = mountEditor({ readonly: true }); + expect(wrapper.find('.cm-content').attributes('contenteditable')).toBe( + 'false', + ); + expect(wrapper.text()).not.toContain('Esc then Tab'); + await wrapper.setProps({ readonly: false }); + expect(wrapper.find('.cm-content').attributes('contenteditable')).toBe( + 'true', + ); + expect(wrapper.text()).toContain('Esc then Tab to leave the editor'); + wrapper.unmount(); + }); + + it('keeps Escape inside the editor so a dialog does not close on it', () => { + const wrapper = mountEditor(); + const onDocument = vi.fn(); + document.addEventListener('keydown', onDocument); + try { + const content = wrapper.find('.cm-content').element; + content.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + expect(onDocument).not.toHaveBeenCalled(); + // Other keys still bubble. + content.dispatchEvent( + new KeyboardEvent('keydown', { key: 'a', bubbles: true }), + ); + expect(onDocument).toHaveBeenCalledTimes(1); + } finally { + document.removeEventListener('keydown', onDocument); + wrapper.unmount(); + } + }); + + it('describes the editable content with the "Esc then Tab" hint', async () => { + const wrapper = mountEditor(); + const hint = wrapper.find('p'); + expect(hint.attributes('aria-hidden')).toBeUndefined(); + const id = hint.attributes('id'); + expect(id).toBeTruthy(); + expect(wrapper.find('.cm-content').attributes('aria-describedby')).toBe(id); + // Unique per editor in the app. + const both = mount( + defineComponent({ + components: { CodeEditor }, + template: `
+ + +
`, + }), + { attachTo: document.body }, + ); + const ids = both.findAll('p').map((p) => p.attributes('id')); + expect(ids).toHaveLength(2); + expect(ids[0]).not.toBe(ids[1]); + both.unmount(); + await wrapper.setProps({ readonly: true }); + expect( + wrapper.find('.cm-content').attributes('aria-describedby'), + ).toBeUndefined(); + wrapper.unmount(); + }); + + it('takes min/max height from the host, following prop changes', async () => { + const wrapper = mountEditor({ minHeight: '100px', maxHeight: '300px' }); + const host = wrapper.find('[data-test="code-editor"]') + .element as HTMLElement; + expect(host.style.maxHeight).toBe('300px'); + await wrapper.setProps({ maxHeight: '200px' }); + expect(host.style.maxHeight).toBe('200px'); + expect(host.style.minHeight).toBe('100px'); + wrapper.unmount(); + }); + + it('renders diagnostics', async () => { + const wrapper = mountEditor({ + modelValue: 'plugins:\n ssh:\n source: [\n', + language: 'yaml', + diagnostics: [ + { row: 3, col: 13, message: 'unexpected end', severity: 'error' }, + ], + }); + await flushPromises(); + expect(wrapper.find('.cm-lintRange').exists()).toBe(true); + expect(wrapper.find('.cm-lintRange-error').exists()).toBe(true); + await wrapper.setProps({ diagnostics: [] }); + expect(wrapper.find('.cm-lintRange').exists()).toBe(false); + wrapper.unmount(); + }); + it('does not put external document replacements in the undo history', async () => { + const { undo } = await import('@codemirror/commands'); + const wrapper = mountEditor({ modelValue: 'module A\n' }); + const view = (wrapper.vm as unknown as { view: Parameters[0] }) + .view; + await wrapper.setProps({ modelValue: 'module B\n' }); + expect(undo(view)).toBe(false); + expect(view.state.doc.toString()).toBe('module B\n'); + expect(wrapper.emitted('update:modelValue')).toBeUndefined(); + wrapper.unmount(); + }); +}); diff --git a/src/components/code-editor/__tests__/CodeMergeView.spec.ts b/src/components/code-editor/__tests__/CodeMergeView.spec.ts new file mode 100644 index 00000000..f317e1ae --- /dev/null +++ b/src/components/code-editor/__tests__/CodeMergeView.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { flushPromises, mount } from '@vue/test-utils'; +import CodeMergeView from '../CodeMergeView.vue'; + +const editorClasses = (el: Element) => + [...el.querySelectorAll('.cm-editor')].map((e) => e.className); + +describe('CodeMergeView', () => { + afterEach(() => document.documentElement.classList.remove('dark')); + + it.each(['split', 'unified'] as const)( + 'follows dark-mode toggles without rebuilding (%s)', + async (mode) => { + const wrapper = mount(CodeMergeView, { + props: { + original: 'a: 1\n', + modified: 'a: 2\n', + language: 'yaml', + mode, + }, + attachTo: document.body, + }); + const editors = [...wrapper.element.querySelectorAll('.cm-editor')]; + const light = editorClasses(wrapper.element); + document.documentElement.classList.add('dark'); + await flushPromises(); + const dark = editorClasses(wrapper.element); + expect(dark).not.toEqual(light); + // The same editors, reconfigured in place. + expect([...wrapper.element.querySelectorAll('.cm-editor')]).toEqual( + editors, + ); + document.documentElement.classList.remove('dark'); + await flushPromises(); + expect(editorClasses(wrapper.element)).toEqual(light); + wrapper.unmount(); + }, + ); +}); diff --git a/src/components/code-editor/__tests__/diagnostics.spec.ts b/src/components/code-editor/__tests__/diagnostics.spec.ts new file mode 100644 index 00000000..c2617cd4 --- /dev/null +++ b/src/components/code-editor/__tests__/diagnostics.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { toDiagnostics } from '../diagnostics'; + +const doc = 'package a\n\nallow if true\n'; + +describe('toDiagnostics', () => { + it('maps 1-based row/col to a range ending at the end of the line', () => { + expect(toDiagnostics(doc, [{ row: 3, col: 7, message: 'm' }])).toEqual([ + { from: 17, to: 24, message: 'm', severity: 'error' }, + ]); + }); + + it('clamps rows and columns into the document', () => { + const [low] = toDiagnostics(doc, [ + { row: 0, col: -5, message: 'm', severity: 'warning' }, + ]); + expect(low).toEqual({ from: 0, to: 9, message: 'm', severity: 'warning' }); + const [high] = toDiagnostics(doc, [{ row: 99, col: 99, message: 'm' }]); + // Last line is empty (trailing newline): a zero-length range at the end. + expect(high.from).toBe(doc.length); + expect(high.to).toBe(doc.length); + const [colPastEnd] = toDiagnostics(doc, [ + { row: 1, col: 500, message: 'm' }, + ]); + expect(colPastEnd).toMatchObject({ from: 9, to: 9 }); + }); + + it('defaults the column to 1', () => { + expect(toDiagnostics('x\ny', [{ row: 2, message: 'm' }])[0]).toMatchObject({ + from: 2, + to: 3, + }); + }); +}); diff --git a/src/components/code-editor/__tests__/index.spec.ts b/src/components/code-editor/__tests__/index.spec.ts new file mode 100644 index 00000000..0f3ea9a2 --- /dev/null +++ b/src/components/code-editor/__tests__/index.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { MAX_LOAD_RETRIES, retryLoad } from '../index'; +import CodeEditorError from '../CodeEditorError.vue'; + +describe('code-editor async loading', () => { + it(`retries a failed chunk load ${MAX_LOAD_RETRIES} times, then fails`, () => { + const retry = vi.fn(); + const fail = vi.fn(); + const err = new Error('Failed to fetch dynamically imported module'); + for (let attempts = 1; attempts <= MAX_LOAD_RETRIES; attempts++) + retryLoad(err, retry, fail, attempts); + expect(retry).toHaveBeenCalledTimes(MAX_LOAD_RETRIES); + expect(fail).not.toHaveBeenCalled(); + retryLoad(err, retry, fail, MAX_LOAD_RETRIES + 1); + expect(fail).toHaveBeenCalledTimes(1); + expect(retry).toHaveBeenCalledTimes(MAX_LOAD_RETRIES); + }); + + it('tells the user when the editor cannot be loaded', () => { + const wrapper = mount(CodeEditorError, { + props: { error: new Error('chunk') }, + }); + expect(wrapper.attributes('role')).toBe('alert'); + expect(wrapper.text()).toContain('The editor failed to load'); + }); +}); diff --git a/src/components/code-editor/diagnostics.ts b/src/components/code-editor/diagnostics.ts new file mode 100644 index 00000000..d4f063a7 --- /dev/null +++ b/src/components/code-editor/diagnostics.ts @@ -0,0 +1,30 @@ +// Pure conversion of row/col problems (e.g. YAML parse errors) into +// CodeMirror lint diagnostics. Rows and columns are 1-based; they are clamped to the +// document, and a diagnostic runs from its column to the end of its line. + +import { Text } from '@codemirror/state'; +import type { Diagnostic } from '@codemirror/lint'; + +export interface EditorDiagnostic { + /** 1-based. */ + row: number; + /** 1-based; defaults to 1. */ + col?: number; + message: string; + severity?: 'error' | 'warning' | 'info'; +} + +export function toDiagnostics( + doc: Text | string, + diags: readonly EditorDiagnostic[], +): Diagnostic[] { + const text = typeof doc === 'string' ? Text.of(doc.split('\n')) : doc; + return diags.map((d) => { + const row = Math.min(Math.max(1, Math.floor(d.row || 1)), text.lines); + const line = text.line(row); + const col = Math.min(Math.max(1, Math.floor(d.col || 1)), line.length + 1); + const from = line.from + col - 1; + const to = Math.max(from, line.to); + return { from, to, message: d.message, severity: d.severity ?? 'error' }; + }); +} diff --git a/src/components/code-editor/index.ts b/src/components/code-editor/index.ts new file mode 100644 index 00000000..7b832d9e --- /dev/null +++ b/src/components/code-editor/index.ts @@ -0,0 +1,35 @@ +// Async entry points: CodeMirror is split into its own chunk and loaded only when an editor or +// a diff is actually rendered (the read-only Configuration tab never loads it). +import { defineAsyncComponent } from 'vue'; +import CodeEditorError from './CodeEditorError.vue'; +import CodeEditorFallback from './CodeEditorFallback.vue'; + +/** Retries of a failed chunk load before the error component is shown. */ +export const MAX_LOAD_RETRIES = 2; + +export function retryLoad( + _error: Error, + retry: () => void, + fail: () => void, + attempts: number, +): void { + if (attempts <= MAX_LOAD_RETRIES) retry(); + else fail(); +} + +export const CodeEditor = defineAsyncComponent({ + loader: () => import('./CodeEditor.vue'), + loadingComponent: CodeEditorFallback, + errorComponent: CodeEditorError, + onError: retryLoad, +}); + +export const CodeMergeView = defineAsyncComponent({ + loader: () => import('./CodeMergeView.vue'), + loadingComponent: CodeEditorFallback, + errorComponent: CodeEditorError, + onError: retryLoad, +}); + +export type { EditorDiagnostic } from './diagnostics'; +export type { EditorLanguage } from './languages'; diff --git a/src/components/code-editor/languages/index.ts b/src/components/code-editor/languages/index.ts new file mode 100644 index 00000000..f5a049f8 --- /dev/null +++ b/src/components/code-editor/languages/index.ts @@ -0,0 +1,20 @@ +import type { Extension } from '@codemirror/state'; +import { yaml } from '@codemirror/lang-yaml'; +import { json, jsonParseLinter } from '@codemirror/lang-json'; +import { linter } from '@codemirror/lint'; + +export type EditorLanguage = 'yaml' | 'json' | 'text'; + +export function languageExtension( + language: EditorLanguage, + lint = true, +): Extension { + switch (language) { + case 'yaml': + return yaml(); + case 'json': + return lint ? [json(), linter(jsonParseLinter())] : json(); + default: + return []; + } +} diff --git a/src/components/code-editor/theme.ts b/src/components/code-editor/theme.ts new file mode 100644 index 00000000..4756b072 --- /dev/null +++ b/src/components/code-editor/theme.ts @@ -0,0 +1,114 @@ +// CodeMirror themes using the app's Tailwind slate palette, light and dark. + +import { EditorView } from '@codemirror/view'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags as t } from '@lezer/highlight'; +import type { Extension } from '@codemirror/state'; + +const lightTheme = EditorView.theme( + { + '&': { backgroundColor: '#ffffff', color: '#0f172a', fontSize: '13px' }, + '.cm-content': { + caretColor: '#0f172a', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + }, + '.cm-gutters': { + backgroundColor: '#f8fafc', + color: '#94a3b8', + borderRight: '1px solid #e2e8f0', + }, + '.cm-activeLine': { backgroundColor: '#f1f5f9' }, + '.cm-activeLineGutter': { backgroundColor: '#e2e8f0' }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': + { + backgroundColor: '#bae6fd', + }, + '&.cm-focused': { outline: '2px solid #38bdf8' }, + }, + { dark: false }, +); + +const darkTheme = EditorView.theme( + { + '&': { backgroundColor: '#0f172a', color: '#e2e8f0', fontSize: '13px' }, + '.cm-content': { + caretColor: '#e2e8f0', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + }, + '.cm-gutters': { + backgroundColor: '#020617', + color: '#64748b', + borderRight: '1px solid #1e293b', + }, + '.cm-activeLine': { backgroundColor: '#1e293b' }, + '.cm-activeLineGutter': { backgroundColor: '#1e293b' }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': + { + backgroundColor: '#0c4a6e', + }, + '&.cm-focused': { outline: '2px solid #0ea5e9' }, + }, + { dark: true }, +); + +const lightHighlight = HighlightStyle.define([ + { tag: t.keyword, color: '#7c3aed' }, + { tag: [t.atom, t.bool, t.null], color: '#c2410c' }, + { tag: t.number, color: '#0369a1' }, + { tag: t.string, color: '#15803d' }, + { tag: t.comment, color: '#64748b', fontStyle: 'italic' }, + { tag: t.operator, color: '#be123c' }, + { tag: t.definition(t.variableName), color: '#1d4ed8', fontWeight: '600' }, + { tag: t.special(t.variableName), color: '#b45309' }, + { tag: t.standard(t.variableName), color: '#0e7490' }, + { tag: [t.propertyName, t.attributeName], color: '#1d4ed8' }, + { tag: t.invalid, color: '#dc2626' }, +]); + +const darkHighlight = HighlightStyle.define([ + { tag: t.keyword, color: '#c4b5fd' }, + { tag: [t.atom, t.bool, t.null], color: '#fdba74' }, + { tag: t.number, color: '#7dd3fc' }, + { tag: t.string, color: '#86efac' }, + { tag: t.comment, color: '#94a3b8', fontStyle: 'italic' }, + { tag: t.operator, color: '#fda4af' }, + { tag: t.definition(t.variableName), color: '#93c5fd', fontWeight: '600' }, + { tag: t.special(t.variableName), color: '#fcd34d' }, + { tag: t.standard(t.variableName), color: '#67e8f9' }, + { tag: [t.propertyName, t.attributeName], color: '#93c5fd' }, + { tag: t.invalid, color: '#f87171' }, +]); + +export function editorTheme(dark: boolean): Extension { + return dark + ? [darkTheme, syntaxHighlighting(darkHighlight)] + : [lightTheme, syntaxHighlighting(lightHighlight)]; +} + +/** Whether the app is in dark mode (useTheme toggles `html.dark`). */ +export function isDarkMode(): boolean { + return ( + typeof document !== 'undefined' && + document.documentElement.classList.contains('dark') + ); +} + +/** Calls `cb` whenever the app's dark mode toggles; returns the disposer. */ +export function onDarkModeChange(cb: (dark: boolean) => void): () => void { + if (typeof MutationObserver === 'undefined') return () => undefined; + const observer = new MutationObserver(() => cb(isDarkMode())); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + return () => observer.disconnect(); +} + +/** + * Layout shared by every editor. The host element carries min/max height (so prop changes + * apply without a new theme); the editor inherits them and scrolls inside. + */ +export const layoutTheme = EditorView.theme({ + '&': { minHeight: 'inherit', maxHeight: 'inherit' }, + '.cm-scroller': { overflow: 'auto' }, +}); diff --git a/vitest.setup.ts b/vitest.setup.ts index 725b6997..88c76d73 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -51,3 +51,41 @@ if ( configurable: true, }); } + +// CodeMirror measures text through Range rects, which jsdom does not implement. Stub them +// (only the CodeEditor spec mounts a real editor; everything else stubs it). +if (typeof Range !== 'undefined') { + const emptyRect = () => + ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON: () => ({}), + }) as DOMRect; + if (!Range.prototype.getBoundingClientRect) { + Range.prototype.getBoundingClientRect = emptyRect; + } + if (!Range.prototype.getClientRects) { + Range.prototype.getClientRects = () => + ({ + length: 0, + item: () => null, + [Symbol.iterator]: function* () {}, + }) as unknown as DOMRectList; + } +} + +// jsdom has no ResizeObserver; PrimeVue's scrollable TabList observes its strip with one. +// This stub is a no-op: ResizeObserver callbacks never fire in any spec. +if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; +}