aboutsummaryrefslogtreecommitdiff
path: root/duet.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 15:36:25 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 15:36:25 -0500
commit3b244ba0492fd86fca051713196067f833f34a1b (patch)
tree177b51527c268262cf83b7b55ad6109bfd94761e /duet.el
parent0155eb670c2f9e072c34671537d95c716a54e011 (diff)
downloadduet-main.tar.gz
duet-main.zip
feat: add the local transfer execution engine and queueHEADmain
Phase 5 turns the pure transfer-specs from Phase 3 into running transfers. duet--run-transfer spawns the backend over make-process, and a serial queue (duet-max-concurrent-transfers, default 1) holds the rest until a slot frees. Each transfer carries a state machine: queued, running, stalled, cancelling, then a terminal done, failed, or cleanup-unverified. The process filter is the hot path, so it stays cheap: it counts output, bounds it to a trailing window, and schedules one coalesced log render. It never refreshes panes. Pane refresh runs once per batch from the sentinel, also coalesced. A move deletes its sources only after the copy exits 0, so a failed move leaves the source untouched. Cancellation kills the process and, for a backend declaring verifiable cleanup, checks the destination for stray temp files before settling on failed versus cleanup-unverified. The process boundary and the temp-file lister are injectable, so the queue and classification logic test against fake results while real rsync gets its own slow integration file. duet-copy, duet-move, duet-mkdir, and duet-delete are wired to the engine. The viewer actions stay stubs until their phase. In-process TRAMP and both-remote rsync still refuse to run here. They land with Phase 6. 172 tests, duet.el at 100% line coverage, compile/lint/complexity green.
Diffstat (limited to 'duet.el')
-rw-r--r--duet.el561
1 files changed, 542 insertions, 19 deletions
diff --git a/duet.el b/duet.el
index 85be734..45c86d6 100644
--- a/duet.el
+++ b/duet.el
@@ -35,6 +35,7 @@
(require 'tramp)
(require 'cl-lib)
+(require 'dired)
(defgroup duet nil
"Dual-pane file commander over dirvish/dired."
@@ -495,6 +496,7 @@ Idempotent: re-registering replaces the prior definitions."
:command #'duet--rsync-command
:capabilities '(:async t :resume t :progress t)
:redaction :none
+ :temp-pattern "\\`\\..*\\.[A-Za-z0-9]\\{6\\}\\'"
:cleanup :verifiable
:normalizer (duet-define-cli-failure-patterns duet--rsync-failure-patterns))))
@@ -751,25 +753,8 @@ nil means the current directory when `duet' is invoked."
(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"))
+;; duet-copy, duet-move, duet-mkdir, and duet-delete are defined with the
+;; transfer execution engine below, since they drive it.
(defun duet-quit ()
"Close the DUET commander, restoring the window layout from before launch."
@@ -856,5 +841,543 @@ right. Each pane is a dired buffer with `duet-mode' enabled. `duet-quit'
(with-selected-window (split-window-right)
(duet--open-pane right-dir))))
+;;; Transfer execution engine, serial queue, and log
+
+;; A transfer is built from a transfer-spec, enqueued, and run as an async
+;; subprocess. The queue runs `duet-max-concurrent-transfers' at a time; the
+;; rest wait. The process filter is the hot path, so it only counts and bounds
+;; output and schedules a coalesced log render — it never refreshes panes or
+;; draws directly. Pane refresh happens once per batch, after the sentinel.
+;; The state machine: queued -> running (-> stalled <-> running) -> done |
+;; failed | cleanup-unverified, with cancelling as the transient kill state.
+
+(defcustom duet-max-concurrent-transfers 1
+ "Maximum number of transfers DUET runs at once.
+Transfers submitted past this limit wait in the queue until a slot frees. The
+default of 1 keeps disk and network contention predictable; raise it when
+independent transfers target unrelated devices."
+ :type 'integer
+ :group 'duet)
+
+(defcustom duet-transfer-stderr-limit 65536
+ "Trailing bytes of a transfer's output DUET retains for failure evidence.
+A long transfer's progress output is bounded to this many trailing bytes so it
+cannot grow memory without limit; the total byte count is tracked separately."
+ :type 'integer
+ :group 'duet)
+
+(defcustom duet-log-render-interval 0.2
+ "Seconds DUET coalesces transfer output before redrawing the log.
+Output arrives in many small chunks; rendering is deferred to one timer per
+interval so a high-volume transfer never drives the display from its filter."
+ :type 'number
+ :group 'duet)
+
+(defcustom duet-transfer-stall-timeout 60
+ "Seconds without output after which a running transfer is flagged stalled.
+Flagging is advisory: the transfer keeps running and clears the flag on its
+next chunk of output. nil disables stall detection."
+ :type '(choice (const :tag "Disabled" nil) integer)
+ :group 'duet)
+
+(cl-defstruct (duet-transfer (:constructor duet-transfer-create) (:copier nil))
+ "One transfer's identity, live process, and terminal result.
+
+Slots:
+
+ id stable monotonic identifier
+ spec the originating transfer-spec plist
+ backend backend name (symbol)
+ route the endpoint route (e.g. `:local')
+ status queued/running/stalled/cancelling/done/failed/
+ cleanup-unverified
+ process the live process object, or nil
+ exit integer exit status, or nil
+ signal terminating signal number, or nil
+ failure normalized failure plist, or nil on success
+ stderr bounded trailing output retained as evidence
+ stderr-bytes total output byte count seen
+ output-count number of output chunks received
+ move-p non-nil when this transfer is the copy half of a move
+ destination-directory directory the transfer writes into
+ source-directories parent directories of the sources, for refresh
+ cleanup-verified non-nil when no stray temp files remain after a stop
+ stall-timer the per-transfer stall timer, or nil"
+ id spec backend route status process
+ exit signal failure
+ (stderr "") (stderr-bytes 0) (output-count 0)
+ move-p destination-directory source-directories
+ cleanup-verified stall-timer)
+
+(defconst duet--active-statuses '(running stalled cancelling)
+ "Statuses at which a transfer still occupies a concurrency slot.")
+
+(defconst duet--terminal-statuses '(done failed cleanup-unverified)
+ "Statuses at which a transfer is finished and off the queue.")
+
+(defvar duet--transfer-id-counter 0
+ "Monotonic source of stable transfer ids.")
+
+(defvar duet--transfers nil
+ "All transfers created this session, most recent first.")
+
+(defvar duet--transfer-queue nil
+ "Non-terminal transfers, in submission order.")
+
+(defun duet--next-transfer-id ()
+ "Return the next stable transfer id."
+ (cl-incf duet--transfer-id-counter))
+
+(defun duet--source-directories (sources)
+ "Return the unique, normalized parent directories of SOURCES."
+ (delete-dups
+ (mapcar (lambda (s)
+ (file-name-as-directory
+ (expand-file-name (or (file-name-directory s) "."))))
+ sources)))
+
+(defun duet--make-transfer (spec &optional move-p)
+ "Create a queued `duet-transfer' from transfer-spec SPEC.
+Non-nil MOVE-P marks the transfer as the copy half of a move."
+ (duet-transfer-create
+ :id (duet--next-transfer-id)
+ :spec spec
+ :backend (plist-get spec :backend)
+ :route (plist-get spec :route)
+ :status 'queued
+ :move-p move-p
+ :destination-directory (plist-get spec :destination-directory)
+ :source-directories (duet--source-directories (plist-get spec :sources))))
+
+;;; Queue and concurrency
+
+(defun duet--running-transfers ()
+ "Return the queued transfers that currently occupy a concurrency slot."
+ (cl-remove-if-not
+ (lambda (tr) (memq (duet-transfer-status tr) duet--active-statuses))
+ duet--transfer-queue))
+
+(defun duet--enqueue-transfer (tr)
+ "Record TR in the history and append it to the run queue. Return TR."
+ (push tr duet--transfers)
+ (setq duet--transfer-queue (append duet--transfer-queue (list tr)))
+ tr)
+
+(defun duet--pump-queue ()
+ "Start queued transfers until the concurrency limit is reached."
+ (let ((next nil))
+ (while (and (< (length (duet--running-transfers))
+ duet-max-concurrent-transfers)
+ (setq next (cl-find 'queued duet--transfer-queue
+ :key #'duet-transfer-status)))
+ (duet--start-transfer next))))
+
+(defun duet--run-transfer (spec &optional move-p)
+ "Enqueue transfer-SPEC, pump the queue, and return the `duet-transfer'.
+SPEC must carry a runnable :argv. An in-process (:tramp) or both-remote
+\(:exec-mode) spec is not executed in this phase and signals a `user-error';
+those routes land with Phase 6. MOVE-P marks the transfer as a move so its
+sources are deleted once the copy succeeds."
+ (unless (and (consp (plist-get spec :argv))
+ (cl-every #'stringp (plist-get spec :argv)))
+ (user-error "DUET: this transfer route is not executable yet (no local argv)"))
+ (let ((tr (duet--make-transfer spec move-p)))
+ (duet--enqueue-transfer tr)
+ (duet--pump-queue)
+ tr))
+
+;;; Launch and the process boundary
+
+(defvar duet--transfer-launcher #'duet--launch-process
+ "Function called with a `duet-transfer' to spawn its process and return it.
+The process-boundary tests stub this so no real subprocess runs.")
+
+(defun duet--launch-process (tr)
+ "Spawn TR's backend process with a bounded filter and a sentinel."
+ (let* ((spec (duet-transfer-spec tr))
+ (default-directory (or (plist-get spec :default-directory) "/"))
+ (proc (make-process
+ :name (format "duet-transfer-%d" (duet-transfer-id tr))
+ :command (plist-get spec :argv)
+ :connection-type 'pipe
+ :noquery t
+ :filter (lambda (_p chunk) (duet--transfer-filter tr chunk))
+ :sentinel (lambda (p _e) (duet--transfer-sentinel tr p)))))
+ (setf (duet-transfer-process tr) proc)
+ proc))
+
+(defun duet--start-transfer (tr)
+ "Move TR to `running' and launch it; a launch error fails it cleanly."
+ (setf (duet-transfer-status tr) 'running)
+ (condition-case err
+ (progn (funcall duet--transfer-launcher tr)
+ (duet--arm-stall-timer tr))
+ (error
+ (duet--transfer-handle-result
+ tr (list :launch-error (error-message-string err))))))
+
+;;; Bounded output filter and stall flagging
+
+(defun duet--transfer-accumulate-stderr (tr chunk)
+ "Append CHUNK to TR's retained output, bounded to `duet-transfer-stderr-limit'."
+ (let* ((combined (concat (duet-transfer-stderr tr) chunk))
+ (limit duet-transfer-stderr-limit)
+ (kept (if (> (length combined) limit)
+ (substring combined (- (length combined) limit))
+ combined)))
+ (setf (duet-transfer-stderr tr) kept
+ (duet-transfer-stderr-bytes tr)
+ (+ (duet-transfer-stderr-bytes tr) (length chunk)))))
+
+(defun duet--transfer-filter (tr chunk)
+ "Record output CHUNK for TR: count it, bound it, recover, redraw.
+Runs from the process filter, so it does no pane refresh and no direct
+rendering — it only schedules a coalesced log render."
+ (cl-incf (duet-transfer-output-count tr))
+ (duet--transfer-accumulate-stderr tr chunk)
+ (when (eq (duet-transfer-status tr) 'stalled)
+ (setf (duet-transfer-status tr) 'running))
+ (duet--rearm-stall-timer tr)
+ (duet--schedule-log-render))
+
+(defun duet--arm-stall-timer (tr)
+ "Arm TR's stall timer when stall detection is enabled."
+ (when duet-transfer-stall-timeout
+ (setf (duet-transfer-stall-timer tr)
+ (run-with-timer duet-transfer-stall-timeout nil
+ #'duet--mark-stalled tr))))
+
+(defun duet--cancel-stall-timer (tr)
+ "Cancel and clear TR's stall timer."
+ (when (timerp (duet-transfer-stall-timer tr))
+ (cancel-timer (duet-transfer-stall-timer tr)))
+ (setf (duet-transfer-stall-timer tr) nil))
+
+(defun duet--rearm-stall-timer (tr)
+ "Reset TR's stall timer after fresh output."
+ (duet--cancel-stall-timer tr)
+ (duet--arm-stall-timer tr))
+
+(defun duet--mark-stalled (tr)
+ "Flag a still-running TR as stalled after a silent stretch."
+ (when (eq (duet-transfer-status tr) 'running)
+ (setf (duet-transfer-status tr) 'stalled)
+ (duet--schedule-log-render)))
+
+;;; Throttled log render
+
+(defvar duet--log-render-timer nil
+ "Pending coalesced log-render timer, or nil.")
+
+(defconst duet--transfer-log-buffer "*DUET Transfers*"
+ "Name of the buffer holding the transfer log.")
+
+(defun duet--schedule-log-render ()
+ "Schedule a single coalesced redraw of the transfer log."
+ (unless duet--log-render-timer
+ (setq duet--log-render-timer
+ (run-with-timer duet-log-render-interval nil
+ #'duet--render-transfer-log))))
+
+(defun duet--render-transfer-log ()
+ "Clear the pending render timer and draw the transfer log once."
+ (when (timerp duet--log-render-timer)
+ (cancel-timer duet--log-render-timer))
+ (setq duet--log-render-timer nil)
+ (duet--draw-transfer-log))
+
+(defun duet--draw-transfer-log ()
+ "Write every transfer's log record into the transfer-log buffer."
+ (with-current-buffer (get-buffer-create duet--transfer-log-buffer)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (dolist (tr (reverse duet--transfers))
+ (insert (duet--format-log-line (duet--transfer-log-record tr)) "\n")))))
+
+(defun duet--format-log-line (record)
+ "Format one transfer-log RECORD as a single display line."
+ (format "[%s] #%d %s %s %s"
+ (plist-get record :status)
+ (plist-get record :id)
+ (plist-get record :backend)
+ (or (plist-get record :route) "")
+ (plist-get record :argv)))
+
+;;; Log-record schema
+
+(defun duet--redact-argv (tr)
+ "Return TR's argv as a single string with the backend's secrets redacted."
+ (let* ((argv (plist-get (duet-transfer-spec tr) :argv))
+ (joined (mapconcat #'identity argv " "))
+ (backend (duet-backend-by-name (duet-transfer-backend tr)))
+ (patterns (and backend (duet-backend-redaction backend))))
+ (duet--redact joined patterns)))
+
+(defun duet--transfer-log-record (tr)
+ "Return TR's log record: a plist of its id, route, redacted argv, and result."
+ (list :id (duet-transfer-id tr)
+ :backend (duet-transfer-backend tr)
+ :route (duet-transfer-route tr)
+ :argv (duet--redact-argv tr)
+ :exit (duet-transfer-exit tr)
+ :signal (duet-transfer-signal tr)
+ :class (plist-get (duet-transfer-failure tr) :class)
+ :evidence (plist-get (duet-transfer-failure tr) :evidence)
+ :status (duet-transfer-status tr)))
+
+;;; Stray temp-file detection (cleanup verification)
+
+(defvar duet--temp-file-lister #'duet--list-stray-temp-files
+ "Function called with a `duet-transfer' returning stray temp-file paths.
+The cancellation/cleanup tests inject a stub.")
+
+(defun duet--list-stray-temp-files (tr)
+ "Return TR backend's leftover temp files in the destination directory."
+ (let* ((backend (duet-backend-by-name (duet-transfer-backend tr)))
+ (pattern (and backend (duet-backend-temp-pattern backend)))
+ (dir (duet-transfer-destination-directory tr)))
+ (when (and pattern dir (file-directory-p dir))
+ (directory-files dir t pattern t))))
+
+;;; Sentinel and terminal-result handling
+
+(defun duet--process-result (proc)
+ "Return a result plist for finished process PROC: (:signal N) or (:exit N)."
+ (let ((status (process-status proc))
+ (code (process-exit-status proc)))
+ (if (eq status 'signal) (list :signal code) (list :exit code))))
+
+(defun duet--transfer-sentinel (tr proc)
+ "Resolve TR's result when its process PROC has exited or been signalled."
+ (when (memq (process-status proc) '(exit signal))
+ (duet--transfer-handle-result tr (duet--process-result proc))))
+
+(defun duet--result-failure-context (tr result)
+ "Return a failure context plist for RESULT, or nil for a clean exit.
+TR supplies the retained output as evidence."
+ (cond
+ ((plist-get result :launch-error)
+ (list :launch-error (plist-get result :launch-error)))
+ ((plist-get result :signal)
+ (list :signal (plist-get result :signal) :stderr (duet-transfer-stderr tr)))
+ ((and (integerp (plist-get result :exit)) (zerop (plist-get result :exit)))
+ nil)
+ (t (list :exit (plist-get result :exit) :stderr (duet-transfer-stderr tr)))))
+
+(defun duet--transfer-handle-result (tr result)
+ "Resolve TR's terminal state from process RESULT and advance the queue."
+ (duet--cancel-stall-timer tr)
+ (setf (duet-transfer-exit tr) (plist-get result :exit)
+ (duet-transfer-signal tr) (plist-get result :signal))
+ (let ((context (duet--result-failure-context tr result)))
+ (if (null context)
+ (duet--finish-transfer tr 'done)
+ (let ((backend (duet-backend-by-name (duet-transfer-backend tr))))
+ (setf (duet-transfer-failure tr)
+ (and backend (duet--normalize-failure backend context)))
+ (duet--finish-transfer tr 'failed)))))
+
+(defun duet--needs-cleanup-check-p (tr proposed)
+ "Return non-nil when TR's non-success stop (PROPOSED) needs a temp-file check."
+ (and (not (eq proposed 'done))
+ (let ((b (duet-backend-by-name (duet-transfer-backend tr))))
+ (and b
+ (eq (duet-backend-cleanup b) :verifiable)
+ (duet-backend-temp-pattern b)))))
+
+(defun duet--resolve-terminal-status (tr proposed)
+ "Return TR's terminal status, refining a stopped transfer that left temps.
+A success keeps PROPOSED; a non-success with stray temp files becomes
+`cleanup-unverified'. Records `cleanup-verified' either way."
+ (if (not (duet--needs-cleanup-check-p tr proposed))
+ (progn (setf (duet-transfer-cleanup-verified tr) t) proposed)
+ (let ((strays (funcall duet--temp-file-lister tr)))
+ (setf (duet-transfer-cleanup-verified tr) (null strays))
+ (if strays 'cleanup-unverified proposed))))
+
+(defun duet--finish-transfer (tr proposed)
+ "Commit TR to its terminal status, finalize a move, refresh, and pump.
+PROPOSED is `done' or `failed'; cleanup verification can refine it."
+ (let ((status (duet--resolve-terminal-status tr proposed)))
+ (setf (duet-transfer-status tr) status)
+ (setq duet--transfer-queue (delq tr duet--transfer-queue))
+ (when (eq status 'done)
+ (when (duet-transfer-move-p tr) (duet--finalize-move tr))
+ (duet--schedule-completion-refresh tr))
+ (duet--schedule-log-render)
+ (duet--pump-queue)
+ status))
+
+;;; Move finalization (delete sources only after the copy succeeds)
+
+(defun duet--delete-source (path)
+ "Delete PATH (file or directory) outright; missing is a no-op."
+ (cond ((not (file-exists-p path)) nil)
+ ((file-directory-p path) (delete-directory path t))
+ (t (delete-file path))))
+
+(defun duet--finalize-move (tr)
+ "Delete TR's sources now that its copy has succeeded (success is the gate)."
+ (dolist (s (plist-get (duet-transfer-spec tr) :sources))
+ (duet--delete-source s)))
+
+;;; Coalesced pane refresh
+
+(defvar duet--refresh-pending nil
+ "Set of directories awaiting a coalesced refresh.")
+
+(defvar duet--refresh-timer nil
+ "Pending coalesced pane-refresh timer, or nil.")
+
+(defun duet--schedule-pane-refresh (dir)
+ "Queue DIR for a single coalesced pane refresh."
+ (cl-pushnew (file-name-as-directory (expand-file-name dir))
+ duet--refresh-pending :test #'equal)
+ (unless duet--refresh-timer
+ (setq duet--refresh-timer
+ (run-with-timer 0 nil #'duet--do-pane-refresh))))
+
+(defun duet--do-pane-refresh ()
+ "Refresh every pending directory exactly once, then clear the set."
+ (when (timerp duet--refresh-timer) (cancel-timer duet--refresh-timer))
+ (setq duet--refresh-timer nil)
+ (let ((dirs duet--refresh-pending))
+ (setq duet--refresh-pending nil)
+ (dolist (d dirs) (duet--refresh-dir d))))
+
+(defun duet--refresh-dir (dir)
+ "Revert any Dired buffer visiting DIR."
+ (dolist (buf (dired-buffers-for-dir (expand-file-name dir)))
+ (with-current-buffer buf (revert-buffer nil t))))
+
+(defun duet--schedule-completion-refresh (tr)
+ "Schedule a refresh of TR's destination, and its sources after a move."
+ (when (duet-transfer-destination-directory tr)
+ (duet--schedule-pane-refresh (duet-transfer-destination-directory tr)))
+ (when (duet-transfer-move-p tr)
+ (dolist (d (duet-transfer-source-directories tr))
+ (duet--schedule-pane-refresh d))))
+
+;;; Cancellation
+
+(defun duet--kill-process (tr)
+ "Interrupt then delete TR's process if it is live."
+ (let ((proc (duet-transfer-process tr)))
+ (when (process-live-p proc)
+ (interrupt-process proc)
+ (delete-process proc))))
+
+(defun duet--cancel-transfer (tr)
+ "Request cancellation of non-terminal TR.
+Move it to `cancelling' and kill its process; the sentinel then records
+whether the backend's temp cleanup could be verified."
+ (unless (memq (duet-transfer-status tr) duet--terminal-statuses)
+ (setf (duet-transfer-status tr) 'cancelling)
+ (duet--cancel-stall-timer tr)
+ (duet--kill-process tr)))
+
+(defun duet-cancel-transfer ()
+ "Cancel the most recent transfer that has not yet finished."
+ (interactive)
+ (let ((tr (cl-find-if
+ (lambda (x) (not (memq (duet-transfer-status x) duet--terminal-statuses)))
+ duet--transfers)))
+ (unless tr (user-error "DUET: no active transfer to cancel"))
+ (duet--cancel-transfer tr)
+ (message "DUET: cancelling transfer #%d" (duet-transfer-id tr))))
+
+;;; Failure explanation
+
+(defun duet--safety-text (safety)
+ "Render a failure SAFETY value (string or symbol) as user-facing text."
+ (cond ((stringp safety) safety)
+ ((eq safety :generic) "Outcome unverified; inspect the destination.")
+ (t "Unknown.")))
+
+(defun duet--format-failure-explanation (tr)
+ "Return a human-readable explanation of TR's outcome.
+For a failure it states the class, cause, safety, evidence, and next actions;
+for a success it says so."
+ (let ((f (duet-transfer-failure tr)))
+ (if (null f)
+ (format "Transfer #%d completed successfully." (duet-transfer-id tr))
+ (mapconcat
+ #'identity
+ (list (format "Transfer #%d failed: %s" (duet-transfer-id tr)
+ (plist-get f :class))
+ (format "Cause: %s" (plist-get f :cause))
+ (format "Safety: %s" (duet--safety-text (plist-get f :safety)))
+ (format "Evidence: %s"
+ (if (plist-get f :evidence)
+ (mapconcat #'identity (plist-get f :evidence) " | ")
+ "(none)"))
+ (format "Next: %s"
+ (mapconcat #'symbol-name (plist-get f :next-actions) ", ")))
+ "\n"))))
+
+(defun duet-explain-transfer-failure ()
+ "Show the failure explanation for the most recent transfer in a buffer."
+ (interactive)
+ (let ((tr (car duet--transfers)))
+ (unless tr (user-error "DUET: no transfers to explain"))
+ (with-current-buffer (get-buffer-create "*DUET Transfer Failure*")
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (duet--format-failure-explanation tr)))
+ (display-buffer (current-buffer)))))
+
+;;; Pane actions wired to the engine
+
+(defun duet--submit-transfer (sources destination-directory move-p)
+ "Build and run a transfer of SOURCES into DESTINATION-DIRECTORY.
+MOVE-P marks a move. Draw the log and return the `duet-transfer'. Signal a
+`user-error' when nothing is selected or no backend handles the pair."
+ (unless sources (user-error "DUET: no files selected"))
+ (let ((spec (duet--transfer-spec sources destination-directory)))
+ (unless spec (user-error "DUET: no backend handles this transfer"))
+ (prog1 (duet--run-transfer spec move-p)
+ (duet--draw-transfer-log))))
+
+(defun duet--start-pane-transfer (move-p)
+ "Transfer the selected files in the active pane to the other pane.
+MOVE-P marks a move. Show the transfer log afterward."
+ (let ((tr (duet--submit-transfer (dired-get-marked-files)
+ (duet--pane-directory (duet--other-pane))
+ move-p)))
+ (display-buffer duet--transfer-log-buffer)
+ tr))
+
+(defun duet-copy ()
+ "Copy the marked files in the active pane to the other pane."
+ (interactive)
+ (duet--start-pane-transfer nil))
+
+(defun duet-move ()
+ "Move the marked files in the active pane to the other pane."
+ (interactive)
+ (duet--start-pane-transfer t))
+
+(defun duet-mkdir (name)
+ "Create directory NAME in the active pane, then refresh it."
+ (interactive "sNew directory name: ")
+ (let ((dir (expand-file-name name default-directory)))
+ (make-directory dir t)
+ (duet--schedule-pane-refresh default-directory)
+ (message "DUET: created %s" dir)))
+
+(defun duet-delete ()
+ "Delete the marked files in the active pane to trash, then refresh."
+ (interactive)
+ (let ((files (dired-get-marked-files))
+ (delete-by-moving-to-trash t))
+ (unless files (user-error "DUET: no files selected"))
+ (when (yes-or-no-p (format "Delete %d item(s) to trash? " (length files)))
+ (dolist (f files)
+ (if (file-directory-p f)
+ (delete-directory f t t)
+ (delete-file f t)))
+ (duet--schedule-pane-refresh default-directory)
+ (message "DUET: deleted %d item(s)" (length files)))))
+
(provide 'duet)
;;; duet.el ends here