aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--duet.el133
-rw-r--r--tests/test-duet-pane.el165
-rw-r--r--tests/test-duet-smoke.el9
3 files changed, 298 insertions, 9 deletions
diff --git a/duet.el b/duet.el
index b546264..4c49eca 100644
--- a/duet.el
+++ b/duet.el
@@ -721,11 +721,138 @@ ungated."
(list :op 'delete :source s :gate 'copy-success)))))
sources)))
+;;; Commander mode, pane layout, and entry
+
+(defcustom duet-default-left-directory nil
+ "Directory shown in DUET's left pane at launch.
+nil means the current directory when `duet' is invoked."
+ :type '(choice (const :tag "Current directory" nil) directory)
+ :group 'duet)
+
+(defcustom duet-default-right-directory nil
+ "Directory shown in DUET's right pane at launch.
+nil means the current directory when `duet' is invoked."
+ :type '(choice (const :tag "Current directory" nil) directory)
+ :group 'duet)
+
+(defvar duet--saved-window-configuration nil
+ "Window configuration saved when DUET launched, restored by `duet-quit'.")
+
+;; The transfer/file actions land with their owning phases; until then they
+;; announce themselves so the key is bound and its precedence is testable.
+
+(defun duet-view ()
+ "View the file under point. Arrives with the viewer phase."
+ (interactive)
+ (user-error "DUET: view is not implemented yet"))
+
+(defun duet-edit ()
+ "Edit the file under point. Arrives with the viewer phase."
+ (interactive)
+ (user-error "DUET: edit is not implemented yet"))
+
+(defun duet-copy ()
+ "Copy the marked files to the other pane. Arrives with transfer execution."
+ (interactive)
+ (user-error "DUET: copy is not implemented yet"))
+
+(defun duet-move ()
+ "Move the marked files to the other pane. Arrives with transfer execution."
+ (interactive)
+ (user-error "DUET: move is not implemented yet"))
+
+(defun duet-mkdir ()
+ "Make a directory in the active pane. Arrives with transfer execution."
+ (interactive)
+ (user-error "DUET: mkdir is not implemented yet"))
+
+(defun duet-delete ()
+ "Delete the marked files (to trash). Arrives with transfer execution."
+ (interactive)
+ (user-error "DUET: delete is not implemented yet"))
+
+(defun duet-quit ()
+ "Close the DUET commander, restoring the window layout from before launch."
+ (interactive)
+ (if duet--saved-window-configuration
+ (progn
+ (set-window-configuration duet--saved-window-configuration)
+ (setq duet--saved-window-configuration nil))
+ (message "DUET: no saved layout to restore")))
+
+(defvar duet-mode-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map (kbd "<f3>") #'duet-view)
+ (define-key map (kbd "<f4>") #'duet-edit)
+ (define-key map (kbd "<f5>") #'duet-copy)
+ (define-key map (kbd "<f6>") #'duet-move)
+ (define-key map (kbd "<f7>") #'duet-mkdir)
+ (define-key map (kbd "<f8>") #'duet-delete)
+ (define-key map (kbd "<f10>") #'duet-quit)
+ map)
+ "Keymap active in DUET commander panes.
+mc/Norton F-keys, taking precedence inside a pane only. dired's own chords
+\(C, R, D, v, +) keep working as free aliases.")
+
+(define-minor-mode duet-mode
+ "Buffer-local minor mode marking a buffer as a DUET commander pane.
+Carries `duet-mode-map', so the single-key F-key actions fire only inside a
+commander pane and leave the global F-keys untouched elsewhere."
+ :lighter " Duet"
+ :keymap duet-mode-map)
+
+(defun duet--sibling-pane (window panes)
+ "Return the commander pane in PANES that is not WINDOW.
+PANES is the list of commander windows. Signal a `user-error' unless there
+are exactly two panes and WINDOW is one of them. This is the explicit
+other-pane resolution that replaces `dired-dwim-target' (the dirvish#36 fix)."
+ (cond
+ ((/= (length panes) 2)
+ (user-error "DUET needs exactly two commander panes (found %d)"
+ (length panes)))
+ ((not (memq window panes))
+ (user-error "Point is not in a DUET commander pane"))
+ (t (car (delq window (copy-sequence panes))))))
+
+(defun duet--commander-windows ()
+ "Return the live windows whose buffer has `duet-mode' enabled."
+ (cl-remove-if-not (lambda (w) (buffer-local-value 'duet-mode (window-buffer w)))
+ (window-list)))
+
+(defun duet--other-pane (&optional window)
+ "Return the sibling commander pane window to WINDOW (default selected)."
+ (duet--sibling-pane (or window (selected-window)) (duet--commander-windows)))
+
+(defun duet--pane-directory (window)
+ "Return the directory shown in commander pane WINDOW."
+ (with-current-buffer (window-buffer window) default-directory))
+
+(defun duet--open-pane (directory)
+ "Open DIRECTORY in the selected window as a DUET commander pane.
+The pane is a dired buffer with `duet-mode' enabled; when the user has dirvish
+rendering dired buffers, it renders as dirvish with no extra work, which is why
+dirvish is recommended but never required."
+ (let ((buffer (dired-noselect (expand-file-name directory))))
+ (set-window-buffer (selected-window) buffer)
+ (with-current-buffer buffer (duet-mode 1))
+ buffer))
+
;;;###autoload
-(defun duet ()
- "Launch the DUET dual-pane file commander."
+(defun duet (&optional left right)
+ "Launch the DUET dual-pane file commander.
+Lay out two side-by-side commander panes: LEFT (or
+`duet-default-left-directory', else the current directory) on the left and
+RIGHT (or `duet-default-right-directory', else the current directory) on the
+right. Each pane is a dired buffer with `duet-mode' enabled. `duet-quit'
+\(F10) restores the window layout from before launch."
(interactive)
- (user-error "DUET is not yet implemented; see the design document"))
+ (setq duet--saved-window-configuration (current-window-configuration))
+ (let ((left-dir (or left duet-default-left-directory default-directory))
+ (right-dir (or right duet-default-right-directory default-directory)))
+ (delete-other-windows)
+ (duet--open-pane left-dir)
+ (with-selected-window (split-window-right)
+ (duet--open-pane right-dir))))
(provide 'duet)
;;; duet.el ends here
diff --git a/tests/test-duet-pane.el b/tests/test-duet-pane.el
new file mode 100644
index 0000000..181309b
--- /dev/null
+++ b/tests/test-duet-pane.el
@@ -0,0 +1,165 @@
+;;; test-duet-pane.el --- Tests for the commander mode and pane layout -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the batch-safe core of the pane layer: the pure sibling-pane
+;; selector (the dirvish#36 fix without needing a live frame), the duet-mode
+;; F-key map, and minor-mode keymap precedence. The two-window resolution is
+;; also exercised against a real split frame. The live two-pane launch and the
+;; F-key feel in a running daemon are Manual tests.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; Pure sibling-pane selection
+
+(ert-deftest test-duet-pane-sibling-returns-other-of-two ()
+ "Given two panes, the sibling is the one that is not the active window."
+ (should (eq 'b (duet--sibling-pane 'a '(a b))))
+ (should (eq 'a (duet--sibling-pane 'b '(a b)))))
+
+(ert-deftest test-duet-pane-sibling-errors-unless-exactly-two ()
+ "Zero, one, or three commander panes is a clear error, not a guess."
+ (should-error (duet--sibling-pane 'a '(a)) :type 'user-error)
+ (should-error (duet--sibling-pane 'a '(a b c)) :type 'user-error))
+
+(ert-deftest test-duet-pane-sibling-errors-when-window-not-a-pane ()
+ "Asking for the sibling of a window that is not one of the panes errors."
+ (should-error (duet--sibling-pane 'x '(a b)) :type 'user-error))
+
+;;; F-key map
+
+(ert-deftest test-duet-pane-fkeys-bound ()
+ "The mc/Norton F-keys map to their DUET commands in `duet-mode-map'."
+ (should (eq 'duet-view (lookup-key duet-mode-map (kbd "<f3>"))))
+ (should (eq 'duet-edit (lookup-key duet-mode-map (kbd "<f4>"))))
+ (should (eq 'duet-copy (lookup-key duet-mode-map (kbd "<f5>"))))
+ (should (eq 'duet-move (lookup-key duet-mode-map (kbd "<f6>"))))
+ (should (eq 'duet-mkdir (lookup-key duet-mode-map (kbd "<f7>"))))
+ (should (eq 'duet-delete (lookup-key duet-mode-map (kbd "<f8>"))))
+ (should (eq 'duet-quit (lookup-key duet-mode-map (kbd "<f10>")))))
+
+;;; Minor-mode precedence
+
+(ert-deftest test-duet-pane-mode-is-buffer-local ()
+ "`duet-mode' is a buffer-local minor mode."
+ (with-temp-buffer
+ (duet-mode 1)
+ (should (bound-and-true-p duet-mode)))
+ (with-temp-buffer
+ (should-not (bound-and-true-p duet-mode))))
+
+(ert-deftest test-duet-pane-fkey-precedence-inside-vs-outside ()
+ "An F-key resolves to a DUET command inside a pane, but not outside one."
+ (with-temp-buffer
+ (duet-mode 1)
+ (should (eq 'duet-copy (key-binding (kbd "<f5>")))))
+ (with-temp-buffer
+ (should-not (eq 'duet-copy (key-binding (kbd "<f5>"))))))
+
+;;; Not-yet-implemented action commands
+
+(ert-deftest test-duet-pane-unimplemented-actions-error ()
+ "The transfer/file actions announce themselves until their phase lands."
+ (dolist (cmd '(duet-view duet-edit duet-copy duet-move duet-mkdir duet-delete))
+ (should-error (funcall cmd) :type 'user-error)))
+
+;;; Launch, quit, and two-window resolution against a real frame
+
+(ert-deftest test-duet-pane-other-pane-resolves-sibling-window ()
+ "With two commander panes, `duet--other-pane' returns the sibling window."
+ (let ((buf-a (generate-new-buffer " *duet-a*"))
+ (buf-b (generate-new-buffer " *duet-b*")))
+ (unwind-protect
+ (progn
+ (delete-other-windows)
+ (set-window-buffer (selected-window) buf-a)
+ (with-current-buffer buf-a (duet-mode 1))
+ (let ((win-b (split-window-right)))
+ (set-window-buffer win-b buf-b)
+ (with-current-buffer buf-b (duet-mode 1))
+ (should (= 2 (length (duet--commander-windows))))
+ (let ((other (duet--other-pane (selected-window))))
+ (should (window-live-p other))
+ (should (eq buf-b (window-buffer other))))))
+ (delete-other-windows)
+ (kill-buffer buf-a)
+ (kill-buffer buf-b))))
+
+(ert-deftest test-duet-launch-creates-two-commander-panes ()
+ "Launching DUET lays out two side-by-side commander panes."
+ (let ((duet--saved-window-configuration nil))
+ (unwind-protect
+ (progn
+ (delete-other-windows)
+ (duet "/tmp" "/tmp")
+ (should (= 2 (length (duet--commander-windows))))
+ (should (window-live-p (duet--other-pane (selected-window)))))
+ (delete-other-windows))))
+
+(ert-deftest test-duet-quit-restores-prior-layout ()
+ "`duet-quit' restores the single-window layout DUET launched from."
+ (delete-other-windows)
+ (duet "/tmp" "/tmp")
+ (should (= 2 (length (duet--commander-windows))))
+ (duet-quit)
+ (should (= 1 (length (window-list)))))
+
+(ert-deftest test-duet-pane-directory-reports-pane-dir ()
+ "`duet--pane-directory' reports the directory a pane is showing."
+ (let ((duet--saved-window-configuration nil))
+ (unwind-protect
+ (progn
+ (delete-other-windows)
+ (duet "/tmp" "/tmp")
+ (should (equal (expand-file-name "/tmp/")
+ (duet--pane-directory (selected-window)))))
+ (delete-other-windows))))
+
+(ert-deftest test-duet-quit-without-launch-messages ()
+ "Quitting with no saved layout messages rather than erroring."
+ (let ((duet--saved-window-configuration nil))
+ (duet-quit)
+ (should-not duet--saved-window-configuration)))
+
+(ert-deftest test-duet-other-pane-defaults-to-selected-window ()
+ "Called with no argument, `duet--other-pane' resolves from the selected window."
+ (let ((duet--saved-window-configuration nil))
+ (unwind-protect
+ (progn
+ (delete-other-windows)
+ (duet "/tmp" "/tmp")
+ (should (window-live-p (duet--other-pane))))
+ (delete-other-windows))))
+
+(ert-deftest test-duet-launch-defaults-to-current-directory ()
+ "With no directory arguments, both panes open on the current directory."
+ (let ((duet--saved-window-configuration nil)
+ (default-directory "/tmp/"))
+ (unwind-protect
+ (progn
+ (delete-other-windows)
+ (duet)
+ (should (= 2 (length (duet--commander-windows)))))
+ (delete-other-windows))))
+
+(provide 'test-duet-pane)
+;;; test-duet-pane.el ends here
diff --git a/tests/test-duet-smoke.el b/tests/test-duet-smoke.el
index 4f88a0e..07d75ea 100644
--- a/tests/test-duet-smoke.el
+++ b/tests/test-duet-smoke.el
@@ -28,13 +28,10 @@
(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
(ert-deftest test-duet-smoke-feature-loaded ()
- "The package source loads and defines its entry command."
+ "The package source loads and defines its entry command and commander mode."
(should (featurep 'duet))
- (should (commandp 'duet)))
-
-(ert-deftest test-duet-smoke-entry-not-yet-implemented ()
- "The entry command errors until the pane layout lands (Phase 4)."
- (should-error (duet)))
+ (should (commandp 'duet))
+ (should (fboundp 'duet-mode)))
(provide 'test-duet-smoke)
;;; test-duet-smoke.el ends here