coplan-engine 0.1.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/coplan/application.css +268 -85
  3. data/app/controllers/coplan/api/v1/comments_controller.rb +7 -7
  4. data/app/controllers/coplan/api/v1/operations_controller.rb +38 -0
  5. data/app/controllers/coplan/comment_threads_controller.rb +26 -72
  6. data/app/controllers/coplan/plans_controller.rb +1 -3
  7. data/app/helpers/coplan/application_helper.rb +44 -0
  8. data/app/helpers/coplan/markdown_helper.rb +1 -0
  9. data/app/javascript/controllers/coplan/comment_form_controller.js +14 -0
  10. data/app/javascript/controllers/coplan/comment_nav_controller.js +247 -0
  11. data/app/javascript/controllers/coplan/text_selection_controller.js +143 -169
  12. data/app/jobs/coplan/automated_review_job.rb +4 -4
  13. data/app/models/coplan/comment_thread.rb +13 -7
  14. data/app/policies/coplan/comment_thread_policy.rb +1 -1
  15. data/app/services/coplan/plans/apply_operations.rb +43 -0
  16. data/app/services/coplan/plans/commit_session.rb +26 -1
  17. data/app/services/coplan/plans/position_resolver.rb +111 -0
  18. data/app/views/coplan/comment_threads/_new_comment_form.html.erb +2 -1
  19. data/app/views/coplan/comment_threads/_reply_form.html.erb +2 -1
  20. data/app/views/coplan/comment_threads/_thread.html.erb +3 -6
  21. data/app/views/coplan/comment_threads/_thread_popover.html.erb +58 -0
  22. data/app/views/coplan/plans/show.html.erb +22 -30
  23. data/app/views/layouts/coplan/application.html.erb +5 -0
  24. data/config/routes.rb +2 -2
  25. data/db/migrate/20260320145453_migrate_comment_thread_statuses.rb +31 -0
  26. data/lib/coplan/version.rb +1 -1
  27. metadata +5 -2
  28. data/app/javascript/controllers/coplan/tabs_controller.js +0 -18
@@ -1,21 +1,34 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  export default class extends Controller {
4
- static targets = ["content", "popover", "form", "anchorInput", "contextInput", "occurrenceInput", "anchorPreview", "anchorQuote"]
4
+ static targets = ["content", "popover", "form", "anchorInput", "contextInput", "occurrenceInput", "anchorPreview", "anchorQuote", "margin", "threads"]
5
5
  static values = { planId: String }
6
6
 
7
7
  connect() {
8
8
  this.selectedText = null
9
+ this._activeMark = null
10
+ this._activePopover = null
9
11
  this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
10
12
  document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
13
+ this._handleScroll = this._handleScroll.bind(this)
14
+ window.addEventListener("scroll", this._handleScroll, { passive: true })
11
15
  this.highlightAnchors()
12
- this.observeThreadLists()
16
+
17
+ // Watch for broadcast-appended threads and re-highlight
18
+ if (this.hasThreadsTarget) {
19
+ this._threadsObserver = new MutationObserver(() => this.highlightAnchors())
20
+ this._threadsObserver.observe(this.threadsTarget, { childList: true })
21
+ }
13
22
  }
14
23
 
15
24
  disconnect() {
16
25
  this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
17
26
  document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
18
- if (this.threadListObserver) this.threadListObserver.disconnect()
27
+ window.removeEventListener("scroll", this._handleScroll)
28
+ if (this._threadsObserver) {
29
+ this._threadsObserver.disconnect()
30
+ this._threadsObserver = null
31
+ }
19
32
  }
20
33
 
21
34
  handleMouseUp(event) {
@@ -32,17 +45,33 @@ export default class extends Controller {
32
45
 
33
46
  checkSelection(event) {
34
47
  const selection = window.getSelection()
35
- const text = selection.toString().trim()
36
48
 
37
- if (text.length < 3) {
38
- this.popoverTarget.style.display = "none"
49
+ if (!selection.rangeCount) return
50
+ const range = selection.getRangeAt(0)
51
+
52
+ // Make sure at least part of the selection is within the content area.
53
+ // Whole-line selections (e.g. triple-click) can set commonAncestorContainer
54
+ // to a parent element above contentTarget, so we check start/end individually.
55
+ const startInContent = this.contentTarget.contains(range.startContainer)
56
+ const endInContent = this.contentTarget.contains(range.endContainer)
57
+ if (!startInContent) {
39
58
  return
40
59
  }
41
60
 
42
- // Make sure selection is within the content area
43
- if (!selection.rangeCount) return
44
- const range = selection.getRangeAt(0)
45
- if (!this.contentTarget.contains(range.commonAncestorContainer)) {
61
+ // Clamp the range to the last rendered markdown element, not the
62
+ // content wrapper's lastChild (which is a hidden popover/form control).
63
+ if (startInContent && !endInContent) {
64
+ const clampTarget = this.hasPopoverTarget
65
+ ? this.popoverTarget.previousElementSibling || this.popoverTarget.previousSibling
66
+ : this.contentTarget.lastChild
67
+ if (clampTarget) range.setEndAfter(clampTarget)
68
+ }
69
+
70
+ // Extract text after clamping so it only contains content-area text
71
+ const text = selection.toString().trim()
72
+
73
+ if (text.length < 3) {
74
+ this.popoverTarget.style.display = "none"
46
75
  return
47
76
  }
48
77
 
@@ -72,14 +101,9 @@ export default class extends Controller {
72
101
  : this.selectedText
73
102
  this.anchorPreviewTarget.style.display = "block"
74
103
 
75
- // Position the form in the sidebar at the same vertical level as the selection
76
- const layoutRect = this.element.getBoundingClientRect()
77
- const popoverRect = this.popoverTarget.getBoundingClientRect()
78
- const offsetTop = popoverRect.top - layoutRect.top
79
- this.formTarget.style.position = "absolute"
80
- this.formTarget.style.top = `${offsetTop}px`
81
-
82
- // Show form, hide popover
104
+ // Position form where the popover was, then show it
105
+ this.formTarget.style.top = this.popoverTarget.style.top
106
+ this.formTarget.style.left = this.popoverTarget.style.left
83
107
  this.formTarget.style.display = "block"
84
108
  this.popoverTarget.style.display = "none"
85
109
 
@@ -108,7 +132,10 @@ export default class extends Controller {
108
132
  if (event.detail.success) {
109
133
  const form = event.target
110
134
  const textarea = form.querySelector("textarea")
111
- if (textarea) textarea.value = ""
135
+ if (textarea) {
136
+ textarea.value = ""
137
+ textarea.blur()
138
+ }
112
139
  }
113
140
  }
114
141
 
@@ -129,7 +156,6 @@ export default class extends Controller {
129
156
  const anchor = event.currentTarget.dataset.anchor
130
157
  if (!anchor) return
131
158
 
132
- const context = event.currentTarget.closest("[data-anchor-context]")?.dataset.anchorContext || ""
133
159
  const occurrence = event.currentTarget.dataset.anchorOccurrence
134
160
 
135
161
  // Remove existing highlights first
@@ -140,13 +166,64 @@ export default class extends Controller {
140
166
  // Build full text for position lookups
141
167
  this.fullText = this.contentTarget.textContent
142
168
 
143
- // Find and highlight using occurrence index (most reliable), then context, then first match
144
- const highlighted = this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight--active", null)
169
+ const highlighted = this.findAndHighlight(anchor, occurrence, "anchor-highlight--active")
145
170
  if (highlighted) {
146
171
  highlighted.scrollIntoView({ behavior: "smooth", block: "center" })
147
172
  }
148
173
  }
149
174
 
175
+ openThreadPopover(event) {
176
+ const threadId = event.currentTarget.dataset.threadId
177
+ if (!threadId) return
178
+
179
+ const popover = document.getElementById(`${threadId}_popover`)
180
+ if (!popover) return
181
+
182
+ const trigger = event.currentTarget
183
+
184
+ popover.style.visibility = "hidden"
185
+ popover.showPopover()
186
+ this._positionPopoverAtMark(popover, trigger)
187
+ popover.style.visibility = "visible"
188
+
189
+ this._activeMark = trigger
190
+ this._activePopover = popover
191
+ }
192
+
193
+ _handleScroll() {
194
+ if (!this._activeMark || !this._activePopover) return
195
+ try {
196
+ if (!this._activePopover.matches(":popover-open")) {
197
+ this._activeMark = null
198
+ this._activePopover = null
199
+ return
200
+ }
201
+ } catch { return }
202
+ this._positionPopoverAtMark(this._activePopover, this._activeMark)
203
+ }
204
+
205
+ _positionPopoverAtMark(popover, mark) {
206
+ const markRect = mark.getBoundingClientRect()
207
+ const popoverRect = popover.getBoundingClientRect()
208
+ const viewportWidth = window.innerWidth
209
+ const viewportHeight = window.innerHeight
210
+
211
+ let top = markRect.top
212
+ let left = markRect.right + 12
213
+
214
+ if (left + popoverRect.width > viewportWidth - 16) {
215
+ left = markRect.left - popoverRect.width - 12
216
+ }
217
+ if (top + popoverRect.height > viewportHeight - 16) {
218
+ top = viewportHeight - popoverRect.height - 16
219
+ }
220
+ if (top < 16) top = 16
221
+ if (left < 16) left = 16
222
+
223
+ popover.style.top = `${top}px`
224
+ popover.style.left = `${left}px`
225
+ }
226
+
150
227
  extractContext(range, selectedText) {
151
228
  // Grab surrounding text for disambiguation
152
229
  const fullText = this.contentTarget.textContent
@@ -214,105 +291,66 @@ export default class extends Controller {
214
291
  })
215
292
  this.contentTarget.normalize()
216
293
 
294
+ // Clear margin dots
295
+ if (this.hasMarginTarget) {
296
+ this.marginTarget.innerHTML = ""
297
+ }
298
+
217
299
  // Build full text once for position lookups
218
300
  this.fullText = this.contentTarget.textContent
219
301
 
220
302
  const threads = this.element.querySelectorAll("[data-anchor-text]")
221
303
  threads.forEach(thread => {
222
304
  const anchor = thread.dataset.anchorText
223
- const context = thread.dataset.anchorContext
224
305
  const occurrence = thread.dataset.anchorOccurrence
306
+ const status = thread.dataset.threadStatus || "pending"
307
+ const threadId = thread.id
308
+
225
309
  if (anchor && anchor.length > 0) {
226
- this.findAndHighlightForThread(anchor, context, occurrence, "anchor-highlight", thread)
310
+ const isOpen = status === "pending" || status === "todo"
311
+ const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
312
+ const specificClass = isOpen ? `anchor-highlight--${status}` : ""
313
+ const classes = `anchor-highlight ${statusClass} ${specificClass}`.trim()
314
+ const mark = this.findAndHighlight(anchor, occurrence, classes)
315
+
316
+ if (mark && threadId) {
317
+ // Make highlight clickable to open popover
318
+ mark.dataset.threadId = threadId
319
+ mark.style.cursor = "pointer"
320
+ mark.addEventListener("click", (e) => this.openThreadPopover(e))
321
+
322
+ // Create margin dot
323
+ if (this.hasMarginTarget) {
324
+ this.createMarginDot(mark, threadId, status)
325
+ }
326
+ }
227
327
  }
228
328
  })
229
-
230
- this.positionThreads()
231
- }
232
-
233
- // Re-highlight and reposition when threads are added/removed via turbo stream broadcasts
234
- observeThreadLists() {
235
- this.threadListObserver = new MutationObserver(() => {
236
- // Debounce — multiple mutations may fire in quick succession
237
- clearTimeout(this._repositionTimer)
238
- this._repositionTimer = setTimeout(() => this.highlightAnchors(), 50)
239
- })
240
-
241
- this.element.querySelectorAll(".comment-threads-list").forEach(list => {
242
- this.threadListObserver.observe(list, { childList: true })
243
- })
244
- }
245
-
246
- repositionThreads() {
247
- // Small delay to let the tab panel become visible before measuring positions
248
- setTimeout(() => this.positionThreads(), 10)
249
329
  }
250
330
 
251
- positionThreads() {
252
- const sidebar = this.element.querySelector(".plan-layout__sidebar")
253
- if (!sidebar) return
254
-
255
- // Pause observer while reordering DOM to avoid triggering a rehighlight loop
256
- if (this.threadListObserver) this.threadListObserver.disconnect()
257
-
258
- this.positionThreadList("#comment-threads", sidebar)
259
- this.positionThreadList("#resolved-comment-threads", sidebar)
260
-
261
- // Re-observe after reordering
262
- if (this.threadListObserver) {
263
- this.element.querySelectorAll(".comment-threads-list").forEach(list => {
264
- this.threadListObserver.observe(list, { childList: true })
265
- })
266
- }
267
- }
268
-
269
- positionThreadList(selector, sidebar) {
270
- const threadList = this.element.querySelector(selector)
271
- if (!threadList) return
272
-
273
- const threads = Array.from(threadList.querySelectorAll(".comment-thread"))
274
- if (threads.length === 0) return
275
-
276
- const sidebarRect = sidebar.getBoundingClientRect()
277
-
278
- // Sort threads by their anchor's vertical position in the document
279
- threads.sort((a, b) => {
280
- const markA = a._highlightMark
281
- const markB = b._highlightMark
282
- const yA = markA ? markA.getBoundingClientRect().top : Infinity
283
- const yB = markB ? markB.getBoundingClientRect().top : Infinity
284
- return yA - yB
285
- })
286
-
287
- // Reorder DOM within this list only
288
- threads.forEach(thread => threadList.appendChild(thread))
289
-
290
- // Position threads vertically
291
- const gap = 8
292
- let cursor = 0
293
-
294
- threads.forEach(thread => {
295
- const mark = thread._highlightMark
296
- let desiredY = cursor
297
-
298
- if (mark) {
299
- desiredY = mark.getBoundingClientRect().top - sidebarRect.top + sidebar.scrollTop
300
- }
301
-
302
- const y = Math.max(desiredY, cursor)
303
- thread.style.marginTop = `${y - cursor}px`
304
- cursor = y + thread.offsetHeight + gap
305
- })
331
+ createMarginDot(highlightMark, threadId, status) {
332
+ const contentRect = this.contentTarget.getBoundingClientRect()
333
+ const markRect = highlightMark.getBoundingClientRect()
334
+ const marginRect = this.marginTarget.getBoundingClientRect()
335
+
336
+ const dot = document.createElement("button")
337
+ const isOpen = status === "pending" || status === "todo"
338
+ const openClass = isOpen ? `margin-dot--open margin-dot--${status}` : "margin-dot--resolved"
339
+ dot.className = `margin-dot ${openClass}`
340
+ dot.style.top = `${markRect.top - marginRect.top}px`
341
+ dot.dataset.threadId = threadId
342
+ dot.addEventListener("click", (e) => this.openThreadPopover(e))
343
+ dot.setAttribute("aria-label", `${status} comment`)
344
+
345
+ this.marginTarget.appendChild(dot)
306
346
  }
307
347
 
308
- // Primary highlight method: uses occurrence index (from server-side OT positions)
309
- // to find the correct Nth occurrence of anchor text in the rendered DOM.
310
- // Falls back to context matching, then to first occurrence.
311
- findAndHighlightForThread(text, context, occurrence, className, threadEl) {
348
+ // Find and highlight the Nth occurrence of text in the rendered DOM.
349
+ // Uses the occurrence index from server-side positional data.
350
+ findAndHighlight(text, occurrence, className) {
312
351
  const fullText = this.fullText
313
352
  let targetIndex = -1
314
353
 
315
- // Strategy 1: Use the occurrence index from the server (most reliable)
316
354
  if (occurrence !== undefined && occurrence !== "") {
317
355
  const occurrenceNum = parseInt(occurrence, 10)
318
356
  if (!isNaN(occurrenceNum)) {
@@ -320,27 +358,9 @@ export default class extends Controller {
320
358
  }
321
359
  }
322
360
 
323
- // Strategy 2: Fall back to context matching
324
- if (targetIndex === -1 && context && context.length > 0) {
325
- const contextIndex = fullText.indexOf(context)
326
- if (contextIndex !== -1) {
327
- targetIndex = fullText.indexOf(text, contextIndex)
328
- if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
329
- targetIndex = fullText.indexOf(text)
330
- }
331
- }
332
- }
333
-
334
- // Strategy 3: Fall back to first occurrence
335
- if (targetIndex === -1) {
336
- targetIndex = fullText.indexOf(text)
337
- }
338
-
339
361
  if (targetIndex === -1) return null
340
362
 
341
- const mark = this.highlightAtIndex(targetIndex, text.length, className)
342
- if (mark && threadEl) threadEl._highlightMark = mark
343
- return mark
363
+ return this.highlightAtIndex(targetIndex, text.length, className)
344
364
  }
345
365
 
346
366
  findNthOccurrence(text, search, n) {
@@ -352,43 +372,6 @@ export default class extends Controller {
352
372
  return pos
353
373
  }
354
374
 
355
- findAndHighlightWithContext(text, context, className) {
356
- // Use context to find the right occurrence of the anchor text
357
- const fullText = this.fullText
358
- let targetIndex
359
-
360
- if (context && context.length > 0) {
361
- const contextIndex = fullText.indexOf(context)
362
- if (contextIndex !== -1) {
363
- // Find the anchor text within the context region
364
- targetIndex = fullText.indexOf(text, contextIndex)
365
- if (targetIndex === -1 || targetIndex > contextIndex + context.length) {
366
- targetIndex = fullText.indexOf(text) // fallback
367
- }
368
- } else {
369
- targetIndex = fullText.indexOf(text)
370
- }
371
- } else {
372
- targetIndex = fullText.indexOf(text)
373
- }
374
-
375
- if (targetIndex === -1) return null
376
-
377
- // Find the thread element to store the mark reference
378
- const threads = this.element.querySelectorAll(".comment-thread[data-anchor-text]")
379
- let threadEl = null
380
- for (const t of threads) {
381
- if (t.dataset.anchorText === text && t.dataset.anchorContext === (context || "")) {
382
- threadEl = t
383
- break
384
- }
385
- }
386
-
387
- const mark = this.highlightAtIndex(targetIndex, text.length, className)
388
- if (mark && threadEl) threadEl._highlightMark = mark
389
- return mark
390
- }
391
-
392
375
  highlightAtIndex(startIndex, length, className) {
393
376
  if (startIndex < 0 || length <= 0) return null
394
377
 
@@ -433,13 +416,4 @@ export default class extends Controller {
433
416
 
434
417
  return firstHighlighted
435
418
  }
436
-
437
- // Keep legacy method for scrollToAnchor (single-use highlight)
438
- findAndHighlight(text, className) {
439
- if (!text || text.length === 0) return null
440
- const fullText = this.contentTarget.textContent
441
- const index = fullText.indexOf(text)
442
- if (index === -1) return null
443
- return this.highlightAtIndex(index, text.length, className)
444
- }
445
419
  }
@@ -46,7 +46,7 @@ module CoPlan
46
46
  plan_version: version,
47
47
  created_by_user: created_by,
48
48
  anchor_text: item[:anchor_text],
49
- status: "open"
49
+ status: "pending"
50
50
  )
51
51
 
52
52
  thread.comments.create!(
@@ -60,10 +60,10 @@ module CoPlan
60
60
  end
61
61
 
62
62
  def broadcast_new_thread(plan, thread)
63
- Broadcaster.prepend_to(
63
+ Broadcaster.append_to(
64
64
  plan,
65
- target: "comment-threads",
66
- partial: "coplan/comment_threads/thread",
65
+ target: "plan-threads",
66
+ partial: "coplan/comment_threads/thread_popover",
67
67
  locals: { thread: thread, plan: plan }
68
68
  )
69
69
  end
@@ -1,6 +1,8 @@
1
1
  module CoPlan
2
2
  class CommentThread < ApplicationRecord
3
- STATUSES = %w[open resolved accepted dismissed].freeze
3
+ STATUSES = %w[pending todo resolved discarded].freeze
4
+ OPEN_STATUSES = %w[pending todo].freeze
5
+ CLOSED_STATUSES = %w[resolved discarded].freeze
4
6
 
5
7
  attr_accessor :anchor_occurrence
6
8
 
@@ -16,10 +18,10 @@ module CoPlan
16
18
 
17
19
  before_create :resolve_anchor_position
18
20
 
19
- scope :open_threads, -> { where(status: "open") }
21
+ scope :open_threads, -> { where(status: OPEN_STATUSES) }
20
22
  scope :current, -> { where(out_of_date: false) }
21
- scope :active, -> { where(status: "open", out_of_date: false) }
22
- scope :archived, -> { where("status != 'open' OR out_of_date = ?", true) }
23
+ scope :active, -> { where(status: OPEN_STATUSES, out_of_date: false) }
24
+ scope :archived, -> { where("status NOT IN (?) OR out_of_date = ?", OPEN_STATUSES, true) }
23
25
 
24
26
  # Transforms anchor positions through intervening version edits using OT.
25
27
  # Threads without positional data (anchor_start/anchor_end/anchor_revision)
@@ -94,11 +96,15 @@ module CoPlan
94
96
  end
95
97
 
96
98
  def accept!(user)
97
- update!(status: "accepted", resolved_by_user: user)
99
+ update!(status: "todo", resolved_by_user: user)
98
100
  end
99
101
 
100
- def dismiss!(user)
101
- update!(status: "dismissed", resolved_by_user: user)
102
+ def discard!(user)
103
+ update!(status: "discarded", resolved_by_user: user)
104
+ end
105
+
106
+ def open?
107
+ OPEN_STATUSES.include?(status)
102
108
  end
103
109
 
104
110
  def anchor_valid?
@@ -12,7 +12,7 @@ module CoPlan
12
12
  record.plan.created_by_user_id == user.id
13
13
  end
14
14
 
15
- def dismiss?
15
+ def discard?
16
16
  record.plan.created_by_user_id == user.id
17
17
  end
18
18
 
@@ -21,6 +21,8 @@ module CoPlan
21
21
  apply_insert_under_heading(op, index)
22
22
  when "delete_paragraph_containing"
23
23
  apply_delete_paragraph_containing(op, index)
24
+ when "replace_section"
25
+ apply_replace_section(op, index)
24
26
  else
25
27
  raise OperationError, "Operation #{index}: unknown op '#{op["op"]}'"
26
28
  end
@@ -103,6 +105,47 @@ module CoPlan
103
105
  applied_data
104
106
  end
105
107
 
108
+ def apply_replace_section(op, index)
109
+ heading = op["heading"]
110
+ new_content = op["new_content"]
111
+
112
+ raise OperationError, "Operation #{index}: replace_section requires 'heading'" if heading.blank?
113
+ raise OperationError, "Operation #{index}: replace_section requires 'new_content'" if new_content.nil?
114
+
115
+ range = if op.key?("_pre_resolved_ranges")
116
+ op["_pre_resolved_ranges"][0]
117
+ else
118
+ Plans::PositionResolver.call(content: @content, operation: op).ranges[0]
119
+ end
120
+
121
+ # For body-only replacements (include_heading: false), ensure
122
+ # newlines separate the heading from new content and new content
123
+ # from the next section.
124
+ include_heading = op.fetch("include_heading", true)
125
+ include_heading = include_heading != false && include_heading != "false"
126
+ effective_content = new_content
127
+ if !include_heading && range[0] == range[1]
128
+ # Prepend newline if heading line doesn't end with one
129
+ if range[0] > 0 && @content[range[0] - 1] != "\n"
130
+ effective_content = "\n#{effective_content}"
131
+ end
132
+ # Append newline if next content starts without one
133
+ after = @content[range[1]]
134
+ if after && after != "\n" && !effective_content.end_with?("\n")
135
+ effective_content = "#{effective_content}\n"
136
+ end
137
+ end
138
+
139
+ @content = @content[0...range[0]] + effective_content + @content[range[1]..]
140
+
141
+ delta = effective_content.length - (range[1] - range[0])
142
+ applied_data = op.except("_pre_resolved_ranges")
143
+ applied_data["resolved_range"] = range
144
+ applied_data["new_range"] = [range[0], range[0] + effective_content.length]
145
+ applied_data["delta"] = delta
146
+ applied_data
147
+ end
148
+
106
149
  def apply_delete_paragraph_containing(op, index)
107
150
  needle = op["needle"]
108
151
 
@@ -65,7 +65,7 @@ module CoPlan
65
65
  rebased_ops = []
66
66
  @session.operations_json.each do |op_data|
67
67
  op_data = op_data.transform_keys(&:to_s)
68
- semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count]
68
+ semantic_keys = %w[op old_text new_text heading content needle occurrence replace_all count new_content include_heading]
69
69
  semantic_op = op_data.slice(*semantic_keys)
70
70
 
71
71
  if op_data["resolved_range"]
@@ -179,6 +179,31 @@ module CoPlan
179
179
 
180
180
  raise SessionConflictError,
181
181
  "Paragraph at target position no longer contains '#{op_data["needle"]}'"
182
+ when "replace_section"
183
+ return unless op_data["heading"]
184
+ include_heading = op_data.fetch("include_heading", true)
185
+ include_heading = include_heading != false && include_heading != "false"
186
+
187
+ if include_heading
188
+ first_line_end = content.index("\n", range[0]) || range[1]
189
+ first_line = content[range[0]...[first_line_end, range[1]].min]
190
+ return if first_line&.rstrip == op_data["heading"]&.rstrip
191
+
192
+ raise SessionConflictError,
193
+ "Section heading changed at target position. Expected '#{op_data["heading"]}' " \
194
+ "but found '#{first_line}'"
195
+ else
196
+ search_pos = range[0]
197
+ search_pos -= 1 while search_pos > 0 && content[search_pos - 1] == "\n"
198
+ heading_line_end = search_pos
199
+ heading_line_start = search_pos > 0 ? (content.rindex("\n", search_pos - 1) || -1) + 1 : 0
200
+ heading_text = content[heading_line_start...heading_line_end]
201
+ return if heading_text == op_data["heading"]
202
+
203
+ raise SessionConflictError,
204
+ "Section heading before target position changed. Expected '#{op_data["heading"]}' " \
205
+ "but found '#{heading_text}'"
206
+ end
182
207
  end
183
208
  end
184
209
  end