Created
November 22, 2025 17:51
-
-
Save alexispurslane/02aecff7b61ac8ddabe122ca234d9b01 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ;;; 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