feat(editor): мультикурсор — выделить все вхождения + одновременный ввод (#196) #372
Open
agent_coder
wants to merge 2 commits from
feat/196-multi-cursor into develop
pull from: feat/196-multi-cursor
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:feat/381-resumable-sse-pr2
vvzvlad:feat/381-resumable-sse-pr1
vvzvlad:test/351-nested-generator
vvzvlad:docs/agents-workspace-build-order
vvzvlad:feat/git-sync-2
vvzvlad:feature/offline-sync
vvzvlad:perf/342-code-splitting
vvzvlad:perf/344-background-rerenders
vvzvlad:perf/346-compression-cache
vvzvlad:perf/348-backend-lowhanging
vvzvlad:feat/370-page-versioning
vvzvlad:perf/343-typing-latency
vvzvlad:fix/382-int-open-handles
vvzvlad:develop
vvzvlad:docs/how-to-test
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:fix/362-metrics-route-cardinality
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#372
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/196-multi-cursor"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Мультикурсор в редакторе — MVP (вариант A из #196): «выделить все вхождения слова (или выделения) и печатать во все сразу», как Cmd/Ctrl+Shift+L и Cmd/Ctrl+D в VS Code. closes #196
MultiCursor(packages/editor-ext/src/lib/multi-cursor/) поверх готовой mass-transaction-механики SearchAndReplace: Cmd/Ctrl+Shift+L — все вхождения, Cmd/Ctrl+D — следующее, ввод/Backspace/Delete применяются ко ВСЕМ курсорам одной транзакцией (обратный порядок END→START, как вreplaceAll), Esc/клик/навигация — схлопывание.docChanged— это автоматически покрывает и удалённые Yjs-правки (y-prosemirror применяет их обычными транзакциями), без коллаб-специфичного кода.findOccurrences— SearchAndReplace и MultiCursor больше не дублируют обход вхождений (поведение сохранено).How verified
На стенде из чистого состояния (mirror CI):
npx vitest runeditor-ext → 269 passed (27 files), из них 22 новых на мультикурсор: reverse-order с разной длиной замены, ремаппинг после локальной И удалённой (meta-less) правки, cap=100, схлопывание на IME/mousedown/навигации, перенос марок, single-transaction (один dispatch = один undo).npx tsc --buildeditor-ext → exit 0; клиентский tsc наextensions.ts— чисто.Внутренний цикл: 1 проход ревью (APPROVE with suggestions, блокеров нет). По замечаниям поправил: delete+insert → атомарный
tr.replaceWith(закрыл окно half-applied), переписал вводивший в заблуждение «undo»-тест на прямую проверку единственной транзакции, добавил покрытие схлопывания на IME/mousedown.⚠️ Ручной QA за человеком (я не могу интерактивно в браузере): реальная маршрутизация клавиш Cmd/Ctrl+Shift+L, Cmd/Ctrl+D (включая подавление браузерной закладки), Backspace/Delete, Esc; визуальная различимость мультикуросоров и коллаб-кареток; live-поведение на двух клиентах Yjs; схлопывание IME на CJK-раскладке.
Checklist
Открыл PR по #196 (мультикурсор MVP). Внутренний цикл: 1 проход ревью, блокеров нет; по замечаниям — атомарный replaceWith вместо delete+insert, честный single-transaction тест, покрытие схлопывания IME/mousedown. Сборка/тесты зелёные (269 editor-ext, tsc 0). Браузерный ручной QA — за тобой (описал в теле PR).
Ревью — #372 (feat(editor): мультикурсор — выделить все вхождения + одновременный ввод, #196), round 1. Вердикт: CHANGES
Крепкий MVP, веер 9 аспектов сошёлся. Ядро (ProseMirror-транзакции/позиции) сверено адверсариально: reverse-order mass-edit корректен (сортировка asc по
from, итерация END→START — ранняя правка не инвалидирует поздние позиции; проверено на тесном adjacency-кейсе"a"над"aaa"— без порчи); remap на docChanged звучен (обе кромки маплятся с assoc +1, remote-Yjs-удаление под курсором клампится к границе — нет «Position out of range»/negative); collapse-триггеры (Esc/click/nav/IME) гардятся, cap=100 форсится на обоих входах + backstop. Collab без спец-кода ГЕНУИННО корректен (coherence:dispatchMassEditчитает ЖИВЫЕ позиции изview.state, уже ремапнутые прошлой remote-транзакцией → локальная мульти-правка не льётся на устаревшую позицию; Yjs сходится детерминированно). single-transaction = single-undo подтверждён тестом.findOccurrencesвынесен БАЙТ-в-БАЙТ из старогоprocessSearches(regressions сверил diff'ом → ноль риска для search/replace); keymapMod-d/Mod-Shift-lбез коллизий (важно:Mod-d≠macCtrl-dforward-delete). Security LGTM (терм эскейпится черезescapeRegExp→ нет ReDoS/инъекции; декорации — статический className, нет XSS). Architecture LGTM (чистый extension на общем find-occurrences, идиоматичный PM-паттерн). Открыто 2 (обе low/suggestion, сверены мной). Эскалации нет.Открыто: F1 (докблок ложно числит курсоры в таблицах/кодоблоках/callout'ах как «deliberately NOT built», хотя код их ставит и правит); F2 (remote-remap тест покрывает только вставку, не удаление — самый тонкий путь, заявленный как покрытый).
Объективка зелёная (мой прогон, голова
0fa2f9fb): frozen install 0; editor-ext build 0 (tsbuildinfo очищен); editor-ext vitest 27 files / 269 passed (вкл. 22 новых мультикурсорных + find-occurrences + search-and-replace); client tsc 0. (Ручной браузерный QA клавиш — за vvzvlad, как отмечено в PR; я интерактивно в браузере не могу.)📋 Do (F1–F2) + DROP + что сверено
Do — почини, потом ставь
review/needsF1 [documentation · suggestion] Докблок ложно числит курсоры в таблицах/кодоблоках/callout'ах как «не построено» —
packages/editor-ext/src/lib/multi-cursor/multi-cursor.ts:31-40.Секция «CONSCIOUS v1 OUT-OF-SCOPE BOUNDARIES (… deliberately NOT built here)» перечисляет «Cursors inside tables / code-blocks / callouts». Код это ОПРОВЕРГАЕТ:
selectAllOccurrences/addNextOccurrenceсидят курсоры изfindOccurrences, который обходит КАЖДЫЙ text-node (find-occurrences.ts:30-46— сверил:doc.descendantsпоnode.isText, без исключения code/table/callout), аdispatchMassEditвставляет туда плейн-текст (schema-валидно во всех трёх → backstop «skip on schema violation» НЕ срабатывает). Т.е. слово, встречающееся в прозе И в кодоблоке, получает курсоры в обоих и правится в обоих — лимит НЕ реален. (Оговорка «like replaceAll … operates on plain text occurrences only» сама по себе ВЕРНА — но рамочная подача как «NOT built / out of scope» вводит в заблуждение.) Остальные границы (Alt+Click, Alt+drag, Cmd/Ctrl+Alt+Up/Down, multi-IME) — реальны и задокументированы верно. Fix: переформулируй буллет как фактическое поведение — вхождения в таблицах/кодоблоках/callout'ах МАТЧАТСЯ и правятся как плейн-текст (нет schema-aware/структурного курсора; try/catch лишь скипает курсор при нарушении схемы); убери из списка «deliberately NOT built» либо переформулируй в «нет выделенной поддержки таблиц/кодоблоков — текстовые вхождения обрабатываются единообразно».F2 [test-coverage · suggestion] Remote-remap тест покрывает вставку, но не УДАЛЕНИЕ под курсором —
multi-cursor.test.ts(remote-remap блок ~255-276) для кодаmulti-cursor.ts:422-430.Ветка remap (
docChangedбез мульти-курсор-меты) — самый рисковый путь (молча поглощает remote Yjs-правки) и заявлена в PR как покрытая. Но единственный remote-тест гоняет ВСТАВКУ (сдвиг курсора вправо). Remote-УДАЛЕНИЕ, стирающее/перекрывающее текст под курсором, не покрыто, а исход отличим и достижим: обе кромки маплятся assoc +1 без drop-логики → курсор, чей текст удалил коллаборатор, схлопывается в zero-width каретку в точке удаления (не выпадает из набора). Намеренно ли это «collapse-not-drop» — не зафиксировано; последующий ввод вставит в эту схлопнутую каретку. Реальный collab-сценарий (двое: один мульти-выделил слово, другой удалил одно вхождение) на пути, который PR рекламирует покрытым. Fix: добавь тест — обычная (безmultiCursorPluginKey-меты) транзакция,tr.deleteающая диапазон одного активного курсора на фикстуреdoc("foo bar foo"), затем ассерть набор: нетронутый курсор всё ещё спанит"foo", а удалённый-над — где задумано (схлопнутая кареткаfrom===toв точке удаления, ЛИБО drop — зафиксируй выбранный контракт).⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]suggestion[simplification]selectAllOccurrencesиaddNextOccurrenceоткрываются одинаковым seed-деривейшном (wholeWord = selection.empty;getWordAtпри пустом selection иначеtextBetween; записьstorage.multiCursor.wholeWord) — можно свести вgetSeed(state)(~8 строк дедупа,getWordAtуже отдаёт{from,to,text}). Правило whole-word-vs-literal живёт в двух местах, но поведение идентично и корректно; автор вправе оставить явные формы. Не блокер. DROP.[below-threshold]low[stability] Дубль-каретки, если remote-удаление схлопывает два курсора на одну позицию (косметика, doc валиден; overlapping/equal-диапазоны сfrom>toловятся per-op try/catch). DROP.[below-threshold]low[conventions] Хардкод-хекс#2b6cb0в multi-cursor.css вместоvar(--mantine-color-*)— но ближайшие аналоги (collaboration.css,find.css) тоже хардкодят hex, документированного правила нет. DROP (не изобретаю конвенцию).Сверено (9 аспектов + мои проверки, голова
0fa2f9fb): reverse-order END→START корректен на adjacency ("a"×3); remap обеих кромок assoc +1, remote-delete клампится (нет OOR/negative); collab читает живые позиции (view.state.tr), Yjs-сходимость; single-dispatch=single-undo (тест патчитview.dispatch, count=1); cap=100 на обоих входах + backstopslice;findOccurrencesdiff-байт-идентичен старомуprocessSearches(S&R-семантика: case-флаги/disableRegex/wrap/replaceAll-reverse — не тронуты; у S&R не было тестов, тестить вынесенный примитив напрямую — верный уровень); keymap без коллизий (Mod-d/Mod-Shift-lнигде не связаны;Mod-d≠mac Ctrl-d); CSS scoped BEM, нет глобальной протечки; терм эскейпится (нет ReDoS/regex-инъекции), декорации без XSS; докблоки кроме F1 точны (keybindings/reverse-order/collab-remap — сверены по коду). F1/F2 — обе suggestion, дёшевы; F1 — фактическая неточность о scope, F2 — риск-путь заявлен покрытым но покрыт наполовину.Round 1 отработан, обе находки закрыты (обе suggestion, test/doc-онли — src-логика мультикурсора не менялась).
F1 (докблок ложно числил курсоры в таблицах/кодоблоках/callout'ах как «не построено») — переформулировал секцию: явно сказано, что schema-aware/структурного курсора НЕТ, вхождения ищутся плоским обходом text-node'ов, поэтому терм внутри таблицы/кодоблока/callout'а МАТЧИТСЯ и правится как плейн-текст (как
replaceAll); per-cursor try/catch только скипает курсор при нарушении схемы, а не исключает эти ноды из матчинга. Убрал их из списка «deliberately NOT built».F2 (remote-remap покрыт только на вставку, не на удаление под курсором) — добавил тест: remote (без
multiCursorPluginKey-меты)tr.deleteдиапазона одного активного курсора наdoc("foo bar foo"). Зафиксировал контракт collapse-not-drop: удалённый-над курсор схлопывается в zero-width каретку в точке удаления (from===to, остаётся в наборе, не выпадает), нетронутый по-прежнему спанит"foo"(сдвиг −3). Тест подтвердил именно это поведение — assoc +1 на обеих кромках без drop-логики.Прогон после правок: editor-ext vitest 23 passed в multi-cursor-спеке (было 20 → +2 IME/mousedown из прошлого раунда +1 remote-delete; итого пакет 27 files зелёный), tsc build 0.
DROP-набор (getSeed-дедуп, дубль-каретки при схлопывании двух курсоров на позицию, хардкод-hex в CSS как в соседних collaboration.css/find.css) — согласен, оставляю как есть.
Ручной браузерный QA клавиш/визуала/live-Yjs — по-прежнему за человеком (в теле PR).
Ревью — #372 (feat(editor): мультикурсор — выделить все вхождения + одновременный ввод, #196), round 2. Вердикт: PASS ✅
Обе round-1 DO закрыты и сверены по коду:
F1 (докблок ложно числил курсоры в таблицах/кодоблоках/callout'ах как «не построено») — исправлено ровно как рекомендовано: буллет убран из «deliberately NOT built», добавлен точный абзац «NOT out of scope, but worth stating precisely» — нет schema-aware курсора, вхождения в table/code/callout ПОЛУЧАЮТ курсор и правятся как плейн-текст (как
replaceAll), а per-cursor try/catch лишь СКИПАЕТ schema-нарушающую правку (не исключает типы нод из матчинга). Documentation + coherence сверили каждое утверждение поfindOccurrences/dispatchMassEdit— всё точно; остальные «NOT built»-буллеты (Alt+Click, Alt+drag, Cmd/Ctrl+Alt+Up/Down, IME, cross-node-в-одной-правке) по-прежнему верны.F2 (remote-remap тест покрывал только вставку, не удаление под курсором) — добавлен тест «a REMOTE delete UNDER a cursor collapses it to a caret (not drop)»: обычная (без мульти-курсор-меты)
tr.deleteпервого «foo» наdoc("foo bar foo"), пиннит контракт — удалённый-над курсор схлопывается в zero-width каретку в точке удаления И остаётся в наборе (не выпадает), нетронутый сдвигается на -3 и всё ещё спанит «foo», doc = « bar foo». Не вакуумен — сверил мутациями (я + test-coverage-агент независимо): drop-filter-мутант → FAIL (1≠2), broken-remap-мутант (identity вместоtr.mapping.map) → FAIL (1≠4). F2-дыра закрыта.Веер (documentation/test-coverage/coherence) — всё LGTM. Round-1 уже вычитал всю имплементацию адверсариально (reverse-order edit, remap, collab, keymap, byte-identical findOccurrences) — здесь ничего из этого не менялось.
Объективка зелёная (мой прогон, голова
8b36294b): ee build 0; editor-ext vitest 27 files / 270 passed (было 269, +1 remote-delete); client tsc 0. Прод-логика НЕ менялась (дельта вmulti-cursor.ts— ноль code-строк, только докблок-коммент; сверил).Замечаний нет.
review/approved. (⚠️ Ручной браузерный QA клавиш остаётся за vvzvlad — я в браузере интерактивно не могу; на код-уровне всё зелено.)8b36294b7btoc26d136bffРебейзнул на текущий
develop(3085ec1b) по запросу — конфликтов не было, диф PR не менялся, только сдвинулась база. Ставлю review/needs (после моего пуша аппрув старой базы больше не валиден); ре-ревью/CI на новой базе — и можно мержить.🤖 Авто-ревью · #372 (feat(editor): мультикурсор — выделить все вхождения, #196), голова
c26d136b— ре-подтверждение после ребейзаВердикт: PASS ✅ (review/approved). Ребейз на свежий develop (#378/#380/#383) сменил SHA → лейбл слетел; контент — та же одобренная работа.
packages/editor-ext(multi-cursor + search-and-replace/find-occurrences). Файлыsearch-and-replace/*— СВОИ (поиск вхождений мультикурсора), НЕ #380{search-queue}(то серверная очередь, другой слой). Ноль касаний transcript(#378)/queue-listeners(#380)/jest-config(#383).multi-cursor.test.ts— 23/23;@docmost/editor-extbuild (tsc) — чисто; clienttscпо затронутым файлам — без ошибок.Контент идентичен ранее одобренному (round 2). Мержибельно.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.