diff --git a/docs/majutsu.org b/docs/majutsu.org index 5cc26eb..ab5a342 100644 --- a/docs/majutsu.org +++ b/docs/majutsu.org @@ -134,7 +134,8 @@ Press ~d~ to open the Diff transient. *** Diff Buffer The diff buffer is highly interactive: -- ~RET~ :: Visit the file in your workspace at the corresponding line. +- ~RET~ :: Visit the appropriate version of the file at point. For working copy diffs, visits the actual file. For committed changes, visits the blob. +- ~C-j~ / ~C-~ :: Visit the workspace file, regardless of diff type. Note: when Evil mode is active, ~C-j~ may be overridden by Evil's section navigation; use ~C-~ instead, which is unaffected. - ~+~ / ~-~ :: Increase or decrease the amount of context shown. - ~t~ :: Toggle word-level refinement. diff --git a/majutsu-base.el b/majutsu-base.el index 946debb..696d5ff 100644 --- a/majutsu-base.el +++ b/majutsu-base.el @@ -262,6 +262,26 @@ of the selected frame." (unless (derived-mode-p mode) (user-error "Command is only valid in %s buffers" mode))) +;;; Change at Point + +(defvar majutsu-buffer-blob-revision) + +(defun majutsu-revision-at-point () + "Return the change-id at point. +This checks multiple sources in order: +1. Section value (jj-commit section) +2. Blob buffer revision +3. Diff buffer revision" + (or (magit-section-value-if 'jj-commit) + (and (bound-and-true-p majutsu-buffer-blob-revision) + majutsu-buffer-blob-revision) + (and (derived-mode-p 'majutsu-diff-mode) + (bound-and-true-p majutsu-buffer-diff-range) + (let ((range majutsu-buffer-diff-range)) + (or (and (equal (car range) "-r") (cadr range)) + (when-let* ((arg (seq-find (lambda (item) (string-prefix-p "--revisions=" item)) range))) + (substring arg (length "--revisions=")))))))) + ;;; _ (provide 'majutsu-base) ;;; majutsu-base.el ends here diff --git a/majutsu-diff.el b/majutsu-diff.el index a77d123..16af87d 100644 --- a/majutsu-diff.el +++ b/majutsu-diff.el @@ -21,6 +21,7 @@ (require 'majutsu-config) (require 'majutsu-selection) (require 'majutsu-section) +(require 'majutsu-file) (require 'magit-diff) ; for faces/font-lock keywords (require 'diff-mode) (require 'smerge-mode) @@ -701,31 +702,146 @@ works with the simplified jj diff we render here." (when-let* ((file (majutsu-file-at-point))) (find-file (expand-file-name file default-directory)))) +(defun majutsu-diff--range-value (range prefix) + "Return the value in RANGE for argument starting with PREFIX." + (when range + (when-let* ((arg (seq-find (lambda (item) (string-prefix-p prefix item)) range))) + (substring arg (length prefix))))) + +(defun majutsu-diff--on-removed-line-p () + "Return non-nil if point is on a removed diff line." + (eq (char-after (line-beginning-position)) ?-)) + +(defun majutsu-diff--default-revset () + "Return the revset implied by the current diff buffer." + (let* ((range majutsu-buffer-diff-range) + (removed (majutsu-diff--on-removed-line-p)) + (from (majutsu-diff--range-value range "--from=")) + (to (majutsu-diff--range-value range "--to=")) + (revisions (majutsu-diff--range-value range "--revisions="))) + (cond + ((and range (equal (car range) "-r") (cadr range)) (cadr range)) + (revisions revisions) + (from (if (and removed from) from (or to from))) + (t "@")))) + +(defun majutsu-diff--hunk-line (section goto-from) + "Return the line number in SECTION for GOTO-FROM side." + (with-slots (content from-range to-range) section + (let ((start (car (if goto-from from-range to-range)))) + (when start + (let ((line start) + (target (point))) + (save-excursion + (goto-char content) + (while (< (point) target) + (let ((ch (char-after (line-beginning-position)))) + (cond + ((eq ch ?+) (unless goto-from (setq line (1+ line)))) + ((eq ch ?-) (when goto-from (setq line (1+ line)))) + (t (setq line (1+ line))))) + (forward-line 1))) + line))))) + +(defun majutsu-diff--hunk-column (section goto-from) + "Return the column for SECTION based on GOTO-FROM side." + (let ((bol (line-beginning-position))) + (if (or (< (point) (oref section content)) + (and (not goto-from) (eq (char-after bol) ?-))) + 0 + (let ((col (current-column))) + (if (memq (char-after bol) '(?+ ?-)) + (max 0 (1- col)) + col))))) + +(defun majutsu-diff--goto-line-col (buffer line col) + "Move point in BUFFER to LINE and COL." + (with-current-buffer buffer + (widen) + (goto-char (point-min)) + (forward-line (max 0 (1- line))) + (move-to-column col))) + +(defun majutsu-diff--visit-workspace-p () + "Return non-nil if the current diff should visit the workspace file. +This is true when diffing the working copy (@) on the new/right side." + (let* ((range majutsu-buffer-diff-range) + (to (majutsu-diff--range-value range "--to=")) + (revisions (majutsu-diff--range-value range "--revisions="))) + (cond + ;; Explicit --to=@ means we're looking at working copy changes + ((equal to "@") t) + ;; No range specified defaults to -r @ (working copy) + ((null range) t) + ;; Single revision diff (-r @) shows working copy + ((and revisions (equal revisions "@")) t) + ;; Otherwise we're looking at committed changes + (t nil)))) + ;;;###autoload -(defun majutsu-diff-visit-file () - "Visit the file at point. +(defun majutsu-diff-visit-file (&optional force-workspace) + "From a diff, visit the appropriate version of the file at point. -When point is on a hunk section, jump to the corresponding line in the -file." - (interactive) - (let ((section (magit-current-section))) - (cond - ((and section (magit-section-match 'jj-hunk section)) - (majutsu-goto-diff-line)) - ((majutsu-file-at-point) - (majutsu-visit-file)) - (t - (user-error "No file at point"))))) +If point is on an added or context line, visit the new/right side. +If point is on a removed line, visit the old/left side. + +For diffs of the working copy (@), this visits the actual file in +the workspace. For diffs of committed changes, this visits the +blob from the appropriate revision. + +With prefix argument FORCE-WORKSPACE, always visit the workspace file +regardless of what the diff is about." + (interactive "P") + (let* ((section (magit-current-section)) + (file (majutsu-file-at-point))) + (unless file + (user-error "No file at point")) + (let* ((goto-from (and section (magit-section-match 'jj-hunk section) + (majutsu-diff--on-removed-line-p))) + (goto-workspace (or force-workspace + (and (majutsu-diff--visit-workspace-p) + (not goto-from)))) + (line (and section (magit-section-match 'jj-hunk section) + (majutsu-diff--hunk-line section goto-from))) + (col (and section (magit-section-match 'jj-hunk section) + (majutsu-diff--hunk-column section goto-from)))) + (if goto-workspace + ;; Visit workspace file + (let ((full-path (expand-file-name file default-directory))) + (if (file-exists-p full-path) + (progn + (find-file full-path) + (when (and line col) + (goto-char (point-min)) + (forward-line (1- line)) + (move-to-column col))) + (user-error "File does not exist in workspace: %s" file))) + ;; Visit blob + (let* ((revset (majutsu-diff--default-revset)) + (buf (majutsu-find-file revset file))) + (when (and buf line col) + (majutsu-diff--goto-line-col buf line col))))))) ;;; Section Keymaps (defvar-keymap majutsu-diff-section-map :doc "Keymap for diff sections." - " " #'majutsu-diff-visit-file) + " " #'majutsu-diff-visit-file + "C-j" #'majutsu-diff-visit-workspace-file + "C-" #'majutsu-diff-visit-workspace-file) + +;;;###autoload +(defun majutsu-diff-visit-workspace-file () + "From a diff, visit the workspace version of the file at point. +Always visits the actual file in the working tree, regardless of +what the diff is about." + (interactive) + (majutsu-diff-visit-file t)) (defvar-keymap majutsu-file-section-map :doc "Keymap for `jj-file' sections." - :parent majutsu-diff-section-map) + :parent majutsu-diff-section-map + "v" #'majutsu-find-file-at-point) (defvar-keymap majutsu-hunk-section-map :doc "Keymap for `jj-hunk' sections." diff --git a/majutsu-edit.el b/majutsu-edit.el index 5fbed9e..e7f291a 100644 --- a/majutsu-edit.el +++ b/majutsu-edit.el @@ -38,11 +38,12 @@ With prefix ARG, pass --ignore-immutable." (interactive "P") - (when-let* ((revset (magit-section-value-if 'jj-commit)) - (args (append (list "edit" revset) - (when arg (list "--ignore-immutable"))))) - (when (zerop (apply #'majutsu-run-jj args)) - (message "Now editing commit %s" revset)))) + (if-let* ((revset (majutsu-revision-at-point)) + (args (append (list "edit" revset) + (when arg (list "--ignore-immutable"))))) + (when (zerop (apply #'majutsu-run-jj args)) + (message "Now editing commit %s" revset)) + (user-error "No revision at point"))) ;;; _ (provide 'majutsu-edit) diff --git a/majutsu-evil.el b/majutsu-evil.el index fded06b..ea6b390 100644 --- a/majutsu-evil.el +++ b/majutsu-evil.el @@ -82,10 +82,28 @@ If KEYMAP is not yet bound, defer binding until it becomes available." majutsu-diff-mode)) (evil-set-initial-state mode majutsu-evil-initial-state)))) +(defun majutsu-evil--adjust-section-bindings () + "Unbind C-j from section maps so Evil navigation takes precedence. +This mirrors `evil-collection-magit-adjust-section-bindings'." + (when (boundp 'majutsu-diff-section-map) + (define-key majutsu-diff-section-map "\C-j" nil)) + (when (boundp 'majutsu-file-section-map) + (define-key majutsu-file-section-map "\C-j" nil)) + (when (boundp 'majutsu-hunk-section-map) + (define-key majutsu-hunk-section-map "\C-j" nil))) + (defun majutsu-evil--define-mode-keys () "Install Evil keybindings for Majutsu maps." + ;; Unbind C-j from section maps first. + (majutsu-evil--adjust-section-bindings) ;; Normal/visual/motion share the same bindings for navigation commands. (majutsu-evil--define-keys '(normal visual motion) 'majutsu-mode-map + (kbd "C-j") #'magit-section-forward + (kbd "C-k") #'magit-section-backward + (kbd "g j") #'magit-section-forward-sibling + (kbd "g k") #'magit-section-backward-sibling + (kbd "]") #'magit-section-forward-sibling + (kbd "[") #'magit-section-backward-sibling (kbd "R") #'majutsu-restore (kbd "g r") #'majutsu-refresh (kbd "`") #'majutsu-process-buffer @@ -114,7 +132,17 @@ If KEYMAP is not yet bound, defer binding until it becomes available." (kbd "Y") #'majutsu-duplicate-dwim) (majutsu-evil--define-keys '(normal visual) 'majutsu-diff-mode-map - (kbd "g d") #'majutsu-jump-to-diffstat-or-diff) + (kbd "g d") #'majutsu-jump-to-diffstat-or-diff + (kbd "C-") #'majutsu-diff-visit-workspace-file) + + ;; majutsu-blob-mode is a minor mode, need hook + define-keys + (add-hook 'majutsu-blob-mode-hook #'evil-normalize-keymaps) + (majutsu-evil--define-keys '(normal visual motion) 'majutsu-blob-mode-map + (kbd "p") #'majutsu-blob-previous + (kbd "n") #'majutsu-blob-next + (kbd "q") #'majutsu-blob-quit + ;; RET visits the revision (edit) + (kbd "RET") #'majutsu-edit-changeset) (majutsu-evil--define-keys '(normal visual motion) 'majutsu-log-mode-map (kbd ".") #'majutsu-log-goto-@ diff --git a/majutsu-file.el b/majutsu-file.el new file mode 100644 index 0000000..2b839a5 --- /dev/null +++ b/majutsu-file.el @@ -0,0 +1,371 @@ +;;; majutsu-file.el --- Finding files -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 0WD0 + +;; Author: 0WD0 +;; Maintainer: 0WD0 +;; Keywords: tools, vc +;; URL: https://github.com/0WD0/majutsu + +;;; Commentary: + +;; Support jj file commands and blob buffers. + +;;; Code: + +(require 'cl-lib) +(require 'subr-x) +(require 'magit-section) +(require 'majutsu-base) +(require 'majutsu-jj) +(require 'majutsu-process) + +(declare-function majutsu-edit-changeset "majutsu-edit" (&optional arg)) + +(defvar majutsu-find-file-hook nil + "Hook run after creating a blob buffer.") + +(defvar majutsu-file--list-cache nil + "Alist cache of file lists keyed by revset string.") + +(defvar-local majutsu-buffer-blob-revset nil + "Input revset string for this blob buffer.") +(defvar-local majutsu-buffer-blob-revision nil + "Resolved single revision (change-id or commit-id) for this blob buffer.") +(defvar-local majutsu-buffer-blob-path nil + "Relative path for this blob buffer.") +(defvar-local majutsu-buffer-blob-root nil + "Repository root for this blob buffer.") + +(put 'majutsu-buffer-blob-revset 'permanent-local t) +(put 'majutsu-buffer-blob-revision 'permanent-local t) +(put 'majutsu-buffer-blob-path 'permanent-local t) +(put 'majutsu-buffer-blob-root 'permanent-local t) + +(add-hook 'majutsu-find-file-hook #'majutsu-blob-mode) + +(defun majutsu-file--normalize-revset (revset) + "Normalize REVSET to a single revision using jj revset functions." + (format "exactly(latest(%s), 1)" revset)) + +(defun majutsu-file--resolve-single-rev (revset) + "Resolve REVSET to a single revision string. +Uses `latest` and `exactly` to enforce a single target." + (let* ((normalized (majutsu-file--normalize-revset revset)) + (result (string-trim + (ansi-color-apply (majutsu-jj-string "log" "-r" normalized + "-T" "change_id" "--no-graph" "--limit" "1"))))) + ;; Return nil when revset yields no result. + (unless (string-empty-p result) + result))) + +(defun majutsu-file--list (revset root) + "Return list of file paths for REVSET in ROOT. +Results are cached in `majutsu-file--list-cache`." + (let* ((normalized (majutsu-file--normalize-revset revset)) + (cache-key (cons root normalized)) + (cached (assoc cache-key majutsu-file--list-cache))) + (if cached + (cdr cached) + (let* ((default-directory root) + (output (majutsu-jj-string "file" "list" "-r" normalized)) + (paths (seq-remove #'string-empty-p (split-string output "\n")))) + (push (cons cache-key paths) majutsu-file--list-cache) + paths)))) + +(defun majutsu-file--show (revset path root) + "Return file contents for REVSET and PATH in ROOT as a string." + (let ((default-directory root)) + (majutsu-jj-string "file" "show" "-r" revset + (majutsu-jj-fileset-quote path)))) + +(defun majutsu-file--buffer-name (revset path) + "Return a blob buffer name for REVSET and PATH." + (format "%s@~%s~" path revset)) + +(defun majutsu-file--root () + "Return repo root for current buffer." + (or (majutsu--buffer-root) (majutsu-toplevel default-directory))) + +(defun majutsu-file--relative-path (root path) + "Return PATH relative to ROOT." + (file-relative-name (expand-file-name path root) root)) + +(defun majutsu-file--path-at-point (root) + "Return path from context or nil." + (or (magit-section-value-if 'jj-file) + (majutsu-file-at-point) + (when-let* ((file buffer-file-name)) + (majutsu-file--relative-path root file)))) + +(defun majutsu-file--read-path (revset root) + "Prompt for a file path from REVSET." + (let* ((paths (majutsu-file--list revset root)) + (default (majutsu-file--path-at-point root))) + (when (and default (not (member default paths))) + (setq default nil)) + (completing-read "Find file: " paths nil t nil nil default))) + +(defun majutsu-file--diff-range-value (range prefix) + "Return the value in RANGE for argument starting with PREFIX." + (when range + (when-let* ((arg (seq-find (lambda (item) (string-prefix-p prefix item)) range))) + (substring arg (length prefix))))) + +(defun majutsu-file--diff-revset () + "Return the revset implied by the current diff buffer, if any." + (when (derived-mode-p 'majutsu-diff-mode) + (let* ((range majutsu-buffer-diff-range) + (removed (eq (char-after (line-beginning-position)) ?-)) + (from (majutsu-file--diff-range-value range "--from=")) + (to (majutsu-file--diff-range-value range "--to=")) + (revisions (majutsu-file--diff-range-value range "--revisions="))) + (cond + ((and range (equal (car range) "-r") (cadr range)) (cadr range)) + (revisions revisions) + (from (if (and removed from) from (or to from))) + (t "@"))))) + +(defun majutsu-file--default-revset () + "Return default revset for the current context." + (or (majutsu-file--diff-revset) + (when-let* ((value (magit-section-value-if 'jj-commit))) + (majutsu--normalize-id-value value)) + "@")) + +(defun majutsu-find-file-read-args (prompt) + "Read revset and file path for PROMPT." + (let* ((root (majutsu-file--root)) + (revset (majutsu-read-revset prompt (majutsu-file--default-revset))) + (path (or (majutsu-file--path-at-point root) + (majutsu-file--read-path revset root)))) + (list revset path))) + +(defun majutsu-find-file--ensure-buffer (root revset path &optional revert) + "Return a buffer visiting PATH from REVSET. +ROOT is the repository root." + (let* ((buf-name (majutsu-file--buffer-name revset path)) + (buffer (get-buffer-create buf-name)) + (resolved (or (majutsu-file--resolve-single-rev revset) + (user-error "Revset does not resolve to a single revision")))) + (with-current-buffer buffer + (when (or revert + (not majutsu-buffer-blob-path)) + (setq majutsu-buffer-blob-root root) + (setq majutsu-buffer-blob-revset revset) + (setq majutsu-buffer-blob-revision resolved) + (setq majutsu-buffer-blob-path path) + (setq default-directory root) + (setq-local revert-buffer-function #'majutsu-file-revert-buffer) + (majutsu-file-revert-buffer nil t) + (run-hooks 'majutsu-find-file-hook))) + buffer)) + +(defun majutsu-file-revert-buffer (_ignore-auto noconfirm) + "Revert the current blob buffer content." + (when (or noconfirm + (not (buffer-modified-p)) + (y-or-n-p "Revert blob buffer? ")) + (let* ((inhibit-read-only t) + (root majutsu-buffer-blob-root) + (revset (majutsu-file--normalize-revset majutsu-buffer-blob-revset)) + (path majutsu-buffer-blob-path) + (content (majutsu-file--show revset path root))) + (erase-buffer) + (insert content) + (let ((buffer-file-name (expand-file-name path root)) + (after-change-major-mode-hook + (seq-difference after-change-major-mode-hook + '(global-diff-hl-mode-enable-in-buffer + global-diff-hl-mode-enable-in-buffers) + #'eq))) + (normal-mode (not enable-local-variables))) + (setq buffer-read-only t) + (set-buffer-modified-p nil) + (goto-char (point-min))))) + +(defun majutsu-find-file--display (revset path display-fn) + "Display PATH from REVSET using DISPLAY-FN." + (let* ((root (majutsu-file--root)) + (path (majutsu-file--relative-path root path)) + (buffer (majutsu-find-file--ensure-buffer root revset path))) + (funcall display-fn buffer))) + +;;;###autoload +(defun majutsu-find-file (revset path) + "View PATH from REVSET in a blob buffer." + (interactive (majutsu-find-file-read-args "Find file")) + (majutsu-find-file--display revset path #'pop-to-buffer)) + +;;;###autoload +(defun majutsu-find-file-at-point () + "View file at point from the relevant revision." + (interactive) + (let* ((root (majutsu-file--root)) + (revset (majutsu-file--default-revset)) + (path (or (majutsu-file--path-at-point root) + (majutsu-file--read-path revset root)))) + (majutsu-find-file revset path))) + +(defun majutsu-bury-or-kill-buffer (&optional bury-buffer) + "Bury the current buffer if displayed in multiple windows, else kill it. +With a prefix argument BURY-BUFFER only bury the buffer even if it is only +displayed in a single window." + (interactive "P") + (if (or bury-buffer (cdr (get-buffer-window-list nil nil t))) + (bury-buffer) + (kill-buffer))) + +(defun majutsu-blob-quit () + "Bury or kill the current blob buffer." + (interactive) + (unless (bound-and-true-p majutsu-blob-mode) + (user-error "Not in a blob buffer")) + (majutsu-bury-or-kill-buffer)) + +(defun majutsu-blob-visit-file () + "Visit the workspace version of the current blob's file." + (interactive) + (unless (and (bound-and-true-p majutsu-blob-mode) + majutsu-buffer-blob-root + majutsu-buffer-blob-path) + (user-error "Not in a blob buffer")) + (let ((file (expand-file-name majutsu-buffer-blob-path + majutsu-buffer-blob-root))) + (find-file file))) + +(defun majutsu-file--revset-for-files (revset path direction) + "Build a revset for PATH and DIRECTION relative to REVSET. +DIRECTION should be either \='prev or \='next." + (let* ((file-set (format "files(%s)" (majutsu-jj-fileset-quote path)))) + (pcase direction + ('prev (format "::%s-&%s" revset file-set)) + ('next (format "roots(%s+::&%s)" revset file-set)) + (_ (user-error "Unknown direction"))))) + +(defun majutsu-file--diff-offset (diff line) + "Return LINE offset after applying DIFF hunks. +DIFF must be a unified diff." + (let ((offset 0)) + (with-temp-buffer + (insert diff) + (goto-char (point-min)) + (catch 'found + (while (re-search-forward + "^@@ -\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? \\\+\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? @@.*\\n" + nil t) + (let* ((from-beg (string-to-number (match-string 1))) + (from-len (if (match-string 2) + (string-to-number (match-string 2)) + 1)) + (to-len (if (match-string 4) + (string-to-number (match-string 4)) + 1))) + (if (<= from-beg line) + (if (<= (+ from-beg from-len) line) + (setq offset (+ offset (- to-len from-len))) + (let ((rest (- line from-beg))) + (while (> rest 0) + (pcase (char-after) + (?\s (setq rest (1- rest))) + (?- (setq offset (1- offset)) + (setq rest (1- rest))) + (?+ (setq offset (1+ offset)))) + (forward-line 1)))) + (throw 'found nil)))))) + (+ line offset))) + +(defun majutsu-file--map-line (root from-rev to-rev path line) + "Map LINE in FROM-REV to the corresponding line in TO-REV." + (let* ((default-directory root) + (diff (majutsu-jj-string "diff" "--from" from-rev "--to" to-rev "--" + (majutsu-jj-fileset-quote path)))) + (if (string-empty-p diff) + line + (majutsu-file--diff-offset diff line)))) + +(defun majutsu-file--goto-line-col (line col) + "Move point to LINE and COL in current buffer." + (widen) + (goto-char (point-min)) + (forward-line (max 0 (1- line))) + (move-to-column col)) + +(defun majutsu-file-prev-change (revset path) + "Return the previous change-id modifying PATH before REVSET." + (let* ((query (majutsu-file--revset-for-files revset path 'prev)) + (result (string-trim + (majutsu-jj-string "log" "-r" query "-G" + "--limit" "1" "-T" "change_id")))) + (unless (string-empty-p result) + result))) + +(defun majutsu-file-next-change (revset path) + "Return the next change-id modifying PATH after REVSET." + (let* ((query (majutsu-file--revset-for-files revset path 'next)) + (result (string-trim + (majutsu-jj-string "log" "-r" query "-G" "--reversed" + "--limit" "1" "-T" "change_id")))) + (unless (string-empty-p result) + result))) + +(defun majutsu-blob-previous () + "Visit previous blob that modified current file." + (interactive) + (unless (and majutsu-buffer-blob-revision majutsu-buffer-blob-path) + (user-error "Not in a blob buffer")) + (let* ((root majutsu-buffer-blob-root) + (from-rev majutsu-buffer-blob-revision) + (path majutsu-buffer-blob-path) + (line (line-number-at-pos)) + (col (current-column))) + (if-let* ((prev (majutsu-file-prev-change from-rev path))) + (let ((target-line (majutsu-file--map-line root from-rev prev path line))) + (majutsu-find-file--display prev path #'switch-to-buffer) + (majutsu-file--goto-line-col target-line col)) + (user-error "You have reached the beginning of time")))) + +(defun majutsu-blob-next () + "Visit next blob that modified current file." + (interactive) + (unless (and majutsu-buffer-blob-revision majutsu-buffer-blob-path) + (user-error "Not in a blob buffer")) + (let* ((root majutsu-buffer-blob-root) + (from-rev majutsu-buffer-blob-revision) + (path majutsu-buffer-blob-path) + (line (line-number-at-pos)) + (col (current-column))) + (if-let* ((next (majutsu-file-next-change from-rev path))) + (let ((target-line (majutsu-file--map-line root from-rev next path line))) + (majutsu-find-file--display next path #'switch-to-buffer) + (majutsu-file--goto-line-col target-line col)) + (user-error "You have reached the end of time")))) + +(defvar-keymap majutsu-blob-mode-map + :doc "Keymap for `majutsu-blob-mode'." + "p" #'majutsu-blob-previous + "n" #'majutsu-blob-next + "q" #'majutsu-blob-quit + "g" #'revert-buffer + ;; RET visits the revision (edit) + " " #'majutsu-edit-changeset) + +(define-minor-mode majutsu-blob-mode + "Enable Majutsu features in blob buffers. + +When called directly from a file buffer, open the @ blob for that file." + :keymap majutsu-blob-mode-map + (when (and majutsu-blob-mode + (not (and majutsu-buffer-blob-root + majutsu-buffer-blob-path))) + (let ((file buffer-file-name)) + (setq majutsu-blob-mode nil) + (if file + (let* ((root (majutsu-file--root)) + (path (majutsu-file--relative-path root file))) + (majutsu-find-file "@" path)) + (user-error "Buffer is not visiting a file"))))) + +;;; _ +(provide 'majutsu-file) +;;; majutsu-file.el ends here diff --git a/majutsu-jj.el b/majutsu-jj.el index f03ec4a..dd43c46 100644 --- a/majutsu-jj.el +++ b/majutsu-jj.el @@ -210,6 +210,30 @@ to do the following. (majutsu--debug "Command output: %s" (string-trim result))) result))) +(defun majutsu-jj--escape-fileset-string (s) + "Escape S for a jj fileset string literal." + (unless (stringp s) + (user-error "majutsu-jj: expected string, got %S" s)) + (apply #'concat + (mapcar (lambda (ch) + (pcase ch + (?\" "\\\"") + (?\\ "\\\\") + (?\t "\\t") + (?\r "\\r") + (?\n "\\n") + (0 "\\0") + (27 "\\e") + (_ + (if (or (< ch 32) (= ch 127)) + (format "\\x%02X" ch) + (string ch))))) + (string-to-list s)))) + +(defun majutsu-jj-fileset-quote (s) + "Return S as a jj fileset string literal." + (format "file:\"%s\"" (majutsu-jj--escape-fileset-string s))) + (defun majutsu-jj-wash (washer keep-error &rest args) "Run jj with ARGS, insert output at point, then call WASHER. KEEP-ERROR matches `magit--git-wash': nil drops stderr on error, diff --git a/majutsu-new.el b/majutsu-new.el index b3c1f7f..65481b0 100644 --- a/majutsu-new.el +++ b/majutsu-new.el @@ -33,7 +33,7 @@ With prefix ARG, open the new transient for interactive selection." (interactive "P") (if arg (call-interactively #'majutsu-new) - (let ((parent (magit-section-value-if 'jj-commit))) + (let ((parent (majutsu-revision-at-point))) (majutsu-new--run-command (if parent (list "new" parent) (list "new")))))) @@ -42,15 +42,17 @@ With prefix ARG, open the new transient for interactive selection." (defun majutsu-new-with-after () "Create a new changeset with the commit at point as --after." (interactive) - (when-let* ((after (magit-section-value-if 'jj-commit))) - (majutsu-new--run-command (list "new" "--insert-after" after)))) + (if-let* ((after (majutsu-revision-at-point))) + (majutsu-new--run-command (list "new" "--insert-after" after)) + (user-error "No revision at point"))) ;;;###autoload (defun majutsu-new-with-before () "Create a new changeset with the commit at point as --before." (interactive) - (when-let* ((before (magit-section-value-if 'jj-commit))) - (majutsu-new--run-command (list "new" "--insert-before" before)))) + (if-let* ((before (majutsu-revision-at-point))) + (majutsu-new--run-command (list "new" "--insert-before" before)) + (user-error "No revision at point"))) ;;; Options and Infixes diff --git a/majutsu-restore.el b/majutsu-restore.el index ac05c14..b974aa1 100644 --- a/majutsu-restore.el +++ b/majutsu-restore.el @@ -65,7 +65,7 @@ In diff buffer on a file section, restore only that file." (let ((file (majutsu-file-at-point))) (if file (when (yes-or-no-p (format "Discard changes to %s? " file)) - (majutsu-run-jj "restore" file)) + (majutsu-run-jj "restore" (majutsu-jj-fileset-quote file))) (when (yes-or-no-p "Discard all working copy changes? ") (majutsu-run-jj "restore"))))) diff --git a/majutsu.el b/majutsu.el index 9e6daa8..b8583c8 100644 --- a/majutsu.el +++ b/majutsu.el @@ -77,6 +77,7 @@ Instead of invoking this alias for `majutsu-log' using (require 'majutsu-edit) (require 'majutsu-git) (require 'majutsu-interactive) + (require 'majutsu-file) (require 'majutsu-rebase) (require 'majutsu-restore) (require 'majutsu-split) diff --git a/test/majutsu-file-test.el b/test/majutsu-file-test.el new file mode 100644 index 0000000..a5d5d23 --- /dev/null +++ b/test/majutsu-file-test.el @@ -0,0 +1,24 @@ +;; -*- lexical-binding: t; -*- +;; Copyright (C) 2026 0WD0 + +;; Author: 0WD0 <1105848296@qq.com> +;; Maintainer: 0WD0 <1105848296@qq.com> +;; Version: 1.0.0 +;; Keywords: tools, vc +;; URL: https://github.com/0WD0/majutsu + +;;; Commentary: + +;; Tests for majutsu file helpers. + +;;; Code: + +(require 'ert) +(require 'majutsu-file) + +(ert-deftest majutsu-file-revset-for-files-quotes-path () + "Paths with single quotes should be fileset-quoted." + (should (equal (majutsu-file--revset-for-files "rev" "test'file" 'prev) + "::rev-&files(file:\"test'file\")"))) + +(provide 'majutsu-file-test) diff --git a/test/majutsu-jj-test.el b/test/majutsu-jj-test.el new file mode 100644 index 0000000..8ebc081 --- /dev/null +++ b/test/majutsu-jj-test.el @@ -0,0 +1,30 @@ +;; -*- lexical-binding: t; -*- +;; Copyright (C) 2026 0WD0 + +;; Author: 0WD0 <1105848296@qq.com> +;; Maintainer: 0WD0 <1105848296@qq.com> +;; Version: 1.0.0 +;; Keywords: tools, vc +;; URL: https://github.com/0WD0/majutsu + +;;; Commentary: + +;; Tests for majutsu-jj helpers. + +;;; Code: + +(require 'ert) +(require 'majutsu-jj) + +(ert-deftest majutsu-jj-fileset-quote-single-quote () + "Single quotes should be preserved inside fileset strings." + (should (equal (majutsu-jj-fileset-quote "test'file") + "file:\"test'file\""))) + +(ert-deftest majutsu-jj-fileset-quote-escapes-specials () + "Double quotes, backslashes, and newlines should be escaped." + (let* ((input "a\"b\\c\n") + (expected "file:\"a\\\"b\\\\c\\n\"")) + (should (equal (majutsu-jj-fileset-quote input) expected)))) + +(provide 'majutsu-jj-test)