From de711c1746014cbd05afc73cf919d107c7eed70f Mon Sep 17 00:00:00 2001 From: Alex Hernandez Date: Sat, 21 Feb 2026 15:12:19 +1000 Subject: [PATCH] :sparkles: handle history --- src/renderer/components/Editor/Editor.css | 24 ++++- .../components/Editor/MarkdownEditor.tsx | 101 ++++++++++++++++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index bbb0ce1..8231be9 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -57,7 +57,6 @@ font-size: 13px; line-height: 1.5; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - pointer-events: none; } .annotation-tooltip-divider { @@ -118,3 +117,26 @@ color: var(--text-muted); font-style: italic; } + +.annotation-tooltip-apply { + display: block; + width: 100%; + margin-top: 2px; + padding: 5px 10px; + background: transparent; + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--accent); + font-family: var(--font-sans); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + text-align: center; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.annotation-tooltip-apply:hover { + background: var(--accent); + color: var(--bg-base); +} diff --git a/src/renderer/components/Editor/MarkdownEditor.tsx b/src/renderer/components/Editor/MarkdownEditor.tsx index c8ee56d..25f1b68 100644 --- a/src/renderer/components/Editor/MarkdownEditor.tsx +++ b/src/renderer/components/Editor/MarkdownEditor.tsx @@ -3,7 +3,7 @@ import { EditorView, Decoration, type DecorationSet, hoverTooltip, keymap } from import { EditorState, StateField, StateEffect, RangeSetBuilder, Compartment } from '@codemirror/state' import { markdown } from '@codemirror/lang-markdown' import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language' -import { history, defaultKeymap, historyKeymap } from '@codemirror/commands' +import { history, defaultKeymap, historyKeymap, invertedEffects } from '@codemirror/commands' import { marked } from 'marked' import DOMPurify from 'dompurify' import { useEditorStore } from '../../store/editorStore' @@ -24,8 +24,20 @@ const rawAnnotationsField = StateField.define({ } }) -// Per-session cache: annotation id → lazily generated analysis text -const tooltipAnalysisCache = new Map() +// Per-session cache: annotation id → { text, suggestion extracted from blockquote } +const tooltipAnalysisCache = new Map() + +// Extract the first blockquote from a markdown string — used as the applicable rewrite +function extractBlockquote(markdown: string): string | null { + const lines = markdown.split('\n') + const bqLines: string[] = [] + for (const line of lines) { + if (line.startsWith('> ')) bqLines.push(line.slice(2)) + else if (line === '>') bqLines.push('') + } + const text = bqLines.join(' ').trim() + return text || null +} // Hover tooltip — lazily streams a specific AI analysis for the hovered passage const annotationHoverTooltip = hoverTooltip( @@ -62,9 +74,40 @@ const annotationHoverTooltip = hoverTooltip( body.innerHTML = DOMPurify.sanitize(raw) } + function showApplyButton(suggestion: string): void { + const btnDivider = document.createElement('div') + btnDivider.className = 'annotation-tooltip-divider' + dom.appendChild(btnDivider) + + const btn = document.createElement('button') + btn.className = 'annotation-tooltip-apply' + btn.textContent = 'Apply suggestion' + btn.addEventListener('click', () => { + const { annotations: anns, setAnnotations } = useEditorStore.getState() + const changeSpec = { from: ann.from, to: ann.to, insert: suggestion } + // Map surviving annotation positions through the text change so + // their from/to reflect the new document offsets. + const changeSet = view.state.changes(changeSpec) + const remaining = anns + .filter(a => a.id !== ann.id) + .map(a => ({ ...a, from: changeSet.mapPos(a.from), to: changeSet.mapPos(a.to) })) + // Combine text replacement + annotation update in one transaction + // so CM history treats them as a single undoable unit (Cmd+Z + // restores both the original text and the highlight together). + view.dispatch({ + changes: changeSpec, + effects: setAnnotationsEffect.of(remaining) + }) + setAnnotations(remaining) + tooltipAnalysisCache.delete(ann.id) + }) + dom.appendChild(btn) + } + const cached = tooltipAnalysisCache.get(ann.id) if (cached) { - showText(cached) + showText(cached.text) + if (cached.suggestion) showApplyButton(cached.suggestion) } else { body.classList.add('annotation-tooltip-loading') body.innerHTML = 'Analysing…' @@ -81,7 +124,7 @@ const annotationHoverTooltip = hoverTooltip( documentContent: activeFileContent, documentPath: activeFilePath ?? '', conversationHistory: [], - userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain specifically what the issue is in this exact passage, then give a direct rewrite of just this passage. Be concise and specific—no generic advice.` + userMessage: `This passage was flagged for ${typeName}: "${ann.matchedText}"\n\nIn 1–2 sentences explain the specific issue, then provide a direct rewrite in a markdown blockquote like this:\n\n> Rewritten passage here.\n\nBe specific to this exact text—no generic advice.` }, (chunk: string) => { if (destroyed) return @@ -90,14 +133,20 @@ const annotationHoverTooltip = hoverTooltip( } ).then(() => { if (destroyed) return - const result = accumulated || ann.message - tooltipAnalysisCache.set(ann.id, result) - showText(result) + const text = accumulated || ann.message + const suggestion = extractBlockquote(text) ?? ann.suggestion ?? null + tooltipAnalysisCache.set(ann.id, { text, suggestion }) + showText(text) + if (suggestion) showApplyButton(suggestion) }).catch(() => { if (!destroyed) showText(ann.message) }) } else { + // Fallback: use stored message and suggestion + const suggestion = ann.suggestion ?? null + tooltipAnalysisCache.set(ann.id, { text: ann.message, suggestion }) showText(ann.message) + if (suggestion) showApplyButton(suggestion) } } @@ -140,6 +189,18 @@ const annotationField = StateField.define({ provide: (f) => EditorView.decorations.from(f) }) +// Teach CM's undo/redo history about annotation state so that Cmd+Z after +// an Apply also restores the highlight. For every transaction that carries a +// setAnnotationsEffect we record the *previous* annotation list as the +// inverse effect; CM history replays it on undo. +const annotationHistory = invertedEffects.of(tr => { + if (tr.effects.some(e => e.is(setAnnotationsEffect))) { + const before = tr.startState.field(rawAnnotationsField) + return [setAnnotationsEffect.of(before)] + } + return [] +}) + const themeCompartment = new Compartment() function buildTheme(fontSize: number, dark: boolean): ReturnType { @@ -223,12 +284,26 @@ export function MarkdownEditor(): JSX.Element { rawAnnotationsField, annotationField, annotationHoverTooltip, + annotationHistory, themeCompartment.of(buildTheme(fontSize, theme === 'dark')), EditorView.lineWrapping, EditorView.updateListener.of((update) => { if (update.docChanged) { setContent(update.state.doc.toString()) } + // When the user Cmd+Z's through an Apply, invertedEffects fires a + // setAnnotationsEffect restoring the old list inside CM. Sync that + // back to Zustand so the React decoration effect also reflects it. + const hasUndoRedo = update.transactions.some( + tr => tr.isUserEvent('undo') || tr.isUserEvent('redo') + ) + if (hasUndoRedo) { + const prev = update.startState.field(rawAnnotationsField) + const curr = update.state.field(rawAnnotationsField) + if (prev !== curr) { + useEditorStore.getState().setAnnotations(curr) + } + } }) ] }), @@ -236,6 +311,16 @@ export function MarkdownEditor(): JSX.Element { }) viewRef.current = view + // Expose view + undo for dev-mode testing (stripped in production) + if (import.meta.env.DEV) { + const w = window as unknown as Record + w.__cmView = view + // eslint-disable-next-line @typescript-eslint/no-var-requires + import('@codemirror/commands').then(({ undo, redo }) => { + w.__cmUndo = () => undo(view) + w.__cmRedo = () => redo(view) + }) + } return () => { view.destroy() viewRef.current = null