diff --git a/src/lib/components/common/DiffEditorModal.svelte b/src/lib/components/common/DiffEditorModal.svelte index 67082c381d..46be266cef 100644 --- a/src/lib/components/common/DiffEditorModal.svelte +++ b/src/lib/components/common/DiffEditorModal.svelte @@ -3,7 +3,7 @@ import { getContext } from 'svelte'; import { copyToClipboard } from '$lib/utils'; import { basicSetup, EditorView } from 'codemirror'; - import { Compartment, type Text } from '@codemirror/state'; + import { Compartment, EditorState, type Text } from '@codemirror/state'; import { keymap, type ViewUpdate } from '@codemirror/view'; import { indentWithTab, isolateHistory } from '@codemirror/commands'; import { LanguageDescription, indentUnit } from '@codemirror/language'; @@ -30,6 +30,12 @@ type MergeDirection } from './diffEditor'; + type FileLabel = { name: string; path: string }; + type ComparisonTexts = { initialContent: string; proposedContent: string }; + export let mode: 'edit' | 'review' = 'edit'; + export let leftFile: FileLabel | undefined = undefined; + export let rightFile: FileLabel | undefined = undefined; + export let onRefresh: (() => Promise) | undefined = undefined; export let show = false; export let initialContent = ''; export let proposedContent: string | undefined = undefined; @@ -47,6 +53,11 @@ proposal: string; path: string | null; title: string; + mode: 'edit' | 'review'; + hasProposal: boolean; + leftFile?: FileLabel; + rightFile?: FileLabel; + onRefresh?: () => Promise; } | null = null; let working = ''; let proposal = ''; @@ -63,8 +74,32 @@ let error = ''; let confirmDiscard = false; + let swapped = false; + let refreshing = false; + let editorRevision = 0; + $: displayedFiles = swapped + ? [session?.rightFile, session?.leftFile] + : [session?.leftFile, session?.rightFile]; + const paneFile = (side: string) => + (side === 'a') !== swapped ? session?.leftFile : session?.rightFile; + const paneLabel = (side: string) => + paneFile(side)?.name ?? (side === 'a' ? $i18n.t('Original') : $i18n.t('Proposed revision')); + const parentPath = (path: string) => path.replace(/[\\/][^\\/]*$/, '') || '/'; + const begin = () => { - session = { original: initialContent, proposal: proposedContent ?? '', path: filePath, title }; + session = { + original: initialContent, + proposal: proposedContent ?? '', + path: filePath, + title, + mode, + hasProposal: proposedContent !== undefined, + leftFile: leftFile && { ...leftFile }, + rightFile: rightFile && { ...rightFile }, + onRefresh + }; + swapped = refreshing = false; + editorRevision++; working = session.original; proposal = session.proposal; comparing = proposedContent !== undefined; @@ -73,7 +108,10 @@ lineChanges = { removed: 0, added: 0 }; copied = null; loading = approximate = false; - error = ''; + error = + mode === 'review' && proposedContent === undefined + ? $i18n.t('Both files are required for comparison.') + : ''; confirmDiscard = false; }; $: if (show && !session) begin(); @@ -87,11 +125,13 @@ onClose(); }; const requestClose = () => { + if (session?.mode === 'review') return close(); if (session && (working !== session.original || proposal !== session.proposal)) confirmDiscard = true; else close(); }; const apply = () => { + if (session?.mode === 'review') return; try { onApply(proposal); close(); @@ -99,6 +139,36 @@ error = cause instanceof Error ? cause.message : String(cause); } }; + const replaceReview = (texts: ComparisonTexts) => { + working = swapped ? texts.proposedContent : texts.initialContent; + proposal = swapped ? texts.initialContent : texts.proposedContent; + activeChunk = 0; + copied = null; + editorRevision++; + }; + const swap = () => { + if (session?.mode !== 'review') return; + swapped = !swapped; + replaceReview({ initialContent: session.original, proposedContent: session.proposal }); + }; + const reload = async () => { + const current = session; + if (current?.mode !== 'review' || !current.onRefresh || refreshing) return; + refreshing = true; + error = ''; + try { + const texts = await current.onRefresh(); + if (session !== current || !show) return; + current.original = texts.initialContent; + current.proposal = texts.proposedContent; + replaceReview(texts); + } catch (cause) { + if (session === current && show) + error = cause instanceof Error ? cause.message : String(cause); + } finally { + if (session === current) refreshing = false; + } + }; const copyPane = async (side: 'a' | 'b') => { const text = side === 'a' ? working : proposal; const currentSession = session; @@ -118,8 +188,10 @@ const refresh = (update?: ViewUpdate, side: 'a' | 'b' = 'a') => { if (!merge) return; if (!update || update.docChanged) { - working = merge.a.state.doc.toString(); - proposal = merge.b.state.doc.toString(); + if (session?.mode !== 'review') { + working = merge.a.state.doc.toString(); + proposal = merge.b.state.doc.toString(); + } chunkCount = merge.chunks.length; lineNumberDigits = String(Math.max(merge.a.state.doc.lines, merge.b.state.doc.lines)).length; lineChanges = changedLineCounts(merge.a.state.doc, merge.b.state.doc, merge.chunks); @@ -169,7 +241,7 @@ }; const copy = (direction: MergeDirection) => { - if (!merge || !merge.chunks[activeChunk]) return; + if (session?.mode === 'review' || !merge || !merge.chunks[activeChunk]) return; const destination = direction === 'to-working' ? merge.a : merge.b; destination.dispatch({ changes: copyChunk( @@ -193,16 +265,20 @@ let alignmentGeometry = ''; const themes = [new Compartment(), new Compartment()]; const language = [new Compartment(), new Compartment()]; - const path = session?.path; + const paths = ['a', 'b'].map((side) => paneFile(side)?.path ?? session?.path); + const review = session?.mode === 'review'; loading = true; const extensions = (side: 'a' | 'b', index: number) => [ basicSetup, + EditorState.readOnly.of(review), + EditorView.editable.of(!review), lineAlignment, keymap.of([indentWithTab, { key: 'Mod-s', run: () => true }]), indentUnit.of(' '), EditorView.lineWrapping, EditorView.contentAttributes.of({ - 'aria-label': side === 'a' ? $i18n.t('Original') : $i18n.t('Proposed revision') + 'aria-label': paneLabel(side), + ...(review ? { tabindex: '0', 'aria-readonly': 'true' } : {}) }), themes[index].of(document.documentElement.classList.contains('dark') ? oneDark : []), language[index].of([]), @@ -289,13 +365,17 @@ attributes: true, attributeFilter: ['class'] }); - const match = path && LanguageDescription.matchFilename(languages, path); - if (match) { - const support = await match.load(); - if (disposed) return; - view.a.dispatch({ effects: language[0].reconfigure(support) }); - view.b.dispatch({ effects: language[1].reconfigure(support) }); - } + await Promise.all( + paths.map(async (path, index) => { + const match = path && LanguageDescription.matchFilename(languages, path); + if (!match) return; + const support = await match.load(); + if (!disposed) + (index === 0 ? view!.a : view!.b).dispatch({ + effects: language[index].reconfigure(support) + }); + }) + ); } catch (cause) { if (!disposed) { error = cause instanceof Error ? cause.message : String(cause); @@ -331,7 +411,7 @@

{session.title || $i18n.t('Compare revision')}

{#if session.path}

{session.path}

{/if} - {#if !comparing} + {#if !comparing && session.mode === 'edit'} @@ -374,22 +454,41 @@ disabled={!chunkCount || activeChunk === chunkCount - 1} on:click={() => reveal(activeChunk + 1)}> - - + {#if session.mode === 'edit'} + + + {:else} + + {#if session.onRefresh} + + {/if} + {/if} {/if} - - {/each} - - {#if comparing} -
- {:else} -
- -
- -
+ +
+ {/each} - {/if} + {#if comparing} + {#key editorRevision} +
+ {/key} + {:else} +
+ +
+ +
+
+ {/if} + + {#if comparing} +
+ +
+ {/if} - {#if comparing} -
+

+ {applyDescription ?? + $i18n.t('Apply uses the proposed revision on the right. Save your draft afterward.')} +

+ - -
- {/if} - -
-

- {applyDescription ?? - $i18n.t('Apply uses the proposed revision on the right. Save your draft afterward.')} -

- - -
+ + + {/if}