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,4 +1,48 @@
1
1
  module CoPlan
2
2
  module ApplicationHelper
3
+ FAVICON_COLORS = {
4
+ "production" => { start: "#3B82F6", stop: "#1E40AF" },
5
+ "staging" => { start: "#F59E0B", stop: "#D97706" },
6
+ "development" => { start: "#10B981", stop: "#047857" },
7
+ }.freeze
8
+
9
+ def coplan_favicon_tag
10
+ colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
11
+ svg = <<~SVG
12
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
13
+ <defs>
14
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
15
+ <stop offset="0%" stop-color="#{colors[:start]}"/>
16
+ <stop offset="100%" stop-color="#{colors[:stop]}"/>
17
+ </linearGradient>
18
+ </defs>
19
+ <rect width="100" height="100" rx="22" fill="url(#g)"/>
20
+ <g opacity=".25" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round">
21
+ <line x1="16" y1="18" x2="84" y2="18"/>
22
+ <line x1="16" y1="28" x2="84" y2="28"/>
23
+ <line x1="16" y1="38" x2="84" y2="38"/>
24
+ <line x1="16" y1="48" x2="84" y2="48"/>
25
+ <line x1="16" y1="58" x2="84" y2="58"/>
26
+ <line x1="16" y1="68" x2="84" y2="68"/>
27
+ <line x1="16" y1="78" x2="84" y2="78"/>
28
+ </g>
29
+ <circle cx="40" cy="46" r="22" fill="#fff" opacity=".55"/>
30
+ <path d="M26,62 L18,78 L36,66 Z" fill="#fff" opacity=".55"/>
31
+ <circle cx="62" cy="54" r="22" fill="#fff" opacity=".55"/>
32
+ <path d="M76,70 L84,84 L68,72 Z" fill="#fff" opacity=".55"/>
33
+ </svg>
34
+ SVG
35
+
36
+ data_uri = "data:image/svg+xml,#{ERB::Util.url_encode(svg.strip)}"
37
+ tag.link(rel: "icon", type: "image/svg+xml", href: data_uri)
38
+ end
39
+
40
+ def coplan_environment_badge
41
+ return if Rails.env.production?
42
+
43
+ label = Rails.env.capitalize
44
+ colors = FAVICON_COLORS.fetch(Rails.env, FAVICON_COLORS["development"])
45
+ tag.span(label, class: "env-badge", style: "background: #{colors[:start]};")
46
+ end
3
47
  end
4
48
  end
@@ -11,6 +11,7 @@ module CoPlan
11
11
  blockquote hr br
12
12
  dd dt dl
13
13
  sup sub
14
+ details summary
14
15
  ].freeze
15
16
 
16
17
  ALLOWED_ATTRIBUTES = %w[id class href src alt title].freeze
@@ -0,0 +1,14 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ submitOnEnter(event) {
5
+ if (event.key !== "Enter") return
6
+ if (event.shiftKey || event.isComposing) return
7
+
8
+ const form = event.target.closest("form")
9
+ if (!form) return
10
+
11
+ event.preventDefault()
12
+ form.requestSubmit()
13
+ }
14
+ }
@@ -0,0 +1,247 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["position", "resolvedToggle"]
5
+ static values = { planId: String }
6
+
7
+ connect() {
8
+ this.currentIndex = -1
9
+ this.activeMark = null
10
+ this.activePopover = null
11
+ this.updatePosition()
12
+ this.handleKeydown = this.handleKeydown.bind(this)
13
+ this.handleScroll = this.handleScroll.bind(this)
14
+ document.addEventListener("keydown", this.handleKeydown)
15
+ window.addEventListener("scroll", this.handleScroll, { passive: true })
16
+ }
17
+
18
+ disconnect() {
19
+ document.removeEventListener("keydown", this.handleKeydown)
20
+ window.removeEventListener("scroll", this.handleScroll)
21
+ }
22
+
23
+ handleKeydown(event) {
24
+ // Don't intercept when typing in inputs/textareas or when modifier keys are held
25
+ const tag = event.target.tagName
26
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target.isContentEditable) return
27
+ if (event.metaKey || event.ctrlKey || event.altKey) return
28
+
29
+ switch (event.key) {
30
+ case "j":
31
+ case "ArrowDown":
32
+ event.preventDefault()
33
+ this.next()
34
+ break
35
+ case "k":
36
+ case "ArrowUp":
37
+ event.preventDefault()
38
+ this.prev()
39
+ break
40
+ case "r":
41
+ event.preventDefault()
42
+ this.focusReply()
43
+ break
44
+ case "a":
45
+ event.preventDefault()
46
+ this.acceptCurrent()
47
+ break
48
+ case "d":
49
+ event.preventDefault()
50
+ this.discardCurrent()
51
+ break
52
+ }
53
+ }
54
+
55
+ get openHighlights() {
56
+ return Array.from(document.querySelectorAll("mark.anchor-highlight--open"))
57
+ }
58
+
59
+ get allHighlights() {
60
+ return Array.from(document.querySelectorAll("mark.anchor-highlight"))
61
+ }
62
+
63
+ next() {
64
+ const highlights = this.openHighlights
65
+ if (highlights.length === 0) return
66
+
67
+ this.currentIndex = (this.currentIndex + 1) % highlights.length
68
+ this.navigateTo(highlights[this.currentIndex])
69
+ }
70
+
71
+ prev() {
72
+ const highlights = this.openHighlights
73
+ if (highlights.length === 0) return
74
+
75
+ this.currentIndex = this.currentIndex <= 0 ? highlights.length - 1 : this.currentIndex - 1
76
+ this.navigateTo(highlights[this.currentIndex])
77
+ }
78
+
79
+ navigateTo(mark) {
80
+ // Remove active highlight from all marks
81
+ document.querySelectorAll(".anchor-highlight--active").forEach(el => {
82
+ el.classList.remove("anchor-highlight--active")
83
+ })
84
+
85
+ // Close any currently open popover first — showPopover() throws
86
+ // InvalidStateError if the same popover is already open, and
87
+ // auto-dismiss only works when showing a *different* popover.
88
+ const openPopover = this.findOpenPopover()
89
+ if (openPopover) {
90
+ try { openPopover.hidePopover() } catch {}
91
+ }
92
+
93
+ // Add active class and scroll into view
94
+ mark.classList.add("anchor-highlight--active")
95
+ mark.scrollIntoView({ behavior: "instant", block: "center" })
96
+
97
+ // Open the thread popover if there's one
98
+ const threadId = mark.dataset.threadId
99
+ if (threadId) {
100
+ const popover = document.getElementById(`${threadId}_popover`)
101
+ if (popover) {
102
+ popover.style.visibility = "hidden"
103
+ popover.showPopover()
104
+ this.positionPopoverAtMark(popover, mark)
105
+ popover.style.visibility = "visible"
106
+ this.activeMark = mark
107
+ this.activePopover = popover
108
+ }
109
+ }
110
+
111
+ this.updatePosition()
112
+ }
113
+
114
+ handleScroll() {
115
+ if (!this.activeMark || !this.activePopover) return
116
+ try {
117
+ if (!this.activePopover.matches(":popover-open")) {
118
+ this.activeMark = null
119
+ this.activePopover = null
120
+ return
121
+ }
122
+ } catch { return }
123
+ this.positionPopoverAtMark(this.activePopover, this.activeMark)
124
+ }
125
+
126
+ positionPopoverAtMark(popover, mark) {
127
+ const markRect = mark.getBoundingClientRect()
128
+ const popoverRect = popover.getBoundingClientRect()
129
+ const viewportWidth = window.innerWidth
130
+ const viewportHeight = window.innerHeight
131
+
132
+ let top = markRect.top
133
+ let left = markRect.right + 12
134
+
135
+ if (left + popoverRect.width > viewportWidth - 16) {
136
+ left = markRect.left - popoverRect.width - 12
137
+ }
138
+ if (top + popoverRect.height > viewportHeight - 16) {
139
+ top = viewportHeight - popoverRect.height - 16
140
+ }
141
+ if (top < 16) top = 16
142
+ if (left < 16) left = 16
143
+
144
+ popover.style.top = `${top}px`
145
+ popover.style.left = `${left}px`
146
+ }
147
+
148
+ focusReply() {
149
+ const popover = this.findOpenPopover()
150
+ if (!popover) return
151
+
152
+ const textarea = popover.querySelector(".thread-popover__reply textarea")
153
+ if (textarea) {
154
+ textarea.focus({ preventScroll: true })
155
+ }
156
+ }
157
+
158
+ acceptCurrent() {
159
+ this.submitPopoverAction("accept")
160
+ }
161
+
162
+ discardCurrent() {
163
+ this.submitPopoverAction("discard")
164
+ }
165
+
166
+ submitPopoverAction(action) {
167
+ const popover = this.findOpenPopover()
168
+ if (!popover) return
169
+
170
+ const form = popover.querySelector(`form[data-action-name='${action}']`)
171
+ if (!form) return
172
+
173
+ // Normalize currentIndex if popover was opened via mouse (not j/k)
174
+ if (this.currentIndex < 0) {
175
+ this.currentIndex = 0
176
+ }
177
+
178
+ // For accept (pending→todo), the thread stays open so we need to
179
+ // explicitly advance. For discard, the thread leaves openHighlights
180
+ // and the current index naturally points to the next one.
181
+ const shouldAdvance = action === "accept"
182
+
183
+ // Watch for the broadcast DOM update that replaces the thread data,
184
+ // then advance to the next thread once the highlights have changed.
185
+ const threadsContainer = document.getElementById("plan-threads")
186
+ if (threadsContainer) {
187
+ const observer = new MutationObserver(() => {
188
+ observer.disconnect()
189
+ this.advanceAfterAction(shouldAdvance)
190
+ })
191
+ observer.observe(threadsContainer, { childList: true, subtree: true })
192
+ }
193
+
194
+ form.requestSubmit()
195
+ }
196
+
197
+ advanceAfterAction(shouldAdvance) {
198
+ const highlights = this.openHighlights
199
+ if (highlights.length === 0) {
200
+ this.currentIndex = -1
201
+ this.updatePosition()
202
+ return
203
+ }
204
+ if (shouldAdvance) {
205
+ this.currentIndex = (this.currentIndex + 1) % highlights.length
206
+ } else if (this.currentIndex >= highlights.length) {
207
+ this.currentIndex = 0
208
+ }
209
+ this.navigateTo(highlights[this.currentIndex])
210
+ }
211
+
212
+ findOpenPopover() {
213
+ try {
214
+ return document.querySelector(".thread-popover:popover-open")
215
+ } catch {
216
+ return Array.from(document.querySelectorAll(".thread-popover[popover]"))
217
+ .find(el => el.checkVisibility?.())
218
+ }
219
+ }
220
+
221
+ toggleResolved() {
222
+ const planLayout = document.querySelector(".plan-layout")
223
+ if (!planLayout) return
224
+
225
+ if (this.resolvedToggleTarget.checked) {
226
+ planLayout.classList.add("plan-layout--show-resolved")
227
+ } else {
228
+ planLayout.classList.remove("plan-layout--show-resolved")
229
+ }
230
+ }
231
+
232
+ updatePosition() {
233
+ if (!this.hasPositionTarget) return
234
+
235
+ const highlights = this.openHighlights
236
+ if (highlights.length === 0) {
237
+ this.positionTarget.textContent = ""
238
+ return
239
+ }
240
+
241
+ if (this.currentIndex < 0) {
242
+ this.positionTarget.textContent = `${highlights.length} total`
243
+ } else {
244
+ this.positionTarget.textContent = `${this.currentIndex + 1} of ${highlights.length}`
245
+ }
246
+ }
247
+ }