Skip to content

Instantly share code, notes, and snippets.

@alexispurslane
Created November 22, 2025 17:51
Show Gist options
  • Select an option

  • Save alexispurslane/02aecff7b61ac8ddabe122ca234d9b01 to your computer and use it in GitHub Desktop.

Select an option

Save alexispurslane/02aecff7b61ac8ddabe122ca234d9b01 to your computer and use it in GitHub Desktop.
;;; copy-file-async.el --- Asynchronous file copying via SCP
;; Version: 1.0
;; Author: Alexis Dumas <[email protected]>
;; Package-Requires: ((emacs "25.1"))
;;; Commentary:
;; Provides `copy-file-async` command to copy files to remote hosts
;; via SCP asynchronously with live progress updates.
;;; Code:
(require 'tramp)
(require 'cl-lib)
(defgroup copy-file-async nil
"Asynchronous file copying via SCP."
:group 'files
:prefix "copy-file-async-")
(defcustom copy-file-async-scp-program "scp"
"Path to the scp program."
:type 'file
:group 'copy-file-async)
(defcustom copy-file-async-scp-options '("-v" "-o" "BatchMode=yes")
"SCP options. -v shows progress, BatchMode=yes prevents password prompts."
:type '(repeat string)
:group 'copy-file-async)
(defcustom copy-file-async-show-progress t
"Whether to show progress in the echo area."
:type 'boolean
:group 'copy-file-async)
(defcustom copy-file-async-progress-interval 0.15
"Minimum seconds between progress updates."
:type 'number
:group 'copy-file-async)
(defcustom copy-file-async-notify-success nil
"Whether to notify on successful transfers."
:type 'boolean
:group 'copy-file-async)
(defvar copy-file-async--processes nil
"List of active SCP processes.")
(defun copy-file-async (local-file remote-tramp)
"Copy LOCAL-FILE to REMOTE-TRAMP asynchronously using scp.
REMOTE-TRAMP is a TRAMP path like /ssh:user@host:/path.
SSH keys must be configured (no password prompts)."
(interactive
(let* ((local (read-file-name "Copy file: " nil nil t))
(remote (read-file-name "To remote (TRAMP path): " "/" nil nil nil)))
(list local remote)))
(let ((local-expanded (expand-file-name local-file)))
(unless (file-exists-p local-expanded)
(error "File not found: %s" local-expanded))
(let* ((parsed (condition-case err
(tramp-dissect-file-name remote-tramp)
(error (error "Invalid TRAMP path '%s': %s"
remote-tramp (error-message-string err)))))
(method (tramp-file-name-method parsed))
(host (tramp-file-name-host parsed)))
(unless (member method '("ssh" "scp"))
(error "Only ssh/scp TRAMP methods are supported, got: %s" method))
(unless host
(error "No host specified in remote path: %s" remote-tramp))
(let* ((user (tramp-file-name-user parsed))
(port (tramp-file-name-port parsed))
(remote-path (or (tramp-file-name-localname parsed) ""))
(remote-dest (if (string-suffix-p "/" remote-path)
(concat remote-path (file-name-nondirectory local-expanded))
remote-path))
(scp-target (if user
(format "%s@%s:%s" user host remote-dest)
(format "%s:%s" host remote-dest)))
(scp-args (append copy-file-async-scp-options
(when port (list "-P" (number-to-string port)))
(list local-expanded scp-target))))
(let ((proc (make-process
:name (format "scp-%s" (file-name-nondirectory local-expanded))
:buffer nil ; No shared buffer
:command (cons copy-file-async-scp-program scp-args)
:connection-type 'pipe
:filter #'copy-file-async--filter
:sentinel #'copy-file-async--sentinel
:noquery t)))
(cl-pushnew proc copy-file-async--processes)
(process-put proc 'local-file local-expanded)
(process-put proc 'remote-dest scp-target)
(process-put proc 'start-time (current-time))
(process-put proc 'partial-line "")
(process-put proc 'output "")
(process-put proc 'last-update 0)
(message "SCP: Starting %s → %s"
(file-name-nondirectory local-expanded) scp-target)
proc)))))
(defun copy-file-async--filter (proc string)
"Process filter for SCP process."
(when (process-live-p proc)
(let* ((partial (process-get proc 'partial-line))
(full (concat partial string))
(lines (split-string full "\n" t))
last-non-empty)
;; Handle partial lines
(if (not (string-suffix-p "\n" string))
(progn
(process-put proc 'partial-line (car (last lines)))
(setq lines (butlast lines)))
(process-put proc 'partial-line ""))
;; Store output and find last non-empty line
(process-put proc 'output (concat (process-get proc 'output) string))
;; Elegant functional approach for simple filtering
(setq last-non-empty (car (last (cl-remove-if #'string-empty-p lines))))
;; Show progress if enabled and throttled
(when (and copy-file-async-show-progress last-non-empty)
(let ((now (float-time)))
(when (> now (+ (process-get proc 'last-update)
copy-file-async-progress-interval))
(process-put proc 'last-update now)
(message "SCP: %s" (copy-file-async--truncate last-non-empty 80))))))))
(defun copy-file-async--truncate (str max-len)
"Truncate STR to MAX-LEN characters."
(if (> (length str) max-len)
(concat (substring str 0 (- max-len 3)) "...")
str))
(defun copy-file-async--sentinel (proc _event)
"Handle SCP process termination."
(let ((exit-code (process-exit-status proc))
(local-file (process-get proc 'local-file))
(remote-dest (process-get proc 'remote-dest))
(start-time (process-get proc 'start-time))
(output (process-get proc 'output)))
(setq copy-file-async--processes (cl-delete proc copy-file-async--processes))
(if (= exit-code 0)
(let ((duration (float-time (time-subtract (current-time) start-time))))
(message "✓ SCP completed (%.2fs): %s → %s"
duration
(file-name-nondirectory local-file)
remote-dest)
(when (and copy-file-async-notify-success (fboundp 'notifications-notify))
(notifications-notify
:title "SCP Transfer Complete"
:body (format "%s\n→ %s"
(file-name-nondirectory local-file)
remote-dest)
:timeout 4000)))
;; Error handling
(message "✗ SCP failed (exit code %d)" exit-code)
(warn "SCP transfer failed:\n Source: %s\n Target: %s\n Error: %s"
local-file remote-dest output)
(when (yes-or-no-p "Show SCP output buffer? ")
(display-buffer
(let ((buf (generate-new-buffer (format "*scp-async-error:%s*"
(process-name proc)))))
(with-current-buffer buf
(insert (format "SCP Command: %s\n\n"
(mapconcat #'identity (process-command proc) " ")))
(insert output))
buf))))))
(defun copy-file-async-kill-all ()
"Kill all active SCP processes."
(interactive)
(let ((count (cl-loop for proc in copy-file-async--processes
when (process-live-p proc)
do (delete-process proc)
and count it into killed
finally return killed)))
(setq copy-file-async--processes nil)
(message "Killed %d SCP process(es)" count)))
(defun copy-file-async-list ()
"List active SCP processes."
(interactive)
(if (null copy-file-async--processes)
(message "No active SCP processes")
(with-current-buffer (get-buffer-create "*scp-processes*")
(erase-buffer)
(insert "Active SCP processes:\n\n")
(cl-loop for proc in copy-file-async--processes
do (insert (format "- %s\n %s → %s\n Started: %s\n\n"
(process-name proc)
(file-name-nondirectory (process-get proc 'local-file))
(process-get proc 'remote-dest)
(format-time-string "%T" (process-get proc 'start-time)))))
(display-buffer (current-buffer)))))
(provide 'copy-file-async)
;;; copy-file-async.el ends here
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment