Helix keybinds for JetBrains products
modal-editor helix jetbrains plugin
0

Configure Feed

Select the types of activity you want to include in your feed.

Group paste into one undo step + skip disabled actions

EditorBridge now computes a minimal-diff replaceString instead of clobbering
the whole document with setText, and applies the text edit and caret sync
inside a single WriteCommandAction. Result: one paste keystroke produces
one undo entry (was two), and incremental highlighting / PSI updates don't
re-fire on unchanged buffer regions.

HelicaseTypedHandler.performIdeAction now calls action.update(event) and
only fires actionPerformed when the presentation is enabled. Without this,
pressing u (or U) with an empty history raised an 'IDE error occurred'
notification — which matches neither Helix's behavior (no-op at boundary)
nor any sensible modal-editor expectation.

Isaac Corbrey (May 20, 2026, 3:32 PM EDT) ad0a03fd dc2f5340

+74 -19
+67 -18
plugin/src/main/kotlin/io/helicase/plugin/EditorBridge.kt
··· 49 49 * Apply [target] to [editor]: replace document text if it changed, then 50 50 * sync the CaretModel. State flags (mode, restoreCursor) are written back 51 51 * to [state]. 52 + * 53 + * Important undo semantic: a single engine step that mutates the buffer 54 + * must produce a SINGLE undo entry. We achieve that by (a) computing the 55 + * minimal diff against the existing document and emitting one 56 + * [Document.replaceString] (rather than `setText`, which clobbers the 57 + * whole buffer), and (b) doing the text edit AND the caret sync inside 58 + * the same [WriteCommandAction] so they share an undo group. 52 59 */ 53 60 fun write(editor: Editor, target: EditorState, state: HelicaseState) { 54 61 val project = editor.project 55 - val currentText = editor.document.charsSequence 56 - val textChanged = target.text.length != currentText.length || 57 - target.text != currentText.toString() 62 + val document = editor.document 63 + val currentText = document.charsSequence 64 + val diff = computeDiff(currentText, target.text) 58 65 59 - if (textChanged) { 60 - if (project != null) { 61 - WriteCommandAction.runWriteCommandAction(project, "Helicase", null, { 62 - editor.document.setText(target.text) 63 - }) 64 - } else { 65 - WriteAction.run<Throwable> { editor.document.setText(target.text) } 66 - } 67 - } 68 - 69 - // Sync carets. 70 - val caretModel = editor.caretModel 71 - val ranges = target.selection.ranges 72 - val caretStates = ranges.map { r -> 66 + val caretStates = target.selection.ranges.map { r -> 73 67 // IntelliJ selections are forward-only (start <= end). We collapse 74 68 // direction info, but record caret offset as head so cursor lands right. 75 69 val start = minOf(r.anchor, r.head) ··· 80 74 editor.offsetToLogicalPosition(end.coerceIn(0, target.text.length)), 81 75 ) 82 76 } 83 - caretModel.caretsAndSelections = caretStates 77 + 78 + val apply = Runnable { 79 + if (diff != null) { 80 + document.replaceString(diff.start, diff.end, diff.replacement) 81 + } 82 + editor.caretModel.caretsAndSelections = caretStates 83 + } 84 + 85 + if (diff != null) { 86 + // Document change → must run inside a write command so undo groups 87 + // text + caret together. 88 + if (project != null) { 89 + WriteCommandAction.runWriteCommandAction(project, "Helicase", null, apply) 90 + } else { 91 + WriteAction.run<Throwable> { apply.run() } 92 + } 93 + } else { 94 + // Pure motion: setting carets needs no write command. Doing so 95 + // anyway would create an empty undo entry. 96 + apply.run() 97 + } 84 98 85 99 state.mode = target.mode 86 100 state.restoreCursor = target.restoreCursor 87 101 state.lastSearch = target.lastSearch 88 102 state.registers = target.registers 103 + } 104 + 105 + private data class Diff(val start: Int, val end: Int, val replacement: String) 106 + 107 + /** 108 + * Minimal-edit diff between [old] and [new]: returns the single 109 + * [Document.replaceString] arguments that transform `old` into `new`, 110 + * or null if they're equal. Shared prefix / suffix are skipped so a 111 + * one-char paste turns into a one-char replaceString — not a full 112 + * document rewrite. 113 + */ 114 + private fun computeDiff(old: CharSequence, new: String): Diff? { 115 + val oLen = old.length 116 + val nLen = new.length 117 + if (oLen == nLen) { 118 + // Fast path: lengths equal AND contents equal → no diff. 119 + var equal = true 120 + for (i in 0 until oLen) { 121 + if (old[i] != new[i]) { equal = false; break } 122 + } 123 + if (equal) return null 124 + } 125 + var prefix = 0 126 + val maxPrefix = minOf(oLen, nLen) 127 + while (prefix < maxPrefix && old[prefix] == new[prefix]) prefix++ 128 + var suffix = 0 129 + val maxSuffix = minOf(oLen - prefix, nLen - prefix) 130 + while (suffix < maxSuffix && old[oLen - 1 - suffix] == new[nLen - 1 - suffix]) suffix++ 131 + val start = prefix 132 + val end = oLen - suffix 133 + val replacement = new.substring(prefix, nLen - suffix) 134 + // Equal-content edge case: end < start can occur on degenerate inputs; 135 + // clamp to avoid an invalid replaceString. 136 + if (end < start) return Diff(start, start, replacement) 137 + return Diff(start, end, replacement) 89 138 } 90 139 }
+7 -1
plugin/src/main/kotlin/io/helicase/plugin/HelicaseTypedHandler.kt
··· 83 83 val event = com.intellij.openapi.actionSystem.AnActionEvent.createFromAnAction( 84 84 action, null, com.intellij.openapi.actionSystem.ActionPlaces.UNKNOWN, dataContext, 85 85 ) 86 - action.actionPerformed(event) 86 + // Check enablement first — calling actionPerformed on a disabled action 87 + // (e.g. Undo with an empty stack) raises an IDE error notification. 88 + // Helix's `u` is a no-op at history boundaries; mirror that. 89 + action.update(event) 90 + if (event.presentation.isEnabled) { 91 + action.actionPerformed(event) 92 + } 87 93 } 88 94 89 95 private fun openSelectInSelectionPrompt(editor: Editor, state: HelicaseState) {