diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 15:36:25 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 15:36:25 -0500 |
| commit | 3b244ba0492fd86fca051713196067f833f34a1b (patch) | |
| tree | 177b51527c268262cf83b7b55ad6109bfd94761e /duet.el | |
| parent | 0155eb670c2f9e072c34671537d95c716a54e011 (diff) | |
| download | duet-main.tar.gz duet-main.zip | |
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.el | 561 |
1 files changed, 542 insertions, 19 deletions
@@ -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 |
