From 3f18a7388d957ca89255a658caa869bee969d816 Mon Sep 17 00:00:00 2001 From: Kyle S Passarelli Date: Wed, 21 Oct 2026 16:36:30 -0600 Subject: [PATCH] Add ghostel --- README.md | 11 ++++++- devops-test.el | 72 +++++++++++++++++++++++++++++++++++++++++++ devops.el | 83 ++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 153 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 592580b..c6fd764 100644 --- a/README.md +++ b/README.md @@ -352,12 +352,21 @@ Caveats: ## Terminal DWIM command I often like using a separate terminal to run most commands, instead of emacs. -This package provides a `devops-open-terminal-dwim` command, which opens the current source block in a terminal. (Only `ghostty` supported at the moment, but more terminals planned.) +This package provides a `devops-open-terminal-dwim` command, which opens a terminal at the heading's target (or at `default-directory` when the heading has none). Any `var` references become environment variables loaded into the (usually remote) shell. The source block content is copied to the clipboard, so you can do `devops-open-terminal-dwim`, then paste, and you will be running the command at the correct location. +`devops-terminal-program` picks the terminal: + +- `ghostty` (default): opens an external [Ghostty](https://ghostty.org) window, running `ssh` for a remote target. +- `ghostel`: opens a [ghostel](https://github.com/dakra/ghostel) terminal inside Emacs, reaching a remote target through TRAMP. Each target gets its own buffer (`*ghostel /ssh:web1:/srv/app/*`), reused on the next call. When that terminal is already open, the `export` lines and the block body are pasted at its prompt, so you only press `RET`. A new terminal gets the variables in its environment, and you yank the body yourself (`C-y`): its shell isn't at a prompt yet, and a paste would run line by line. + +```elisp +(setq devops-terminal-program 'ghostel) +``` + ## devops-lob `devops.el` uses org's Library of Babel (LOB) to save commonly used server commands. diff --git a/devops-test.el b/devops-test.el index eb1b56b..f1259fc 100644 --- a/devops-test.el +++ b/devops-test.el @@ -1706,6 +1706,78 @@ override (issue #14)." '("ghostty" "-e" "ssh" "-t" "example.com" "cd /srv/app && $SHELL")))) +(ert-deftest devops--ghostel-buffer-name-test () + "Each target gets its own ghostel terminal, local or remote." + (should (equal (devops--ghostel-buffer-name "/ssh:deploy@example.com:/srv/app/") + "*ghostel /ssh:deploy@example.com:/srv/app/*")) + (should-not (equal (devops--ghostel-buffer-name "/ssh:web1:/srv/") + (devops--ghostel-buffer-name "/ssh:web2:/srv/")))) + +(ert-deftest devops--ghostel-environment-test () + "Env vars become KEY=VALUE entries, any value printed as text." + (should (equal (devops--ghostel-environment '(("FOO" . "a b") ("N" . 3))) + '("FOO=a b" "N=3"))) + (should-not (devops--ghostel-environment nil))) + +(ert-deftest devops--terminal-input-test () + "Exports, shell-quoted, come before the body; nothing to paste is nil." + (should (equal (devops--terminal-input '(("FOO" . "a b")) "echo $FOO") + "export FOO=a\\ b\necho $FOO")) + (should (equal (devops--terminal-input nil "ls") "ls")) + (should (equal (devops--terminal-input '(("N" . 3)) nil) "export N=3")) + (should-not (devops--terminal-input nil nil))) + +(defvar ghostel-buffer-name) +(defvar ghostel-environment) + +(defmacro devops-test--with-fake-ghostel (&rest body) + "Run BODY with a stand-in for the ghostel package. +`ghostel' records the buffer name and environment it was called with in +`started' and returns a buffer of that name; `ghostel-paste-string' +records its text in `pasted'. Buffers it made are killed afterwards." + (declare (indent 0) (debug t)) + ;; `features' is not special, so it is set and restored, not let-bound. + `(let ((saved-features features) + (ghostel-buffer-name "*ghostel*") + (ghostel-environment '("KEEP=1")) + (started nil) + (pasted nil)) + (cl-letf (((symbol-function 'ghostel) + (lambda (&optional _arg) + (push (list ghostel-buffer-name ghostel-environment) started) + (get-buffer-create ghostel-buffer-name))) + ((symbol-function 'ghostel-paste-string) + (lambda (text) (push (list (buffer-name) text) pasted)))) + (unwind-protect + (progn + (push 'ghostel features) + ,@body) + (setq features saved-features) + (dolist (s started) + (when-let* ((buf (get-buffer (car s)))) + (kill-buffer buf))))))) + +(ert-deftest devops--open-ghostel-new-terminal-test () + "A new terminal gets the env vars in its environment; nothing is pasted." + (devops-test--with-local-target dir + (devops-test--with-fake-ghostel + (devops--open-ghostel dir '(("FOO" . "bar")) "echo $FOO") + (should (equal started + `((,(devops--ghostel-buffer-name (file-name-as-directory dir)) + ("FOO=bar" "KEEP=1"))))) + (should-not pasted)))) + +(ert-deftest devops--open-ghostel-open-terminal-test () + "A terminal already open gets exports and body pasted, not its environment." + (devops-test--with-local-target dir + (devops-test--with-fake-ghostel + (let ((name (devops--ghostel-buffer-name (file-name-as-directory dir)))) + (get-buffer-create name) + (push (list name nil) started) ; so the macro kills it + (devops--open-ghostel dir '(("FOO" . "bar")) "echo $FOO") + (should (equal (car started) `(,name ("KEEP=1")))) + (should (equal pasted `((,name "export FOO=bar\necho $FOO")))))))) + ;;; Drift detection (devops-drift.el) (defmacro devops-test--with-drift-check (target org-fmt &rest body) diff --git a/devops.el b/devops.el index 6ebc5db..bfa6e71 100644 --- a/devops.el +++ b/devops.el @@ -91,8 +91,12 @@ :prefix "devops-") (defcustom devops-terminal-program 'ghostty - "Terminal program to use for externally opening target locations." - :type '(choice (const ghostty)) + "Terminal program `devops-open-terminal-dwim' opens target locations in. +`ghostty' is an external terminal, which reaches a remote target by +running ssh. `ghostel' is a terminal inside Emacs, from the ghostel +package, which reaches it through TRAMP." + :type '(choice (const :tag "Ghostty (external)" ghostty) + (const :tag "Ghostel (inside Emacs)" ghostel)) :group 'devops) (defcustom devops-enable-session-async nil @@ -875,13 +879,63 @@ is an alist of (NAME . VALUE) exported in that shell." `("ghostty" ,(concat "--working-directory=" dir) "-e" "bash" "-c" ,(concat env-exports " && exec $SHELL")) `("ghostty" ,(concat "--working-directory=" dir)))))) -(defun devops--open-terminal-at-dir (dir &optional env-vars) - "Open `devops-terminal-program' in DIR with ENV-VARS exported." +(defvar ghostel-buffer-name) +(defvar ghostel-environment) +(declare-function ghostel "ghostel" (&optional arg)) +(declare-function ghostel-paste-string "ghostel" (string)) + +(defun devops--ghostel-buffer-name (dir) + "Return the name of the ghostel terminal for DIR, one per target." + (format "*ghostel %s*" (abbreviate-file-name dir))) + +(defun devops--ghostel-environment (env-vars) + "Return ENV-VARS, an alist of (NAME . VALUE), as `ghostel-environment'." + (mapcar (lambda (pair) (format "%s=%s" (car pair) (cdr pair))) + env-vars)) + +(defun devops--terminal-input (env-vars body) + "Return the text to paste at a shell prompt, or nil. +ENV-VARS, an alist of (NAME . VALUE), become `export' lines ahead of +BODY, which may be nil." + (let ((lines (append (mapcar (lambda (pair) + (format "export %s=%s" (car pair) + (shell-quote-argument + (format "%s" (cdr pair))))) + env-vars) + (and body (list body))))) + (and lines (string-join lines "\n")))) + +(defun devops--open-ghostel (dir env-vars body) + "Pop to the ghostel terminal for DIR, with ENV-VARS exported. +A new terminal starts with ENV-VARS in its environment. A terminal +already open gets ENV-VARS and BODY pasted at its prompt, not run. +A new one does not, since its shell would run each pasted line as it +started; BODY is in the kill ring for it." + (unless (require 'ghostel nil t) + (user-error "Package ghostel is not installed")) + (let* ((default-directory (file-name-as-directory (expand-file-name dir))) + (ghostel-buffer-name (devops--ghostel-buffer-name default-directory)) + (open (get-buffer ghostel-buffer-name)) + (ghostel-environment (append (unless open + (devops--ghostel-environment env-vars)) + ghostel-environment)) + (buf (ghostel))) + (when-let* ((open) + (text (devops--terminal-input env-vars body))) + (with-current-buffer buf + (ghostel-paste-string text))))) + +(defun devops--open-terminal-at-dir (dir &optional env-vars body) + "Open `devops-terminal-program' in DIR with ENV-VARS exported. +BODY, a src block's body or nil, is for the program to offer at the +prompt." (pcase devops-terminal-program ('ghostty (let ((ghostty (devops--ghostty-command dir env-vars))) (message "Calling process:\n%s" (string-join ghostty " ")) - (apply #'start-process "devops-terminal" nil ghostty))))) + (apply #'start-process "devops-terminal" nil ghostty))) + ('ghostel + (devops--open-ghostel dir env-vars body)))) (defun devops-src-block-env-vars () "Return alist of evaluated :var params from current src block." @@ -909,16 +963,21 @@ is an alist of (NAME . VALUE) exported in that shell." ;;;###autoload (defun devops-open-terminal-dwim () - "Open terminal at contextual directory. -In a src block, if the : copies body to clipboard and exports :var env vars." + "Open `devops-terminal-program' at the heading's target. +With no target, open it in `default-directory'. In a shell src block, +export the block's :var arguments as environment variables and copy its +body to the kill ring. Ghostel also pastes them at the prompt of a +terminal already open at the target." (interactive) - (let* ((dir (devops--heading-target-dir)) + (let* ((dir (or (devops--heading-target-dir) default-directory)) (env-vars (devops-src-block-env-vars)) - (lang (org-element-property :language (org-element-at-point)))) - (when (or (string= lang "shell") (string= lang "sh")) - (kill-new (devops--src-block-body)) + (lang (org-element-property :language (org-element-at-point))) + (body (and (member lang '("shell" "sh")) + (devops--src-block-body)))) + (when body + (kill-new body) (message "Source block copied to kill ring.")) - (devops--open-terminal-at-dir dir env-vars))) + (devops--open-terminal-at-dir dir env-vars body))) (defun devops-unload-function () "Turn `devops-mode' off, removing its advice before unloading.