reviewkit 0.1.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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/CODE_OF_CONDUCT.md +123 -0
  4. data/CONTRIBUTING.md +44 -0
  5. data/MIT-LICENSE +20 -0
  6. data/README.md +335 -0
  7. data/Rakefile +7 -0
  8. data/SECURITY.md +18 -0
  9. data/app/assets/builds/reviewkit/application.css +2 -0
  10. data/app/assets/javascripts/reviewkit/application.js +12 -0
  11. data/app/assets/javascripts/reviewkit/controllers/file_nav_controller.js +24 -0
  12. data/app/assets/javascripts/reviewkit/controllers/review_index_controller.js +84 -0
  13. data/app/assets/tailwind/reviewkit/application.css +865 -0
  14. data/app/controllers/reviewkit/application_controller.rb +80 -0
  15. data/app/controllers/reviewkit/comments_controller.rb +147 -0
  16. data/app/controllers/reviewkit/review_threads_controller.rb +277 -0
  17. data/app/controllers/reviewkit/reviews_controller.rb +142 -0
  18. data/app/helpers/reviewkit/application_helper.rb +12 -0
  19. data/app/helpers/reviewkit/asset_helper.rb +39 -0
  20. data/app/helpers/reviewkit/diff_helper.rb +230 -0
  21. data/app/helpers/reviewkit/flash_helper.rb +36 -0
  22. data/app/helpers/reviewkit/frame_helper.rb +37 -0
  23. data/app/helpers/reviewkit/icon_helper.rb +107 -0
  24. data/app/helpers/reviewkit/review_thread_helper.rb +54 -0
  25. data/app/models/concerns/reviewkit/notifies_lifecycle_events.rb +39 -0
  26. data/app/models/reviewkit/application_record.rb +7 -0
  27. data/app/models/reviewkit/comment.rb +29 -0
  28. data/app/models/reviewkit/current.rb +7 -0
  29. data/app/models/reviewkit/document.rb +79 -0
  30. data/app/models/reviewkit/review.rb +66 -0
  31. data/app/models/reviewkit/review_thread.rb +75 -0
  32. data/app/services/reviewkit/diffs/intraline_budget.rb +40 -0
  33. data/app/services/reviewkit/diffs/intraline_diff.rb +220 -0
  34. data/app/services/reviewkit/diffs/split_diff.rb +112 -0
  35. data/app/services/reviewkit/reviews/create.rb +57 -0
  36. data/app/views/layouts/reviewkit/application.html.erb +15 -0
  37. data/app/views/reviewkit/comments/_comment.html.erb +53 -0
  38. data/app/views/reviewkit/comments/_edit_form.html.erb +26 -0
  39. data/app/views/reviewkit/comments/_form.html.erb +16 -0
  40. data/app/views/reviewkit/review_threads/_bucket.html.erb +53 -0
  41. data/app/views/reviewkit/review_threads/_bucket_frame.html.erb +13 -0
  42. data/app/views/reviewkit/review_threads/_bucket_row.html.erb +55 -0
  43. data/app/views/reviewkit/review_threads/_edit_form.html.erb +29 -0
  44. data/app/views/reviewkit/review_threads/_thread.html.erb +87 -0
  45. data/app/views/reviewkit/reviews/_document.html.erb +41 -0
  46. data/app/views/reviewkit/reviews/_document_split.html.erb +73 -0
  47. data/app/views/reviewkit/reviews/_document_unified.html.erb +57 -0
  48. data/app/views/reviewkit/reviews/_edit_form.html.erb +35 -0
  49. data/app/views/reviewkit/reviews/_index_content.html.erb +160 -0
  50. data/app/views/reviewkit/reviews/_review_sidebar.html.erb +70 -0
  51. data/app/views/reviewkit/reviews/_show_content.html.erb +164 -0
  52. data/app/views/reviewkit/reviews/index.html.erb +11 -0
  53. data/app/views/reviewkit/reviews/show.html.erb +11 -0
  54. data/app/views/reviewkit/shared/_flash.html.erb +10 -0
  55. data/bin/console +4 -0
  56. data/bin/lint +4 -0
  57. data/bin/rails +14 -0
  58. data/bin/setup +9 -0
  59. data/bin/test +4 -0
  60. data/config/importmap.rb +6 -0
  61. data/config/routes.rb +24 -0
  62. data/db/migrate/20260331181500_create_reviewkit_reviews.rb +19 -0
  63. data/db/migrate/20260331181600_create_reviewkit_documents.rb +23 -0
  64. data/db/migrate/20260331181700_create_reviewkit_review_threads.rb +23 -0
  65. data/db/migrate/20260331181800_create_reviewkit_comments.rb +15 -0
  66. data/db/migrate/20260401093000_add_description_to_reviewkit_reviews.rb +7 -0
  67. data/lib/generators/reviewkit/controllers/controllers_generator.rb +24 -0
  68. data/lib/generators/reviewkit/controllers/templates/comments_controller_extension.rb +13 -0
  69. data/lib/generators/reviewkit/controllers/templates/review_threads_controller_extension.rb +13 -0
  70. data/lib/generators/reviewkit/controllers/templates/reviews_controller_extension.rb +19 -0
  71. data/lib/generators/reviewkit/install/install_generator.rb +52 -0
  72. data/lib/generators/reviewkit/install/templates/importmap.rb +3 -0
  73. data/lib/generators/reviewkit/install/templates/reviewkit.rb +19 -0
  74. data/lib/generators/reviewkit/models/models_generator.rb +24 -0
  75. data/lib/generators/reviewkit/models/templates/comment_extension.rb +21 -0
  76. data/lib/generators/reviewkit/models/templates/review_extension.rb +22 -0
  77. data/lib/generators/reviewkit/models/templates/review_thread_extension.rb +21 -0
  78. data/lib/generators/reviewkit/views/views_generator.rb +15 -0
  79. data/lib/reviewkit/configuration.rb +33 -0
  80. data/lib/reviewkit/engine.rb +67 -0
  81. data/lib/reviewkit/version.rb +5 -0
  82. data/lib/reviewkit.rb +26 -0
  83. data/lib/tasks/reviewkit_tasks.rake +12 -0
  84. data/sig/reviewkit.rbs +129 -0
  85. metadata +238 -0
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewkit
4
+ class ReviewThread < ApplicationRecord
5
+ include NotifiesLifecycleEvents
6
+
7
+ enum :status, {
8
+ open: "open",
9
+ resolved: "resolved",
10
+ outdated: "outdated"
11
+ }, validate: true
12
+
13
+ belongs_to :review, inverse_of: :review_threads
14
+ belongs_to :document, inverse_of: :review_threads
15
+ belongs_to :resolved_by, polymorphic: true, optional: true
16
+ has_many :comments, -> { order(:created_at, :id) }, dependent: :destroy, inverse_of: :review_thread
17
+
18
+ validates :line_code, presence: true
19
+ validates :side, presence: true
20
+ validates :side, inclusion: { in: %w[old new] }
21
+ validate :anchor_line_must_match_side
22
+ validate :document_must_belong_to_review
23
+ validate :metadata_must_be_hash
24
+
25
+ before_validation :assign_review_from_document
26
+ before_validation :normalize_metadata
27
+
28
+ scope :open, -> { where(status: "open") }
29
+ scope :outdated, -> { where(status: "outdated") }
30
+ scope :resolved, -> { where(status: "resolved") }
31
+
32
+ def resolve!
33
+ update!(status: "resolved", resolved_at: Time.current, resolved_by: Reviewkit::Current.actor)
34
+ end
35
+
36
+ def reopen!
37
+ update!(status: "open", resolved_at: nil, resolved_by: nil)
38
+ end
39
+
40
+ def mark_outdated!
41
+ update!(status: "outdated", resolved_at: nil, resolved_by: nil)
42
+ end
43
+
44
+ private
45
+
46
+ def assign_review_from_document
47
+ self.review ||= document&.review
48
+ end
49
+
50
+ def normalize_metadata
51
+ self.metadata = metadata.deep_stringify_keys if metadata.is_a?(Hash)
52
+ end
53
+
54
+ def anchor_line_must_match_side
55
+ return if side.blank?
56
+ return if side == "old" && old_line.present?
57
+ return if side == "new" && new_line.present?
58
+
59
+ errors.add(:base, "must include a #{side} line anchor")
60
+ end
61
+
62
+ def document_must_belong_to_review
63
+ return if review.blank? || document.blank?
64
+ return if document.review_id == review_id
65
+
66
+ errors.add(:document, "must belong to the same review")
67
+ end
68
+
69
+ def metadata_must_be_hash
70
+ return if metadata.nil? || metadata.is_a?(Hash)
71
+
72
+ errors.add(:metadata, "must be a hash")
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewkit
4
+ module Diffs
5
+ class IntralineBudget
6
+ def self.allow?(...)
7
+ new(...).allow?
8
+ end
9
+
10
+ def initialize(old_text:, new_text:, review_document_count: nil, changed_row_count: nil, limits: Reviewkit.config.intraline_limits)
11
+ @old_text = old_text.to_s
12
+ @new_text = new_text.to_s
13
+ @review_document_count = review_document_count
14
+ @changed_row_count = changed_row_count
15
+ @limits = limits
16
+ end
17
+
18
+ def allow?
19
+ return false unless @limits.enabled
20
+ return false if exceeds_limit?(@review_document_count, @limits.max_review_files)
21
+ return false if exceeds_limit?(@changed_row_count, @limits.max_changed_lines)
22
+ return false if exceeds_line_length_limit?
23
+
24
+ true
25
+ end
26
+
27
+ private
28
+
29
+ def exceeds_limit?(value, limit)
30
+ value.present? && limit.present? && value > limit
31
+ end
32
+
33
+ def exceeds_line_length_limit?
34
+ return false unless @limits.max_line_length.present?
35
+
36
+ [ @old_text.length, @new_text.length ].max > @limits.max_line_length
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "diff/lcs"
4
+
5
+ module Reviewkit
6
+ module Diffs
7
+ class IntralineDiff
8
+ SIMILARITY_THRESHOLD = 0.5
9
+ TOKEN_PATTERN = /\s+|[[:alnum:]_]+|[^[:alnum:]_\s]+/
10
+
11
+ def self.call(...)
12
+ new(...).call
13
+ end
14
+
15
+ def initialize(old_text:, new_text:)
16
+ @old_text = old_text.to_s
17
+ @new_text = new_text.to_s
18
+ end
19
+
20
+ def call
21
+ return empty_ranges if @old_text == @new_text
22
+
23
+ old_tokens = tokenize(@old_text)
24
+ new_tokens = tokenize(@new_text)
25
+ ranges = empty_ranges
26
+ pending_old = []
27
+ pending_new = []
28
+ old_index = 0
29
+ new_index = 0
30
+ shared_non_whitespace_chars = 0
31
+
32
+ Diff::LCS.sdiff(token_texts(old_tokens), token_texts(new_tokens)).each do |change|
33
+ case change.action
34
+ when "="
35
+ shared_non_whitespace_chars += append_group_ranges(ranges, pending_old, pending_new)
36
+ pending_old = []
37
+ pending_new = []
38
+ shared_non_whitespace_chars += non_whitespace_character_count(old_tokens.fetch(old_index).fetch("text"))
39
+ old_index += 1
40
+ new_index += 1
41
+ when "!"
42
+ pending_old << old_tokens.fetch(old_index)
43
+ pending_new << new_tokens.fetch(new_index)
44
+ old_index += 1
45
+ new_index += 1
46
+ when "-"
47
+ pending_old << old_tokens.fetch(old_index)
48
+ old_index += 1
49
+ when "+"
50
+ pending_new << new_tokens.fetch(new_index)
51
+ new_index += 1
52
+ end
53
+ end
54
+
55
+ shared_non_whitespace_chars += append_group_ranges(ranges, pending_old, pending_new)
56
+ normalized_ranges = normalize_ranges(ranges)
57
+
58
+ return normalized_ranges if meets_similarity_threshold?(shared_non_whitespace_chars)
59
+
60
+ empty_ranges
61
+ end
62
+
63
+ private
64
+
65
+ def tokenize(text)
66
+ cursor = 0
67
+
68
+ text.to_enum(:scan, TOKEN_PATTERN).map do
69
+ token_text = Regexp.last_match(0)
70
+ token = {
71
+ "kind" => token_kind(token_text),
72
+ "text" => token_text,
73
+ "start" => cursor,
74
+ "end" => cursor + token_text.length
75
+ }
76
+ cursor = token.fetch("end")
77
+ token
78
+ end
79
+ end
80
+
81
+ def token_texts(tokens)
82
+ tokens.map { |token| token.fetch("text") }
83
+ end
84
+
85
+ def token_kind(token_text)
86
+ return "whitespace" if token_text.match?(/\A\s+\z/)
87
+ return "word" if token_text.match?(/\A[[:alnum:]_]+\z/)
88
+
89
+ "punctuation"
90
+ end
91
+
92
+ def append_group_ranges(ranges, old_tokens, new_tokens)
93
+ return 0 if old_tokens.empty? && new_tokens.empty?
94
+
95
+ if old_tokens.empty?
96
+ ranges["new"] << range_from_tokens(new_tokens)
97
+ return 0
98
+ end
99
+
100
+ if new_tokens.empty?
101
+ ranges["old"] << range_from_tokens(old_tokens)
102
+ return 0
103
+ end
104
+
105
+ if refine_single_token_pair?(old_tokens, new_tokens)
106
+ return append_character_ranges(ranges, old_tokens.first, new_tokens.first)
107
+ end
108
+
109
+ ranges["old"] << range_from_tokens(old_tokens)
110
+ ranges["new"] << range_from_tokens(new_tokens)
111
+ 0
112
+ end
113
+
114
+ def refine_single_token_pair?(old_tokens, new_tokens)
115
+ return false unless old_tokens.one? && new_tokens.one?
116
+
117
+ old_token = old_tokens.first
118
+ new_token = new_tokens.first
119
+ return true if old_token.fetch("kind") == "whitespace" || new_token.fetch("kind") == "whitespace"
120
+ return false unless old_token.fetch("kind") == new_token.fetch("kind")
121
+
122
+ old_text = old_token.fetch("text")
123
+ new_text = new_token.fetch("text")
124
+
125
+ old_text.start_with?(new_text) ||
126
+ new_text.start_with?(old_text) ||
127
+ old_text.end_with?(new_text) ||
128
+ new_text.end_with?(old_text) ||
129
+ common_prefix_length(old_text, new_text).positive? ||
130
+ common_suffix_length(old_text, new_text).positive?
131
+ end
132
+
133
+ def append_character_ranges(ranges, old_token, new_token)
134
+ old_offset = old_token.fetch("start")
135
+ new_offset = new_token.fetch("start")
136
+ old_index = 0
137
+ new_index = 0
138
+ shared_non_whitespace_chars = 0
139
+
140
+ Diff::LCS.sdiff(old_token.fetch("text").chars, new_token.fetch("text").chars).each do |change|
141
+ case change.action
142
+ when "="
143
+ shared_non_whitespace_chars += 1 unless change.old_element.match?(/\s/)
144
+ old_index += 1
145
+ new_index += 1
146
+ when "!"
147
+ ranges["old"] << build_range(old_offset + old_index, old_offset + old_index + 1)
148
+ ranges["new"] << build_range(new_offset + new_index, new_offset + new_index + 1)
149
+ old_index += 1
150
+ new_index += 1
151
+ when "-"
152
+ ranges["old"] << build_range(old_offset + old_index, old_offset + old_index + 1)
153
+ old_index += 1
154
+ when "+"
155
+ ranges["new"] << build_range(new_offset + new_index, new_offset + new_index + 1)
156
+ new_index += 1
157
+ end
158
+ end
159
+
160
+ shared_non_whitespace_chars
161
+ end
162
+
163
+ def range_from_tokens(tokens)
164
+ build_range(tokens.first.fetch("start"), tokens.last.fetch("end"))
165
+ end
166
+
167
+ def normalize_ranges(ranges)
168
+ {
169
+ "old" => merge_ranges(Array(ranges["old"])),
170
+ "new" => merge_ranges(Array(ranges["new"]))
171
+ }
172
+ end
173
+
174
+ def merge_ranges(ranges)
175
+ ranges
176
+ .map { |range| build_range(range.fetch("start"), range.fetch("end")) }
177
+ .reject { |range| range.fetch("start") >= range.fetch("end") }
178
+ .sort_by { |range| [ range.fetch("start"), range.fetch("end") ] }
179
+ .each_with_object([]) do |range, merged|
180
+ if merged.empty? || range.fetch("start") > merged.last.fetch("end")
181
+ merged << range
182
+ else
183
+ merged.last["end"] = [ merged.last.fetch("end"), range.fetch("end") ].max
184
+ end
185
+ end
186
+ end
187
+
188
+ def build_range(start_index, end_index)
189
+ { "start" => start_index, "end" => end_index }
190
+ end
191
+
192
+ def meets_similarity_threshold?(shared_non_whitespace_chars)
193
+ denominator = [
194
+ non_whitespace_character_count(@old_text),
195
+ non_whitespace_character_count(@new_text)
196
+ ].max
197
+
198
+ return true if denominator.zero?
199
+
200
+ (shared_non_whitespace_chars.to_f / denominator) >= SIMILARITY_THRESHOLD
201
+ end
202
+
203
+ def non_whitespace_character_count(text)
204
+ text.each_char.count { |character| !character.match?(/\s/) }
205
+ end
206
+
207
+ def common_prefix_length(old_text, new_text)
208
+ old_text.chars.zip(new_text.chars).take_while { |old_char, new_char| old_char == new_char }.size
209
+ end
210
+
211
+ def common_suffix_length(old_text, new_text)
212
+ old_text.chars.reverse.zip(new_text.chars.reverse).take_while { |old_char, new_char| old_char == new_char }.size
213
+ end
214
+
215
+ def empty_ranges
216
+ { "old" => [], "new" => [] }
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha1"
4
+ require "diff/lcs"
5
+
6
+ module Reviewkit
7
+ module Diffs
8
+ class SplitDiff
9
+ def self.call(...)
10
+ new(...).call
11
+ end
12
+
13
+ def initialize(old_content:, new_content:, review_document_count: nil)
14
+ @old_content = old_content.to_s
15
+ @new_content = new_content.to_s
16
+ @review_document_count = review_document_count
17
+ end
18
+
19
+ def call
20
+ old_line_number = 0
21
+ new_line_number = 0
22
+ stats = { "additions" => 0, "deletions" => 0, "changes" => 0, "context" => 0 }
23
+ changes = Diff::LCS.sdiff(lines(@old_content), lines(@new_content)).to_a
24
+ changed_row_count = changes.count { |change| change.action == "!" }
25
+
26
+ rows = changes.map do |change|
27
+ case change.action
28
+ when "="
29
+ old_line_number += 1
30
+ new_line_number += 1
31
+ stats["context"] += 1
32
+ build_row("context", old_line_number, new_line_number, change.old_element, change.new_element)
33
+ when "!"
34
+ old_line_number += 1
35
+ new_line_number += 1
36
+ stats["changes"] += 1
37
+ stats["additions"] += 1
38
+ stats["deletions"] += 1
39
+ inline_changes = build_inline_changes(
40
+ change.old_element,
41
+ change.new_element,
42
+ changed_row_count: changed_row_count
43
+ )
44
+
45
+ build_row(
46
+ "changed",
47
+ old_line_number,
48
+ new_line_number,
49
+ change.old_element,
50
+ change.new_element,
51
+ inline_changes: inline_changes
52
+ )
53
+ when "-"
54
+ old_line_number += 1
55
+ stats["deletions"] += 1
56
+ build_row("removed", old_line_number, nil, change.old_element, nil)
57
+ when "+"
58
+ new_line_number += 1
59
+ stats["additions"] += 1
60
+ build_row("added", nil, new_line_number, nil, change.new_element)
61
+ end
62
+ end.compact
63
+
64
+ { "rows" => rows, "stats" => stats }
65
+ end
66
+
67
+ private
68
+
69
+ def build_row(kind, old_line, new_line, old_text, new_text, inline_changes: nil)
70
+ normalized_old = old_text.to_s
71
+ normalized_new = new_text.to_s
72
+
73
+ row = {
74
+ "kind" => kind,
75
+ "line_code" => Digest::SHA1.hexdigest(
76
+ [ kind, old_line, new_line, normalized_old, normalized_new ].join("\u0000")
77
+ ),
78
+ "new_line" => new_line,
79
+ "new_text" => normalized_new,
80
+ "old_line" => old_line,
81
+ "old_text" => normalized_old
82
+ }
83
+
84
+ row["inline_changes"] = inline_changes if inline_changes_present?(inline_changes)
85
+ row
86
+ end
87
+
88
+ def lines(content)
89
+ return [] if content.empty?
90
+
91
+ content.lines(chomp: true)
92
+ end
93
+
94
+ def build_inline_changes(old_text, new_text, changed_row_count:)
95
+ return unless Reviewkit::Diffs::IntralineBudget.allow?(
96
+ old_text: old_text,
97
+ new_text: new_text,
98
+ review_document_count: @review_document_count,
99
+ changed_row_count: changed_row_count
100
+ )
101
+
102
+ Reviewkit::Diffs::IntralineDiff.call(old_text:, new_text:)
103
+ end
104
+
105
+ def inline_changes_present?(inline_changes)
106
+ return false if inline_changes.blank?
107
+
108
+ inline_changes.values.any?(&:present?)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Reviewkit
4
+ module Reviews
5
+ class Create
6
+ def self.call(...)
7
+ new(...).call
8
+ end
9
+
10
+ def initialize(title:, description: nil, creator: nil, reviewable: nil, external_reference: nil, metadata: {}, review_attributes: {}, status: "draft", documents: [])
11
+ @title = title
12
+ @description = description
13
+ @creator = creator
14
+ @reviewable = reviewable
15
+ @external_reference = external_reference
16
+ @metadata = metadata
17
+ @review_attributes = review_attributes
18
+ @status = status
19
+ @documents = documents
20
+ end
21
+
22
+ def call
23
+ Reviewkit::Current.set(actor: @creator, source: self.class.name) do
24
+ Review.transaction do
25
+ review = Review.new(
26
+ {
27
+ title: @title,
28
+ description: @description,
29
+ status: @status,
30
+ external_reference: @external_reference,
31
+ metadata: @metadata,
32
+ reviewable: @reviewable
33
+ }.merge(@review_attributes)
34
+ )
35
+ review.creator = @creator
36
+ review.save!
37
+
38
+ @documents.each_with_index do |document, index|
39
+ payload = document.to_h.with_indifferent_access
40
+ review.documents.create!(
41
+ intraline_review_document_count: @documents.size,
42
+ path: payload.fetch(:path),
43
+ language: payload.fetch(:language, "plaintext"),
44
+ old_content: payload.fetch(:old_content, ""),
45
+ new_content: payload.fetch(:new_content, ""),
46
+ metadata: payload.fetch(:metadata, {}),
47
+ position: payload.fetch(:position, index)
48
+ )
49
+ end
50
+
51
+ review
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <title>Reviewkit</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+ <%= yield :head %>
9
+ </head>
10
+
11
+ <body class="min-h-screen bg-slate-50 text-slate-950 antialiased">
12
+ <%= render "reviewkit/shared/flash" %>
13
+ <%= yield %>
14
+ </body>
15
+ </html>
@@ -0,0 +1,53 @@
1
+ <% review_frame_id = local_assigns[:frame_id] || dom_id(comment.review_thread.review, :review) %>
2
+
3
+ <%= turbo_frame_tag dom_id(comment) do %>
4
+ <article class="reviewkit-comment-card">
5
+ <header class="reviewkit-comment-header">
6
+ <div class="reviewkit-comment-meta">
7
+ <span class="text-[13px] font-semibold text-slate-900">
8
+ <%= reviewkit_actor_label(comment.author) %>
9
+ </span>
10
+
11
+ <% if reviewkit_comment_edited?(comment) %>
12
+ <span class="reviewkit-comment-edited">edited</span>
13
+ <% end %>
14
+ </div>
15
+
16
+ <div class="flex items-center gap-2">
17
+ <div class="text-[11px] text-slate-500">
18
+ <%= time_ago_in_words(comment.created_at) %> ago
19
+ </div>
20
+
21
+ <% if reviewkit_comment_manageable?(comment) %>
22
+ <div class="reviewkit-inline-actions" aria-label="Comment actions">
23
+ <%= link_to edit_review_thread_comment_path(comment.review_thread, comment),
24
+ class: "reviewkit-inline-icon-button",
25
+ data: { turbo_frame: dom_id(comment) },
26
+ aria: { label: "Edit comment" },
27
+ title: "Edit comment" do %>
28
+ <%= reviewkit_pencil_icon %>
29
+ <% end %>
30
+
31
+ <% if reviewkit_comment_destroyable?(comment) %>
32
+ <%= link_to review_thread_comment_path(comment.review_thread, comment, frame_id: review_frame_id, view: local_assigns[:view_mode]),
33
+ class: "reviewkit-inline-icon-button reviewkit-inline-icon-button--danger",
34
+ data: {
35
+ turbo_method: :delete,
36
+ turbo_frame: review_frame_id,
37
+ turbo_confirm: "Delete this comment?"
38
+ },
39
+ aria: { label: "Delete comment" },
40
+ title: "Delete comment" do %>
41
+ <%= reviewkit_trash_icon %>
42
+ <% end %>
43
+ <% end %>
44
+ </div>
45
+ <% end %>
46
+ </div>
47
+ </header>
48
+
49
+ <div class="mt-1.5 text-[13px] leading-5 text-slate-700">
50
+ <%= reviewkit_render_comment_body(comment) %>
51
+ </div>
52
+ </article>
53
+ <% end %>
@@ -0,0 +1,26 @@
1
+ <%= turbo_frame_tag dom_id(comment) do %>
2
+ <%= form_with model: [comment.review_thread, comment], class: "reviewkit-comment-edit-form" do |form| %>
3
+ <% if comment.errors.any? %>
4
+ <div class="reviewkit-comment-error">
5
+ <%= safe_join(comment.errors.full_messages.map { |error| content_tag(:div, error) }) %>
6
+ </div>
7
+ <% end %>
8
+
9
+ <label class="block">
10
+ <span class="mb-1 block text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Edit comment</span>
11
+ <%= form.text_area :body,
12
+ rows: 3,
13
+ class: "reviewkit-textarea",
14
+ autofocus: true %>
15
+ </label>
16
+
17
+ <div class="flex items-center justify-end gap-2">
18
+ <%= link_to "Cancel",
19
+ review_thread_comment_path(comment.review_thread, comment),
20
+ class: "reviewkit-button reviewkit-button--ghost reviewkit-button--small",
21
+ data: { turbo_frame: dom_id(comment) } %>
22
+ <%= form.submit "Save",
23
+ class: "reviewkit-button reviewkit-button--primary reviewkit-button--small" %>
24
+ </div>
25
+ <% end %>
26
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <%= form_with model: [thread, Reviewkit::Comment.new], class: "space-y-2 border-t border-slate-200 bg-slate-50 px-3 py-2.5" do |form| %>
2
+ <%= form.hidden_field :frame_id, value: local_assigns[:frame_id] %>
3
+ <%= form.hidden_field :view, value: local_assigns[:view_mode] %>
4
+ <label class="block">
5
+ <span class="mb-1 block text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">Reply</span>
6
+ <%= form.text_area :body,
7
+ rows: 2,
8
+ class: "reviewkit-textarea",
9
+ placeholder: "Add to the conversation..." %>
10
+ </label>
11
+
12
+ <div class="flex justify-end">
13
+ <%= form.submit "Reply",
14
+ class: "reviewkit-button reviewkit-button--secondary reviewkit-button--small" %>
15
+ </div>
16
+ <% end %>
@@ -0,0 +1,53 @@
1
+ <div class="reviewkit-thread-stack">
2
+ <% threads.each do |thread| %>
3
+ <%= render "reviewkit/review_threads/thread",
4
+ expanded: composer_open,
5
+ frame_id: frame_id,
6
+ review: review,
7
+ thread: thread,
8
+ view_mode: view_mode %>
9
+ <% end %>
10
+
11
+ <div
12
+ class="<%= [("hidden" unless composer_open), "reviewkit-thread-composer"].compact.join(" ") %>"
13
+ >
14
+ <% if thread_errors.present? %>
15
+ <div class="mb-2 border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-xs text-rose-700">
16
+ <%= safe_join(thread_errors.map { |error| content_tag(:div, error) }) %>
17
+ </div>
18
+ <% end %>
19
+
20
+ <%= form_with url: review_review_threads_path(review), scope: :review_thread, class: "space-y-2" do |form| %>
21
+ <%= form.hidden_field :document_id, value: document.id %>
22
+ <%= form.hidden_field :frame_id, value: frame_id %>
23
+ <%= form.hidden_field :line_code, value: line_code %>
24
+ <%= form.hidden_field :view, value: view_mode %>
25
+ <%= form.hidden_field :side,
26
+ value: composer_side.presence || (row&.dig("new_line").present? ? "new" : "old") %>
27
+ <%= form.hidden_field :old_line, value: row&.dig("old_line") %>
28
+ <%= form.hidden_field :new_line, value: row&.dig("new_line") %>
29
+ <%= form.hidden_field :old_text, value: row&.dig("old_text") %>
30
+ <%= form.hidden_field :new_text, value: row&.dig("new_text") %>
31
+
32
+ <label class="block">
33
+ <span class="mb-1 block text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500">
34
+ Start a discussion on the <%= (composer_side.presence || (row&.dig("new_line").present? ? "new" : "old")) %> side
35
+ </span>
36
+ <%= form.text_area :body,
37
+ rows: 3,
38
+ class: "reviewkit-textarea",
39
+ placeholder: "Leave a review comment for this line...",
40
+ autofocus: composer_open %>
41
+ </label>
42
+
43
+ <div class="flex items-center justify-end gap-2">
44
+ <%= link_to "Cancel",
45
+ review_path(review, document_id: document.id, view: view_mode),
46
+ class: "reviewkit-button reviewkit-button--secondary reviewkit-button--small",
47
+ data: { turbo_frame: frame_id } %>
48
+ <%= form.submit "Comment",
49
+ class: "reviewkit-button reviewkit-button--primary reviewkit-button--small" %>
50
+ </div>
51
+ <% end %>
52
+ </div>
53
+ </div>
@@ -0,0 +1,13 @@
1
+ <%= turbo_frame_tag reviewkit_thread_bucket_id(document, line_code) do %>
2
+ <%= render "reviewkit/review_threads/bucket",
3
+ composer_open: local_assigns.fetch(:composer_open, false),
4
+ composer_side: local_assigns[:composer_side],
5
+ document: document,
6
+ frame_id: local_assigns[:frame_id],
7
+ review: review,
8
+ row: row,
9
+ thread_errors: thread_errors,
10
+ threads: threads,
11
+ line_code: line_code,
12
+ view_mode: local_assigns[:view_mode] %>
13
+ <% end %>