What it does
edits.sh connects to a remote server via SFTP, pulls files into a local temp directory, and opens them in your preferred editor. When you save and close a file, it is pushed back to the remote. Files closed without saving are skipped. The temp directory is cleaned up on exit.
edits.sh means you use your prefered editor with your personal configurations: your keybindings, themes, plugins, LSP, and snippets. It is a lightweight alternative to configuring a full remote development environment for quick edits on servers you don't control or connect to very often.
Usage
chmod +x edits.sh
sudo cp edits.sh /usr/local/bin/edits
edits user@host:/path/to/dir to edit a
directory, or
edits user@host:/path/file.txt to
target a single file. Run with no argument to be
prompted.
Flags
| -e ext |
Extension(s) to pull and edit. Defaults to
txt. Pass a comma-separated list to
match multiple types: -e sh,py,conf.
|
| -r | Recurse into subdirectories. Pulls matching files from the full directory tree under the given remote path. |
| -w |
Watch mode. After the initial edit session, watches
the local file(s) and auto-uploads on every save.
Requires inotifywait (Linux) or
fswatch (macOS). Press
Ctrl-C to stop watching.
|
| -f | Force single-file mode. Use when the remote path points to a file but lacks an extension, bypassing the directory detection heuristic. |
| -y | Skip the confirmation prompt and begin editing immediately. |
| -h | Print usage help and exit. |
| $EDITOR |
Respects the $EDITOR environment
variable. Defaults to nano if unset.
|
Source
#!/usr/bin/env bash # edits.sh - pull remote files, edit locally, push back on save # chmod +x edits.sh - make script executable # sudo cp edits.sh /usr/local/bin/edits # credit - barney matthews (www.barney.me) set -euo pipefail # ── Colors ──────────────────────────────────────────────────────────────────── if [[ -t 1 ]]; then GRN='[0;32m'; YLW='[0;33m'; BLU='[0;34m' CYN='[0;36m'; RED='[0;31m'; DIM='[2m'; RST='[0m' else GRN=''; YLW=''; BLU=''; CYN=''; RED=''; DIM=''; RST='' fi info() { echo -e "${BLU}→${RST} $*"; } ok() { echo -e "${GRN}✔${RST} $*"; } skip() { echo -e "${DIM}–${RST} ${DIM}$*${RST}"; } warn() { echo -e "${YLW}⚠${RST} $*"; } err() { echo -e "${RED}✖${RST} $*" >&2; } upload() { echo -e "${CYN}↑${RST} $*"; } # ── Usage ───────────────────────────────────────────────────────────────────── usage() { echo "" echo " edits [-e ext[,ext]] [-r] [-w] [-f] [-y] [-h] [user@host:/path]" echo "" echo " -e ext Extension(s) to edit, comma-separated (default: txt)" echo " -r Recurse into subdirectories" echo " -w Watch mode: auto-upload on every save, keep watching until Ctrl-C" echo " -f Force single-file mode (skip directory detection heuristic)" echo " -y Skip confirmation prompt" echo " -h Show this help" echo "" echo " Save and close a file to upload it. Close without saving to skip." echo " In watch mode (-w) the file is uploaded on every detected save." echo "" } # ── Defaults ────────────────────────────────────────────────────────────────── EDITOR="${EDITOR:-nano}" EXTS="txt" RECURSIVE=false WATCH=false SINGLE_FILE=false YES=false REMOTE="" # ── Parse flags ─────────────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in -e) EXTS="$2"; shift 2 ;; -r) RECURSIVE=true; shift ;; -w) WATCH=true; shift ;; -f) SINGLE_FILE=true; shift ;; -y) YES=true; shift ;; -h) usage; exit 0 ;; *) REMOTE="$1"; shift ;; esac done # ── Prompt if no remote provided ────────────────────────────────────────────── if [[ -z "$REMOTE" ]]; then read -rp "Remote path (user@host:/path): " REMOTE fi REMOTE_HOST="${REMOTE%%:*}" REMOTE_PATH="${REMOTE#*:}" WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT # ── Upload helper (single file) ─────────────────────────────────────────────── push_file() { local local_file="$1" local remote_dir="$2" sftp -q "$REMOTE_HOST" <<SFTP cd "$remote_dir" put "$local_file" SFTP } # ── Watch a single file ─────────────────────────────────────────────────────── watch_file() { local f="$1" local remote_dir="$2" local watcher="" if command -v inotifywait &>/dev/null; then watcher="inotify" elif command -v fswatch &>/dev/null; then watcher="fswatch" else err "Watch mode requires inotifywait (inotify-tools) or fswatch. Neither found." exit 1 fi info "Watching $(basename "$f") — Ctrl-C to stop" if [[ "$watcher" == "inotify" ]]; then while inotifywait -q -e close_write "$f" 2>/dev/null; do upload "$(basename "$f") saved — uploading ..." push_file "$f" "$remote_dir" ok "Uploaded $(basename "$f")" done else fswatch -0 --event Updated "$f" | while IFS= read -r -d '' _event; do upload "$(basename "$f") saved — uploading ..." push_file "$f" "$remote_dir" ok "Uploaded $(basename "$f")" done fi } # ── Pull files ──────────────────────────────────────────────────────────────── FILES=() # Single-file mode: explicit -f flag OR path looks like a file (has extension) IS_SINGLE=false if [[ "$SINGLE_FILE" == true ]]; then IS_SINGLE=true elif [[ "$REMOTE_PATH" =~ \.[^/]+$ ]]; then IS_SINGLE=true fi if [[ "$IS_SINGLE" == true ]]; then info "Pulling $(basename "$REMOTE_PATH") ..." if sftp -q "$REMOTE_HOST" <<SFTP 2>/dev/null lcd "$WORK_DIR" get "$REMOTE_PATH" SFTP then FILES=("$WORK_DIR/$(basename "$REMOTE_PATH")") else err "Could not pull $REMOTE_PATH" exit 1 fi else info "Pulling *.$EXTS from $REMOTE_PATH ..." SFTP_CMDS="lcd "$WORK_DIR" cd "$REMOTE_PATH"" IFS=',' read -ra EXT_LIST <<< "$EXTS" for ext in "${EXT_LIST[@]}"; do ext="${ext// /}" if [[ "$RECURSIVE" == true ]]; then SFTP_CMDS+=" find . -name "*.$ext" | while read f; do get "\$f"; done" else SFTP_CMDS+=" mget *.$ext" fi done sftp -q "$REMOTE_HOST" <<< "$(echo -e "$SFTP_CMDS")" || true for ext in "${EXT_LIST[@]}"; do ext="${ext// /}" while IFS= read -r f; do FILES+=("$f") done < <(find "$WORK_DIR" -name "*.$ext" -type f) done fi if [[ ${#FILES[@]} -eq 0 ]]; then err "No matching files found." exit 1 fi echo "" info "Found ${#FILES[@]} file(s): $(basename -a "${FILES[@]}" | tr '\n' ' ')" echo "" # ── Watch mode ──────────────────────────────────────────────────────────────── if [[ "$WATCH" == true ]]; then if [[ ${#FILES[@]} -gt 1 ]]; then warn "Watch mode with multiple files opens each editor sequentially, then watches all." warn "For best results, use -w with a single file target." echo "" fi for f in "${FILES[@]}"; do info "Opening $(basename "$f") for initial edit ..." "$EDITOR" "$f" done for f in "${FILES[@]}"; do watch_file "$f" "$REMOTE_PATH" & done wait exit 0 fi # ── Normal (one-shot) mode ──────────────────────────────────────────────────── CHANGED=() for f in "${FILES[@]}"; do MTIME_BEFORE="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")" "$EDITOR" "$f" MTIME_AFTER="$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f")" if [[ "$MTIME_BEFORE" != "$MTIME_AFTER" ]]; then CHANGED+=("$f") else skip "No changes to $(basename "$f"), skipping." fi done if [[ ${#CHANGED[@]} -gt 0 ]]; then echo "" info "Uploading ${#CHANGED[@]} file(s) ..." UPLOAD_CMDS="cd "$REMOTE_PATH"" for f in "${CHANGED[@]}"; do upload "$(basename "$f")" UPLOAD_CMDS+=" put "$f"" done sftp -q "$REMOTE_HOST" <<< "$(echo -e "$UPLOAD_CMDS")" echo "" ok "Done." fi