Files
andros 22af851d6a Prepare package for MELPA submission
- Add GPL-3.0 LICENSE file
- Add checks.sh script for quality validation (checkdoc, package-lint, melpazoid)
- Fix checkdoc issue: quote symbol in docstring
- Remove redundant smerge-mode-hook (already handled by globalized mode)
- Move configuration examples from example-config.el to README
- Add advanced customization sections to README (lazy loading, terminal, Magit)
- Update Contributing section with link to contribution guidelines
- Update .gitignore to exclude melpazoid/ directory

All MELPA quality checks now pass successfully.
2026-02-23 08:40:20 +01:00

240 lines
8.5 KiB
EmacsLisp

;;; conflict-buttons.el --- Clickable buttons for smerge-mode conflicts -*- lexical-binding: t; -*-
;; Copyright (C) 2026 Andros Fenollosa
;; Author: Andros Fenollosa <hi@andros.dev>
;; Version: 0.1.0
;; Package-Requires: ((emacs "26.1"))
;; Keywords: vc, tools, convenience
;; URL: https://git.andros.dev/andros/conflict-buttons.el
;; SPDX-License-Identifier: GPL-3.0-or-later
;;; Commentary:
;; This package adds clickable inline buttons to smerge-mode conflict markers,
;; similar to VS Code's CodeLens feature. When a merge conflict is detected,
;; buttons appear above the conflict markers allowing one-click resolution.
;;
;; Available actions:
;; - Accept Current: keep the current branch's changes
;; - Accept Incoming: keep the incoming branch's changes
;; - Accept Both: keep both changes
;; - Compare: open ediff to compare versions
;;
;; The buttons appear automatically when smerge-mode is active.
;;
;; Usage:
;; (add-hook 'smerge-mode-hook #'conflict-buttons-mode)
;;
;; Or enable globally:
;; (conflict-buttons-global-mode 1)
;;; Code:
(require 'smerge-mode)
(defgroup conflict-buttons nil
"Clickable buttons for merge conflict resolution."
:group 'smerge
:prefix "conflict-buttons-")
(defcustom conflict-buttons-separator " | "
"Separator string between buttons."
:type 'string
:group 'conflict-buttons)
(defcustom conflict-buttons-style 'unicode
"Style for button rendering.
Options are:
`unicode' - use Unicode box drawing characters
`ascii' - use simple ASCII brackets"
:type '(choice (const :tag "Unicode" unicode)
(const :tag "ASCII" ascii))
:group 'conflict-buttons)
(defface conflict-buttons-button-face
'((t :inherit button :weight bold))
"Face for conflict resolution buttons."
:group 'conflict-buttons)
(defface conflict-buttons-button-hover-face
'((t :inherit highlight :weight bold))
"Face for conflict resolution buttons when mouse hovers over them."
:group 'conflict-buttons)
(defvar-local conflict-buttons--overlays nil
"List of overlays created by `conflict-buttons-mode'.")
(defvar-local conflict-buttons--timer nil
"Timer for delayed refresh of conflict buttons.")
(defvar conflict-buttons-mode) ;; Defined later by define-minor-mode
(defun conflict-buttons--create-button (label action)
"Create a propertized button string with LABEL to execute ACTION."
(let ((map (make-sparse-keymap))
(formatted-label (if (eq conflict-buttons-style 'unicode)
(format "[ %s ]" label)
(format "[%s]" label))))
(define-key map [mouse-1] action)
(define-key map (kbd "RET") action)
(propertize formatted-label
'face 'conflict-buttons-button-face
'mouse-face 'conflict-buttons-button-hover-face
'keymap map
'help-echo (format "Click to %s" label))))
(defun conflict-buttons--goto-conflict ()
"Move point to the nearest conflict marker."
(let ((start (point)))
;; Try to find conflict marker backwards first
(unless (re-search-backward "^<<<<<<< " nil t)
;; If not found backwards, try forwards
(goto-char start)
(unless (re-search-forward "^<<<<<<< " nil t)
(error "No conflict found")))
;; Position cursor inside the conflict (after the marker)
(forward-line 1)))
(defun conflict-buttons--keep-current ()
"Keep the current branch's version and refresh buttons."
(interactive)
(condition-case err
(progn
(save-excursion
(conflict-buttons--goto-conflict)
(smerge-keep-current))
(conflict-buttons--refresh))
(error (message "Error resolving conflict: %s" (error-message-string err)))))
(defun conflict-buttons--keep-other ()
"Keep the incoming branch's version and refresh buttons."
(interactive)
(condition-case err
(progn
(save-excursion
(conflict-buttons--goto-conflict)
(smerge-keep-lower))
(conflict-buttons--refresh))
(error (message "Error resolving conflict: %s" (error-message-string err)))))
(defun conflict-buttons--keep-all ()
"Keep both versions and refresh buttons."
(interactive)
(condition-case err
(progn
(save-excursion
(conflict-buttons--goto-conflict)
(smerge-keep-all))
(conflict-buttons--refresh))
(error (message "Error resolving conflict: %s" (error-message-string err)))))
(defun conflict-buttons--compare ()
"Open ediff to compare conflict versions."
(interactive)
(condition-case err
(progn
(save-excursion
(conflict-buttons--goto-conflict)
(call-interactively #'smerge-ediff))
;; Refresh after ediff in case the conflict was resolved
(run-with-idle-timer 0.5 nil
(let ((buf (current-buffer)))
(lambda ()
(when (buffer-live-p buf)
(with-current-buffer buf
(conflict-buttons--refresh)))))))
(error (message "Error comparing conflict: %s" (error-message-string err)))))
(defun conflict-buttons--create-buttons-string ()
"Create the complete button string with all actions."
(concat
(conflict-buttons--create-button "Accept Current" #'conflict-buttons--keep-current)
conflict-buttons-separator
(conflict-buttons--create-button "Accept Incoming" #'conflict-buttons--keep-other)
conflict-buttons-separator
(conflict-buttons--create-button "Accept Both" #'conflict-buttons--keep-all)
conflict-buttons-separator
(conflict-buttons--create-button "Compare" #'conflict-buttons--compare)
"\n"))
(defun conflict-buttons--add-overlay (pos)
"Add button overlay at position POS."
(let ((ov (make-overlay pos pos)))
(overlay-put ov 'before-string (conflict-buttons--create-buttons-string))
(overlay-put ov 'conflict-buttons t)
(push ov conflict-buttons--overlays)))
(defun conflict-buttons--remove-overlays ()
"Remove all conflict button overlays."
(mapc #'delete-overlay conflict-buttons--overlays)
(setq conflict-buttons--overlays nil))
(defun conflict-buttons--refresh ()
"Refresh conflict button overlays."
(when conflict-buttons-mode
(conflict-buttons--remove-overlays)
(conflict-buttons--install)))
(defun conflict-buttons--install ()
"Install button overlays on all conflict markers in the buffer."
(save-excursion
(goto-char (point-min))
(while (re-search-forward "^<<<<<<< " nil t)
(conflict-buttons--add-overlay (line-beginning-position)))))
(defun conflict-buttons--after-change (_beg _end _len)
"Hook function to refresh buttons after the buffer is modified."
(when (and conflict-buttons-mode
(bound-and-true-p smerge-mode))
;; Cancel existing timer to avoid accumulation
(when conflict-buttons--timer
(cancel-timer conflict-buttons--timer))
;; Create new timer with buffer context
(let ((buf (current-buffer)))
(setq conflict-buttons--timer
(run-with-idle-timer 0.1 nil
(lambda ()
(when (buffer-live-p buf)
(with-current-buffer buf
(conflict-buttons--refresh)))))))))
;;;###autoload
(define-minor-mode conflict-buttons-mode
"Toggle display of clickable buttons for merge conflict resolution.
When enabled, clickable buttons appear above merge conflict markers,
allowing quick resolution with a single click. The buttons provide
actions to accept the current version, accept the incoming version,
accept both, or compare the versions using ediff."
:lighter " CB"
:group 'conflict-buttons
(if conflict-buttons-mode
(progn
(conflict-buttons--install)
(add-hook 'after-change-functions #'conflict-buttons--after-change nil t))
(conflict-buttons--remove-overlays)
;; Cancel pending timer
(when conflict-buttons--timer
(cancel-timer conflict-buttons--timer)
(setq conflict-buttons--timer nil))
(remove-hook 'after-change-functions #'conflict-buttons--after-change t)))
;;;###autoload
(define-globalized-minor-mode conflict-buttons-global-mode
conflict-buttons-mode
(lambda ()
(when (bound-and-true-p smerge-mode)
(conflict-buttons-mode 1)))
:group 'conflict-buttons)
;;;###autoload
(defun conflict-buttons-setup ()
"Setup conflict-buttons to activate with `smerge-mode'.
Add this to your init file:
(add-hook \\='smerge-mode-hook #\\='conflict-buttons-setup)"
(conflict-buttons-mode 1))
(provide 'conflict-buttons)
;;; conflict-buttons.el ends here