-
Notifications
You must be signed in to change notification settings - Fork 9
Refuse comments whose anchor doesn't resolve — no pin, no comment #173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,7 +17,14 @@ class CommentThread < ApplicationRecord | |
|
|
||
| validates :status, presence: true, inclusion: { in: STATUSES } | ||
|
|
||
| before_create :resolve_anchor_position | ||
| # Resolution runs before validation so validation can see its result: | ||
| # a thread whose anchor never resolved renders nowhere — no highlight, | ||
| # no popover, no way to reach it from the page — so it is refused at | ||
| # the door rather than created invisible. Create-only on both: a | ||
| # resolved thread whose content later drifts is the out_of_date flow, | ||
| # not a validity problem. | ||
| before_validation :resolve_anchor_position, on: :create | ||
| validate :anchor_must_resolve, on: :create | ||
|
|
||
| scope :open_threads, -> { where(status: OPEN_STATUSES) } | ||
| scope :current, -> { where(out_of_date: false) } | ||
|
|
@@ -67,7 +74,7 @@ def self.mark_out_of_date_for_new_version!(new_version) | |
|
|
||
| begin | ||
| new_range = Plans::TransformRange.transform_through_versions( | ||
| [thread.anchor_start, thread.anchor_end], | ||
| [ thread.anchor_start, thread.anchor_end ], | ||
| intervening | ||
| ) | ||
| thread.update_columns( | ||
|
|
@@ -166,8 +173,8 @@ def anchor_context_with_highlight(chars: 100) | |
| content = plan.current_content | ||
| return nil unless content.present? | ||
|
|
||
| context_start = [anchor_start - chars, 0].max | ||
| context_end = [anchor_end + chars, content.length].min | ||
| context_start = [ anchor_start - chars, 0 ].max | ||
| context_end = [ anchor_end + chars, content.length ].min | ||
|
|
||
| before = content[context_start...anchor_start] | ||
| anchor = content[anchor_start...anchor_end] | ||
|
|
@@ -182,6 +189,12 @@ def self.strip_markdown(content) | |
|
|
||
| private | ||
|
|
||
| def anchor_must_resolve | ||
| return if anchor_text.blank? || anchor_start.present? | ||
|
|
||
| errors.add(:anchor_text, "doesn't match the plan content — the comment would have nowhere to appear") | ||
| end | ||
|
|
||
| def resolve_anchor_position | ||
| return unless anchor_text.present? | ||
|
|
||
|
|
@@ -205,11 +218,20 @@ def resolve_anchor_position | |
| normalized_anchor = anchor_text.gsub("\t", " ") | ||
| stripped_ranges = find_all_occurrences(stripped, normalized_anchor) | ||
|
|
||
| # Mermaid labels line-break on literal <br/> tags, and the browser | ||
| # reads the label back without them — "first<br/>fetching" renders | ||
| # (and gets selected) as "firstfetching". Drop the tags from the | ||
| # search text; the position map keeps pointing at the source. | ||
| if stripped_ranges.empty? | ||
| stripped, pos_map = remove_break_tags(stripped, pos_map) | ||
| stripped_ranges = find_all_occurrences(stripped, normalized_anchor) | ||
| end | ||
|
|
||
| ranges = stripped_ranges.map do |s, e| | ||
| raw_start = first_real_pos(pos_map, s, :forward) | ||
| raw_end = first_real_pos(pos_map, e - 1, :backward) | ||
| next nil unless raw_start && raw_end | ||
| [raw_start, raw_end + 1] | ||
| [ raw_start, raw_end + 1 ] | ||
| end.compact | ||
| end | ||
|
|
||
|
|
@@ -221,6 +243,23 @@ def resolve_anchor_position | |
| end | ||
| end | ||
|
|
||
| # Removes <br>/<br/> tags from stripped text, carrying the position | ||
| # map along so matches still resolve to raw source positions. | ||
| def remove_break_tags(text, pos_map) | ||
|
Comment on lines
+246
to
+248
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two Mermaid labels contain the same anchor split by AGENTS.md reference: AGENTS.md:L139-L139 Useful? React with 👍 / 👎. |
||
| kept = +"" | ||
| map = [] | ||
| last = 0 | ||
| text.scan(/<br\s*\/?>/i) do | ||
| m = Regexp.last_match | ||
| kept << text[last...m.begin(0)] | ||
| map.concat(pos_map[last...m.begin(0)]) | ||
| last = m.end(0) | ||
| end | ||
| kept << text[last..] | ||
| map.concat(pos_map[last..]) | ||
| [ kept, map ] | ||
| end | ||
|
|
||
| # Finds the nearest non-sentinel (-1) position in the pos_map, | ||
| # scanning forward or backward from the given index. | ||
| def first_real_pos(pos_map, idx, direction) | ||
|
|
@@ -236,7 +275,7 @@ def find_all_occurrences(text, search) | |
| ranges = [] | ||
| start_pos = 0 | ||
| while (idx = text.index(search, start_pos)) | ||
| ranges << [idx, idx + search.length] | ||
| ranges << [ idx, idx + search.length ] | ||
| start_pos = idx + search.length | ||
| end | ||
| ranges | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the selected
<br>-split Mermaid label has the same visible text as ordinary prose earlier in the plan, the browser submits its DOM-wide occurrence (for example, 2), but the raw search finds only the prose occurrence. Because the new break-tag fallback runs only whenrangesis empty, it is skipped,anchor_startremains unset, and this valid comment is rejected as unresolved. Build the occurrence list from a single rendered-text representation so both forms participate in occurrence selection.AGENTS.md reference: AGENTS.md:L129-L129
Useful? React with 👍 / 👎.