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
@@ -20,6 +20,8 @@ module CoPlan
20
20
  resolve_insert_under_heading
21
21
  when "delete_paragraph_containing"
22
22
  resolve_delete_paragraph_containing
23
+ when "replace_section"
24
+ resolve_replace_section
23
25
  else
24
26
  raise OperationError, "Unknown operation: #{@op["op"]}"
25
27
  end
@@ -153,6 +155,115 @@ module CoPlan
153
155
  paragraphs
154
156
  end
155
157
 
158
+ def resolve_replace_section
159
+ heading = @op["heading"]
160
+ raise OperationError, "replace_section requires 'heading'" if heading.blank?
161
+ raise OperationError, "replace_section requires 'new_content'" if @op["new_content"].nil?
162
+
163
+ include_heading = @op.fetch("include_heading", true)
164
+ # Normalize: accept both string and boolean
165
+ include_heading = include_heading != false && include_heading != "false"
166
+
167
+ headings = parse_headings(@content)
168
+ matches = headings.select { |h| h[:text] == heading }
169
+
170
+ if matches.empty?
171
+ raise OperationError, "replace_section: heading_not_found — no heading matching '#{heading}'"
172
+ end
173
+
174
+ if matches.length > 1
175
+ match_details = matches.map { |m| { heading: m[:text], line: m[:line] } }
176
+ raise OperationError, "replace_section: ambiguous_heading — found #{matches.length} headings matching '#{heading}': #{match_details.inspect}"
177
+ end
178
+
179
+ match = matches.first
180
+ target_level = match[:level]
181
+
182
+ # Section starts at the heading line start
183
+ section_start = match[:line_start]
184
+
185
+ # Section ends at the next heading of equal or higher level, or EOF
186
+ next_heading = headings.find { |h| h[:line_start] > match[:line_start] && h[:level] <= target_level }
187
+ section_end = next_heading ? next_heading[:line_start] : @content.length
188
+
189
+ # Strip all trailing newlines from the section range so the separator
190
+ # between sections falls outside the replaced range. This ensures
191
+ # replacement content won't merge into the next heading.
192
+ section_end = section_end.to_i
193
+ while section_end > section_start && @content[section_end - 1] == "\n"
194
+ section_end -= 1
195
+ end
196
+
197
+ range = if include_heading
198
+ [section_start, section_end]
199
+ else
200
+ # Skip past the heading line itself
201
+ heading_line_end = @content.index("\n", section_start)
202
+ if heading_line_end
203
+ body_start = heading_line_end + 1
204
+ # Skip blank line after heading
205
+ while body_start < section_end && @content[body_start] == "\n"
206
+ body_start += 1
207
+ end
208
+ # When trailing newlines are stripped, section_end can retreat
209
+ # behind body_start. Use an empty range at body_start to avoid
210
+ # an inverted range and keep the insertion point after the heading newline.
211
+ [body_start, [body_start, section_end].max]
212
+ else
213
+ # Heading is the only line — body is empty
214
+ [section_end, section_end]
215
+ end
216
+ end
217
+
218
+ Resolution.new(op: "replace_section", ranges: [range])
219
+ end
220
+
221
+ # Parse markdown headings, respecting code fences (``` blocks).
222
+ # Returns an array of hashes: { text:, level:, line:, line_start:, line_end: }
223
+ def parse_headings(content)
224
+ headings = []
225
+ in_code_fence = false
226
+ fence_char = nil
227
+ fence_length = 0
228
+ line_number = 0
229
+ pos = 0
230
+
231
+ content.each_line do |line|
232
+ line_number += 1
233
+ line_start = pos
234
+ line_end = pos + line.length
235
+ stripped = line.chomp
236
+
237
+ fence_match = stripped.match(/\A(`{3,}|~{3,})(.*)\z/)
238
+ if fence_match
239
+ fence_chars = fence_match[1]
240
+ info_string = fence_match[2]
241
+ if in_code_fence
242
+ # Close only if fence char and length match, and no info string
243
+ if fence_chars[0] == fence_char && fence_chars.length >= fence_length && info_string.strip.empty?
244
+ in_code_fence = false
245
+ end
246
+ else
247
+ in_code_fence = true
248
+ fence_char = fence_chars[0]
249
+ fence_length = fence_chars.length
250
+ end
251
+ elsif !in_code_fence && (m = stripped.match(/\A(\#{1,6})\s+(.+)/))
252
+ headings << {
253
+ level: m[1].length,
254
+ text: stripped,
255
+ line: line_number,
256
+ line_start: line_start,
257
+ line_end: line_end
258
+ }
259
+ end
260
+
261
+ pos = line_end
262
+ end
263
+
264
+ headings
265
+ end
266
+
156
267
  # Determine the character range to delete so that removing
157
268
  # content[range[0]...range[1]] produces clean output with
158
269
  # correct paragraph spacing.
@@ -8,7 +8,8 @@
8
8
  <blockquote class="comment-form__quote" data-coplan--text-selection-target="anchorQuote"></blockquote>
9
9
  </div>
10
10
  <div class="form-group">
11
- <textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required></textarea>
11
+ <textarea name="comment_thread[body_markdown]" id="comment_thread_body_markdown" rows="3" placeholder="Write a comment..." required
12
+ data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
12
13
  </div>
13
14
  <div class="comment-form__actions">
14
15
  <button type="submit" class="btn btn--primary btn--sm">Comment</button>
@@ -1,7 +1,8 @@
1
1
  <div class="comment-thread__reply" id="<%= dom_id(thread, :reply_form) %>">
2
2
  <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
3
3
  <div class="form-group">
4
- <textarea name="comment[body_markdown]" rows="2" placeholder="Reply..." required></textarea>
4
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
5
+ data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
5
6
  </div>
6
7
  <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
7
8
  <% end %>
@@ -5,7 +5,7 @@
5
5
  data-thread-id="<%= thread.id %>">
6
6
  <div class="comment-thread__header">
7
7
  <div class="comment-thread__meta">
8
- <span class="badge badge--<%= thread.status == 'open' ? 'live' : 'abandoned' %>"><%= thread.status %></span>
8
+ <span class="badge badge--<%= thread.open? ? 'live' : 'abandoned' %>"><%= thread.status %></span>
9
9
  <% if thread.out_of_date? %>
10
10
  <span class="badge badge--abandoned">out of date</span>
11
11
  <% end %>
@@ -26,16 +26,13 @@
26
26
  <% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer.id %>
27
27
  <% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer.id %>
28
28
 
29
- <% if thread.status == "open" %>
29
+ <% if thread.open? %>
30
30
  <%= render partial: "coplan/comment_threads/reply_form", locals: { thread: thread, plan: plan } %>
31
31
 
32
32
  <div class="comment-thread__actions">
33
- <% if is_plan_author || is_thread_author %>
34
- <%= link_to "Resolve", resolve_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
35
- <% end %>
36
33
  <% if is_plan_author %>
37
34
  <%= link_to "Accept", accept_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
38
- <%= link_to "Dismiss", dismiss_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
35
+ <%= link_to "Discard", discard_plan_comment_thread_path(plan, thread), data: { turbo_method: :patch }, class: "btn btn--secondary btn--sm" %>
39
36
  <% end %>
40
37
  </div>
41
38
  <% else %>
@@ -0,0 +1,58 @@
1
+ <div class="thread-popover-data"
2
+ id="<%= dom_id(thread) %>"
3
+ data-anchor-text="<%= thread.anchor_text %>"
4
+ data-anchor-occurrence="<%= thread.anchor_occurrence_index %>"
5
+ data-thread-id="<%= thread.id %>"
6
+ data-thread-status="<%= thread.status %>">
7
+
8
+ <div popover="auto" id="<%= dom_id(thread) %>_popover" class="thread-popover">
9
+ <div class="thread-popover__header">
10
+ <span class="badge badge--<%= thread.status %>"><%= thread.status %></span>
11
+ <% if thread.out_of_date? %>
12
+ <span class="badge badge--abandoned">out of date</span>
13
+ <% end %>
14
+ </div>
15
+
16
+ <% if thread.anchored? %>
17
+ <blockquote class="thread-popover__quote"><%= thread.anchor_preview %></blockquote>
18
+ <% end %>
19
+
20
+ <div class="thread-popover__comments" id="<%= dom_id(thread, :comments) %>">
21
+ <% thread.comments.each do |comment| %>
22
+ <%= render partial: "coplan/comments/comment", locals: { comment: comment } %>
23
+ <% end %>
24
+ </div>
25
+
26
+ <%# current_user may not be available during Turbo Stream broadcasts %>
27
+ <% viewer = local_assigns.fetch(:current_user, nil) %>
28
+ <% is_plan_author = viewer.nil? || plan.created_by_user_id == viewer&.id %>
29
+ <% is_thread_author = viewer.nil? || thread.created_by_user_id == viewer&.id %>
30
+
31
+ <% if thread.open? %>
32
+ <div class="thread-popover__reply">
33
+ <%= form_with url: plan_comment_thread_comments_path(plan, thread), method: :post, data: { action: "turbo:submit-end->coplan--text-selection#resetReplyForm" } do |f| %>
34
+ <div class="form-group">
35
+ <textarea name="comment[body_markdown]" rows="2" placeholder="Press r to reply" required
36
+ data-controller="coplan--comment-form" data-action="keydown->coplan--comment-form#submitOnEnter"></textarea>
37
+ </div>
38
+ <button type="submit" class="btn btn--secondary btn--sm">Reply</button>
39
+ <% end %>
40
+ </div>
41
+
42
+ <div class="thread-popover__actions">
43
+ <% if is_plan_author %>
44
+ <% if thread.status == "pending" %>
45
+ <%= button_to "Accept (a)", accept_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "accept" } %>
46
+ <% end %>
47
+ <%= button_to "Discard (d)", discard_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm", form: { "data-action-name": "discard" } %>
48
+ <% end %>
49
+ </div>
50
+ <% else %>
51
+ <% if is_plan_author || is_thread_author %>
52
+ <div class="thread-popover__actions">
53
+ <%= button_to "Reopen", reopen_plan_comment_thread_path(plan, thread), method: :patch, class: "btn btn--secondary btn--sm" %>
54
+ </div>
55
+ <% end %>
56
+ <% end %>
57
+ </div>
58
+ </div>
@@ -5,6 +5,7 @@
5
5
  <div class="plan-content card">
6
6
  <% if @plan.current_content.present? %>
7
7
  <div class="plan-layout" data-controller="coplan--text-selection" data-coplan--text-selection-plan-id-value="<%= @plan.id %>">
8
+ <div class="plan-layout__margin" data-coplan--text-selection-target="margin"></div>
8
9
  <div class="plan-layout__content" data-coplan--text-selection-target="content">
9
10
  <%= render_markdown(@plan.current_content) %>
10
11
 
@@ -13,39 +14,14 @@
13
14
  💬 Comment
14
15
  </button>
15
16
  </div>
16
- </div>
17
17
 
18
- <div class="plan-layout__sidebar">
19
18
  <%= render partial: "coplan/comment_threads/new_comment_form", locals: { plan: @plan } %>
19
+ </div>
20
20
 
21
- <div class="comment-tabs" data-controller="coplan--tabs" data-coplan--tabs-active-class="comment-tab--active">
22
- <div class="comment-tabs__nav">
23
- <button class="comment-tab comment-tab--active" data-coplan--tabs-target="tab" data-action="coplan--tabs#switch coplan--text-selection#repositionThreads" data-index="0">
24
- Open <span class="comment-tab__count" id="open-thread-count"><%= @active_threads.size if @active_threads.any? %></span>
25
- </button>
26
- <button class="comment-tab" data-coplan--tabs-target="tab" data-action="coplan--tabs#switch coplan--text-selection#repositionThreads" data-index="1">
27
- Resolved <span class="comment-tab__count" id="resolved-thread-count"><%= @archived_threads.size if @archived_threads.any? %></span>
28
- </button>
29
- </div>
30
-
31
- <div class="comment-tabs__panel" data-coplan--tabs-target="panel">
32
- <div class="comment-threads-list" id="comment-threads">
33
- <% @active_threads.each do |thread| %>
34
- <%= render partial: "coplan/comment_threads/thread", locals: { thread: thread, plan: @plan, current_user: current_user } %>
35
- <% end %>
36
- <p class="text-sm text-muted" id="open-threads-empty" <% if @active_threads.any? %>style="display: none;"<% end %>>No open comments.</p>
37
- </div>
38
- </div>
39
-
40
- <div class="comment-tabs__panel" data-coplan--tabs-target="panel" style="display: none;">
41
- <div class="comment-threads-list" id="resolved-comment-threads">
42
- <% @archived_threads.each do |thread| %>
43
- <%= render partial: "coplan/comment_threads/thread", locals: { thread: thread, plan: @plan, current_user: current_user } %>
44
- <% end %>
45
- <p class="text-sm text-muted" id="resolved-threads-empty" <% if @archived_threads.any? %>style="display: none;"<% end %>>No resolved comments.</p>
46
- </div>
47
- </div>
48
- </div>
21
+ <div id="plan-threads" data-coplan--text-selection-target="threads">
22
+ <% @threads.select(&:anchored?).each do |thread| %>
23
+ <%= render partial: "coplan/comment_threads/thread_popover", locals: { thread: thread, plan: @plan, current_user: current_user } %>
24
+ <% end %>
49
25
  </div>
50
26
  </div>
51
27
  <% else %>
@@ -54,3 +30,19 @@
54
30
  </div>
55
31
  <% end %>
56
32
  </div>
33
+
34
+ <% open_count = @threads.count(&:open?) %>
35
+ <% if @threads.any? %>
36
+ <div class="comment-toolbar" data-controller="coplan--comment-nav" data-coplan--comment-nav-plan-id-value="<%= @plan.id %>">
37
+ <span class="comment-toolbar__count">💬 <%= open_count %> open</span>
38
+ <div class="comment-toolbar__nav">
39
+ <button class="btn btn--secondary btn--sm" data-action="coplan--comment-nav#prev" title="Previous comment">↑</button>
40
+ <span class="comment-toolbar__position" data-coplan--comment-nav-target="position"></span>
41
+ <button class="btn btn--secondary btn--sm" data-action="coplan--comment-nav#next" title="Next comment">↓</button>
42
+ </div>
43
+ <label class="comment-toolbar__toggle">
44
+ <input type="checkbox" data-action="coplan--comment-nav#toggleResolved" data-coplan--comment-nav-target="resolvedToggle">
45
+ Show resolved (<%= @threads.count { |t| !t.open? } %>)
46
+ </label>
47
+ </div>
48
+ <% end %>
@@ -5,7 +5,11 @@
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
6
  <%= csrf_meta_tags %>
7
7
  <%= csp_meta_tag %>
8
+ <%= coplan_favicon_tag %>
8
9
  <%= yield :head %>
10
+ <link rel="preconnect" href="https://fonts.googleapis.com">
11
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
12
+ <link href="https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap" rel="stylesheet">
9
13
  <%= stylesheet_link_tag "coplan/application", "data-turbo-track": "reload" %>
10
14
  <%= javascript_importmap_tags %>
11
15
  </head>
@@ -15,6 +19,7 @@
15
19
  <%= link_to coplan.root_path, class: "site-nav__brand" do %>
16
20
  <%= image_tag "coplan/coplan-logo-sm.png", alt: "CoPlan", class: "site-nav__logo" %>
17
21
  CoPlan
22
+ <%= coplan_environment_badge %>
18
23
  <% end %>
19
24
  <ul class="site-nav__links">
20
25
  <% if signed_in? %>
data/config/routes.rb CHANGED
@@ -7,7 +7,7 @@ CoPlan::Engine.routes.draw do
7
7
  member do
8
8
  patch :resolve
9
9
  patch :accept
10
- patch :dismiss
10
+ patch :discard
11
11
  patch :reopen
12
12
  end
13
13
  resources :comments, only: [:create]
@@ -31,7 +31,7 @@ CoPlan::Engine.routes.draw do
31
31
  resources :comments, only: [:create], controller: "comments" do
32
32
  post :reply, on: :member
33
33
  patch :resolve, on: :member
34
- patch :dismiss, on: :member
34
+ patch :discard, on: :member
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,31 @@
1
+ class MigrateCommentThreadStatuses < ActiveRecord::Migration[8.0]
2
+ def up
3
+ # open → pending (default for new comments from non-authors)
4
+ # accepted → resolved (collapse accepted into resolved)
5
+ # dismissed → discarded (rename)
6
+ # resolved stays resolved
7
+ #
8
+ # We also add "todo" as a new status (author agrees with feedback).
9
+ # Existing "open" threads become "pending" since we can't determine authorship here.
10
+ execute <<~SQL
11
+ UPDATE coplan_comment_threads SET status = 'pending' WHERE status = 'open'
12
+ SQL
13
+ execute <<~SQL
14
+ UPDATE coplan_comment_threads SET status = 'discarded' WHERE status = 'dismissed'
15
+ SQL
16
+ execute <<~SQL
17
+ UPDATE coplan_comment_threads SET status = 'resolved' WHERE status = 'accepted'
18
+ SQL
19
+ change_column_default :coplan_comment_threads, :status, "pending"
20
+ end
21
+
22
+ def down
23
+ change_column_default :coplan_comment_threads, :status, "open"
24
+ execute <<~SQL
25
+ UPDATE coplan_comment_threads SET status = 'open' WHERE status IN ('pending', 'todo')
26
+ SQL
27
+ execute <<~SQL
28
+ UPDATE coplan_comment_threads SET status = 'dismissed' WHERE status = 'discarded'
29
+ SQL
30
+ end
31
+ end
@@ -1,3 +1,3 @@
1
1
  module CoPlan
2
- VERSION = "0.1.3"
2
+ VERSION = "0.4.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coplan-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Block
@@ -159,9 +159,10 @@ files:
159
159
  - app/helpers/coplan/application_helper.rb
160
160
  - app/helpers/coplan/comments_helper.rb
161
161
  - app/helpers/coplan/markdown_helper.rb
162
+ - app/javascript/controllers/coplan/comment_form_controller.js
163
+ - app/javascript/controllers/coplan/comment_nav_controller.js
162
164
  - app/javascript/controllers/coplan/dropdown_controller.js
163
165
  - app/javascript/controllers/coplan/line_selection_controller.js
164
- - app/javascript/controllers/coplan/tabs_controller.js
165
166
  - app/javascript/controllers/coplan/text_selection_controller.js
166
167
  - app/jobs/coplan/application_job.rb
167
168
  - app/jobs/coplan/automated_review_job.rb
@@ -197,6 +198,7 @@ files:
197
198
  - app/views/coplan/comment_threads/_new_comment_form.html.erb
198
199
  - app/views/coplan/comment_threads/_reply_form.html.erb
199
200
  - app/views/coplan/comment_threads/_thread.html.erb
201
+ - app/views/coplan/comment_threads/_thread_popover.html.erb
200
202
  - app/views/coplan/comments/_comment.html.erb
201
203
  - app/views/coplan/dashboard/show.html.erb
202
204
  - app/views/coplan/plan_versions/index.html.erb
@@ -216,6 +218,7 @@ files:
216
218
  - config/routes.rb
217
219
  - db/migrate/20260226200000_create_coplan_schema.rb
218
220
  - db/migrate/20260313210000_expand_content_markdown_to_mediumtext.rb
221
+ - db/migrate/20260320145453_migrate_comment_thread_statuses.rb
219
222
  - lib/coplan.rb
220
223
  - lib/coplan/configuration.rb
221
224
  - lib/coplan/engine.rb
@@ -1,18 +0,0 @@
1
- import { Controller } from "@hotwired/stimulus"
2
-
3
- export default class extends Controller {
4
- static targets = ["tab", "panel"]
5
- static classes = ["active"]
6
-
7
- switch(event) {
8
- const index = parseInt(event.currentTarget.dataset.index)
9
-
10
- this.tabTargets.forEach((tab, i) => {
11
- tab.classList.toggle(this.activeClass, i === index)
12
- })
13
-
14
- this.panelTargets.forEach((panel, i) => {
15
- panel.style.display = i === index ? "" : "none"
16
- })
17
- }
18
- }