diff --git a/eca-chat.el b/eca-chat.el index b3a1b03..ea249a0 100644 --- a/eca-chat.el +++ b/eca-chat.el @@ -14,12 +14,11 @@ (require 'f) (require 'markdown-mode) (require 'compat) -(require 'ediff) -(require 'smerge-mode) (require 'eca-util) (require 'eca-api) (require 'eca-mcp) +(require 'eca-diff) (require 'evil nil t) @@ -145,8 +144,7 @@ Must be a valid model supported by server, check `eca-chat-select-model`." (defcustom eca-chat-diff-tool 'ediff "Select the method for displaying file-change diffs in ECA chat." :type '(choice (const :tag "Side-by-side Ediff" ediff) - (const :tag "Merge-style Smerge" smerge) - (const :tag "Plain text diff" text)) + (const :tag "Merge-style Smerge" smerge)) :group 'eca) @@ -922,227 +920,6 @@ If FORCE? decide to OPEN? or not." (propertize (concat "-" (number-to-string (plist-get details :linesRemoved))) 'font-lock-face 'error) " ")) -(defun eca-chat--parse-unified-diff (diff-text) - "Parse DIFF-TEXT and return a plist with :original and :new strings." - (let ((orig '()) (new '()) in-hunk) - (dolist (l (split-string diff-text "\n")) - (cond - ((string-match "^@@.*@@$" l) (setq in-hunk t)) - ((and in-hunk (string-prefix-p " " l)) - (push (substring l 1) orig) (push (substring l 1) new)) - ((and in-hunk (string-prefix-p "-" l)) - (push (substring l 1) orig)) - ((and in-hunk (string-prefix-p "+" l)) - (push (substring l 1) new)))) - (list :original (string-join (nreverse orig) "\n") - :new (string-join (nreverse new) "\n")))) - - -(defun eca-chat--show-diff-text (path diff) - "Show DIFF for file at PATH as plain unified diff text. -Adds \\='q\\=' to quit." - (with-current-buffer (get-buffer-create (format "*eca-diff:%s*" path)) - (let ((inhibit-read-only t)) - (erase-buffer) - (insert diff) - (diff-mode) - (goto-char (point-min)) - ;; Add a local 'q' binding to kill this buffer quickly - (let ((map (make-sparse-keymap))) - (set-keymap-parent map (current-local-map)) - (define-key map (kbd "q") (lambda () (interactive) (kill-buffer (current-buffer)))) - (use-local-map map))) - (pop-to-buffer (current-buffer)))) - -(defun eca-chat--show-diff-ediff (path diff) - "Show DIFF for file at PATH using Ediff side-by-side in windows. -Uses window configuration management instead of creating frames. - -If the current window is a side window, temporarily clear side-window -protections so Ediff can split windows freely. The original window -configuration is restored when Ediff quits via the cleanup hook." - (let* ((parsed (eca-chat--parse-unified-diff diff)) - (orig (plist-get parsed :original)) - (new (plist-get parsed :new)) - (buf-orig (generate-new-buffer (format "*eca-diff-orig:%s*" path))) - (buf-new (generate-new-buffer (format "*eca-diff-new:%s*" path))) - (cwc (current-window-configuration)) - (orig-selected (selected-window)) - (ediff-buffers-before - (seq-filter (lambda (b) - (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name b))) - (buffer-list))) - (session-ediff-buffers nil) - (cleanup-fn nil) - (after-setup-fn nil)) - ;; Fill temporary buffers - (with-current-buffer buf-orig - (let ((inhibit-read-only t)) - (erase-buffer) - (insert orig) - (set-buffer-modified-p nil))) - (with-current-buffer buf-new - (let ((inhibit-read-only t)) - (erase-buffer) - (insert new) - (set-buffer-modified-p nil))) - - ;; Temporarily relax side-window protections in the current frame so - ;; Ediff can split windows. The original configuration will be - ;; restored by `cleanup-fn' (which calls `set-window-configuration'). - (let ((frame (selected-frame))) - (dolist (w (seq-filter (lambda (w) - (and (eq (window-frame w) frame) - (or (window-parameter w 'no-delete-other-windows) - (window-parameter w 'window-side)))) - (window-list))) - (when (window-live-p w) - (set-window-parameter w 'no-delete-other-windows nil) - (set-window-parameter w 'window-side nil)))) - - ;; Ensure Ediff has a single full window to manage (it will split it). - (unless (one-window-p t) - (delete-other-windows)) - - ;; Cleanup: restore windows and kill temp/session buffers - (setq cleanup-fn - (lambda () - ;; Restore window configuration saved at the beginning - (when (window-configuration-p cwc) - (set-window-configuration cwc)) - ;; Ensure focus returns to the original window if still live - (when (window-live-p orig-selected) - (select-window orig-selected)) - (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) - (when (buffer-live-p buf-new) (kill-buffer buf-new)) - (dolist (b session-ediff-buffers) - (when (and b (buffer-live-p b)) - (kill-buffer b))) - (remove-hook 'ediff-quit-hook cleanup-fn))) - - ;; After-setup hook: capture ediff buffers and move to first diff - (setq after-setup-fn - (lambda () - (let ((ediff-buffers-after - (seq-filter (lambda (b) - (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" (buffer-name b))) - (buffer-list)))) - (setq session-ediff-buffers - (seq-filter (lambda (b) - (and (not (member b ediff-buffers-before)) - (not (string-match-p "\*Ediff Registry\*" (buffer-name b))))) - ediff-buffers-after))) - (condition-case _err - (progn - (setq ediff-current-difference -1) - (ediff-next-difference)) - (error nil)) - (remove-hook 'ediff-after-setup-windows-hook after-setup-fn))) - - (add-hook 'ediff-quit-hook cleanup-fn) - (add-hook 'ediff-after-setup-windows-hook after-setup-fn) - - ;; Start Ediff in the prepared window environment. Ediff will manage - ;; splits from here on. On error, restore original windows/config. - (condition-case err - (ediff-buffers buf-orig buf-new) - (error - ;; On error remove hooks and kill temps, and restore windows - (remove-hook 'ediff-quit-hook cleanup-fn) - (remove-hook 'ediff-after-setup-windows-hook after-setup-fn) - (when (window-configuration-p cwc) - (set-window-configuration cwc)) - (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) - (when (buffer-live-p buf-new) (kill-buffer buf-new)) - (message "eca-chat: error starting ediff: %s" err))))) - -(defun eca-chat--show-diff-smerge (path diff) - "Show DIFF for file at PATH using Smerge in a dedicated window. -Uses regular window management instead of creating frames. - -If the current window is a side window, temporarily clear side-window -protections so `delete-other-windows' can succeed. The original window -configuration is restored when the smerge buffer is killed." - (let* ((parsed (eca-chat--parse-unified-diff diff)) - (orig (plist-get parsed :original)) - (new (plist-get parsed :new)) - (buf (generate-new-buffer (format "*eca-smerge:%s*" path))) - (cwc (current-window-configuration)) - (orig-selected (selected-window)) - (frame (selected-frame)) - (cleanup-running nil) - cleanup-fn - window-config-hook) - - ;; Fill buffer with conflict markers - (with-current-buffer buf - (let ((inhibit-read-only t)) - (erase-buffer) - (insert (concat "<<<<<<< Original\n" orig "\n=======\n" new "\n>>>>>>> New\n")) - (diff-mode) - (smerge-mode 1) - (goto-char (point-min)))) - - ;; Define cleanup that restores window configuration and kills the buffer - (setq cleanup-fn - (lambda () - (unless cleanup-running - (setq cleanup-running t) - (when (window-configuration-p cwc) - (ignore-errors (set-window-configuration cwc))) - (when (window-live-p orig-selected) - (select-window orig-selected)) - (when (buffer-live-p buf) - (with-current-buffer buf - (remove-hook 'kill-buffer-hook cleanup-fn t))) - (when (functionp window-config-hook) - (remove-hook 'window-configuration-change-hook window-config-hook)) - (when (buffer-live-p buf) - (kill-buffer buf))))) - - ;; If buffer is no longer visible, run cleanup - (setq window-config-hook - (lambda () - (unless (get-buffer-window buf t) - (funcall cleanup-fn)))) - (add-hook 'window-configuration-change-hook window-config-hook) - - ;; Add local keymap and kill hook to the smerge buffer - (with-current-buffer buf - (let ((map (make-sparse-keymap))) - (set-keymap-parent map (current-local-map)) - (define-key map (kbd "q") (lambda () (interactive) (funcall cleanup-fn))) - (use-local-map map)) - (add-hook 'kill-buffer-hook cleanup-fn nil t)) - - ;; Temporarily relax side-window protections in the current frame so - ;; `delete-other-windows' can succeed. The original window - ;; configuration is restored by `cleanup-fn' (which calls `set-window-configuration'). - (dolist (w (seq-filter (lambda (w) - (and (eq (window-frame w) frame) - (or (window-parameter w 'no-delete-other-windows) - (window-parameter w 'window-side)))) - (window-list))) - (when (window-live-p w) - (set-window-parameter w 'no-delete-other-windows nil) - (set-window-parameter w 'window-side nil))) - ;; Present the buffer full-frame - (when (window-live-p (frame-root-window frame)) - (select-window (frame-root-window frame))) - (unless (one-window-p t) - (delete-other-windows)) - (switch-to-buffer buf) - - ;; Return nil explicitly - nil)) - -(defun eca-chat--show-diff (path diff) - "Dispatch DIFF view based on `eca-chat-diff-tool` for PATH." - (pcase eca-chat-diff-tool - ('ediff (eca-chat--show-diff-ediff path diff)) - ('smerge (eca-chat--show-diff-smerge path diff)) - (_ (eca-chat--show-diff-text path diff)))) - (defun eca-chat--relativize-filename-for-workspace-root (filename roots) "Relativize the FILENAME if a workspace root is found for ROOTS." (or (-some->> (-first (lambda (root) (f-ancestor-of? root filename)) roots) @@ -1318,6 +1095,37 @@ of (LINE . CHARACTER) representing the current selection or cursor position." (declare-function evil-delete-backward-word "evil" ()) (declare-function evil-delete-back-to-indentation "evil" ()) +(defun eca-chat--parse-unified-diff (diff-text) + "Compatibility wrapper that delegates to `eca-diff-parse-unified-diff'. + +DIFF-TEXT is the unified diff string to parse and returns the parsed +plist produced by `eca-diff-parse-unified-diff'." + (eca-diff-parse-unified-diff diff-text)) + +(defun eca-chat--show-diff-ediff (path diff) + "Compatibility wrapper delegating to `eca-diff-show-ediff'. + +PATH is the file path being shown and DIFF is the unified diff text. +This wrapper passes the current buffer as CHAT-BUF so `eca-diff' can +restore the chat display after Ediff quits." + (eca-diff-show-ediff path diff (current-buffer) (lambda (b) (ignore-errors (eca-chat--display-buffer b))))) + + +(defun eca-chat--show-diff-smerge (path diff) + "Compatibility wrapper delegating to `eca-diff-show-smerge'. + +PATH is the file path being shown and DIFF is the unified diff text. +This wrapper passes the current buffer as CHAT-BUF so `eca-diff' can +restore the chat display after smerge quits." + (eca-diff-show-smerge path diff (current-buffer) (lambda (b) (ignore-errors (eca-chat--display-buffer b))))) + + +(defun eca-chat--show-diff (path diff) + "Dispatch DIFF view based on `eca-chat-diff-tool` for PATH." + (pcase eca-chat-diff-tool + ('ediff (eca-chat--show-diff-ediff path diff)) + ('smerge (eca-chat--show-diff-smerge path diff)))) + ;; Public (define-derived-mode eca-chat-mode markdown-mode "eca-chat" diff --git a/eca-diff.el b/eca-diff.el new file mode 100644 index 0000000..d25c5cf --- /dev/null +++ b/eca-diff.el @@ -0,0 +1,297 @@ +;;; eca-diff.el --- Diff helpers for ECA -*- lexical-binding: t; -*- +;; +;; Utilities to show unified diffs via text, ediff or smerge. +;; This file is extracted from eca-chat.el to keep diff rendering +;; logic isolated and reusable. +;; +;;; Commentary: +;; +;; Diff helpers for ECA: functions to parse and +;; display unified diffs via text, ediff or smerge. +;; This file is extracted from eca-chat.el to keep diff rendering +;; logic isolated and reusable. +;; +;;; Code: + +(require 'f) +(require 'ediff) +(require 'smerge-mode) +(require 'seq) +(require 'subr-x) + +(defun eca-diff-parse-unified-diff (diff-text) + "Parse DIFF-TEXT and return a plist with :original and :new strings. +Only hunks (lines between @@ ... @@) are considered for the content. +This mirrors the original parser used by the chat UI." + (let ((orig '()) (new '()) in-hunk) + (dolist (l (split-string diff-text "\n")) + (cond + ((string-match "^@@.*@@$" l) (setq in-hunk t)) + ((and in-hunk (string-prefix-p " " l)) + (push (substring l 1) orig) (push (substring l 1) new)) + ((and in-hunk (string-prefix-p "-" l)) + (push (substring l 1) orig)) + ((and in-hunk (string-prefix-p "+" l)) + (push (substring l 1) new)))) + (list :original (string-join (nreverse orig) "\n") + :new (string-join (nreverse new) "\n")))) + +(defun eca-diff--default-redisplay-fn (chat-buf) + "Default redisplay function used when a chat RE-DISPLAY-FN is not provided. +Shows CHAT-BUF using `display-buffer' if it's still alive." + (when (buffer-live-p chat-buf) + (ignore-errors (display-buffer chat-buf)))) + +(defun eca-diff-show-ediff (path diff &optional chat-buf redisplay-fn _) + "Show DIFF for file at PATH using Ediff side-by-side in windows. +If CHAT-BUF is provided it will be used to attempt to re-display the +chat buffer after Ediff quits. If REDISPLAY-FN is provided it will be +called with CHAT-BUF to perform that re-display (default: display-buffer). +ROOTS may be passed for path relativization if desired. + +This function tries to be Doom-compatible when Emacs runs Doom popup +system (it ignores those popup rules for the generated ediff buffers)." + (let* ((parsed (eca-diff-parse-unified-diff diff)) + (orig (plist-get parsed :original)) + (new (plist-get parsed :new)) + (buf-orig (generate-new-buffer (format "*eca-diff-orig:%s*" path))) + (buf-new (generate-new-buffer (format "*eca-diff-new:%s*" path))) + (chat-buf chat-buf) + (redisplay-fn (or redisplay-fn #'eca-diff--default-redisplay-fn)) + ;; Doom-specific: Check if we're in a popup context + (doom-popup-p (and (boundp '+popup-buffer-mode) +popup-buffer-mode)) + (in-doom-p (featurep 'doom)) + ;; Don't save window config if in Doom popup - causes conflicts + (cwc (unless doom-popup-p (current-window-configuration))) + (orig-selected (selected-window)) + (orig-buffer chat-buf) + (ediff-buffers-before + (seq-filter (lambda (b) + (string-match-p "\\*\\(ediff-\\|Ediff Control\\)" + (buffer-name b))) + (buffer-list))) + session-ediff-buffers + cleanup-fn + after-setup-fn) + + ;; Doom-specific: Set popup rules to prevent interference + (when in-doom-p + (when (fboundp 'set-popup-rule!) + ;; Prevent ECA diff buffers from being managed by Doom's popup system + (set-popup-rule! "^\\*eca-diff-" :ignore t) + (set-popup-rule! "^\\*Ediff Control Panel" :ignore t))) + + ;; Fill temporary buffers + (with-current-buffer buf-orig + (let ((inhibit-read-only t)) + (erase-buffer) + (insert orig) + (set-buffer-modified-p nil))) + (with-current-buffer buf-new + (let ((inhibit-read-only t)) + (erase-buffer) + (insert new) + (set-buffer-modified-p nil))) + + ;; Only manipulate windows if not in Doom popup context + (unless doom-popup-p + ;; Temporarily relax side-window protections so Ediff can split + (let ((frame (selected-frame))) + (dolist (w (seq-filter (lambda (w) + (and (eq (window-frame w) frame) + (or (window-parameter w 'no-delete-other-windows) + (window-parameter w 'window-side)))) + (window-list))) + (when (window-live-p w) + (set-window-parameter w 'no-delete-other-windows nil) + (set-window-parameter w 'window-side nil)))) + ;; Ensure Ediff has a single full window to manage + (unless (one-window-p t) + (delete-other-windows))) + + ;; Enhanced cleanup with Doom compatibility + (setq cleanup-fn + (lambda () + ;; Kill temp buffers first to avoid conflicts + (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) + (when (buffer-live-p buf-new) (kill-buffer buf-new)) + ;; Kill any additional Ediff-generated buffers + (dolist (b session-ediff-buffers) + (when (and b (buffer-live-p b)) + (kill-buffer b))) + + ;; Doom-aware window restoration + (cond + ;; If we were in a Doom popup, use Doom's restoration + (doom-popup-p + (cond + ((and (fboundp '+popup/restore) (buffer-live-p orig-buffer)) + (+popup/restore)) + ((buffer-live-p orig-buffer) + (switch-to-buffer orig-buffer)))) + + ;; Standard window config restoration with Doom-compatible delay + (cwc + ;; Use longer delay for Doom compatibility + (run-with-timer (if in-doom-p 0.3 0.1) nil + (lambda () + (condition-case err + (progn + (set-window-configuration cwc) + (when (window-live-p orig-selected) + (select-window orig-selected)) + ;; Re-display chat buffer in side window if needed + (when (and (buffer-live-p chat-buf) + (or (not (get-buffer-window chat-buf t)) + (not (window-parameter (get-buffer-window chat-buf t) 'window-side)))) + (ignore-errors (funcall redisplay-fn chat-buf)))) + (error + (message "ECA: Could not restore window config: %s" err) + ;; Fallback: ensure chat buffer is visible + (when (buffer-live-p chat-buf) + (ignore-errors (funcall redisplay-fn chat-buf)))))))) + + ;; Fallback for cases without saved config + (t + (when (and (buffer-live-p chat-buf) + (or (not (get-buffer-window chat-buf t)) + (not (window-parameter (get-buffer-window chat-buf t) 'window-side)))) + (ignore-errors (funcall redisplay-fn chat-buf))))) + + (remove-hook 'ediff-quit-hook cleanup-fn))) + + ;; After-setup hook: capture ediff buffers and move to first diff + (setq after-setup-fn + (lambda () + (let ((ediff-buffers-after + (seq-filter (lambda (b) + (string-match-p "\\*\\(ediff-\\|Ediff\n Control\\)" (buffer-name b))) + (buffer-list)))) + (setq session-ediff-buffers + (seq-filter (lambda (b) + (and (not (member b ediff-buffers-before)) + (not (string-match-p "*Ediff\n Registry*" (buffer-name b))))) + ediff-buffers-after))) + (condition-case _err + (progn + (setq ediff-current-difference -1) + (ediff-next-difference)) + (error nil)) + (remove-hook 'ediff-after-setup-windows-hook after-setup-fn))) + + ;; Install hooks + (add-hook 'ediff-quit-hook cleanup-fn) + (add-hook 'ediff-after-setup-windows-hook after-setup-fn) + + ;; Start Ediff with error handling + (condition-case err + (ediff-buffers buf-orig buf-new) + (error + ;; On error, remove hooks and restore windows + (remove-hook 'ediff-quit-hook cleanup-fn) + (remove-hook 'ediff-after-setup-windows-hook after-setup-fn) + + ;; Doom-aware error recovery + (cond + (cwc (set-window-configuration cwc)) + (doom-popup-p + (when (buffer-live-p orig-buffer) + (switch-to-buffer orig-buffer)))) + + ;; Re-display chat buffer on error + (when (and (buffer-live-p chat-buf) + (or (not (get-buffer-window chat-buf t)) + (not (window-parameter (get-buffer-window chat-buf t) 'window-side)))) + (ignore-errors (funcall redisplay-fn chat-buf))) + + ;; Kill temp buffers + (when (buffer-live-p buf-orig) (kill-buffer buf-orig)) + (when (buffer-live-p buf-new) (kill-buffer buf-new)) + (message "eca-diff: error starting ediff: %s" err))))) + +(defun eca-diff-show-smerge (path diff &optional chat-buf redisplay-fn _) + "Show DIFF for file at PATH using Smerge in a dedicated window. +If CHAT-BUF is provided, REDISPLAY-FN (called with CHAT-BUF) will be used +to re-show the chat window when the smerge buffer is killed." + (let* ((parsed (eca-diff-parse-unified-diff diff)) + (orig (plist-get parsed :original)) + (new (plist-get parsed :new)) + (buf (generate-new-buffer (format "*eca-smerge:%s*" path))) + (cwc (current-window-configuration)) + (orig-selected (selected-window)) + (frame (selected-frame)) + (cleanup-running nil) + cleanup-fn + window-config-hook + (redisplay-fn (or redisplay-fn #'eca-diff--default-redisplay-fn))) + + ;; Fill buffer with conflict markers + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (concat "<<<<<<< Original\n" orig "\n=======\n" new "\n>>>>>>> New\n")) + (diff-mode) + (smerge-mode 1) + (goto-char (point-min)))) + + ;; Define cleanup that restores window configuration and kills the buffer + (setq cleanup-fn + (lambda () + (unless cleanup-running + (setq cleanup-running t) + (when (window-configuration-p cwc) + (ignore-errors (set-window-configuration cwc))) + (when (window-live-p orig-selected) + (select-window orig-selected)) + (when (buffer-live-p buf) + (with-current-buffer buf + (remove-hook 'kill-buffer-hook cleanup-fn t))) + (when (functionp window-config-hook) + (remove-hook 'window-configuration-change-hook window-config-hook)) + (when (buffer-live-p buf) + (kill-buffer buf)) + ;; Re-display chat buffer if needed + (when (and (buffer-live-p chat-buf) + (or (not (get-buffer-window chat-buf t)) + (not (window-parameter (get-buffer-window chat-buf t) 'window-side)))) + (ignore-errors (funcall redisplay-fn chat-buf)))))) + + ;; If buffer is no longer visible, run cleanup + (setq window-config-hook + (lambda () + (unless (get-buffer-window buf t) + (funcall cleanup-fn)))) + (add-hook 'window-configuration-change-hook window-config-hook) + + ;; Add local keymap and kill hook to the smerge buffer + (with-current-buffer buf + (let ((map (make-sparse-keymap))) + (set-keymap-parent map (current-local-map)) + (define-key map (kbd "q") (lambda () (interactive) (funcall cleanup-fn))) + (use-local-map map)) + (add-hook 'kill-buffer-hook cleanup-fn nil t)) + + ;; Temporarily relax side-window protections in the current frame so + ;; `delete-other-windows' can succeed. The original window + ;; configuration is restored by `cleanup-fn' (which calls `set-window-configuration'). + (dolist (w (seq-filter (lambda (w) + (and (eq (window-frame w) frame) + (or (window-parameter w 'no-delete-other-windows) + (window-parameter w 'window-side)))) + (window-list))) + (when (window-live-p w) + (set-window-parameter w 'no-delete-other-windows nil) + (set-window-parameter w 'window-side nil))) + ;; Present the buffer full-frame + (when (window-live-p (frame-root-window frame)) + (select-window (frame-root-window frame))) + (unless (one-window-p t) + (delete-other-windows)) + (switch-to-buffer buf) + + ;; Return nil explicitly + nil)) + + +(provide 'eca-diff) +;;; eca-diff.el ends here