branch: externals/forgejo
commit 21cfae9b7ae5ce85f0554850e9e9b2a9617f7a38
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>

    view: Follow issue comment deeplinks
---
 lisp/forgejo-issue.el       |  50 ++++++-----
 lisp/forgejo-pull.el        |  50 ++++++-----
 lisp/forgejo-view.el        | 153 +++++++++++++++++++++------------
 tests/forgejo-test-issue.el |  94 ++++++++++++++++++++
 tests/forgejo-test-pull.el  |  95 +++++++++++++++++++++
 tests/forgejo-test-view.el  | 204 ++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 549 insertions(+), 97 deletions(-)

diff --git a/lisp/forgejo-issue.el b/lisp/forgejo-issue.el
index c630c88434..80b5647644 100644
--- a/lisp/forgejo-issue.el
+++ b/lisp/forgejo-issue.el
@@ -256,8 +256,9 @@ Empty input clears all filters."
 (defun forgejo-issue--render-detail (buf-name host-url owner repo issue-alist
                                              timeline-alists &optional 
comment-id)
   "Render issue detail into BUF-NAME from alist data.
-HOST-URL is the instance URL.  When COMMENT-ID is non-nil, jump to
-that comment after rendering."
+HOST-URL is the instance URL.  OWNER and REPO identify the repository.
+ISSUE-ALIST is the issue data and TIMELINE-ALISTS is its timeline.  When
+COMMENT-ID is non-nil, jump to that comment after rendering."
   (forgejo-view--render-detail buf-name host-url owner repo issue-alist
                                timeline-alists
                                #'forgejo-issue-view-mode
@@ -266,9 +267,10 @@ that comment after rendering."
                                comment-id))
 
 (defun forgejo-issue--sync-detail (host owner repo number buf-name
-                                        &optional restore-line)
-  "Sync issue NUMBER from API in background, re-render BUF-NAME if changed.
-When RESTORE-LINE is non-nil, go to that line after re-rendering."
+                                        &optional restore-line comment-id)
+  "Sync issue NUMBER from API for HOST OWNER/REPO into BUF-NAME.
+When RESTORE-LINE is non-nil, go to that line after re-rendering.
+When COMMENT-ID is non-nil, jump to that comment after re-rendering."
   (let ((host-url (forgejo--host-url-for-hostname host))
         (endpoint (format "repos/%s/%s/issues/%d" owner repo number))
         (tl-endpoint (format "repos/%s/%s/issues/%d/timeline"
@@ -287,7 +289,7 @@ When RESTORE-LINE is non-nil, go to that line after 
re-rendering."
            (format-time-string "%Y-%m-%dT%H:%M:%SZ" nil t))
           (forgejo-view--re-render
            buf-name host-url host owner repo number
-           #'forgejo-issue--render-detail restore-line)
+           #'forgejo-issue--render-detail restore-line comment-id)
           (forgejo-view--sync-reactions
            host-url host owner repo number buf-name timeline)))))))
 
@@ -312,22 +314,26 @@ When COMMENT-ID is non-nil, jump to that comment after 
rendering."
          (buf-name (format "*forgejo-issue: %s/%s#%d*" owner repo number))
          (issue-alist (forgejo-db-get-issue host owner repo number))
          (tl-rows (forgejo-db-get-timeline host owner repo number))
-         (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows)))
-    (if issue-alist
-        (switch-to-buffer
-         (forgejo-issue--render-detail buf-name host-url owner repo
-                                       issue-alist tl-alists comment-id))
-      (with-current-buffer (get-buffer-create buf-name)
-        (let ((inhibit-read-only t))
-          (erase-buffer)
-          (insert "Loading..."))
-        (forgejo-issue-view-mode)
-        (setq forgejo-repo--host host-url
-              forgejo-repo--owner owner
-              forgejo-repo--name repo
-              forgejo-view--target-comment-id comment-id)
-        (switch-to-buffer (current-buffer))))
-    (forgejo-issue--sync-detail host owner repo number buf-name)))
+         (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows))
+         (buf (if issue-alist
+                  (forgejo-issue--render-detail buf-name host-url owner repo
+                                                issue-alist tl-alists 
comment-id)
+                (with-current-buffer (get-buffer-create buf-name)
+                  (let ((inhibit-read-only t))
+                    (erase-buffer)
+                    (insert "Loading..."))
+                  (forgejo-issue-view-mode)
+                  (setq forgejo-repo--host host-url
+                        forgejo-repo--owner owner
+                        forgejo-repo--name repo
+                        forgejo-view--target-comment-id comment-id)
+                  (current-buffer))))
+         (sync-target (with-current-buffer buf
+                        (forgejo-view--comment-target-for-sync
+                         forgejo-view--ewoc comment-id))))
+    (switch-to-buffer buf)
+    (forgejo-view--goto-target-comment comment-id)
+    (forgejo-issue--sync-detail host owner repo number buf-name nil 
sync-target)))
 
 ;;; Issue-specific commands
 
diff --git a/lisp/forgejo-pull.el b/lisp/forgejo-pull.el
index 0ca16415fa..7ca8222ee6 100644
--- a/lisp/forgejo-pull.el
+++ b/lisp/forgejo-pull.el
@@ -297,8 +297,9 @@ accepted so a SHA from a sibling worktree or remote can be 
used."
 (defun forgejo-pull--render-detail (buf-name host-url owner repo pr-alist
                                             timeline-alists &optional 
comment-id)
   "Render PR detail into BUF-NAME from alist data.
-HOST-URL is the instance URL.  When COMMENT-ID is non-nil, jump to
-that comment after rendering."
+HOST-URL is the instance URL.  OWNER and REPO identify the repository.
+PR-ALIST is the pull request data and TIMELINE-ALISTS is its timeline.
+When COMMENT-ID is non-nil, jump to that comment after rendering."
   (forgejo-view--render-detail buf-name host-url owner repo pr-alist
                                timeline-alists
                                #'forgejo-pull-view-mode
@@ -321,9 +322,10 @@ Each review-type event gains a review_state key looked up 
by review_id."
             timeline)))
 
 (defun forgejo-pull--sync-detail (host owner repo number buf-name
-                                       &optional restore-line)
-  "Sync PR NUMBER from API in background, re-render BUF-NAME if changed.
-When RESTORE-LINE is non-nil, go to that line after re-rendering."
+                                       &optional restore-line comment-id)
+  "Sync PR NUMBER from API for HOST OWNER/REPO into BUF-NAME.
+When RESTORE-LINE is non-nil, go to that line after re-rendering.
+When COMMENT-ID is non-nil, jump to that comment after re-rendering."
   (let ((host-url (forgejo--host-url-for-hostname host))
         (pr-endpoint (format "repos/%s/%s/pulls/%d" owner repo number))
         (tl-endpoint (format "repos/%s/%s/issues/%d/timeline"
@@ -349,7 +351,7 @@ When RESTORE-LINE is non-nil, go to that line after 
re-rendering."
                 (format-time-string "%Y-%m-%dT%H:%M:%SZ" nil t))
                (forgejo-view--re-render
                 buf-name host-url host owner repo number
-                #'forgejo-pull--render-detail restore-line)
+                #'forgejo-pull--render-detail restore-line comment-id)
                (forgejo-view--sync-reactions
                 host-url host owner repo number buf-name enriched)
                (let ((tl-alists (mapcar #'forgejo-db--row-to-timeline-alist
@@ -381,22 +383,26 @@ When COMMENT-ID is non-nil, jump to that comment after 
rendering."
          (buf-name (format "*forgejo-pr: %s/%s#%d*" owner repo number))
          (pr-alist (forgejo-db-get-issue host owner repo number))
          (tl-rows (forgejo-db-get-timeline host owner repo number))
-         (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows)))
-    (if pr-alist
-        (switch-to-buffer
-         (forgejo-pull--render-detail buf-name host-url owner repo
-                                     pr-alist tl-alists comment-id))
-      (with-current-buffer (get-buffer-create buf-name)
-        (let ((inhibit-read-only t))
-          (erase-buffer)
-          (insert "Loading..."))
-        (forgejo-pull-view-mode)
-        (setq forgejo-repo--host host-url
-             forgejo-repo--owner owner
-             forgejo-repo--name repo
-              forgejo-view--target-comment-id comment-id)
-        (switch-to-buffer (current-buffer))))
-    (forgejo-pull--sync-detail host owner repo number buf-name)))
+         (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows))
+         (buf (if pr-alist
+                  (forgejo-pull--render-detail buf-name host-url owner repo
+                                               pr-alist tl-alists comment-id)
+                (with-current-buffer (get-buffer-create buf-name)
+                  (let ((inhibit-read-only t))
+                    (erase-buffer)
+                    (insert "Loading..."))
+                  (forgejo-pull-view-mode)
+                  (setq forgejo-repo--host host-url
+                        forgejo-repo--owner owner
+                        forgejo-repo--name repo
+                        forgejo-view--target-comment-id comment-id)
+                  (current-buffer))))
+         (sync-target (with-current-buffer buf
+                        (forgejo-view--comment-target-for-sync
+                         forgejo-view--ewoc comment-id))))
+    (switch-to-buffer buf)
+    (forgejo-view--goto-target-comment comment-id)
+    (forgejo-pull--sync-detail host owner repo number buf-name nil 
sync-target)))
 
 ;;; PR-specific commands
 
diff --git a/lisp/forgejo-view.el b/lisp/forgejo-view.el
index ace2dc812e..1c53c1606a 100644
--- a/lisp/forgejo-view.el
+++ b/lisp/forgejo-view.el
@@ -63,7 +63,7 @@
 
 (defvar-local forgejo-view--sync-fn nil
   "Function to sync the current item from the API.
-Called with (HOST OWNER REPO NUMBER BUF-NAME &optional RESTORE-LINE).")
+Called with (HOST OWNER REPO NUMBER BUF-NAME &optional RESTORE-LINE 
COMMENT-ID).")
 
 (defvar-local forgejo-view--browse-fn nil
   "Function to open the current item in the browser.
@@ -277,6 +277,15 @@ COMMENT-ID is nil unless URL has a #issuecomment-N 
fragment."
              (and (match-string 4 url)
                   (string-to-number (match-string 4 url))))))
 
+(defun forgejo-view--url-target-at-point ()
+  "Return (HOST-URL OWNER REPO NUMBER COMMENT-ID) for Forgejo URL at point."
+  (when-let* ((url (forgejo-buffer--url-at-point))
+              (parsed (forgejo-view--parse-forgejo-url url))
+              (url-obj (url-generic-parse-url url))
+              (protocol (url-type url-obj))
+              (host (url-host url-obj)))
+    (cons (format "%s://%s" protocol host) parsed)))
+
 (defun forgejo-view-browse-url (url &rest _args)
   "Open URL in forgejo.el if it's a Forgejo issue/PR, else in browser."
   (if-let* ((parsed (forgejo-view--parse-forgejo-url url))
@@ -343,12 +352,16 @@ in forgejo.el buffers rather than the web browser."
   "Follow the link at point.
 Handles #N issue/PR refs and markdown URLs."
   (interactive)
-  (cond
-   ((get-text-property (point) 'forgejo-ref-number)
-    (forgejo-view-follow-ref))
-   ((forgejo-buffer--url-at-point)
-    (forgejo-view-browse-url (forgejo-buffer--url-at-point)))
-   (t (user-error "No link at point"))))
+  (if-let* ((target (forgejo-view--url-target-at-point)))
+      (pcase-let ((`(,host-url ,owner ,repo ,number ,comment-id) target))
+        (let ((forgejo-repo--host host-url))
+          (forgejo-view-item owner repo number comment-id)))
+    (cond
+     ((get-text-property (point) 'forgejo-ref-number)
+      (forgejo-view-follow-ref))
+     ((forgejo-buffer--url-at-point)
+      (forgejo-view-browse-url (forgejo-buffer--url-at-point)))
+     (t (user-error "No link at point")))))
 
 (declare-function forgejo-review-open-thread "forgejo-review.el" ())
 
@@ -418,6 +431,12 @@ Handles #N issue/PR refs and markdown URLs."
   "Return the data plist of the EWOC node at point, or nil."
   (forgejo-buffer--node-at-point forgejo-view--ewoc))
 
+(defun forgejo-view--comment-id-at-point ()
+  "Return the id of the comment node at point, or nil."
+  (when-let* ((node (forgejo-view--node-at-point)))
+    (and (eq (plist-get node :type) 'comment)
+         (plist-get node :id))))
+
 ;;; Item routing
 
 (declare-function forgejo-issue-view "forgejo-issue.el"
@@ -436,21 +455,23 @@ DB-normalised form (`pull_request' is t or nil)."
   "View issue or PR NUMBER in OWNER/REPO.
 Checks the DB first, then the API if uncached.  When COMMENT-ID is
 non-nil, jump to that comment after the detail view renders."
-  (let* ((host (url-host (url-generic-parse-url forgejo-repo--host)))
-         (cached (forgejo-db-get-issue host owner repo number)))
+  (let* ((host-url forgejo-repo--host)
+         (host (url-host (url-generic-parse-url host-url)))
+         (cached (forgejo-db-get-issue host owner repo number))
+         (view (lambda (item)
+                 (let ((forgejo-repo--host host-url))
+                   (if (forgejo-view--pr-p item)
+                       (forgejo-pull-view owner repo number comment-id)
+                     (forgejo-issue-view owner repo number comment-id))))))
     (if cached
-        (if (forgejo-view--pr-p cached)
-            (forgejo-pull-view owner repo number comment-id)
-          (forgejo-issue-view owner repo number comment-id))
+        (funcall view cached)
       (forgejo-api-get
-       forgejo-repo--host
+       host-url
        (format "repos/%s/%s/issues/%d" owner repo number) nil
        (lambda (data _headers)
          (when data
            (forgejo-db-save-issues host owner repo (list data))
-           (if (forgejo-view--pr-p data)
-               (forgejo-pull-view owner repo number comment-id)
-             (forgejo-issue-view owner repo number comment-id))))))))
+           (funcall view data)))))))
 
 ;;; List-view format
 
@@ -499,26 +520,44 @@ LIST-MODE is the major mode symbol for the list buffer."
 
 ;;; Detail-view rendering
 
+(defun forgejo-view--comment-node (ewoc comment-id)
+  "Return the EWOC comment node for COMMENT-ID, or nil."
+  (and ewoc
+       (cl-loop for node = (ewoc-nth ewoc 0) then (ewoc-next ewoc node)
+                while node
+                for data = (ewoc-data node)
+                when (and (eq 'comment (plist-get data :type))
+                          (eql comment-id (plist-get data :id)))
+                return node)))
+
 (defun forgejo-view--goto-comment (ewoc comment-id)
-  "Move point to the EWOC node for COMMENT-ID, or `point-min'.
+  "Move point to the EWOC comment node for COMMENT-ID.
 Returns non-nil when a matching comment node was found."
-  (or (cl-loop for node = (ewoc-nth ewoc 0) then (ewoc-next ewoc node)
-               while node
-               for data = (ewoc-data node)
-               when (and (eq 'comment (plist-get data :type))
-                         (eql comment-id (plist-get data :id)))
-               return (progn (goto-char (ewoc-location node)) t))
-      (progn (goto-char (point-min)) nil)))
+  (when-let* ((node (forgejo-view--comment-node ewoc comment-id)))
+    (ewoc-goto-node ewoc node)
+    t))
+
+(defun forgejo-view--goto-target-comment (comment-id)
+  "Move point to COMMENT-ID when non-nil."
+  (and comment-id
+       (forgejo-view--goto-comment forgejo-view--ewoc comment-id)))
+
+(defun forgejo-view--comment-target-for-sync (ewoc comment-id)
+  "Return COMMENT-ID when EWOC does not already contain it."
+  (and comment-id
+       (not (forgejo-view--comment-node ewoc comment-id))
+       comment-id))
 
 (defun forgejo-view--render-detail (buf-name host-url owner repo item-alist
                                             timeline-alists view-mode-fn
                                             sync-fn browse-fn
                                             &optional comment-id)
   "Render detail view into BUF-NAME from alist data.
-HOST-URL is the full instance URL.  ITEM-ALIST is the issue or PR data.
-TIMELINE-ALISTS is the timeline.  VIEW-MODE-FN activates the major mode.
-SYNC-FN and BROWSE-FN are stored as buffer-locals for action commands.
-When COMMENT-ID is non-nil, jump to that comment after rendering.
+HOST-URL is the full instance URL.  OWNER and REPO identify the repo.
+ITEM-ALIST is the issue or PR data.  TIMELINE-ALISTS is the timeline.
+VIEW-MODE-FN activates the major mode.  SYNC-FN and BROWSE-FN are stored
+as buffer-locals for action commands.  When COMMENT-ID is non-nil, jump
+to that comment after rendering.
 
 First call into a buffer does a full paint.  Subsequent calls reconcile
 the existing EWOC against the new nodes, avoiding any work for
@@ -532,13 +571,13 @@ unchanged nodes."
          (number (alist-get 'number item-alist)))
     (if (and existing-ewoc (ewoc-buffer existing-ewoc))
         (forgejo-view--reconcile-into existing-buf nodes item-alist
-                                      host owner repo number)
+                                      host owner repo number comment-id)
       (forgejo-view--first-paint buf-name nodes item-alist host-url host
                                  owner repo number view-mode-fn
                                  sync-fn browse-fn comment-id))))
 
 (defun forgejo-view--resolve-sticky-target (buf-name comment-id)
-  "Pick the sticky comment id for BUF-NAME, falling back to COMMENT-ID."
+  "Return COMMENT-ID or BUF-NAME's existing sticky comment target."
   (or comment-id
       (and (get-buffer buf-name)
            (buffer-local-value 'forgejo-view--target-comment-id
@@ -547,7 +586,8 @@ unchanged nodes."
 (defun forgejo-view--install-detail-locals (host-url owner repo item-alist
                                                      sync-fn browse-fn
                                                      sticky-target)
-  "Set the detail-view buffer-local variables in the current buffer."
+  "Set detail locals for HOST-URL OWNER REPO ITEM-ALIST.
+SYNC-FN, BROWSE-FN, and STICKY-TARGET are stored as buffer-local state."
   (setq forgejo-repo--host host-url
         forgejo-repo--owner owner
         forgejo-repo--name repo
@@ -568,7 +608,9 @@ Stores the EWOC in `forgejo-view--ewoc' and returns it."
 (defun forgejo-view--first-paint (buf-name nodes item-alist host-url host
                                            owner repo number view-mode-fn
                                            sync-fn browse-fn comment-id)
-  "Build the EWOC from scratch in BUF-NAME and paint NODES."
+  "Build a fresh detail view in BUF-NAME from NODES and ITEM-ALIST.
+HOST-URL, HOST, OWNER, REPO, and NUMBER identify the issue or PR.
+VIEW-MODE-FN, SYNC-FN, BROWSE-FN, and COMMENT-ID configure the view."
   (forgejo-buffer--fontify-node-bodies nodes)
   (let ((sticky-target (forgejo-view--resolve-sticky-target buf-name 
comment-id)))
     (with-current-buffer (get-buffer-create buf-name)
@@ -582,16 +624,18 @@ Stores the EWOC in `forgejo-view--ewoc' and returns it."
       (forgejo-buffer--update-reactions
        forgejo-view--ewoc host owner repo number)
       (setq header-line-format (forgejo-buffer--header-line item-alist))
-      (if sticky-target
-          (forgejo-view--goto-comment forgejo-view--ewoc sticky-target)
+      (unless (and sticky-target
+                   (forgejo-view--goto-target-comment sticky-target))
         (goto-char (point-min)))
       (set-buffer-modified-p nil)
       (current-buffer))))
 
-(defun forgejo-view--reconcile-into (buf nodes item-alist host owner repo 
number)
-  "Reconcile EWOC in BUF against NODES; refresh dependent state.
-Point is left untouched.  Updates `forgejo-view--data', reactions, and
-the header line only if it changed."
+(defun forgejo-view--reconcile-into (buf nodes item-alist host owner repo 
number
+                                         &optional comment-id)
+  "Reconcile EWOC in BUF against NODES for ITEM-ALIST.
+HOST, OWNER, REPO, and NUMBER identify the issue or PR.  Point is left
+untouched unless COMMENT-ID is non-nil.  Updates `forgejo-view--data',
+reactions, and the header line only if it changed."
   (with-current-buffer buf
     (let ((inhibit-read-only t))
       (setq forgejo-view--data item-alist)
@@ -601,7 +645,8 @@ the header line only if it changed."
       (let ((new-header (forgejo-buffer--header-line item-alist)))
         (unless (equal header-line-format new-header)
           (setq header-line-format new-header)))
-      (set-buffer-modified-p nil))
+      (set-buffer-modified-p nil)
+      (forgejo-view--goto-target-comment comment-id))
     buf))
 
 ;;; Reaction sync
@@ -645,17 +690,18 @@ event alists (used to extract comment IDs)."
 ;;; Re-render from DB
 
 (defun forgejo-view--re-render (buf-name host-url host owner repo number
-                                        render-fn &optional restore-line)
+                                         render-fn &optional restore-line 
comment-id)
   "Re-render detail buffer BUF-NAME from fresh DB data.
-HOST-URL is the instance.  HOST is the hostname.
-RENDER-FN is called with (BUF-NAME HOST-URL OWNER REPO ITEM TIMELINE).
-Restores point to RESTORE-LINE if given."
+HOST-URL is the instance.  HOST is the hostname.  OWNER, REPO, and
+NUMBER identify the issue or PR.  RENDER-FN is called with the cached
+item, timeline, and COMMENT-ID.  COMMENT-ID takes precedence over
+RESTORE-LINE when both are non-nil."
   (when (buffer-live-p (get-buffer buf-name))
     (let* ((issue (forgejo-db-get-issue host owner repo number))
            (tl-rows (forgejo-db-get-timeline host owner repo number))
            (tl-alists (mapcar #'forgejo-db--row-to-timeline-alist tl-rows)))
-      (funcall render-fn buf-name host-url owner repo issue tl-alists)
-      (when restore-line
+      (funcall render-fn buf-name host-url owner repo issue tl-alists 
comment-id)
+      (when (and restore-line (not comment-id))
         (with-current-buffer buf-name
           (goto-char (point-min))
           (forward-line (1- restore-line)))))))
@@ -996,18 +1042,19 @@ Only types you have already reacted with show the remove 
option."
   "Force sync the current detail view.
 Refreshes wall-clock-dependent display (relative timestamps) from
 cached data immediately, then fires the async sync.  Per-node
-invalidation keeps point on the current comment; the reconcile path
-preserves it from there."
+invalidation keeps point on the current comment; the sync render uses
+that comment id as its explicit target."
   (interactive)
   (when-let* ((data forgejo-view--data)
               (number (alist-get 'number data))
               (sync-fn forgejo-view--sync-fn))
-    (when forgejo-view--ewoc
-      (forgejo-buffer--ewoc-invalidate-all forgejo-view--ewoc))
-    (let ((host (url-host (url-generic-parse-url forgejo-repo--host))))
-      (funcall sync-fn host forgejo-repo--owner
-               forgejo-repo--name number
-               (buffer-name) nil))))
+    (let ((comment-id (forgejo-view--comment-id-at-point)))
+      (when forgejo-view--ewoc
+        (forgejo-buffer--ewoc-invalidate-all forgejo-view--ewoc))
+      (let ((host (url-host (url-generic-parse-url forgejo-repo--host))))
+        (funcall sync-fn host forgejo-repo--owner
+                 forgejo-repo--name number
+                 (buffer-name) nil comment-id)))))
 
 (defun forgejo-view-copy-url ()
   "Copy the URL at point to the kill ring."
diff --git a/tests/forgejo-test-issue.el b/tests/forgejo-test-issue.el
index e6d7e2d3c4..13e4d2ed53 100644
--- a/tests/forgejo-test-issue.el
+++ b/tests/forgejo-test-issue.el
@@ -9,6 +9,8 @@
 (require 'ert)
 (require 'cl-lib)
 
+(defvar forgejo-markdown-mode)
+
 (setq forgejo-markdown-mode 'text-mode)
 (require 'forgejo-issue)
 
@@ -88,5 +90,97 @@
       (should-not close-called)
       (should-not sync-called))))
 
+;;; Group 4: Detail view entry
+
+(defun forgejo-test-issue--issue-alist ()
+  "Return an issue alist for detail view tests."
+  '((number . 1)
+    (title . "Test issue")
+    (state . "open")
+    (body . "")
+    (user . ((login . "alice")))
+    (created_at . "2026-01-01T00:00:00Z")
+    (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-issue--comment-alist (id)
+  "Return a timeline comment alist with ID."
+  `((type . "comment")
+    (id . ,id)
+    (body . ,(format "Comment %d" id))
+    (user . ((login . "commenter")))
+    (created_at . "2026-01-01T00:00:00Z")
+    (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-issue--timeline (&rest ids)
+  "Return timeline comment events for IDS."
+  (mapcar #'forgejo-test-issue--comment-alist ids))
+
+(ert-deftest forgejo-test-issue-view-passes-missing-comment-id-to-sync ()
+  (let ((forgejo-repo--host "https://codeberg.org";)
+        sync-args)
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) '((number . 1))))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) nil))
+                  ((symbol-function 'forgejo-issue--render-detail)
+                   (lambda (buf-name &rest _args) (get-buffer-create 
buf-name)))
+                  ((symbol-function 'forgejo-issue--sync-detail)
+                   (lambda (&rest args) (setq sync-args args))))
+          (forgejo-issue-view "owner" "repo" 1 99)
+          (should (equal sync-args
+                         '("codeberg.org" "owner" "repo" 1
+                           "*forgejo-issue: owner/repo#1*" nil 99))))
+      (when-let* ((buf (get-buffer "*forgejo-issue: owner/repo#1*")))
+        (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-issue-view-omits-present-comment-id-from-sync ()
+  (let ((forgejo-repo--host "https://codeberg.org";)
+        sync-args)
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) '((number . 1))))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) nil))
+                  ((symbol-function 'forgejo-issue--render-detail)
+                   (lambda (buf-name &rest _args)
+                     (with-current-buffer (get-buffer-create buf-name)
+                       (setq forgejo-view--ewoc (ewoc-create #'ignore nil nil 
t))
+                       (ewoc-enter-last forgejo-view--ewoc '(:type comment :id 
99))
+                       (current-buffer))))
+                  ((symbol-function 'forgejo-issue--sync-detail)
+                   (lambda (&rest args) (setq sync-args args))))
+          (forgejo-issue-view "owner" "repo" 1 99)
+          (should (equal sync-args
+                         '("codeberg.org" "owner" "repo" 1
+                           "*forgejo-issue: owner/repo#1*" nil nil))))
+      (when-let* ((buf (get-buffer "*forgejo-issue: owner/repo#1*")))
+        (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-issue-view-jumps-existing-buffer-window ()
+  (let ((forgejo-repo--host "https://codeberg.org";)
+        (buf-name "*forgejo-issue: owner/repo#1*")
+        (other (get-buffer-create "*forgejo-test-issue-other*")))
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) (forgejo-test-issue--issue-alist)))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) (forgejo-test-issue--timeline 10 20)))
+                  ((symbol-function 'forgejo-db--row-to-timeline-alist)
+                   #'identity)
+                  ((symbol-function 'forgejo-buffer--update-reactions)
+                   #'ignore)
+                  ((symbol-function 'forgejo-issue--sync-detail)
+                   #'ignore))
+          (forgejo-issue-view "owner" "repo" 1)
+          (forgejo-view--goto-comment forgejo-view--ewoc 10)
+          (switch-to-buffer other)
+          (forgejo-issue-view "owner" "repo" 1 20)
+          (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+      (when-let* ((buf (get-buffer buf-name)))
+        (kill-buffer buf))
+      (when (buffer-live-p other)
+        (kill-buffer other)))))
+
 (provide 'forgejo-test-issue)
 ;;; forgejo-test-issue.el ends here
diff --git a/tests/forgejo-test-pull.el b/tests/forgejo-test-pull.el
index e32d8dfa9e..539b3a0378 100644
--- a/tests/forgejo-test-pull.el
+++ b/tests/forgejo-test-pull.el
@@ -9,6 +9,8 @@
 (require 'ert)
 (require 'cl-lib)
 
+(defvar forgejo-markdown-mode)
+
 (setq forgejo-markdown-mode 'text-mode)
 (require 'forgejo-pull)
 
@@ -88,5 +90,98 @@
       (should-not close-called)
       (should-not sync-called))))
 
+;;; Group 4: Detail view entry
+
+(defun forgejo-test-pull--pr-alist ()
+  "Return a PR alist for detail view tests."
+  '((number . 1)
+    (title . "Test PR")
+    (state . "open")
+    (body . "")
+    (pull_request . t)
+    (user . ((login . "alice")))
+    (created_at . "2026-01-01T00:00:00Z")
+    (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-pull--comment-alist (id)
+  "Return a timeline comment alist with ID."
+  `((type . "comment")
+    (id . ,id)
+    (body . ,(format "Comment %d" id))
+    (user . ((login . "commenter")))
+    (created_at . "2026-01-01T00:00:00Z")
+    (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-pull--timeline (&rest ids)
+  "Return timeline comment events for IDS."
+  (mapcar #'forgejo-test-pull--comment-alist ids))
+
+(ert-deftest forgejo-test-pull-view-passes-missing-comment-id-to-sync ()
+  (let ((forgejo-repo--host "https://codeberg.org";)
+        sync-args)
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) '((number . 1))))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) nil))
+                  ((symbol-function 'forgejo-pull--render-detail)
+                   (lambda (buf-name &rest _args) (get-buffer-create 
buf-name)))
+                  ((symbol-function 'forgejo-pull--sync-detail)
+                   (lambda (&rest args) (setq sync-args args))))
+          (forgejo-pull-view "owner" "repo" 1 99)
+          (should (equal sync-args
+                         '("codeberg.org" "owner" "repo" 1
+                           "*forgejo-pr: owner/repo#1*" nil 99))))
+      (when-let* ((buf (get-buffer "*forgejo-pr: owner/repo#1*")))
+        (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-pull-view-omits-present-comment-id-from-sync ()
+  (let ((forgejo-repo--host "https://codeberg.org";)
+        sync-args)
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) '((number . 1))))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) nil))
+                  ((symbol-function 'forgejo-pull--render-detail)
+                   (lambda (buf-name &rest _args)
+                     (with-current-buffer (get-buffer-create buf-name)
+                       (setq forgejo-view--ewoc (ewoc-create #'ignore nil nil 
t))
+                       (ewoc-enter-last forgejo-view--ewoc '(:type comment :id 
99))
+                       (current-buffer))))
+                  ((symbol-function 'forgejo-pull--sync-detail)
+                   (lambda (&rest args) (setq sync-args args))))
+          (forgejo-pull-view "owner" "repo" 1 99)
+          (should (equal sync-args
+                         '("codeberg.org" "owner" "repo" 1
+                           "*forgejo-pr: owner/repo#1*" nil nil))))
+      (when-let* ((buf (get-buffer "*forgejo-pr: owner/repo#1*")))
+        (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-pull-view-jumps-existing-buffer-window ()
+  (let ((forgejo-repo--host "https://codeberg.org";)
+        (buf-name "*forgejo-pr: owner/repo#1*")
+        (other (get-buffer-create "*forgejo-test-pull-other*")))
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) (forgejo-test-pull--pr-alist)))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) (forgejo-test-pull--timeline 10 20)))
+                  ((symbol-function 'forgejo-db--row-to-timeline-alist)
+                   #'identity)
+                  ((symbol-function 'forgejo-buffer--update-reactions)
+                   #'ignore)
+                  ((symbol-function 'forgejo-pull--sync-detail)
+                   #'ignore))
+          (forgejo-pull-view "owner" "repo" 1)
+          (forgejo-view--goto-comment forgejo-view--ewoc 10)
+          (switch-to-buffer other)
+          (forgejo-pull-view "owner" "repo" 1 20)
+          (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+      (when-let* ((buf (get-buffer buf-name)))
+        (kill-buffer buf))
+      (when (buffer-live-p other)
+        (kill-buffer other)))))
+
 (provide 'forgejo-test-pull)
 ;;; forgejo-test-pull.el ends here
diff --git a/tests/forgejo-test-view.el b/tests/forgejo-test-view.el
index 8d2494c965..4085c8f0ed 100644
--- a/tests/forgejo-test-view.el
+++ b/tests/forgejo-test-view.el
@@ -7,6 +7,10 @@
 ;;; Code:
 
 (require 'ert)
+(require 'cl-lib)
+
+(defvar forgejo-markdown-mode)
+(defvar forgejo-repo--host)
 
 (setq forgejo-markdown-mode 'text-mode)
 (require 'forgejo-view)
@@ -38,5 +42,205 @@
                "https://example.com/some/page";))
   (should-not (forgejo-view--parse-forgejo-url nil)))
 
+;;; Group 2: EWOC comment navigation
+
+(defun forgejo-test-view--issue-alist ()
+  "Return a minimal issue alist for detail rendering."
+  '((number . 1)
+    (title . "Issue")
+    (state . "open")
+    (body . "")
+    (user . ((login . "user")))
+    (created_at . "2026-01-01T00:00:00Z")
+    (updated_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-view--comment-alist (id)
+  "Return a minimal comment timeline event for ID."
+  `((id . ,id)
+    (type . "comment")
+    (body . ,(format "Comment %d" id))
+    (user . ((login . "commenter")))
+    (created_at . "2026-01-01T00:00:00Z")))
+
+(defun forgejo-test-view--timeline (&rest ids)
+  "Return timeline comment events for IDS."
+  (mapcar #'forgejo-test-view--comment-alist ids))
+
+(ert-deftest forgejo-test-view-goto-comment-finds-ewoc-node ()
+  (with-temp-buffer
+    (let ((ewoc (ewoc-create
+                 (lambda (node) (insert (format "%S\n" node))) nil nil t)))
+      (ewoc-enter-last ewoc '(:type event :id 20))
+      (ewoc-enter-last ewoc '(:type comment :id 10))
+      (ewoc-enter-last ewoc '(:type comment :id 20))
+      (should (forgejo-view--goto-comment ewoc 20))
+      (should (equal (ewoc-data (ewoc-locate ewoc))
+                     '(:type comment :id 20))))))
+
+(ert-deftest forgejo-test-view-comment-target-for-sync-present ()
+  (with-temp-buffer
+    (let ((ewoc (ewoc-create #'ignore nil nil t)))
+      (ewoc-enter-last ewoc '(:type comment :id 10))
+      (should-not (forgejo-view--comment-target-for-sync ewoc 10)))))
+
+(ert-deftest forgejo-test-view-comment-target-for-sync-missing ()
+  (with-temp-buffer
+    (let ((ewoc (ewoc-create #'ignore nil nil t)))
+      (ewoc-enter-last ewoc '(:type comment :id 10))
+      (should (eql 20 (forgejo-view--comment-target-for-sync ewoc 20))))))
+
+(ert-deftest forgejo-test-view-comment-target-for-sync-nil-ewoc ()
+  (should (eql 20 (forgejo-view--comment-target-for-sync nil 20))))
+
+(ert-deftest forgejo-test-view-render-detail-comment-id-jumps-first-paint ()
+  (let ((buf-name "*forgejo-test-first-paint-jump*"))
+    (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+      (let ((buf (forgejo-view--render-detail
+                  buf-name "https://codeberg.org"; "owner" "repo"
+                  (forgejo-test-view--issue-alist)
+                  (forgejo-test-view--timeline 10 20)
+                  #'fundamental-mode #'ignore #'ignore 20)))
+        (unwind-protect
+            (with-current-buffer buf
+              (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+          (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-render-detail-comment-id-jumps-existing-buffer 
()
+  (let ((buf-name "*forgejo-test-existing-buffer-jump*"))
+    (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+      (let ((buf (forgejo-view--render-detail
+                  buf-name "https://codeberg.org"; "owner" "repo"
+                  (forgejo-test-view--issue-alist)
+                  (forgejo-test-view--timeline 10 20)
+                  #'fundamental-mode #'ignore #'ignore)))
+        (unwind-protect
+            (with-current-buffer
+                (forgejo-view--render-detail
+                 buf-name "https://codeberg.org"; "owner" "repo"
+                 (forgejo-test-view--issue-alist)
+                 (forgejo-test-view--timeline 10 20)
+                 #'fundamental-mode #'ignore #'ignore 20)
+              (should (eql 20 (plist-get (forgejo-view--node-at-point) :id))))
+          (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-render-detail-keeps-point-without-comment-id ()
+  (let ((buf-name "*forgejo-test-existing-buffer-keep-point*")
+        point-before)
+    (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+      (let ((buf (forgejo-view--render-detail
+                  buf-name "https://codeberg.org"; "owner" "repo"
+                  (forgejo-test-view--issue-alist)
+                  (forgejo-test-view--timeline 10 20)
+                  #'fundamental-mode #'ignore #'ignore)))
+        (unwind-protect
+            (with-current-buffer buf
+              (setq forgejo-view--target-comment-id 20)
+              (forgejo-view--goto-comment forgejo-view--ewoc 10)
+              (setq point-before (point))
+              (forgejo-view--render-detail
+               buf-name "https://codeberg.org"; "owner" "repo"
+               (forgejo-test-view--issue-alist)
+               (forgejo-test-view--timeline 10 20)
+               #'fundamental-mode #'ignore #'ignore)
+              (should (eql point-before (point))))
+          (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-url-target-at-point-includes-comment-id ()
+  (with-temp-buffer
+    (insert "https://codeberg.org/guix/guix/issues/5125#issuecomment-9233177";)
+    (goto-char (point-min))
+    (should (equal (forgejo-view--url-target-at-point)
+                   '("https://codeberg.org"; "guix" "guix" 5125 9233177)))))
+
+(ert-deftest forgejo-test-view-follow-link-passes-url-comment-id ()
+  (let (called)
+    (cl-letf (((symbol-function 'forgejo-view-item)
+               (lambda (&rest args)
+                 (setq called (cons forgejo-repo--host args)))))
+      (with-temp-buffer
+        (insert 
"https://codeberg.org/guix/guix/issues/5125#issuecomment-9233177";)
+        (goto-char (point-min))
+        (forgejo-view-follow-link)
+        (should (equal called
+                       '("https://codeberg.org"; "guix" "guix" 5125 
9233177)))))))
+
+(ert-deftest forgejo-test-view-refresh-passes-comment-at-point ()
+  (let ((buf-name "*forgejo-test-refresh-comment*")
+        called)
+    (cl-letf (((symbol-function 'forgejo-buffer--update-reactions) #'ignore))
+      (let ((buf (forgejo-view--render-detail
+                  buf-name "https://codeberg.org"; "owner" "repo"
+                  (forgejo-test-view--issue-alist)
+                  (forgejo-test-view--timeline 10 20)
+                  #'fundamental-mode
+                  (lambda (&rest args) (setq called args))
+                  #'ignore)))
+        (unwind-protect
+            (with-current-buffer buf
+              (setq forgejo-view--target-comment-id 20)
+              (forgejo-view--goto-comment forgejo-view--ewoc 10)
+              (forgejo-view-refresh)
+              (should (equal called
+                             '("codeberg.org" "owner" "repo" 1
+                               "*forgejo-test-refresh-comment*" nil 10))))
+          (when (buffer-live-p buf) (kill-buffer buf)))))))
+
+(ert-deftest forgejo-test-view-re-render-passes-comment-id ()
+  (let ((buf (get-buffer-create "*forgejo-test-re-render-comment*"))
+        called)
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) (forgejo-test-view--issue-alist)))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) (forgejo-test-view--timeline 10)))
+                  ((symbol-function 'forgejo-db--row-to-timeline-alist)
+                   #'identity))
+          (forgejo-view--re-render
+           (buffer-name buf) "https://codeberg.org"; "codeberg.org"
+           "owner" "repo" 1
+           (lambda (&rest args) (setq called args)) nil 10)
+          (should (equal called
+                         (list (buffer-name buf) "https://codeberg.org";
+                               "owner" "repo"
+                               (forgejo-test-view--issue-alist)
+                               (forgejo-test-view--timeline 10)
+                               10))))
+      (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-view-re-render-comment-id-beats-restore-line ()
+  (let ((buf (get-buffer-create "*forgejo-test-re-render-precedence*"))
+        restored)
+    (unwind-protect
+        (cl-letf (((symbol-function 'forgejo-db-get-issue)
+                   (lambda (&rest _args) (forgejo-test-view--issue-alist)))
+                  ((symbol-function 'forgejo-db-get-timeline)
+                   (lambda (&rest _args) nil)))
+          (with-current-buffer buf
+            (erase-buffer)
+            (insert "line 1\nline 2\n")
+            (goto-char (point-min)))
+          (forgejo-view--re-render
+           (buffer-name buf) "https://codeberg.org"; "codeberg.org"
+           "owner" "repo" 1 #'ignore 2 10)
+          (with-current-buffer buf
+            (setq restored (line-number-at-pos)))
+          (should (= restored 1)))
+      (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest forgejo-test-view-item-keeps-host-in-async-callback ()
+  (let ((forgejo-repo--host "https://alt.example";)
+        callback seen-host)
+    (cl-letf (((symbol-function 'forgejo-db-get-issue)
+               (lambda (&rest _args) nil))
+              ((symbol-function 'forgejo-api-get)
+               (lambda (_host _endpoint _params cb) (setq callback cb)))
+              ((symbol-function 'forgejo-db-save-issues) #'ignore)
+              ((symbol-function 'forgejo-issue-view)
+               (lambda (&rest _args) (setq seen-host forgejo-repo--host))))
+      (forgejo-view-item "owner" "repo" 1 99)
+      (let ((forgejo-repo--host "https://wrong.example";))
+        (funcall callback '((number . 1)) nil))
+      (should (equal seen-host "https://alt.example";)))))
+
 (provide 'forgejo-test-view)
 ;;; forgejo-test-view.el ends here


Reply via email to