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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/coplan/application.css +268 -85
- data/app/controllers/coplan/api/v1/comments_controller.rb +7 -7
- data/app/controllers/coplan/api/v1/operations_controller.rb +38 -0
- data/app/controllers/coplan/comment_threads_controller.rb +26 -72
- data/app/controllers/coplan/plans_controller.rb +1 -3
- data/app/helpers/coplan/application_helper.rb +44 -0
- data/app/helpers/coplan/markdown_helper.rb +1 -0
- data/app/javascript/controllers/coplan/comment_form_controller.js +14 -0
- data/app/javascript/controllers/coplan/comment_nav_controller.js +247 -0
- data/app/javascript/controllers/coplan/text_selection_controller.js +143 -169
- data/app/jobs/coplan/automated_review_job.rb +4 -4
- data/app/models/coplan/comment_thread.rb +13 -7
- data/app/policies/coplan/comment_thread_policy.rb +1 -1
- data/app/services/coplan/plans/apply_operations.rb +43 -0
- data/app/services/coplan/plans/commit_session.rb +26 -1
- data/app/services/coplan/plans/position_resolver.rb +111 -0
- data/app/views/coplan/comment_threads/_new_comment_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_reply_form.html.erb +2 -1
- data/app/views/coplan/comment_threads/_thread.html.erb +3 -6
- data/app/views/coplan/comment_threads/_thread_popover.html.erb +58 -0
- data/app/views/coplan/plans/show.html.erb +22 -30
- data/app/views/layouts/coplan/application.html.erb +5 -0
- data/config/routes.rb +2 -2
- data/db/migrate/20260320145453_migrate_comment_thread_statuses.rb +31 -0
- data/lib/coplan/version.rb +1 -1
- metadata +5 -2
- 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
|
|
@@ -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
|
+
}
|