coplan-engine 0.2.0 → 1.0.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/stylesheets/coplan/application.css +350 -7
  3. data/app/channels/coplan/plan_presence_channel.rb +45 -0
  4. data/app/controllers/coplan/api/v1/comments_controller.rb +2 -2
  5. data/app/controllers/coplan/api/v1/operations_controller.rb +41 -3
  6. data/app/controllers/coplan/api/v1/plans_controller.rb +2 -2
  7. data/app/controllers/coplan/api/v1/sessions_controller.rb +2 -2
  8. data/app/controllers/coplan/plans_controller.rb +1 -0
  9. data/app/controllers/coplan/settings/tokens_controller.rb +1 -1
  10. data/app/helpers/coplan/application_helper.rb +57 -0
  11. data/app/helpers/coplan/comments_helper.rb +4 -3
  12. data/app/helpers/coplan/markdown_helper.rb +7 -1
  13. data/app/javascript/controllers/coplan/comment_nav_controller.js +126 -13
  14. data/app/javascript/controllers/coplan/content_nav_controller.js +252 -0
  15. data/app/javascript/controllers/coplan/presence_controller.js +44 -0
  16. data/app/javascript/controllers/coplan/text_selection_controller.js +203 -56
  17. data/app/models/coplan/comment.rb +4 -0
  18. data/app/models/coplan/comment_thread.rb +58 -16
  19. data/app/models/coplan/plan.rb +1 -0
  20. data/app/models/coplan/plan_viewer.rb +26 -0
  21. data/app/services/coplan/plans/apply_operations.rb +43 -0
  22. data/app/services/coplan/plans/commit_session.rb +26 -1
  23. data/app/services/coplan/plans/markdown_text_extractor.rb +142 -0
  24. data/app/services/coplan/plans/position_resolver.rb +111 -0
  25. data/app/views/coplan/comment_threads/_reply_form.html.erb +1 -1
  26. data/app/views/coplan/comment_threads/_thread_popover.html.erb +4 -4
  27. data/app/views/coplan/comments/_comment.html.erb +3 -0
  28. data/app/views/coplan/plans/_header.html.erb +1 -0
  29. data/app/views/coplan/plans/_viewers.html.erb +16 -0
  30. data/app/views/coplan/plans/show.html.erb +25 -3
  31. data/app/views/layouts/coplan/application.html.erb +2 -0
  32. data/db/migrate/20260327000000_create_coplan_plan_viewers.rb +15 -0
  33. data/lib/coplan/version.rb +1 -1
  34. metadata +8 -1
@@ -6,8 +6,14 @@ export default class extends Controller {
6
6
 
7
7
  connect() {
8
8
  this.selectedText = null
9
- this.contentTarget.addEventListener("mouseup", this.handleMouseUp.bind(this))
10
- document.addEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
9
+ this._activeMark = null
10
+ this._activePopover = null
11
+ this._boundHandleMouseUp = this.handleMouseUp.bind(this)
12
+ this._boundHandleDocumentMouseDown = this.handleDocumentMouseDown.bind(this)
13
+ this._handleScroll = this._handleScroll.bind(this)
14
+ this.contentTarget.addEventListener("mouseup", this._boundHandleMouseUp)
15
+ document.addEventListener("mousedown", this._boundHandleDocumentMouseDown)
16
+ window.addEventListener("scroll", this._handleScroll, { passive: true })
11
17
  this.highlightAnchors()
12
18
 
13
19
  // Watch for broadcast-appended threads and re-highlight
@@ -18,8 +24,9 @@ export default class extends Controller {
18
24
  }
19
25
 
20
26
  disconnect() {
21
- this.contentTarget.removeEventListener("mouseup", this.handleMouseUp.bind(this))
22
- document.removeEventListener("mousedown", this.handleDocumentMouseDown.bind(this))
27
+ this.contentTarget.removeEventListener("mouseup", this._boundHandleMouseUp)
28
+ document.removeEventListener("mousedown", this._boundHandleDocumentMouseDown)
29
+ window.removeEventListener("scroll", this._handleScroll)
23
30
  if (this._threadsObserver) {
24
31
  this._threadsObserver.disconnect()
25
32
  this._threadsObserver = null
@@ -31,6 +38,20 @@ export default class extends Controller {
31
38
  setTimeout(() => this.checkSelection(event), 10)
32
39
  }
33
40
 
41
+ dismiss(event) {
42
+ // Close the comment form if it's visible
43
+ if (this.hasFormTarget && this.formTarget.style.display === "block") {
44
+ event.preventDefault()
45
+ this.hideAndResetForm()
46
+ return
47
+ }
48
+ // Close the selection popover if it's visible
49
+ if (this.hasPopoverTarget && this.popoverTarget.style.display === "block") {
50
+ event.preventDefault()
51
+ this.popoverTarget.style.display = "none"
52
+ }
53
+ }
54
+
34
55
  handleDocumentMouseDown(event) {
35
56
  // Hide popover if clicking outside it
36
57
  if (this.hasPopoverTarget && !this.popoverTarget.contains(event.target)) {
@@ -40,17 +61,36 @@ export default class extends Controller {
40
61
 
41
62
  checkSelection(event) {
42
63
  const selection = window.getSelection()
43
- const text = selection.toString().trim()
44
64
 
45
- if (text.length < 3) {
46
- this.popoverTarget.style.display = "none"
65
+ if (!selection.rangeCount) return
66
+ const range = selection.getRangeAt(0)
67
+
68
+ // Make sure at least part of the selection is within the content area.
69
+ // Whole-line selections (e.g. triple-click) can set commonAncestorContainer
70
+ // to a parent element above contentTarget, so we check start/end individually.
71
+ const startInContent = this.contentTarget.contains(range.startContainer)
72
+ const endInContent = this.contentTarget.contains(range.endContainer)
73
+ if (!startInContent) {
47
74
  return
48
75
  }
49
76
 
50
- // Make sure selection is within the content area
51
- if (!selection.rangeCount) return
52
- const range = selection.getRangeAt(0)
53
- if (!this.contentTarget.contains(range.commonAncestorContainer)) {
77
+ // Clamp the range to the last rendered markdown element, not the
78
+ // content wrapper's lastChild (which is a hidden popover/form control).
79
+ if (startInContent && !endInContent) {
80
+ const clampTarget = this.hasPopoverTarget
81
+ ? this.popoverTarget.previousElementSibling || this.popoverTarget.previousSibling
82
+ : this.contentTarget.lastChild
83
+ if (clampTarget) range.setEndAfter(clampTarget)
84
+ }
85
+
86
+ // Extract text after clamping so it only contains content-area text.
87
+ // Normalize tabs to spaces — browser selections across table cells
88
+ // produce tab-separated text, but the server matches against
89
+ // space-separated plain text extracted from the markdown AST.
90
+ const text = selection.toString().replace(/\t/g, " ").trim()
91
+
92
+ if (text.length < 3) {
93
+ this.popoverTarget.style.display = "none"
54
94
  return
55
95
  }
56
96
 
@@ -111,7 +151,10 @@ export default class extends Controller {
111
151
  if (event.detail.success) {
112
152
  const form = event.target
113
153
  const textarea = form.querySelector("textarea")
114
- if (textarea) textarea.value = ""
154
+ if (textarea) {
155
+ textarea.value = ""
156
+ textarea.blur()
157
+ }
115
158
  }
116
159
  }
117
160
 
@@ -155,40 +198,49 @@ export default class extends Controller {
155
198
  const popover = document.getElementById(`${threadId}_popover`)
156
199
  if (!popover) return
157
200
 
158
- // Position the popover near the clicked element
159
201
  const trigger = event.currentTarget
160
- const triggerRect = trigger.getBoundingClientRect()
161
202
 
162
- // Hide visually while positioning to prevent flash
163
203
  popover.style.visibility = "hidden"
164
204
  popover.showPopover()
205
+ this._positionPopoverAtMark(popover, trigger)
206
+ popover.style.visibility = "visible"
207
+
208
+ this._activeMark = trigger
209
+ this._activePopover = popover
210
+ }
211
+
212
+ _handleScroll() {
213
+ if (!this._activeMark || !this._activePopover) return
214
+ try {
215
+ if (!this._activePopover.matches(":popover-open")) {
216
+ this._activeMark = null
217
+ this._activePopover = null
218
+ return
219
+ }
220
+ } catch { return }
221
+ this._positionPopoverAtMark(this._activePopover, this._activeMark)
222
+ }
165
223
 
166
- // Position after showing (popover needs to be in top layer first)
224
+ _positionPopoverAtMark(popover, mark) {
225
+ const markRect = mark.getBoundingClientRect()
167
226
  const popoverRect = popover.getBoundingClientRect()
168
227
  const viewportWidth = window.innerWidth
169
228
  const viewportHeight = window.innerHeight
170
229
 
171
- // Default: right of the content area, aligned with the trigger
172
- let top = triggerRect.top
173
- let left = triggerRect.right + 12
230
+ let top = markRect.top
231
+ let left = markRect.right + 12
174
232
 
175
- // If it would overflow right, position to the left
176
233
  if (left + popoverRect.width > viewportWidth - 16) {
177
- left = triggerRect.left - popoverRect.width - 12
234
+ left = markRect.left - popoverRect.width - 12
178
235
  }
179
-
180
- // If it would overflow bottom, shift up
181
236
  if (top + popoverRect.height > viewportHeight - 16) {
182
237
  top = viewportHeight - popoverRect.height - 16
183
238
  }
184
-
185
- // Ensure it doesn't go outside viewport
186
239
  if (top < 16) top = 16
187
240
  if (left < 16) left = 16
188
241
 
189
242
  popover.style.top = `${top}px`
190
243
  popover.style.left = `${left}px`
191
- popover.style.visibility = "visible"
192
244
  }
193
245
 
194
246
  extractContext(range, selectedText) {
@@ -218,15 +270,22 @@ export default class extends Controller {
218
270
 
219
271
  // Computes the 1-based occurrence number of the selected text in the DOM content.
220
272
  // This is sent to the server so resolve_anchor_position picks the right match.
273
+ // Uses whitespace-normalized matching for consistency with findAndHighlight.
221
274
  computeOccurrence(range, text) {
222
275
  const offset = this.getSelectionOffset(range)
223
276
  const fullText = this.contentTarget.textContent
277
+ const { normText, origIndices } = this._buildNormalizedMap(fullText)
278
+ const normSearch = this._normalizeWhitespace(text)
279
+
280
+ // Map the DOM offset to the normalized string offset
281
+ let normOffset = origIndices.findIndex(orig => orig >= offset)
282
+ if (normOffset === -1) normOffset = normText.length
224
283
 
225
284
  let count = 0
226
285
  let pos = -1
227
- while ((pos = fullText.indexOf(text, pos + 1)) !== -1) {
286
+ while ((pos = normText.indexOf(normSearch, pos + 1)) !== -1) {
228
287
  count++
229
- if (pos >= offset) return count
288
+ if (pos >= normOffset) return count
230
289
  }
231
290
  return count > 0 ? count : 1
232
291
  }
@@ -276,21 +335,31 @@ export default class extends Controller {
276
335
  if (anchor && anchor.length > 0) {
277
336
  const isOpen = status === "pending" || status === "todo"
278
337
  const statusClass = isOpen ? "anchor-highlight--open" : "anchor-highlight--resolved"
279
- const mark = this.findAndHighlight(anchor, occurrence, `anchor-highlight ${statusClass}`)
280
-
281
- if (mark && threadId) {
282
- // Make highlight clickable to open popover
283
- mark.dataset.threadId = threadId
284
- mark.style.cursor = "pointer"
285
- mark.addEventListener("click", (e) => this.openThreadPopover(e))
286
-
287
- // Create margin dot
338
+ const specificClass = isOpen ? `anchor-highlight--${status}` : ""
339
+ const classes = `anchor-highlight ${statusClass} ${specificClass}`.trim()
340
+ const marks = this.findAndHighlightAll(anchor, occurrence, classes)
341
+
342
+ if (marks.length > 0 && threadId) {
343
+ // Make all highlight marks clickable to open popover.
344
+ // If a mark is shared with another thread, keep the first
345
+ // thread's binding — the margin dot still provides access.
346
+ marks.forEach(mark => {
347
+ if (!mark.dataset.threadId) {
348
+ mark.dataset.threadId = threadId
349
+ mark.style.cursor = "pointer"
350
+ mark.addEventListener("click", (e) => this.openThreadPopover(e))
351
+ }
352
+ })
353
+
354
+ // Create margin dot aligned to the first mark
288
355
  if (this.hasMarginTarget) {
289
- this.createMarginDot(mark, threadId, status)
356
+ this.createMarginDot(marks[0], threadId, status)
290
357
  }
291
358
  }
292
359
  }
293
360
  })
361
+
362
+ this.element.dispatchEvent(new CustomEvent("coplan:anchors-updated", { bubbles: true }))
294
363
  }
295
364
 
296
365
  createMarginDot(highlightMark, threadId, status) {
@@ -300,44 +369,106 @@ export default class extends Controller {
300
369
 
301
370
  const dot = document.createElement("button")
302
371
  const isOpen = status === "pending" || status === "todo"
303
- dot.className = `margin-dot margin-dot--${isOpen ? "open" : "resolved"}`
372
+ const openClass = isOpen ? `margin-dot--open margin-dot--${status}` : "margin-dot--resolved"
373
+ dot.className = `margin-dot ${openClass}`
304
374
  dot.style.top = `${markRect.top - marginRect.top}px`
305
375
  dot.dataset.threadId = threadId
306
376
  dot.addEventListener("click", (e) => this.openThreadPopover(e))
307
- dot.title = `${status} comment`
377
+ dot.setAttribute("aria-label", `${status} comment`)
308
378
 
309
379
  this.marginTarget.appendChild(dot)
310
380
  }
311
381
 
312
382
  // Find and highlight the Nth occurrence of text in the rendered DOM.
313
383
  // Uses the occurrence index from server-side positional data.
384
+ // Performs whitespace-normalized matching so that anchor text captured
385
+ // from browser selections (which may differ in whitespace from the DOM
386
+ // textContent, e.g. tabs in table selections) can still be located.
314
387
  findAndHighlight(text, occurrence, className) {
315
388
  const fullText = this.fullText
316
- let targetIndex = -1
317
389
 
318
- if (occurrence !== undefined && occurrence !== "") {
319
- const occurrenceNum = parseInt(occurrence, 10)
320
- if (!isNaN(occurrenceNum)) {
321
- targetIndex = this.findNthOccurrence(fullText, text, occurrenceNum)
390
+ if (occurrence === undefined || occurrence === "") return null
391
+
392
+ const occurrenceNum = parseInt(occurrence, 10)
393
+ if (isNaN(occurrenceNum)) return null
394
+
395
+ const match = this._findNthNormalized(fullText, text, occurrenceNum)
396
+ if (!match) return null
397
+
398
+ return this.highlightAtIndex(match.startIndex, match.matchLength, className)
399
+ }
400
+
401
+ // Like findAndHighlight but returns all created/reused marks (for multi-cell spans).
402
+ findAndHighlightAll(text, occurrence, className) {
403
+ const fullText = this.fullText
404
+
405
+ if (occurrence === undefined || occurrence === "") return []
406
+
407
+ const occurrenceNum = parseInt(occurrence, 10)
408
+ if (isNaN(occurrenceNum)) return []
409
+
410
+ const match = this._findNthNormalized(fullText, text, occurrenceNum)
411
+ if (!match) return []
412
+
413
+ return this.highlightAtIndexAll(match.startIndex, match.matchLength, className)
414
+ }
415
+
416
+ // Collapses runs of whitespace (spaces, tabs, newlines) into single spaces.
417
+ _normalizeWhitespace(str) {
418
+ return str.replace(/\s+/g, " ")
419
+ }
420
+
421
+ // Builds a whitespace-normalized version of `text` with a parallel array
422
+ // mapping each normalized position back to its original index.
423
+ // Returns { normText, origIndices } where origIndices[i] is the original
424
+ // index of the character at normalized position i.
425
+ _buildNormalizedMap(text) {
426
+ let normText = ""
427
+ const origIndices = []
428
+ let inWhitespace = false
429
+
430
+ for (let i = 0; i < text.length; i++) {
431
+ if (/\s/.test(text[i])) {
432
+ if (!inWhitespace) {
433
+ normText += " "
434
+ origIndices.push(i)
435
+ inWhitespace = true
436
+ }
437
+ } else {
438
+ normText += text[i]
439
+ origIndices.push(i)
440
+ inWhitespace = false
322
441
  }
323
442
  }
324
443
 
325
- if (targetIndex === -1) return null
326
-
327
- return this.highlightAtIndex(targetIndex, text.length, className)
444
+ return { normText, origIndices }
328
445
  }
329
446
 
330
- findNthOccurrence(text, search, n) {
447
+ // Finds the Nth occurrence of `search` in `text` using whitespace-normalized
448
+ // matching. Returns { startIndex, matchLength } in the *original* text,
449
+ // or null if not found.
450
+ _findNthNormalized(text, search, n) {
451
+ const { normText, origIndices } = this._buildNormalizedMap(text)
452
+ const normSearch = this._normalizeWhitespace(search)
453
+
331
454
  let pos = -1
332
455
  for (let i = 0; i <= n; i++) {
333
- pos = text.indexOf(search, pos + 1)
334
- if (pos === -1) return -1
456
+ pos = normText.indexOf(normSearch, pos + 1)
457
+ if (pos === -1) return null
335
458
  }
336
- return pos
459
+
460
+ const origStart = origIndices[pos]
461
+ const origEnd = origIndices[pos + normSearch.length - 1] + 1
462
+ return { startIndex: origStart, matchLength: origEnd - origStart }
337
463
  }
338
464
 
339
465
  highlightAtIndex(startIndex, length, className) {
340
- if (startIndex < 0 || length <= 0) return null
466
+ const marks = this.highlightAtIndexAll(startIndex, length, className)
467
+ return marks[0] || null
468
+ }
469
+
470
+ highlightAtIndexAll(startIndex, length, className) {
471
+ if (startIndex < 0 || length <= 0) return []
341
472
 
342
473
  const walker = document.createTreeWalker(
343
474
  this.contentTarget,
@@ -355,7 +486,7 @@ export default class extends Controller {
355
486
  }
356
487
 
357
488
  const matchEnd = startIndex + length
358
- let firstHighlighted = null
489
+ const marks = []
359
490
 
360
491
  for (let i = 0; i < textNodes.length; i++) {
361
492
  const tn = textNodes[i]
@@ -364,9 +495,25 @@ export default class extends Controller {
364
495
  if (nodeEnd <= startIndex) continue
365
496
  if (tn.start >= matchEnd) break
366
497
 
498
+ // Skip structural whitespace text nodes inside table elements —
499
+ // wrapping these in <mark> produces invalid HTML and breaks table layout.
500
+ const parentTag = tn.node.parentElement?.tagName
501
+ if (parentTag && /^(TABLE|THEAD|TBODY|TFOOT|TR)$/.test(parentTag)) continue
502
+
367
503
  const localStart = Math.max(0, startIndex - tn.start)
368
504
  const localEnd = Math.min(tn.node.textContent.length, matchEnd - tn.start)
369
505
 
506
+ // Skip zero-length ranges (e.g. from text node splits by prior highlights)
507
+ if (localEnd <= localStart) continue
508
+
509
+ // If the text is already inside a highlight mark (from another thread
510
+ // anchored to the same text), reuse that mark instead of nesting.
511
+ const existingMark = tn.node.parentElement?.closest("mark.anchor-highlight")
512
+ if (existingMark) {
513
+ if (!marks.includes(existingMark)) marks.push(existingMark)
514
+ continue
515
+ }
516
+
370
517
  const range = document.createRange()
371
518
  range.setStart(tn.node, localStart)
372
519
  range.setEnd(tn.node, localEnd)
@@ -375,9 +522,9 @@ export default class extends Controller {
375
522
  mark.className = className
376
523
  range.surroundContents(mark)
377
524
 
378
- if (!firstHighlighted) firstHighlighted = mark
525
+ marks.push(mark)
379
526
  }
380
527
 
381
- return firstHighlighted
528
+ return marks
382
529
  }
383
530
  }
@@ -11,6 +11,10 @@ module CoPlan
11
11
 
12
12
  after_create_commit :notify_plan_author, if: :first_comment_in_thread?
13
13
 
14
+ def agent?
15
+ agent_name.present? || author_type.in?(%w[local_agent cloud_persona])
16
+ end
17
+
14
18
  private
15
19
 
16
20
  def first_comment_in_thread?
@@ -112,23 +112,25 @@ module CoPlan
112
112
  !out_of_date
113
113
  end
114
114
 
115
- # Returns the 0-based occurrence index of anchor_text in the raw markdown,
116
- # computed from anchor_start. The frontend uses this to find the correct
117
- # occurrence in the rendered DOM text instead of relying on context matching.
115
+ # Returns the 0-based occurrence index of anchor_text in the rendered
116
+ # (stripped) content, computed from anchor_start. The frontend uses this
117
+ # to find the correct occurrence in the rendered DOM text.
118
118
  def anchor_occurrence_index
119
119
  return nil unless anchored? && anchor_start.present?
120
120
 
121
121
  content = plan.current_content
122
122
  return nil unless content.present?
123
123
 
124
- count = 0
125
- pos = 0
126
- while (idx = content.index(anchor_text, pos))
127
- break if idx >= anchor_start
128
- count += 1
129
- pos = idx + 1
130
- end
131
- count
124
+ stripped, pos_map = self.class.strip_markdown(content)
125
+ # Map raw anchor_start to its position in the stripped string.
126
+ # Use >= to find the closest valid position if anchor_start falls
127
+ # on a stripped formatting character.
128
+ stripped_start = pos_map.index { |raw_idx| raw_idx >= anchor_start }
129
+ return nil if stripped_start.nil?
130
+
131
+ normalized_anchor = anchor_text.gsub("\t", " ")
132
+ ranges = find_all_occurrences(stripped, normalized_anchor)
133
+ ranges.index { |s, _| s >= stripped_start } || 0
132
134
  end
133
135
 
134
136
  def anchor_context_with_highlight(chars: 100)
@@ -147,6 +149,10 @@ module CoPlan
147
149
  "#{before}**#{anchor}**#{after}"
148
150
  end
149
151
 
152
+ def self.strip_markdown(content)
153
+ Plans::MarkdownTextExtractor.call(content)
154
+ end
155
+
150
156
  private
151
157
 
152
158
  def resolve_anchor_position
@@ -158,11 +164,26 @@ module CoPlan
158
164
  occurrence = self.anchor_occurrence || 1
159
165
  return if occurrence < 1
160
166
 
161
- ranges = []
162
- start_pos = 0
163
- while (idx = content.index(anchor_text, start_pos))
164
- ranges << [idx, idx + anchor_text.length]
165
- start_pos = idx + anchor_text.length
167
+ # First, try an exact match against the raw markdown.
168
+ ranges = find_all_occurrences(content, anchor_text)
169
+
170
+ # If no exact match, the selected text may span markdown formatting
171
+ # (e.g. DOM text "Hello me you" vs raw "Hello `me` you", or table
172
+ # cell text without pipe delimiters). Parse the markdown AST to
173
+ # extract plain text with source position mapping.
174
+ if ranges.empty?
175
+ stripped, pos_map = self.class.strip_markdown(content)
176
+ # Normalize tabs to spaces — browser selections across table cells
177
+ # produce tab-separated text, but the stripped markdown uses spaces.
178
+ normalized_anchor = anchor_text.gsub("\t", " ")
179
+ stripped_ranges = find_all_occurrences(stripped, normalized_anchor)
180
+
181
+ ranges = stripped_ranges.map do |s, e|
182
+ raw_start = first_real_pos(pos_map, s, :forward)
183
+ raw_end = first_real_pos(pos_map, e - 1, :backward)
184
+ next nil unless raw_start && raw_end
185
+ [raw_start, raw_end + 1]
186
+ end.compact
166
187
  end
167
188
 
168
189
  if ranges.length >= occurrence
@@ -172,5 +193,26 @@ module CoPlan
172
193
  self.anchor_revision = plan.current_revision
173
194
  end
174
195
  end
196
+
197
+ # Finds the nearest non-sentinel (-1) position in the pos_map,
198
+ # scanning forward or backward from the given index.
199
+ def first_real_pos(pos_map, idx, direction)
200
+ step = direction == :forward ? 1 : -1
201
+ while idx >= 0 && idx < pos_map.length
202
+ return pos_map[idx] if pos_map[idx] >= 0
203
+ idx += step
204
+ end
205
+ nil
206
+ end
207
+
208
+ def find_all_occurrences(text, search)
209
+ ranges = []
210
+ start_pos = 0
211
+ while (idx = text.index(search, start_pos))
212
+ ranges << [idx, idx + search.length]
213
+ start_pos = idx + search.length
214
+ end
215
+ ranges
216
+ end
175
217
  end
176
218
  end
@@ -10,6 +10,7 @@ module CoPlan
10
10
  has_many :comment_threads, dependent: :destroy
11
11
  has_many :edit_sessions, dependent: :destroy
12
12
  has_one :edit_lease, dependent: :destroy
13
+ has_many :plan_viewers, dependent: :destroy
13
14
 
14
15
  after_initialize { self.tags ||= [] }
15
16
  after_initialize { self.metadata ||= {} }
@@ -0,0 +1,26 @@
1
+ module CoPlan
2
+ class PlanViewer < ApplicationRecord
3
+ STALE_THRESHOLD = 45.seconds
4
+
5
+ belongs_to :plan
6
+ belongs_to :user, class_name: "CoPlan::User"
7
+
8
+ scope :active, -> { where(last_seen_at: STALE_THRESHOLD.ago..) }
9
+
10
+ def self.track(plan:, user:)
11
+ record = find_or_initialize_by(plan: plan, user: user)
12
+ record.update!(last_seen_at: Time.current)
13
+ record
14
+ rescue ActiveRecord::RecordNotUnique
15
+ retry
16
+ end
17
+
18
+ def self.expire(plan:, user:)
19
+ where(plan: plan, user: user).update_all(last_seen_at: STALE_THRESHOLD.ago - 1.second)
20
+ end
21
+
22
+ def self.active_viewers_for(plan)
23
+ active.where(plan: plan).joins(:user).includes(:user).order("coplan_users.name").map(&:user)
24
+ end
25
+ end
26
+ end
@@ -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