diff --git a/CHANGELOG.md b/CHANGELOG.md index d4da7fdc..725be023 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### New Features +- Add TRAMP support by spawning one Copilot server per remote host. Remote files are served by a server started on their host (over SSH, inside a container, etc.), which also enables sandboxing the server. A new `copilot-disconnect` command shuts down the server for the current buffer's host. ([#403](https://github.com/copilot-emacs/copilot.el/issues/403)) - Add experimental Agent mode for Copilot Chat (`copilot-chat-use-agent-mode`). When enabled, Copilot can run client-side tools (`run_in_terminal`, `create_file`, `get_errors`, `fetch_web_page`); each invocation prompts for confirmation unless listed in `copilot-chat-auto-approve-tools`. ([#441](https://github.com/copilot-emacs/copilot.el/issues/441)) - Add native binary installation support. `copilot-install-server` now falls back to downloading precompiled binaries when npm is unavailable, removing the Node.js requirement on supported platforms. A new `copilot-install-server-native` command is also available for explicit native installation. diff --git a/README.md b/README.md index a46e30f3..8f027d99 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,12 @@ Customization variables: - **`copilot-nes-auto-dismiss-move-count`** — cursor movements before auto-dismissing (default `3`) - **`copilot-nes-auto-dismiss-distance`** — max lines between point and suggestion before auto-dismissing (default `40`) +### Remote files (TRAMP) + +Copilot works in buffers visiting remote files over TRAMP. A separate language server is started on each remote host (over SSH, inside a container, and so on) and used for that host's buffers, while local buffers keep using the local server. This also lets you sandbox the server by running it inside a restricted container. + +The server must be installed on the remote host (run `M-x copilot-install-server` from a buffer on that host). `M-x copilot-disconnect` shuts down the server for the current buffer's host. + ### Keybindings `copilot-mode` does not set any keybindings by default. Use `copilot-completion-map` (active while a completion overlay is visible) to bind keys: diff --git a/copilot-chat.el b/copilot-chat.el index 2b6607b1..b5a6d24a 100644 --- a/copilot-chat.el +++ b/copilot-chat.el @@ -683,7 +683,7 @@ If not currently streaming, reset the conversation instead." (buffer-local-value 'copilot-chat--streaming-p chat-buf)) (with-current-buffer chat-buf (when (and copilot-chat--request-id (copilot--connection-alivep)) - (jsonrpc-notify copilot--connection + (jsonrpc-notify (gethash (copilot--connection-key) copilot--connections) '$/cancelRequest (list :id copilot-chat--request-id))) (copilot-chat--end-streaming) diff --git a/copilot.el b/copilot.el index a627b42e..38728d0d 100644 --- a/copilot.el +++ b/copilot.el @@ -240,8 +240,10 @@ from available models." (defvar-local copilot--keymap-overlay nil "Overlay used to surround point and make copilot-completion-keymap activate.") -(defvar copilot--connection nil - "Copilot server jsonrpc connection instance.") +(defvar copilot--connections (make-hash-table :test #'equal) + "Hash table mapping connection key to Copilot server jsonrpc connection. +Keys are either \"local\" or a TRAMP remote prefix string +\(e.g. \"/ssh:user@host:\").") (defvar-local copilot--line-bias 1 "Line bias for Copilot completion.") @@ -261,28 +263,51 @@ Incremented after each change.") "Return non-nil if the buffer has changed since last completion." (not (= copilot--last-doc-version copilot--doc-version))) -(defvar copilot--opened-buffers nil - "List of buffers that have been opened in Copilot.") - -(defvar copilot--workspace-folders nil - "List of workspace folder URIs already reported to the server.") - -(defvar copilot--status nil - "Current server status from `didChangeStatus' notification. -Plist with keys :kind, :busy, and :message.") - -(defvar copilot--progress-sessions (make-hash-table :test 'equal) - "Hash table of active progress sessions, keyed by token. -Each value is a plist with :title, :message, and :percentage.") +(defvar copilot--opened-buffers (make-hash-table :test #'equal) + "Hash table mapping connection key to list of buffers opened in Copilot.") + +(defvar copilot--workspace-folders (make-hash-table :test #'equal) + "Hash table mapping connection key to a list of workspace folder URIs. +The URIs are those already reported to that key's server.") + +(defvar copilot--status (make-hash-table :test #'equal) + "Hash table mapping connection key to server status plist. +Each value is a plist with keys :kind, :busy, and :message.") + +(defvar copilot--progress-sessions (make-hash-table :test #'equal) + "Hash table mapping connection key to a hash table of progress sessions. +Inner hash tables are keyed by token; each value is a plist with +:title, :message, and :percentage.") + +(defun copilot--connection-key () + "Return the connection key for the current buffer. +Returns \"local\" for local buffers, or the TRAMP remote prefix string +\(e.g. \"/ssh:user@host:\") for remote buffers." + (if (and buffer-file-name (file-remote-p buffer-file-name)) + (file-remote-p buffer-file-name) + "local")) + +(defun copilot--key-for-connection (conn) + "Return the connection key string for CONN, or nil if not found." + (let (found) + (maphash (lambda (k v) (when (eq v conn) (setq found k))) + copilot--connections) + found)) + +(defun copilot--local-file-name (path) + "Return the local part of PATH, stripping any TRAMP prefix. +For local paths returns PATH unchanged." + (or (file-remote-p path 'localname) path)) (defun copilot--progress-lighter () - "Compute mode-line progress indicator from active sessions. + "Compute mode-line progress indicator for the current buffer's server. Returns nil when no active sessions. Otherwise returns a string like \"[title: message]\" or \"[title: 42%]\" from the most recently updated session." - (when (> (hash-table-count copilot--progress-sessions) 0) + (when-let* ((sessions (gethash (copilot--connection-key) copilot--progress-sessions)) + (_ (> (hash-table-count sessions) 0))) (let (latest) - (maphash (lambda (_k v) (setq latest v)) copilot--progress-sessions) + (maphash (lambda (_k v) (setq latest v)) sessions) (let ((title (plist-get latest :title)) (message (plist-get latest :message)) (percentage (plist-get latest :percentage))) @@ -293,9 +318,10 @@ recently updated session." (defun copilot--status-lighter () "Compute the mode-line lighter string from `copilot--status'." - (let ((kind (plist-get copilot--status :kind)) - (busy (plist-get copilot--status :busy)) - (progress (copilot--progress-lighter))) + (let* ((status (gethash (copilot--connection-key) copilot--status)) + (kind (plist-get status :kind)) + (busy (plist-get status :busy)) + (progress (copilot--progress-lighter))) (concat (cond ((or (null kind) (and (equal kind "Normal") (not busy))) @@ -576,9 +602,9 @@ downloading precompiled native binaries from the npm registry." "Simply ignore the response.") (defun copilot--connection-alivep () - "Non-nil if the `copilot--connection' is alive." - (and copilot--connection - (zerop (process-exit-status (jsonrpc--process copilot--connection))))) + "Non-nil if the Copilot server connection for the current buffer is alive." + (when-let* ((conn (gethash (copilot--connection-key) copilot--connections))) + (zerop (process-exit-status (jsonrpc--process conn))))) (defmacro copilot--request (method &optional params &rest args) "Send a request to the copilot server for METHOD with PARAMS and ARGS. @@ -587,14 +613,15 @@ reject the request with a schema-validation error." `(progn (unless (copilot--connection-alivep) (copilot--start-server)) - (jsonrpc-request copilot--connection ,method (or ,params (make-hash-table)) ,@args))) + (jsonrpc-request (gethash (copilot--connection-key) copilot--connections) + ,method (or ,params (make-hash-table)) ,@args))) (defmacro copilot--notify (&rest args) "Send a notification to the copilot server with ARGS." `(progn (unless (copilot--connection-alivep) (copilot--start-server)) - (jsonrpc-notify copilot--connection ,@args))) + (jsonrpc-notify (gethash (copilot--connection-key) copilot--connections) ,@args))) (cl-defmacro copilot--async-request (method params &rest args &key @@ -620,7 +647,7 @@ Returns the request ID (a number) so callers can cancel the request later." ;; jsonrpc will use temp buffer for callbacks, so we need to save the ;; current buffer and restore it inside callback (let ((buf (current-buffer))) - (car (jsonrpc--async-request-1 copilot--connection + (car (jsonrpc--async-request-1 (gethash (copilot--connection-key) copilot--connections) ,method ,params :success-fn (lambda (result) (if (buffer-live-p buf) @@ -633,11 +660,12 @@ Returns the request ID (a number) so callers can cancel the request later." ,method err))) ,@filtered-args)))))) -(defun copilot--shutdown-server (&optional at-exit) - "Shut down the Copilot server with the standard LSP shutdown sequence. +(defun copilot--shutdown-server (&optional key at-exit) + "Shut down the Copilot server identified by KEY. +KEY defaults to `(copilot--connection-key)' for the current buffer. Sends a `shutdown' request followed by an `exit' notification, then -cleans up the connection and resets global state. Safe to call when -there is no active connection. +cleans up the connection and resets state for that key. Safe to call +when there is no active connection for that key. When AT-EXIT is non-nil, skip the final `jsonrpc-shutdown' call. That function waits on `accept-process-output', which can busy-loop forever @@ -645,25 +673,40 @@ when several jsonrpc connections are alive at once, due to an Emacs bug fixed only on Emacs 31+ (see https://github.com/copilot-emacs/copilot.el/issues/469). At exit that would hang Emacs indefinitely, and the server process is reaped by Emacs anyway, so the `exit' notification is enough." - (when copilot--connection - (condition-case _err - (jsonrpc-request copilot--connection 'shutdown nil :timeout 3) - (error nil)) - (condition-case _err - (jsonrpc-notify copilot--connection 'exit nil) - (error nil)) - (unless at-exit - (jsonrpc-shutdown copilot--connection)) - (setq copilot--connection nil) - (setq copilot--opened-buffers nil) - (setq copilot--workspace-folders nil) - (setq copilot--status nil))) - -(defun copilot--shutdown-server-at-exit () - "Shut down the Copilot server from `kill-emacs-hook'. + (let* ((k (or key (copilot--connection-key))) + (conn (gethash k copilot--connections))) + (when conn + (condition-case _err + (jsonrpc-request conn 'shutdown nil :timeout 3) + (error nil)) + (condition-case _err + (jsonrpc-notify conn 'exit nil) + (error nil)) + (unless at-exit + (jsonrpc-shutdown conn)) + (remhash k copilot--connections) + (remhash k copilot--opened-buffers) + (remhash k copilot--workspace-folders) + (remhash k copilot--status) + (remhash k copilot--progress-sessions)))) + +(defun copilot--shutdown-all-servers (&optional at-exit) + "Shut down all active Copilot server connections. +AT-EXIT is passed through to `copilot--shutdown-server'." + (dolist (key (hash-table-keys copilot--connections)) + (copilot--shutdown-server key at-exit))) + +(defun copilot--shutdown-all-servers-at-exit () + "Shut down all Copilot servers from `kill-emacs-hook'. Skips the blocking `jsonrpc-shutdown' step so Emacs can exit without hanging. See `copilot--shutdown-server'." - (copilot--shutdown-server t)) + (copilot--shutdown-all-servers t)) + +;;;###autoload +(defun copilot-disconnect () + "Shut down the Copilot server for the current buffer's host." + (interactive) + (copilot--shutdown-server (copilot--connection-key))) (defun copilot--command () "Return the command-line to start copilot server." @@ -671,20 +714,36 @@ hanging. See `copilot--shutdown-server'." (list (copilot-server-executable)) copilot-server-args)) -(defun copilot--make-connection () - "Establish copilot jsonrpc connection." - (let ((make-fn (apply-partially - #'make-instance - 'jsonrpc-process-connection - :name "copilot" - :request-dispatcher #'copilot--handle-request - :notification-dispatcher #'copilot--handle-notification - :process (make-process :name "copilot server" - :command (copilot--command) - :coding 'utf-8-emacs-unix - :connection-type 'pipe - :stderr (get-buffer-create "*copilot stderr*") - :noquery t)))) +(defun copilot--make-connection (remote-prefix) + "Establish a Copilot jsonrpc connection. +REMOTE-PREFIX is the TRAMP remote prefix string (e.g. \"/ssh:user@host:\") for +remote connections, or nil for a local connection. When non-nil, the server +process is started on the remote host via TRAMP by binding `default-directory'." + (let* ((default-directory (if remote-prefix + (concat remote-prefix "~/") + default-directory)) + (stderr-name (if remote-prefix + (format "*copilot stderr %s*" remote-prefix) + "*copilot stderr*")) + (make-fn (apply-partially + #'make-instance + 'jsonrpc-process-connection + :name "copilot" + :request-dispatcher #'copilot--handle-request + :notification-dispatcher #'copilot--handle-notification + :on-shutdown + (lambda (_conn) + (copilot--log + 'error "Copilot server%s exited. See %s for details." + (if remote-prefix (format " (%s)" remote-prefix) "") + stderr-name)) + :process (make-process :name "copilot server" + :command (copilot--command) + :coding 'utf-8-emacs-unix + :connection-type 'pipe + :stderr (get-buffer-create stderr-name) + :noquery t + :file-handler t)))) (condition-case nil (funcall make-fn :events-buffer-config `(:size ,copilot-log-max)) (invalid-slot-name @@ -692,7 +751,9 @@ hanging. See `copilot--shutdown-server'." (funcall make-fn :events-buffer-scrollback-size copilot-log-max))))) (defun copilot--effective-lsp-settings () - "Return the effective LSP settings, including completion model." + "Return the effective LSP settings, including completion model. +Always returns at least an empty hash table so the server receives +a JSON object rather than null for the settings field." (let ((settings (copy-sequence copilot-lsp-settings))) (when copilot-completion-model (let* ((github (or (plist-get settings :github) '())) @@ -700,31 +761,39 @@ hanging. See `copilot--shutdown-server'." (setq copilot-section (plist-put copilot-section :selectedCompletionModel copilot-completion-model)) (setq github (plist-put github :copilot copilot-section)) (setq settings (plist-put settings :github github)))) - settings)) + (or settings (make-hash-table)))) (defun copilot--start-server () - "Start the copilot server process in local." - (cond - ((not (file-exists-p (copilot-server-executable))) - (user-error "Server is not installed, please install via `M-x copilot-install-server`")) - (t - (let ((installed-version (copilot-installed-version))) - (when (and copilot-lsp-server-version (not (equal installed-version copilot-lsp-server-version))) - (warn "This package has been tested for Copilot LSP server version %s but version %s has been detected. + "Start the copilot server process for the current buffer's host. +For remote buffers (TRAMP), the server is started on the remote host." + (let* ((key (copilot--connection-key)) + (remote-prefix (if (equal key "local") nil key))) + ;; Limit the remote `default-directory' rebinding to the executable check + ;; and process creation so that subsequent calls (e.g. `vc-root-dir' inside + ;; `copilot--workspace-root') keep the buffer's actual directory. + (let ((default-directory (if remote-prefix + (concat remote-prefix "~/") + default-directory))) + (unless (or remote-prefix (file-exists-p (copilot-server-executable))) + (user-error "Server is not installed%s, please install via `M-x copilot-install-server`" + (if remote-prefix (format " on %s" remote-prefix) ""))) + (let ((installed-version (copilot-installed-version))) + (when (and copilot-lsp-server-version (not (equal installed-version copilot-lsp-server-version))) + (warn "This package has been tested for Copilot LSP server version %s but version %s has been detected. You can change the installed version with `M-x copilot-reinstall-server` or remove this warning by changing the value of `copilot-lsp-server-version'." - copilot-lsp-server-version installed-version))) - (setq copilot--connection (copilot--make-connection)) - (setq copilot--workspace-folders nil) - (copilot--log 'info "Copilot server started.") + copilot-lsp-server-version installed-version))) + (puthash key (copilot--make-connection remote-prefix) copilot--connections)) + (puthash key nil copilot--workspace-folders) + (copilot--log 'info "Copilot server started%s." + (if remote-prefix (format " on %s" remote-prefix) "")) (let* ((root (copilot--workspace-root)) (root-uri (when root (copilot--path-to-uri root))) (folders (when root-uri - (setq copilot--workspace-folders (list root-uri)) + (puthash key (list root-uri) copilot--workspace-folders) (vector (list :uri root-uri :name (file-name-nondirectory (directory-file-name root))))))) (copilot--request 'initialize - `(:processId - ,(emacs-pid) + `(,@(if remote-prefix '() `(:processId ,(emacs-pid))) ,@(when root-uri `(:rootUri ,root-uri)) :capabilities (:workspace @@ -742,14 +811,15 @@ You can change the installed version with `M-x copilot-reinstall-server` or remo `(:networkProxy ,copilot-network-proxy)))))) (copilot--notify 'initialized '()) (copilot--notify 'workspace/didChangeConfiguration `(:settings ,(copilot--effective-lsp-settings))) - (add-hook 'kill-emacs-hook #'copilot--shutdown-server-at-exit)))) + (add-hook 'kill-emacs-hook #'copilot--shutdown-all-servers-at-exit))) ;; ;; login / logout ;; (defun copilot-login () - "Login to Copilot." + "Login to Copilot. +When called from a remote buffer, authenticates with the remote Copilot server." (interactive) (copilot--dbind (status user ((:userCode user-code)) ((:verificationUri verification-uri))) @@ -888,7 +958,7 @@ a request that was just initiated by a wrapper command.") Sends `$/cancelRequest' to the server and resets the stored request ID." (when copilot--completion-request-id (when (copilot--connection-alivep) - (jsonrpc-notify copilot--connection + (jsonrpc-notify (gethash (copilot--connection-key) copilot--connections) '$/cancelRequest (list :id copilot--completion-request-id))) (setq copilot--completion-request-id nil))) @@ -923,7 +993,9 @@ Sends `$/cancelRequest' to the server and resets the stored request ID." tab-width))) (defun copilot--workspace-root () - "Return the root directory of the current workspace, or nil." + "Return the local part of the root directory of the current workspace, or nil. +For TRAMP remote buffers the TRAMP prefix is stripped so the returned path +is a plain absolute path suitable for use in URIs sent to the server." (when buffer-file-name (let ((root (or (and (fboundp 'project-current) (when-let* ((proj (project-current))) @@ -933,17 +1005,20 @@ Sends `$/cancelRequest' to the server and resets the stored request ID." (and (fboundp 'vc-root-dir) (vc-root-dir))))) (when root - (file-truename root))))) + (copilot--local-file-name (file-truename root)))))) (defun copilot--get-relative-path () - "Get relative path to current buffer." + "Get relative path to current buffer. +For TRAMP remote buffers, the TRAMP prefix is stripped before computing +the relative path, so the result is a plain relative path." (cond ((not buffer-file-name) "") (t - (if-let* ((root (copilot--workspace-root))) - (file-relative-name buffer-file-name root) - (file-name-nondirectory buffer-file-name))))) + (let ((local-name (copilot--local-file-name buffer-file-name))) + (if-let* ((root (copilot--workspace-root))) + (file-relative-name local-name root) + (file-name-nondirectory local-name)))))) (defun copilot--path-to-uri (path) "Convert file PATH to a URI string." @@ -955,9 +1030,11 @@ Sends `$/cancelRequest' to the server and resets the stored request ID." (concat "file://" (url-encode-url path))))) (defun copilot--get-uri () - "Get URI of current buffer." + "Get URI of current buffer. +For TRAMP remote buffers the TRAMP prefix is stripped so the URI contains +the plain remote path as seen by the server process." (if buffer-file-name - (copilot--path-to-uri buffer-file-name) + (copilot--path-to-uri (copilot--local-file-name buffer-file-name)) (concat "file:///buffer/" (url-encode-url (buffer-name (current-buffer)))))) (defun copilot--get-source () @@ -1066,7 +1143,7 @@ POS defaults to point. Character offset is in UTF-16 code units." ;; base from the start. For now leave it as is. :indentSize indent :insertSpaces (if indent-tabs-mode :json-false t) - :path (buffer-file-name) + :path (when buffer-file-name (copilot--local-file-name buffer-file-name)) :uri (copilot--get-uri) :relativePath (copilot--get-relative-path) :languageId (copilot--get-language-id) @@ -1145,9 +1222,16 @@ TRIGGER-KIND is 1 for invoked, 2 for automatic (default)." Each request METHOD can have only one HANDLER." (puthash method handler copilot--request-handlers)) -(defun copilot--handle-request (_ method msg) - "Handle MSG of type METHOD by calling the appropriate registered handler." - (let ((handler (gethash method copilot--request-handlers))) +(defvar copilot--current-connection nil + "The jsonrpc connection currently dispatching a notification or request. +Dynamically bound during dispatch so handlers can identify which +server sent the message.") + +(defun copilot--handle-request (conn method msg) + "Handle MSG of type METHOD by calling the appropriate registered handler. +CONN is bound to `copilot--current-connection' for the duration of the call." + (let ((copilot--current-connection conn) + (handler (gethash method copilot--request-handlers))) (when handler (funcall handler msg)))) @@ -1159,9 +1243,11 @@ Each request METHOD can have only one HANDLER." (let ((handlers (gethash method copilot--notification-handlers '()))) (puthash method (cons handler handlers) copilot--notification-handlers))) -(defun copilot--handle-notification (_ method msg) - "Handle MSG of type METHOD by calling all appropriate registered handlers." - (let ((handlers (gethash method copilot--notification-handlers '()))) +(defun copilot--handle-notification (conn method msg) + "Handle MSG of type METHOD by calling all appropriate registered handlers. +CONN is bound to `copilot--current-connection' for the duration of each call." + (let ((copilot--current-connection conn) + (handlers (gethash method copilot--notification-handlers '()))) (dolist (handler handlers) (funcall handler msg)))) @@ -1211,9 +1297,10 @@ Each request METHOD can have only one HANDLER." (copilot-on-notification 'didChangeStatus (lambda (msg) - (copilot--dbind (kind busy message) msg - (setq copilot--status (list :kind kind :busy (eq busy t) :message message)) - (force-mode-line-update t)))) + (let ((key (copilot--key-for-connection copilot--current-connection))) + (copilot--dbind (kind busy message) msg + (puthash key (list :kind kind :busy (eq busy t) :message message) copilot--status) + (force-mode-line-update t))))) (copilot-on-request 'window/showMessageRequest @@ -1254,25 +1341,30 @@ Each request METHOD can have only one HANDLER." (copilot-on-notification '$/progress (lambda (msg) - (copilot--dbind (token value) msg - (let ((kind (plist-get value :kind))) - (cond - ((equal kind "begin") - (puthash token - (list :title (plist-get value :title) - :message (plist-get value :message) - :percentage (plist-get value :percentage)) - copilot--progress-sessions)) - ((equal kind "report") - (let ((session (gethash token copilot--progress-sessions))) - (when session - (when (plist-member value :message) - (plist-put session :message (plist-get value :message))) - (when (plist-member value :percentage) - (plist-put session :percentage (plist-get value :percentage)))))) - ((equal kind "end") - (remhash token copilot--progress-sessions))) - (force-mode-line-update t))))) + (let* ((key (copilot--key-for-connection copilot--current-connection)) + (sessions (or (gethash key copilot--progress-sessions) + (let ((h (make-hash-table :test 'equal))) + (puthash key h copilot--progress-sessions) + h)))) + (copilot--dbind (token value) msg + (let ((kind (plist-get value :kind))) + (cond + ((equal kind "begin") + (puthash token + (list :title (plist-get value :title) + :message (plist-get value :message) + :percentage (plist-get value :percentage)) + sessions)) + ((equal kind "report") + (let ((session (gethash token sessions))) + (when session + (when (plist-member value :message) + (plist-put session :message (plist-get value :message))) + (when (plist-member value :percentage) + (plist-put session :percentage (plist-get value :percentage)))))) + ((equal kind "end") + (remhash token sessions))) + (force-mode-line-update t)))))) (defun copilot--get-panel-completions (callback) "Get panel completions with CALLBACK." @@ -1550,23 +1642,26 @@ Uppercase CHAR disables `case-fold-search', mirroring `zap-to-char'." "Ensure the current buffer has been opened with the Copilot server. Sends workspace folder and `textDocument/didOpen' notifications if the buffer has not been registered yet. Safe to call multiple times." - (when-let* ((root (copilot--workspace-root)) - (root-uri (copilot--path-to-uri root))) - (unless (member root-uri copilot--workspace-folders) - (push root-uri copilot--workspace-folders) - (copilot--notify 'workspace/didChangeWorkspaceFolders - (list :event - (list :added (vector (list :uri root-uri - :name (file-name-nondirectory - (directory-file-name root)))) - :removed []))))) - (unless (seq-contains-p copilot--opened-buffers (current-buffer)) - (add-to-list 'copilot--opened-buffers (current-buffer)) - (copilot--notify 'textDocument/didOpen - (list :textDocument (list :uri (copilot--get-uri) - :languageId (copilot--get-language-id) - :version copilot--doc-version - :text (copilot--get-source)))))) + (let ((key (copilot--connection-key))) + (when-let* ((root (copilot--workspace-root)) + (root-uri (copilot--path-to-uri root))) + (unless (member root-uri (gethash key copilot--workspace-folders)) + (puthash key (cons root-uri (gethash key copilot--workspace-folders)) + copilot--workspace-folders) + (copilot--notify 'workspace/didChangeWorkspaceFolders + (list :event + (list :added (vector (list :uri root-uri + :name (file-name-nondirectory + (directory-file-name root)))) + :removed []))))) + (unless (seq-contains-p (gethash key copilot--opened-buffers) (current-buffer)) + (puthash key (cons (current-buffer) (gethash key copilot--opened-buffers)) + copilot--opened-buffers) + (copilot--notify 'textDocument/didOpen + (list :textDocument (list :uri (copilot--get-uri) + :languageId (copilot--get-language-id) + :version copilot--doc-version + :text (copilot--get-source))))))) (defun copilot--on-doc-focus (window) "Notify that the document WINDOW has been focussed or opened." @@ -1575,18 +1670,21 @@ the buffer has not been registered yet. Safe to call multiple times." ;; send a notification for the window gaining focus and only if the buffer has ;; copilot-mode enabled. (when (and copilot-mode (eq window (selected-window))) - (if (seq-contains-p copilot--opened-buffers (current-buffer)) + (if (seq-contains-p (gethash (copilot--connection-key) copilot--opened-buffers) (current-buffer)) (copilot--notify 'textDocument/didFocus (list :textDocument (list :uri (copilot--get-uri)))) (copilot--ensure-doc-open)))) (defun copilot--on-doc-close (&rest _args) "Notify that the document has been closed." - (when (seq-contains-p copilot--opened-buffers (current-buffer)) - (when (copilot--connection-alivep) - (jsonrpc-notify copilot--connection 'textDocument/didClose - (list :textDocument (list :uri (copilot--get-uri))))) - (setq copilot--opened-buffers (delete (current-buffer) copilot--opened-buffers)))) + (let* ((key (copilot--connection-key)) + (opened (gethash key copilot--opened-buffers))) + (when (seq-contains-p opened (current-buffer)) + (when (copilot--connection-alivep) + (jsonrpc-notify (gethash key copilot--connections) + 'textDocument/didClose + (list :textDocument (list :uri (copilot--get-uri))))) + (puthash key (delete (current-buffer) opened) copilot--opened-buffers)))) ;;;###autoload (defun copilot-complete () diff --git a/dev/integration-smoke.el b/dev/integration-smoke.el index 0e4c0760..a1fca889 100644 --- a/dev/integration-smoke.el +++ b/dev/integration-smoke.el @@ -131,9 +131,7 @@ (kill-buffer test-buf))) ;; Shutdown - (when copilot--connection - (jsonrpc-shutdown copilot--connection) - (setq copilot--connection nil)) + (copilot--shutdown-all-servers) (message "\nDone.")) (test--run) diff --git a/test/copilot-nes-test.el b/test/copilot-nes-test.el index 46974bfa..1e0277c5 100644 --- a/test/copilot-nes-test.el +++ b/test/copilot-nes-test.el @@ -107,8 +107,7 @@ (edit (list :text "new" :range (list :start (list :line 0 :character 0) :end (list :line 0 :character 5)) - :command cmd)) - (copilot--connection t)) + :command cmd))) (spy-on 'copilot--connection-alivep :and-return-value t) (spy-on 'jsonrpc-notify) (copilot-nes--display edit) @@ -501,8 +500,7 @@ (edit (list :text "planet" :range (list :start (list :line 0 :character 6) :end (list :line 0 :character 11)) - :command cmd)) - (copilot--connection t)) + :command cmd))) (spy-on 'copilot--connection-alivep :and-return-value t) (spy-on 'jsonrpc-notify) (copilot-nes--display edit) diff --git a/test/copilot-test.el b/test/copilot-test.el index 73abd843..84afaa32 100644 --- a/test/copilot-test.el +++ b/test/copilot-test.el @@ -41,7 +41,7 @@ ;; The second call also signals (from our spy), but we just want ;; to verify both keyword variants are attempted. (condition-case nil - (copilot--make-connection) + (copilot--make-connection nil) (invalid-slot-name nil)) (expect (spy-calls-count 'make-instance) :to-be-greater-than 1)))) @@ -362,27 +362,31 @@ (describe "copilot--on-doc-close" (it "does not start the server when connection is not alive" (with-temp-buffer - (add-to-list 'copilot--opened-buffers (current-buffer)) - (spy-on 'copilot--connection-alivep :and-return-value nil) - (spy-on 'jsonrpc-notify) - (copilot--on-doc-close) - ;; Should not send notification when server is not alive - (expect 'jsonrpc-notify :not :to-have-been-called) - ;; But should still clean up opened-buffers - (expect (seq-contains-p copilot--opened-buffers (current-buffer)) - :not :to-be-truthy))) + (let ((copilot--opened-buffers (let ((h (make-hash-table :test #'equal))) + (puthash "local" (list (current-buffer)) h) h))) + (spy-on 'copilot--connection-alivep :and-return-value nil) + (spy-on 'jsonrpc-notify) + (copilot--on-doc-close) + ;; Should not send notification when server is not alive + (expect 'jsonrpc-notify :not :to-have-been-called) + ;; But should still clean up opened-buffers + (expect (seq-contains-p (gethash "local" copilot--opened-buffers) (current-buffer)) + :not :to-be-truthy)))) (it "sends notification when connection is alive" (with-temp-buffer - (add-to-list 'copilot--opened-buffers (current-buffer)) - (spy-on 'copilot--connection-alivep :and-return-value t) - (spy-on 'jsonrpc-notify) - (let ((copilot--connection t)) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--opened-buffers (let ((h (make-hash-table :test #'equal))) + (puthash "local" (list (current-buffer)) h) h))) + (spy-on 'copilot--connection-alivep :and-return-value t) + (spy-on 'jsonrpc-notify) (copilot--on-doc-close) ;; Should send didClose notification (expect 'jsonrpc-notify :to-have-been-called) ;; And clean up opened-buffers - (expect (seq-contains-p copilot--opened-buffers (current-buffer)) + (expect (seq-contains-p (gethash "local" copilot--opened-buffers) (current-buffer)) :not :to-be-truthy))))) ;; @@ -583,13 +587,13 @@ (describe "copilot--lsp-settings-changed" (it "does not restart the server" - (let ((copilot--connection nil)) + (let ((copilot--connections (make-hash-table :test #'equal))) (spy-on 'copilot--start-server) (copilot--lsp-settings-changed 'copilot-lsp-settings '(:new "value")) (expect 'copilot--start-server :not :to-have-been-called))) (it "does not send notification when connection is not alive" - (let ((copilot--connection nil)) + (let ((copilot--connections (make-hash-table :test #'equal))) (spy-on 'jsonrpc-notify) (copilot--lsp-settings-changed 'copilot-lsp-settings '(:new "value")) (expect 'jsonrpc-notify :not :to-have-been-called)))) @@ -624,31 +628,36 @@ (describe "copilot--status-lighter" (it "returns \" Copilot\" when status is nil" - (let ((copilot--status nil)) + (let ((copilot--status (make-hash-table :test #'equal))) (expect (copilot--status-lighter) :to-equal " Copilot"))) (it "returns \" Copilot\" for Normal and not busy" - (let ((copilot--status '(:kind "Normal" :busy nil :message ""))) + (let ((copilot--status (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(:kind "Normal" :busy nil :message "") h) h))) (expect (copilot--status-lighter) :to-equal " Copilot"))) (it "returns \" Copilot*\" for Normal and busy" - (let ((copilot--status '(:kind "Normal" :busy t :message ""))) + (let ((copilot--status (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(:kind "Normal" :busy t :message "") h) h))) (expect (copilot--status-lighter) :to-equal " Copilot*"))) (it "returns propertized warning string for Warning kind" - (let ((copilot--status '(:kind "Warning" :busy nil :message "some warning"))) + (let ((copilot--status (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(:kind "Warning" :busy nil :message "some warning") h) h))) (let ((result (copilot--status-lighter))) (expect result :to-equal " Copilot:Warning") (expect (get-text-property 0 'face result) :to-equal 'warning)))) (it "returns propertized error string for Error kind" - (let ((copilot--status '(:kind "Error" :busy nil :message "auth failed"))) + (let ((copilot--status (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(:kind "Error" :busy nil :message "auth failed") h) h))) (let ((result (copilot--status-lighter))) (expect result :to-equal " Copilot:Error") (expect (get-text-property 0 'face result) :to-equal 'error)))) (it "returns propertized inactive string for Inactive kind" - (let ((copilot--status '(:kind "Inactive" :busy nil :message ""))) + (let ((copilot--status (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(:kind "Inactive" :busy nil :message "") h) h))) (let ((result (copilot--status-lighter))) (expect result :to-equal " Copilot:Inactive") (expect (get-text-property 0 'face result) :to-equal 'shadow))))) @@ -659,25 +668,30 @@ (describe "didChangeStatus handler" (it "sets copilot--status from notification" - (let ((copilot--status nil)) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--status (make-hash-table :test #'equal))) (spy-on 'force-mode-line-update) - ;; Simulate the notification by looking up and calling the handler - (let ((handlers (gethash 'didChangeStatus copilot--notification-handlers))) - (expect handlers :to-be-truthy) - (funcall (car handlers) - '(:kind "Warning" :busy nil :message "something")) - (expect (plist-get copilot--status :kind) :to-equal "Warning") - (expect (plist-get copilot--status :busy) :to-equal nil) - (expect (plist-get copilot--status :message) :to-equal "something") - (expect 'force-mode-line-update :to-have-been-called-with t)))) + (expect (gethash 'didChangeStatus copilot--notification-handlers) :to-be-truthy) + (copilot--handle-notification fake-conn 'didChangeStatus + '(:kind "Warning" :busy nil :message "something")) + (let ((status (gethash "local" copilot--status))) + (expect (plist-get status :kind) :to-equal "Warning") + (expect (plist-get status :busy) :to-equal nil) + (expect (plist-get status :message) :to-equal "something")) + (expect 'force-mode-line-update :to-have-been-called-with t))) (it "normalizes :json-false to nil for busy flag" - (let ((copilot--status nil)) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--status (make-hash-table :test #'equal))) (spy-on 'force-mode-line-update) - (let ((handlers (gethash 'didChangeStatus copilot--notification-handlers))) - (funcall (car handlers) - '(:kind "Normal" :busy :json-false :message nil)) - (expect (plist-get copilot--status :busy) :to-equal nil))))) + (copilot--handle-notification fake-conn 'didChangeStatus + '(:kind "Normal" :busy :json-false :message nil)) + (let ((status (gethash "local" copilot--status))) + (expect (plist-get status :busy) :to-equal nil))))) ;; ;; window/showMessageRequest handler @@ -803,76 +817,89 @@ (describe "$/progress handler" (it "stores session on begin and reports progress" - (let ((copilot--progress-sessions (make-hash-table :test 'equal))) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--progress-sessions (make-hash-table :test 'equal))) (spy-on 'force-mode-line-update) (expect (gethash '$/progress copilot--notification-handlers) :to-be-truthy) (copilot--handle-notification - nil '$/progress + fake-conn '$/progress '(:token "tok1" :value (:kind "begin" :title "Indexing" :message "Starting"))) - (expect (hash-table-count copilot--progress-sessions) :to-equal 1) - (let ((session (gethash "tok1" copilot--progress-sessions))) + (let* ((sessions (gethash "local" copilot--progress-sessions)) + (session (gethash "tok1" sessions))) + (expect (hash-table-count sessions) :to-equal 1) (expect (plist-get session :title) :to-equal "Indexing") (expect (plist-get session :message) :to-equal "Starting")) (expect (copilot--progress-lighter) :to-equal " [Indexing: Starting]") (expect 'force-mode-line-update :to-have-been-called-with t))) (it "updates session on report" - (let ((copilot--progress-sessions (make-hash-table :test 'equal))) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--progress-sessions (make-hash-table :test 'equal))) (spy-on 'force-mode-line-update) (copilot--handle-notification - nil '$/progress + fake-conn '$/progress '(:token "tok1" :value (:kind "begin" :title "Indexing" :message "Starting"))) (copilot--handle-notification - nil '$/progress + fake-conn '$/progress '(:token "tok1" :value (:kind "report" :message "50 files" :percentage 42))) - (let ((session (gethash "tok1" copilot--progress-sessions))) + (let* ((sessions (gethash "local" copilot--progress-sessions)) + (session (gethash "tok1" sessions))) (expect (plist-get session :message) :to-equal "50 files") (expect (plist-get session :percentage) :to-equal 42)) (expect (copilot--progress-lighter) :to-equal " [Indexing: 50 files]"))) (it "removes session on end" - (let ((copilot--progress-sessions (make-hash-table :test 'equal))) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--progress-sessions (make-hash-table :test 'equal))) (spy-on 'force-mode-line-update) (copilot--handle-notification - nil '$/progress - '(:token "tok1" - :value (:kind "begin" :title "Indexing"))) + fake-conn '$/progress + '(:token "tok1" :value (:kind "begin" :title "Indexing"))) (copilot--handle-notification - nil '$/progress - '(:token "tok1" - :value (:kind "end"))) - (expect (hash-table-count copilot--progress-sessions) :to-equal 0) + fake-conn '$/progress + '(:token "tok1" :value (:kind "end"))) + (expect (hash-table-count (gethash "local" copilot--progress-sessions)) :to-equal 0) (expect (copilot--progress-lighter) :to-be nil))) (it "tracks multiple tokens independently" - (let ((copilot--progress-sessions (make-hash-table :test 'equal))) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--progress-sessions (make-hash-table :test 'equal))) (spy-on 'force-mode-line-update) (copilot--handle-notification - nil '$/progress - '(:token "tok1" - :value (:kind "begin" :title "Indexing"))) - (copilot--handle-notification - nil '$/progress - '(:token "tok2" - :value (:kind "begin" :title "Loading"))) - (expect (hash-table-count copilot--progress-sessions) :to-equal 2) + fake-conn '$/progress + '(:token "tok1" :value (:kind "begin" :title "Indexing"))) (copilot--handle-notification - nil '$/progress - '(:token "tok1" - :value (:kind "end"))) - (expect (hash-table-count copilot--progress-sessions) :to-equal 1) - (expect (gethash "tok1" copilot--progress-sessions) :to-be nil) - (expect (gethash "tok2" copilot--progress-sessions) :to-be-truthy))) + fake-conn '$/progress + '(:token "tok2" :value (:kind "begin" :title "Loading"))) + (let ((sessions (gethash "local" copilot--progress-sessions))) + (expect (hash-table-count sessions) :to-equal 2) + (copilot--handle-notification + fake-conn '$/progress + '(:token "tok1" :value (:kind "end"))) + (expect (hash-table-count sessions) :to-equal 1) + (expect (gethash "tok1" sessions) :to-be nil) + (expect (gethash "tok2" sessions) :to-be-truthy)))) (it "includes progress in mode-line lighter" - (let ((copilot--progress-sessions (make-hash-table :test 'equal)) - (copilot--status nil)) + (let* ((fake-conn 'test-conn) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" fake-conn h) h)) + (copilot--progress-sessions (make-hash-table :test 'equal)) + (copilot--status (make-hash-table :test #'equal))) (spy-on 'force-mode-line-update) (copilot--handle-notification - nil '$/progress + fake-conn '$/progress '(:token "tok1" :value (:kind "begin" :title "Indexing" :percentage 42))) (expect (copilot--status-lighter) :to-equal " Copilot [Indexing: 42%]")))) @@ -884,9 +911,12 @@ (describe "copilot--shutdown-server" (it "sends shutdown request and exit notification when connection is alive" (let* ((conn (make-symbol "fake-conn")) - (copilot--connection conn) - (copilot--opened-buffers '(buf1)) - (copilot--workspace-folders '("file:///tmp"))) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" conn h) h)) + (copilot--opened-buffers (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(buf1) h) h)) + (copilot--workspace-folders (let ((h (make-hash-table :test #'equal))) + (puthash "local" '("file:///tmp") h) h))) (spy-on 'jsonrpc-request) (spy-on 'jsonrpc-notify) (spy-on 'jsonrpc-shutdown) @@ -898,11 +928,14 @@ (expect 'jsonrpc-shutdown :to-have-been-called))) (it "handles unresponsive server gracefully" - (let ((copilot--connection (make-symbol "fake-conn")) - (copilot--opened-buffers '(buf1)) - (copilot--workspace-folders '("file:///tmp"))) - (spy-on 'jsonrpc-request :and-call-fake - (lambda (&rest _) (error "Timeout"))) + (let* ((conn (make-symbol "fake-conn")) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" conn h) h)) + (copilot--opened-buffers (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(buf1) h) h)) + (copilot--workspace-folders (let ((h (make-hash-table :test #'equal))) + (puthash "local" '("file:///tmp") h) h))) + (spy-on 'jsonrpc-request :and-call-fake (lambda (&rest _) (error "Timeout"))) (spy-on 'jsonrpc-notify) (spy-on 'jsonrpc-shutdown) ;; Should not signal an error @@ -910,10 +943,10 @@ ;; Should still attempt exit and cleanup (expect 'jsonrpc-notify :to-have-been-called) (expect 'jsonrpc-shutdown :to-have-been-called) - (expect copilot--connection :to-be nil))) + (expect (gethash "local" copilot--connections) :to-be nil))) (it "is a no-op when connection is nil" - (let ((copilot--connection nil)) + (let ((copilot--connections (make-hash-table :test #'equal))) (spy-on 'jsonrpc-request) (spy-on 'jsonrpc-notify) (spy-on 'jsonrpc-shutdown) @@ -923,38 +956,61 @@ (expect 'jsonrpc-shutdown :not :to-have-been-called))) (it "resets global state" - (let ((copilot--connection (make-symbol "fake-conn")) - (copilot--opened-buffers '(buf1 buf2)) - (copilot--workspace-folders '("file:///a" "file:///b")) - (copilot--status '(:kind "Error" :busy nil :message "stale"))) + (let* ((conn (make-symbol "fake-conn")) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" conn h) h)) + (copilot--opened-buffers (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(buf1 buf2) h) h)) + (copilot--workspace-folders (let ((h (make-hash-table :test #'equal))) + (puthash "local" '("file:///a" "file:///b") h) h)) + (copilot--status (let ((h (make-hash-table :test #'equal))) + (puthash "local" '(:kind "Error" :busy nil :message "stale") h) h))) (spy-on 'jsonrpc-request) (spy-on 'jsonrpc-notify) (spy-on 'jsonrpc-shutdown) (copilot--shutdown-server) - (expect copilot--connection :to-be nil) - (expect copilot--opened-buffers :to-be nil) - (expect copilot--workspace-folders :to-be nil) - (expect copilot--status :to-be nil))) + (expect (gethash "local" copilot--connections) :to-be nil) + (expect (gethash "local" copilot--opened-buffers) :to-be nil) + (expect (gethash "local" copilot--workspace-folders) :to-be nil) + (expect (gethash "local" copilot--status) :to-be nil))) (it "skips jsonrpc-shutdown at exit to avoid hanging Emacs" (let* ((conn (make-symbol "fake-conn")) - (copilot--connection conn) - (copilot--opened-buffers '(buf1)) - (copilot--workspace-folders '("file:///tmp"))) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" conn h) h)) + (copilot--opened-buffers (make-hash-table :test #'equal)) + (copilot--workspace-folders (make-hash-table :test #'equal)) + (copilot--status (make-hash-table :test #'equal)) + (copilot--progress-sessions (make-hash-table :test #'equal))) (spy-on 'jsonrpc-request) (spy-on 'jsonrpc-notify) (spy-on 'jsonrpc-shutdown) - (copilot--shutdown-server t) + (copilot--shutdown-server "local" t) ;; The blocking shutdown call must be skipped on exit ... (expect 'jsonrpc-shutdown :not :to-have-been-called) - ;; ... but the server is still told to exit and state is reset. + ;; ... but the server is still told to exit and its state is cleared. (expect 'jsonrpc-notify :to-have-been-called-with conn 'exit nil) - (expect copilot--connection :to-be nil))) - - (it "delegates to copilot--shutdown-server with at-exit from kill-emacs-hook" - (spy-on 'copilot--shutdown-server) - (copilot--shutdown-server-at-exit) - (expect 'copilot--shutdown-server :to-have-been-called-with t))) + (expect (gethash "local" copilot--connections) :to-be nil))) + + (it "shuts down every server at exit without the blocking call" + (let* ((conn1 (make-symbol "conn1")) + (conn2 (make-symbol "conn2")) + (copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" conn1 h) + (puthash "/ssh:host:" conn2 h) h)) + (copilot--opened-buffers (make-hash-table :test #'equal)) + (copilot--workspace-folders (make-hash-table :test #'equal)) + (copilot--status (make-hash-table :test #'equal)) + (copilot--progress-sessions (make-hash-table :test #'equal))) + (spy-on 'jsonrpc-request) + (spy-on 'jsonrpc-notify) + (spy-on 'jsonrpc-shutdown) + (copilot--shutdown-all-servers-at-exit) + ;; Both servers are torn down ... + (expect (hash-table-count copilot--connections) :to-equal 0) + ;; ... and the busy-looping shutdown is skipped for all of them. + (expect 'jsonrpc-shutdown :not :to-have-been-called) + (expect (spy-calls-count 'jsonrpc-notify) :to-equal 2)))) ;; ;; $/cancelRequest @@ -964,7 +1020,8 @@ (it "sends $/cancelRequest and clears ID when request is in-flight" (with-temp-buffer (setq-local copilot--completion-request-id 42) - (let ((copilot--connection t)) + (let ((copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" t h) h))) (spy-on 'copilot--connection-alivep :and-return-value t) (spy-on 'jsonrpc-notify) (copilot--cancel-completion) @@ -982,7 +1039,7 @@ (it "clears ID without notifying when connection is dead" (with-temp-buffer (setq-local copilot--completion-request-id 99) - (let ((copilot--connection nil)) + (let ((copilot--connections (make-hash-table :test #'equal))) (spy-on 'jsonrpc-notify) (copilot--cancel-completion) (expect 'jsonrpc-notify :not :to-have-been-called) @@ -1066,12 +1123,13 @@ (with-temp-buffer (emacs-lisp-mode) (insert "(+ 1 2)") - (let ((copilot--opened-buffers nil) - (copilot--connection t)) + (let* ((copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" t h) h)) + (copilot--opened-buffers (make-hash-table :test #'equal))) (spy-on 'copilot--connection-alivep :and-return-value t) (spy-on 'jsonrpc-notify) (copilot--ensure-doc-open) - (expect (seq-contains-p copilot--opened-buffers (current-buffer)) + (expect (seq-contains-p (gethash "local" copilot--opened-buffers) (current-buffer)) :to-be-truthy) ;; Should have sent textDocument/didOpen (let ((calls (spy-calls-all-args 'jsonrpc-notify))) @@ -1081,8 +1139,10 @@ (it "is a no-op when buffer is already registered" (with-temp-buffer - (let ((copilot--opened-buffers (list (current-buffer))) - (copilot--connection t)) + (let* ((copilot--connections (let ((h (make-hash-table :test #'equal))) + (puthash "local" t h) h)) + (copilot--opened-buffers (let ((h (make-hash-table :test #'equal))) + (puthash "local" (list (current-buffer)) h) h))) (spy-on 'copilot--connection-alivep :and-return-value t) (spy-on 'jsonrpc-notify) (copilot--ensure-doc-open)