diff --git a/.eslintignore b/.eslintignore
index c42749513..1c96db0f4 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -7,3 +7,4 @@
/.clj-kondo
/site
/test-data
+/repl-output-ui
diff --git a/.gitignore b/.gitignore
index 352b1a179..554692436 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,6 +50,7 @@ jspm_packages
lib/
cljs-out/
test-out/
+repl-output-ui/js
# This and that
.nrepl-port
diff --git a/.prettierignore b/.prettierignore
index 06de27580..8d0959b2e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -9,4 +9,5 @@
**/.shadow-cljs/
**/out/
clojure.tmLanguage.json
-*.md
\ No newline at end of file
+/repl-output-ui
+*.md
diff --git a/.vscodeignore b/.vscodeignore
index 170cfe594..30f722d7a 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -9,6 +9,7 @@
!clojure.tmLanguage.json
!assets/**
!out/**
+!repl-output-ui/**
!package.json
!README.md
!CHANGELOG.md
diff --git a/deps-clj-version b/deps-clj-version
index fe5f5616e..53211646f 100644
--- a/deps-clj-version
+++ b/deps-clj-version
@@ -1 +1 @@
-v1.12.0.1517
\ No newline at end of file
+v1.12.0.1530
\ No newline at end of file
diff --git a/deps.clj.jar b/deps.clj.jar
index 3ce1b85eb..ce19194b2 100644
Binary files a/deps.clj.jar and b/deps.clj.jar differ
diff --git a/deps.edn b/deps.edn
index 9ff2b22e5..47208035c 100644
--- a/deps.edn
+++ b/deps.edn
@@ -8,6 +8,11 @@
org.clojars.liverm0r/dartclojure {:mvn/version "0.2.23-SNAPSHOT"}
vvvvalvalval/supdate {:mvn/version "0.2.3"}
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
+ no.cjohansen/replicant {:git/url "https://github.com/cjohansen/replicant.git"
+ :git/sha "ef2fecbe301cafa8449b74a1e2cbe4fc3ddc18ac"}
+ org.clojars.abhinav/snitch {:mvn/version "0.1.14"}
+ tortue/spy {:mvn/version "2.15.0"}
#_#_org.clojars.liverm0r/dartclojure {:local/root "../DartClojure"}}
+ :aliases {:dev {:extra-deps {org.clojars.abhinav/snitch {:mvn/version "0.1.14"}}}}
:paths ["src/cljs-lib/src"
- "src/cljs-lib/test"]}
\ No newline at end of file
+ "src/cljs-lib/test"]}
diff --git a/package.json b/package.json
index 0a7c561e9..1c33dae3e 100644
--- a/package.json
+++ b/package.json
@@ -178,7 +178,8 @@
"enum": [
"repl-window",
"output-channel",
- "terminal"
+ "terminal",
+ "webview"
],
"default": "repl-window",
"markdownDescription": "Destination for evaluation results. (Clojure data returned from an evaluation)."
@@ -188,7 +189,8 @@
"enum": [
"repl-window",
"output-channel",
- "terminal"
+ "terminal",
+ "webview"
],
"default": "repl-window",
"markdownDescription": "Destination for evaluation output (stdout/stderr from an evaluation)."
@@ -198,7 +200,8 @@
"enum": [
"repl-window",
"output-channel",
- "terminal"
+ "terminal",
+ "webview"
],
"default": "repl-window",
"markdownDescription": "Destination for other output (Calva messages, out-of-band stdout/stderr, etcetera)."
@@ -1356,6 +1359,11 @@
"category": "Calva",
"enablement": "editorLangId == clojure"
},
+ {
+ "command": "calva.clearReplOutputWebview",
+ "title": "Clear REPL Output Webview",
+ "category": "Calva"
+ },
{
"command": "calva.interruptAllEvaluations",
"title": "Interrupt Running Evaluations",
@@ -1428,7 +1436,7 @@
},
{
"command": "calva.printLastStacktrace",
- "title": "Print Last Stacktrace to REPL Window",
+ "title": "Print last stacktrace to result output destination",
"enablement": "calva:connected",
"category": "Calva"
},
@@ -1955,6 +1963,11 @@
"command": "calva.showResultOutputDestination",
"title": "Show/Open the result output destination"
},
+ {
+ "category": "Calva",
+ "command": "calva.showReplOutputWebview",
+ "title": "Show/Open the REPL output webview"
+ },
{
"category": "Calva",
"command": "calva.showReplWindow",
@@ -3311,16 +3324,16 @@
},
"scripts": {
"watch-docs": "mkdocs serve",
- "clean": "rimraf ./out && rimraf ./tsconfig.tsbuildinfo && rimraf ./cljs-out",
+ "clean": "rimraf ./out ./tsconfig.tsbuildinfo ./cljs-out ./repl-output-ui/js",
"update-grammar": "node ./src/calva-fmt/update-grammar.js ./src/calva-fmt/atom-language-clojure/grammars/clojure.cson clojure.tmLanguage.json",
"precompile": "npm i && npm run clean && npm run update-grammar",
- "compile-cljs": "npx shadow-cljs compile :calva-lib :test",
+ "compile-cljs": "npx shadow-cljs compile :calva-lib :test :repl-output-ui",
"compile-ts": "npx tsc --project ./tsconfig.json",
"compile": "npm run compile-cljs && npm run compile-ts",
"watch-ts": "npx tsc --watch --project ./tsconfig.json",
- "watch-cljs": "npx shadow-cljs -d cider/cider-nrepl:0.28.5 watch :calva-lib :test",
+ "watch-cljs": "npx shadow-cljs -d cider/cider-nrepl:0.28.5 watch :calva-lib :test :repl-output-ui",
"watch-ts-with-strict-nulls": "npx tsc --watch --project ./tsconfig.json --strictNullChecks",
- "release-cljs": "npx shadow-cljs release :calva-lib :test",
+ "release-cljs": "npx shadow-cljs release :calva-lib :test :repl-output-ui",
"release": "webpack --mode production",
"prerelease": "npm run precompile && npm run release-cljs",
"compile-test": "tsc -p ./",
diff --git a/repl-output-ui/css/main.css b/repl-output-ui/css/main.css
new file mode 100644
index 000000000..6478c6f47
--- /dev/null
+++ b/repl-output-ui/css/main.css
@@ -0,0 +1,2 @@
+/* This file is linked in the repl output webview. It's not currently used, but I'm leaving it so if we want
+ to add styles in the future it's easy to do so. */
diff --git a/shadow-cljs.edn b/shadow-cljs.edn
index e977707a9..d9d00f050 100644
--- a/shadow-cljs.edn
+++ b/shadow-cljs.edn
@@ -22,10 +22,23 @@
:js2cljs calva.js2cljs.converter/convert-bridge
:dart2clj calva.dartclojure/convert-bridge
:readConfigEdn calva.read-config/config-edn->js-bridge
- :html2hiccup calva.html2hiccup/html->hiccup-convert-bridge}
+ :html2hiccup calva.html2hiccup/html->hiccup-convert-bridge
+ :initializeCljs calva.util/initialize-cljs
+ :showReplOutputWebviewPanel calva.repl.webview.core/show-repl-output-webview-panel
+ :appendToReplOutputWebview calva.repl.webview.core/append
+ :appendStackTraceToReplOutputWebview calva.repl.webview.core/append-stacktrace
+ :clearReplOutputWebview calva.repl.webview.core/clear-webview}
:output-to "out/cljs-lib/cljs-lib.js"}
:test
{:target :node-test
:output-to "out/cljs-lib/test/cljs-lib-tests.js"
:ns-regexp "-test$"
- :autorun true}}}
+ :autorun true}
+ :repl-output-ui
+ {:target :browser
+ ;; TODO: Do the asset-path and output-dir values make sense?
+ :asset-path "js"
+ :output-dir "repl-output-ui/js"
+ :modules {:main {:init-fn calva.repl.webview.ui/main}}
+ :devtools {:loader-mode :eval
+ :devtools-url "http://localhost:9630"}}}}
diff --git a/src/cljs-lib/src/calva/repl/webview/core.cljs b/src/cljs-lib/src/calva/repl/webview/core.cljs
new file mode 100644
index 000000000..221311c60
--- /dev/null
+++ b/src/cljs-lib/src/calva/repl/webview/core.cljs
@@ -0,0 +1,226 @@
+(ns calva.repl.webview.core
+ (:require
+ [calva.util :as util]
+ [clojure.string :as str]))
+
+(defonce repl-output-webview-panel (atom nil))
+
+(defn dispose-repl-output-webview-panel
+ [webview-panel-atom]
+ (reset! webview-panel-atom nil))
+
+(defn post-message-to-webview [^js webview-panel message]
+ (when webview-panel
+ (.. webview-panel
+ -webview
+ (postMessage (pr-str (merge
+ {:id (str (random-uuid))} ;; Provide an id if one wasn't provided by the caller
+ message))))))
+
+;; The connect-src and unsafe-eval are only needed in development mode for the shadow-cljs
+;; dev workflow to function properly
+(defn get-webview-html
+ [{:env/keys [is-debug]} {:keys [js-source css-href csp-source]}]
+ (str "
+
+
+
+
+
+
+
+
+
+ REPL Output
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"))
+
+(defn get-js-source
+ [{:keys [vscode/vscode]
+ vscode-context :vscode/context}
+ {:keys [^js webview-panel]}]
+ (let [extension-uri (.. ^js vscode-context -extensionUri)
+ js-path (.. ^js vscode -Uri (joinPath extension-uri "repl-output-ui" "js" "main.js"))]
+ (.. ^js webview-panel -webview (asWebviewUri js-path))))
+
+(defn get-css-path
+ [{:keys [vscode/vscode]
+ vscode-context :vscode/context}]
+ (let [extension-uri (.. ^js vscode-context -extensionUri)]
+ (.. ^js vscode -Uri (joinPath extension-uri "repl-output-ui" "css" "main.css"))))
+
+(defn set-webview-html!
+ [context
+ {:keys [^js webview-panel]}]
+ (let [js-source (get-js-source context {:webview-panel webview-panel})
+ css-path (get-css-path context)
+ css-href (.. ^js webview-panel -webview (asWebviewUri css-path))
+ csp-source (.. ^js webview-panel -webview -cspSource)
+ webview-html (get-webview-html context {:js-source js-source :css-href css-href :csp-source csp-source})]
+ (set! (.. webview-panel -webview -html) webview-html)))
+
+(defn set-code-theme!
+ "Takes a context, a webview panel and a vscode.ColorThemeKind enum value and sets the code theme in the webview based
+ on the given color theme kind."
+ [{:keys [vscode/vscode]} {:keys [color-theme-kind webview-panel]}]
+ (let [color-theme-kind-enum (.. ^js vscode -ColorThemeKind)
+ code-theme (condp = color-theme-kind
+ (.. color-theme-kind-enum -Dark) "dark"
+ (.. color-theme-kind-enum -Light) "light"
+ (.. color-theme-kind-enum -HighContrast) "high-contrast"
+ (.. color-theme-kind-enum -HighContrastLight) "high-contrast-light"
+ nil)]
+ (if code-theme
+ (post-message-to-webview webview-panel {:command/name "set-code-theme"
+ :content code-theme})
+ (util/log-to-console
+ :error
+ "Cannot set code theme in output webview. There is no code theme set for the ColorThemeKind enum value of"
+ color-theme-kind))))
+
+(defn create-color-theme-change-listener
+ [{:keys [^js vscode/vscode] :as context}
+ {:keys [webview-panel]}]
+ (.. vscode -window
+ (onDidChangeActiveColorTheme
+ (fn [e]
+ (set-code-theme! context {:color-theme-kind (.. e -kind)
+ :webview-panel webview-panel})))))
+
+(defn add-subscriptions []
+ (let [context {:vscode/vscode @util/vscode}
+ subscriptions [(create-color-theme-change-listener context {:webview-panel @repl-output-webview-panel})]]
+ (run! (fn [subscription]
+ (.. ^js @util/vscode-context -subscriptions (push subscription)))
+ subscriptions)))
+
+(defn create-repl-output-webview-panel
+ [context]
+ (let [webview-panel (.. ^js @util/vscode -window
+ (createWebviewPanel
+ "calva:repl-output"
+ "REPL Output"
+ #js {:preserveFocus true
+ :viewColumn (.. ^js @util/vscode -ViewColumn -Beside)}
+ #js {:enableScripts true
+ ;; If performance or memory consumption becomes a problem, we can use the setState
+ ;; and getState to manually retain the context of the webview when it's hidden.
+ ;; See https://code.visualstudio.com/api/extension-guides/webview#persistence
+ :retainContextWhenHidden true
+ :enableFindWidget true}))]
+ (.. ^js webview-panel (onDidDispose (fn [] (dispose-repl-output-webview-panel repl-output-webview-panel))))
+ (set-webview-html! context {:webview-panel webview-panel})
+ (reset! repl-output-webview-panel webview-panel)
+ (add-subscriptions)
+ webview-panel))
+
+;; TODO: Write spec/schema for context
+(defn ^:export show-repl-output-webview-panel []
+ (let [context {:env/is-debug (if (= js/process.env.IS_DEBUG "true") true false)
+ :vscode/vscode @util/vscode
+ :vscode/context @util/vscode-context}
+ ^js webview-panel (or @repl-output-webview-panel (create-repl-output-webview-panel context))
+ active-code-theme-kind (.. ^js @util/vscode -window -activeColorTheme -kind)]
+ (.. webview-panel (reveal nil true))
+ (set-code-theme! context {:color-theme-kind active-code-theme-kind
+ :webview-panel webview-panel})))
+
+;; TODO: Add tests
+;; TODO: Refactor this to use a mapping of output category -> command name
+(defn ^:export append
+ [^js options message]
+ (let [output-category (.-outputCategory options)]
+ (case output-category
+ "otherOut" (post-message-to-webview @repl-output-webview-panel {:command/name "show-stdout"
+ :content message})
+ "evalOut" (post-message-to-webview @repl-output-webview-panel {:command/name "show-stdout"
+ :content message})
+ "evalResults" (post-message-to-webview @repl-output-webview-panel {:command/name "show-result"
+ :content message})
+ ;; TODO: Make this show differently?
+ "evalErr" (post-message-to-webview @repl-output-webview-panel {:command/name "show-stdout"
+ :content message})
+ "otherErr" (post-message-to-webview @repl-output-webview-panel {:command/name "show-stdout"
+ :content message})
+ "clojure" (post-message-to-webview @repl-output-webview-panel {:command/name "show-result"
+ :content message})
+ (util/log-to-console
+ :error
+ (str "Cannot append content to output webview. No outputCategory matches \"" output-category "\"")))))
+
+(def stacktrace-classes-to-ignore
+ #{"clojure.lang.RestFn"
+ "clojure.lang.AFn"})
+
+(defn stacktrace-entry->string
+ [{:keys [var name file line]}]
+ (let [name (or var name)]
+ (str name " (" file ":" line ")")))
+
+(defn ^:export append-stacktrace
+ [^js stacktrace]
+ (let [stacktrace (js->clj stacktrace :keywordize-keys true)
+ stacktrace-message (->> stacktrace
+ (filter (fn [{:keys [flags class]}]
+ (and (not (some #{"dup"} flags))
+ (not (contains? stacktrace-classes-to-ignore class)))))
+ (map stacktrace-entry->string)
+ (str/join "\n"))]
+ (post-message-to-webview @repl-output-webview-panel {:command/name "show-stdout"
+ :content stacktrace-message})))
+
+(defn ^:export clear-webview []
+ (post-message-to-webview @repl-output-webview-panel {:command/name "clear-webview"}))
+
+;; TODO: See if can send repl output to webview when it's hidden and see it once unhidden
+;; "You cannot send messages to a hidden webview, even when retainContextWhenHidden is enabled."
+;; https://code.visualstudio.com/api/extension-guides/webview#theming-webview-content
diff --git a/src/cljs-lib/src/calva/repl/webview/ui.cljs b/src/cljs-lib/src/calva/repl/webview/ui.cljs
new file mode 100644
index 000000000..63fb28a14
--- /dev/null
+++ b/src/cljs-lib/src/calva/repl/webview/ui.cljs
@@ -0,0 +1,152 @@
+(ns calva.repl.webview.ui
+ (:require
+ [replicant.dom :as replicant]
+ [clojure.string :as str]
+ [cljs.reader :as reader]))
+
+;; The DOM element where output is written
+(def output-dom-element (js/document.getElementById "output"))
+
+;; See here for a description of this function: https://code.visualstudio.com/api/extension-guides/webview#passing-messages-from-a-webview-to-an-extension
+(defonce vs-code-api (js/acquireVsCodeApi))
+
+(defmulti run-command
+ "Runs a given command with the given args."
+ (fn [_replicant-data command & _args]
+ command))
+
+(defmethod run-command :repl-output/highlight-code
+ [{:replicant/keys [node]} _command _args]
+ (.. js/window -hljs (highlightElement node)))
+
+(defn dispatch
+ "Dispatches commands in hook-data"
+ [replicant-data hook-data]
+ (doseq [[command-name & args] hook-data]
+ (apply run-command replicant-data command-name args)))
+
+(replicant/set-dispatch! dispatch)
+
+(defn repl-output-element
+ "Creates a repl output element - adding a unique ID to the :output-element/id attribute."
+ [element-data]
+ (merge element-data
+ {:output-element/id (random-uuid)}))
+
+(defonce state
+ (atom {:repl-output/elements
+ ;; TODO: Create schemas for these elements
+ [#_(repl-output-element {:output-element/type :output-element.type/eval-result
+ :output-element/content "{:a 1}"})
+ #_(repl-output-element {:output-element/type :output-element.type/stdout
+ :output-element/content "hello world"})]}))
+
+(defn clojure-code-hiccup
+ "Accepts a string of Clojure code and returns hiccup for rendering it in the output view."
+ [clojure-code]
+ [:pre [:code {:class "language-clojure" :replicant/on-render [[:repl-output/highlight-code]]} clojure-code]])
+
+(defmulti repl-output-element-hiccup
+ "Returns hiccup for rendering a given output element."
+ :output-element/type)
+
+(defmethod repl-output-element-hiccup :output-element.type/eval-result
+ [element]
+ (clojure-code-hiccup (:output-element/content element)))
+
+(defmethod repl-output-element-hiccup :output-element.type/stdout
+ [element]
+ (let [content (:output-element/content element)
+ lines (str/split-lines content)]
+ (into [:p] (map (fn [line] [:span line [:br]]) lines))))
+
+(defn repl-output-hiccup
+ [state]
+ (into [:div]
+ (map repl-output-element-hiccup (:repl-output/elements state))))
+
+(defn render [state]
+ (replicant/render output-dom-element (repl-output-hiccup state)))
+
+(defn render-repl-output
+ "The watch function for the output elements that renders the output elements."
+ [_key _atom _old-state new-state]
+ (render new-state))
+
+(defn scroll-to-bottom
+ "Scrolls to the bottom of the output view."
+ [_key _atom _old-state _new-state]
+ (.. output-dom-element (scrollIntoView #js {:behavior "instant" :block "end"})))
+
+(defn save-state
+ [_key _atom _old-state new-state]
+ (.. vs-code-api (saveState new-state)))
+
+;; TODO: Use this map to add watches to the state atom
+(def state-watchers
+ {#_#_:save-state save-state
+ :render-repl-output render-repl-output
+ :scroll-to-bottom scroll-to-bottom})
+
+(run! (fn [[key f]]
+ (add-watch state key f))
+ state-watchers)
+
+(defn add-repl-output-element
+ [element]
+ (swap! state update :repl-output/elements conj element))
+
+(defn add-eval-result
+ [content]
+ (add-repl-output-element (repl-output-element {:output-element/type :output-element.type/eval-result
+ :output-element/content content})))
+
+(defn add-stdout
+ [content]
+ (add-repl-output-element (repl-output-element {:output-element/type :output-element.type/stdout
+ :output-element/content content})))
+
+(defn ^:export clear-webview []
+ (swap! state assoc :repl-output/elements []))
+
+(defn set-code-theme!
+ [theme]
+ (let [code-theme-link-nodes (js/document.querySelectorAll "[data-code-theme]")]
+ (.. code-theme-link-nodes (forEach (fn [^js node]
+ (let [code-theme (.. node -dataset -codeTheme)]
+ (if (= code-theme theme)
+ (.. node (removeAttribute "disabled"))
+ (.. node (setAttribute "disabled" "disabled")))))))))
+
+(defn handle-message
+ [^js message]
+ (let [message-data (reader/read-string (.-data message))
+ command-name (:command/name message-data)
+ content (:content message-data)]
+ (case command-name
+ "show-result" (add-eval-result content)
+ "show-stdout" (add-stdout content)
+ "clear-webview" (clear-webview)
+ "set-code-theme" (set-code-theme! content))))
+
+(defn add-event-listeners []
+ (.. js/window
+ (addEventListener "message" handle-message)))
+
+(defn ^:export main []
+ (add-event-listeners)
+ ;; TODO: Persist state and reload it when webview is created so that the webview content persists
+ ;; in the UI when the webview is hidden then focused again
+ ;; https://code.visualstudio.com/api/extension-guides/webview#persistence
+ (render @state))
+
+(comment
+ (def code-theme-links (js/document.querySelectorAll "[data-code-theme]"))
+ (.. code-theme-links (forEach (fn [node]
+ (prn node))))
+ (time (dotimes [_ 1000000] (clojure.string/capitalize "aBcDeF")))
+ (simple-benchmark [] (clojure.string/capitalize "aBcDeF") 1000000)
+ (.. vs-code-api (setState @state))
+ (.. vs-code-api (getState))
+ (js/acquireVsCodeApi)
+ :rcf)
diff --git a/src/cljs-lib/src/calva/state.cljs b/src/cljs-lib/src/calva/state.cljs
index cee0a2caf..fcc4b8e2e 100644
--- a/src/cljs-lib/src/calva/state.cljs
+++ b/src/cljs-lib/src/calva/state.cljs
@@ -17,4 +17,4 @@
(comment
(set-state-value! "hello" "world")
(get-state)
- (remove-state-value! "hello"))
\ No newline at end of file
+ (remove-state-value! "hello"))
diff --git a/src/cljs-lib/src/calva/util.cljs b/src/cljs-lib/src/calva/util.cljs
new file mode 100644
index 000000000..85d8736b4
--- /dev/null
+++ b/src/cljs-lib/src/calva/util.cljs
@@ -0,0 +1,48 @@
+(ns calva.util
+ (:require [calva.state :as state]))
+
+(defonce vscode (atom nil))
+(defonce vscode-context (atom nil))
+
+(def project-root-uri-key "connect.projectDirNew")
+
+(defn get-first-workspace-folder-uri []
+ (-> (.. ^js @vscode -workspace -workspaceFolders)
+ first ;; Handle nil here?
+ (.. -uri)))
+
+(defn ^:export get-project-root-uri
+ ([]
+ (get-project-root-uri true))
+ ([use-cache]
+ (if use-cache
+ (if-let [project-directory-uri (state/get-state-value project-root-uri-key)]
+ project-directory-uri
+ (get-first-workspace-folder-uri))
+ (get-first-workspace-folder-uri))))
+
+(defn ^:export initialize-cljs
+ "This is meant to be called upon extension activation, and will store the vscode api reference and the context in atoms.
+
+ This allows the cljs code to access the vscode API, without having to require it, which can cause testing issues.
+
+ We cannot run unit tests on code that imports the vscode API, because it's only available at runtime.
+ All cljs code is bundled into a single file and required by the TypeScript code, which means we cannot
+ write unit tests for any TypeScript code that imports the cljs code, if any of the cljs code requires the VS Code API."
+ [^js vsc ^js ctx]
+ (reset! vscode vsc)
+ (reset! vscode-context ctx))
+
+(defn log-to-console
+ "Log to the console. This is a simple interface to js/console.* functions.
+ The first argument is the log level. Log levels accepted are :info, :error, and :warn.
+ Arguments after the first are passed to the js/console.* function.
+ This function exists to help with testing, since using with-redefs with a js/console.*
+ function directly doesn't seem to work."
+ [& args]
+ (let [log-level (first args)
+ log-fn (case log-level
+ :info js/console.info
+ :error js/console.error
+ :warn js/console.warn)]
+ (apply log-fn (rest args))))
diff --git a/src/cljs-lib/test/calva/repl/webview/core_test.cljs b/src/cljs-lib/test/calva/repl/webview/core_test.cljs
new file mode 100644
index 000000000..c685104ae
--- /dev/null
+++ b/src/cljs-lib/test/calva/repl/webview/core_test.cljs
@@ -0,0 +1,164 @@
+(ns calva.repl.webview.core-test
+ (:require
+ [calva.repl.webview.core :as sut]
+ [calva.util :as util]
+ [cljs.reader :as reader]
+ [cljs.test :refer-macros [deftest testing is run-tests]]
+ [spy.core :as spy]))
+
+(defn wrap-spy
+ "This is a helper that returns a function that calls the spy, so that the shadow-cljs doesn't complain,
+ which is does if a spy is used and called directly in a test - it will say the thing is not a function"
+ [spy]
+ (fn [& args]
+ (apply spy args)))
+
+(deftest dispose-repl-output-webview-panel-test
+ (testing "Given an atom holding some value, should set the value to nil"
+ (let [webview-panel-atom (atom {:mock "webview-panel"})]
+ (sut/dispose-repl-output-webview-panel webview-panel-atom)
+ (is (= nil @webview-panel-atom)))))
+
+(deftest post-message-to-webview-test
+ (testing "Given a webview panel and a message, should post the message to the webview panel with an :id attribute added to it"
+ (let [post-message-spy (spy/spy)
+ ;; Using spy this way is a workaround to avoid an error mentioned in this issue:
+ ;; https://github.com/alexanderjamesking/spy/issues/29
+ ;; I tried setting static-fns to false in the build config's compiler-options, but that didn't fix the issue
+ webview-panel-mock (clj->js {:webview {:postMessage (fn [& args] (apply post-message-spy args))}})
+ message {:hello "world"}]
+ (sut/post-message-to-webview webview-panel-mock message)
+ (let [calls (spy/calls post-message-spy)
+ message-arg (reader/read-string (ffirst calls))]
+ (is (= 1 (count calls)))
+ (is (= "world" (:hello message-arg)))
+ (is (string? (:id message-arg)))))))
+
+(deftest get-webview-html-test
+ (testing "Given valid args and that the environment is debug, should return the expected html markup"
+ (let [result (sut/get-webview-html {:env/is-debug true} {:js-source "js-source"
+ :css-href "css-href"
+ :csp-source "csp-source"})]
+ (is (= 1 (count (re-seq #"js-source" result))))
+ (is (= 1 (count (re-seq #"css-href" result))))
+ ;; It should be in the style-src and script-src directives in the content security policy
+ (is (= 2 (count (re-seq #"csp-source" result))))
+ (is (= 1 (count (re-seq #"'unsafe-eval'" result))))
+ (is (= 1 (count (re-seq #"connect-src ws://localhost:9630/api/remote-relay" result))))))
+ (testing "Given valid args and that the environment is not debug, should return the expected html markup"
+ (let [result (sut/get-webview-html {:env/is-debug false} {:js-source "js-source"
+ :css-href "css-href"
+ :csp-source "csp-source"})]
+ (is (= 1 (count (re-seq #"js-source" result))))
+ (is (= 1 (count (re-seq #"css-href" result))))
+ ;; It should be in the style-src and script-src directives in the content security policy
+ (is (= 2 (count (re-seq #"csp-source" result))))
+ (is (zero? (count (re-seq #"'unsafe-eval'" result))))
+ (is (zero? (count (re-seq #"connect-src ws://localhost:9630/api/remote-relay" result)))))))
+
+(deftest get-js-source-test
+ (testing "Given a context and a webview-panel,"
+ (let [join-path-spy (spy/stub "some-path")
+ extension-uri "extension-uri"
+ context {:vscode/context (clj->js {:extensionUri extension-uri})
+ :vscode/vscode (clj->js {:Uri {:joinPath (wrap-spy join-path-spy)}})}
+ as-webview-uri-spy (spy/stub "some-webview-uri")
+ webview-panel (clj->js {:webview {:asWebviewUri (wrap-spy as-webview-uri-spy)}})
+ result (sut/get-js-source context {:webview-panel webview-panel})]
+ (testing "should call joinPath with expected args"
+ (is (spy/called-once-with? join-path-spy extension-uri "repl-output-ui" "js" "main.js")))
+ (testing "should call asWebviewUri with result of call to joinPath"
+ (is (spy/called-once-with? as-webview-uri-spy "some-path")))
+ (testing "should return result of call to asWebviewUri"
+ (is (= "some-webview-uri" result))))))
+
+(deftest get-css-path
+ (testing "Given a context"
+ (let [join-path-spy (spy/stub "some-path")
+ extension-uri "extension-uri"
+ context {:vscode/context (clj->js {:extensionUri extension-uri})
+ :vscode/vscode (clj->js {:Uri {:joinPath (wrap-spy join-path-spy)}})}
+ result (sut/get-css-path context)]
+ (testing "should call joinPath with expected args"
+ (is (spy/called-once-with? join-path-spy extension-uri "repl-output-ui" "css" "main.css")))
+ (testing "should return the result of joinPath"
+ (is (= "some-path" result))))))
+
+(deftest set-webview-html!-test
+ (testing "Given a context and a webview panel"
+ (let [context {:some "context"}
+ get-js-source-spy (spy/stub "some-js-source")
+ get-css-path-spy (spy/stub "some-css-path")
+ as-webview-uri-spy (spy/stub "some-css-href")
+ ^js webview-panel (clj->js {:webview {:asWebviewUri (wrap-spy as-webview-uri-spy)
+ :cspSource "some-csp-source"}})
+ get-webview-html-spy (spy/stub "some-html")]
+ (with-redefs [sut/get-js-source (wrap-spy get-js-source-spy)
+ sut/get-css-path (wrap-spy get-css-path-spy)
+ sut/get-webview-html (wrap-spy get-webview-html-spy)]
+ (sut/set-webview-html! context {:webview-panel webview-panel})
+ (testing "should call get-js-source with expected args"
+ (is (spy/called-once-with? get-js-source-spy context {:webview-panel webview-panel})))
+ (testing "should call get-css-path with expected args"
+ (is (spy/called-once-with? get-css-path-spy context)))
+ (testing "should call asWebviewUri with expected args"
+ (is (spy/called-once-with? as-webview-uri-spy "some-css-path")))
+ (testing "should call get-webview-html with expected args"
+ (is (spy/called-once-with? get-webview-html-spy context {:js-source "some-js-source"
+ :css-href "some-css-href"
+ :csp-source "some-csp-source"})))
+ (testing "should set webview html to result of call to get-webview-html"
+ (is (= "some-html" (.. webview-panel -webview -html))))))))
+
+(deftest set-code-theme!-test
+ (testing "Given a context and a ColorThemeKind,"
+ (let [color-theme-kind-enum {:Dark 0
+ :Light 1
+ :HighContrast 2
+ :HighContrastLight 3}
+ context {:vscode/vscode (clj->js {:ColorThemeKind color-theme-kind-enum})}
+ webview-panel {:some "mock-webview-panel"}]
+ (testing "when the ColorThemeKind is Dark, should set the code theme to dark"
+ (let [post-message-to-webview-spy (spy/spy)]
+ (with-redefs [sut/post-message-to-webview (wrap-spy post-message-to-webview-spy)]
+ (sut/set-code-theme! context {:color-theme-kind (:Dark color-theme-kind-enum)
+ :webview-panel webview-panel})
+ (is (spy/called-once-with? post-message-to-webview-spy webview-panel {:command/name "set-code-theme"
+ :content "dark"})))))
+ (testing "when the ColorThemeKind is Light, should set the code theme to light"
+ (let [post-message-to-webview-spy (spy/spy)]
+ (with-redefs [sut/post-message-to-webview (wrap-spy post-message-to-webview-spy)]
+ (sut/set-code-theme! context {:color-theme-kind (:Light color-theme-kind-enum)
+ :webview-panel webview-panel})
+ (is (spy/called-once-with? post-message-to-webview-spy webview-panel {:command/name "set-code-theme"
+ :content "light"})))))
+ (testing "when the ColorThemeKind is HighContrast, should set the code theme to high-contrast"
+ (let [post-message-to-webview-spy (spy/spy)]
+ (with-redefs [sut/post-message-to-webview (wrap-spy post-message-to-webview-spy)]
+ (sut/set-code-theme! context {:color-theme-kind (:HighContrast color-theme-kind-enum)
+ :webview-panel webview-panel})
+ (is (spy/called-once-with? post-message-to-webview-spy webview-panel {:command/name "set-code-theme"
+ :content "high-contrast"})))))
+ (testing "when the ColorThemeKind is HighContrastLight, should set the code theme to high-contrast-light"
+ (let [post-message-to-webview-spy (spy/spy)]
+ (with-redefs [sut/post-message-to-webview (wrap-spy post-message-to-webview-spy)]
+ (sut/set-code-theme! context {:color-theme-kind (:HighContrastLight color-theme-kind-enum)
+ :webview-panel webview-panel})
+ (is (spy/called-once-with? post-message-to-webview-spy webview-panel {:command/name "set-code-theme"
+ :content "high-contrast-light"})))))
+ (testing "when there is no configured code theme for the ColorThemeKind, should log the expected error"
+ (let [log-to-console-spy (spy/spy)
+ color-theme-kind 99]
+ (with-redefs [util/log-to-console (wrap-spy log-to-console-spy)]
+ (sut/set-code-theme! context {:color-theme-kind color-theme-kind
+ :webview-panel webview-panel})
+ (is (spy/called-once-with?
+ log-to-console-spy
+ :error
+ "Cannot set code theme in output webview. There is no code theme set for the ColorThemeKind enum value of"
+ color-theme-kind))))))))
+
+(deftest create-color-theme-change-listener-test
+ (testing "Given a context and a webview panel, should call onDidChangeActiveColorTheme and pass it a function"))
+
+(run-tests)
diff --git a/src/evaluate.ts b/src/evaluate.ts
index 7806aa6f7..4b12f6db9 100644
--- a/src/evaluate.ts
+++ b/src/evaluate.ts
@@ -10,7 +10,7 @@ import * as outputWindow from './repl-window/repl-doc';
import * as namespace from './namespace';
import * as replHistory from './repl-window/repl-history';
import { formatAsLineComments } from './results-output/util';
-import { getStateValue } from '../out/cljs-lib/cljs-lib';
+import { getStateValue, appendStackTraceToReplOutputWebview } from '../out/cljs-lib/cljs-lib';
import { getConfig } from './config';
import * as replSession from './nrepl/repl-session';
import * as getText from './util/get-text';
@@ -239,6 +239,18 @@ async function evaluateCodeUpdatingUI(
ns,
replSessionType: session.replType,
});
+ if (output.getDestinationConfiguration().evalOutput === 'webview') {
+ session
+ .stacktrace()
+ .then((stacktrace) => {
+ if (stacktrace && stacktrace.stacktrace) {
+ appendStackTraceToReplOutputWebview(stacktrace.stacktrace);
+ }
+ })
+ .catch((e) => {
+ console.error(`Failed fetching stacktrace: ${e.message}`);
+ });
+ }
}
}
}
diff --git a/src/extension.ts b/src/extension.ts
index bdbb63fc5..ec07535e2 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -31,7 +31,12 @@ import * as replHistory from './repl-window/repl-history';
import * as config from './config';
import * as snippets from './custom-snippets';
import * as whenContexts from './when-contexts';
-import { setStateValue } from '../out/cljs-lib/cljs-lib';
+import {
+ setStateValue,
+ initializeCljs,
+ clearReplOutputWebview,
+ showReplOutputWebviewPanel,
+} from '../out/cljs-lib/cljs-lib';
import * as edit from './edit';
import * as nreplLogging from './nrepl/logging';
import * as converters from './converters';
@@ -77,6 +82,11 @@ function initializeState() {
async function activate(context: vscode.ExtensionContext) {
console.info('Calva activate START');
+ // Store a reference to the vscode API in the cljs so it can call the API using that reference,
+ // because requiring the vscode API poses issues with being able to test the cljs lib.
+ // We cannot run unit tests on code that imports the vscode API, because it's only available at runtime.
+ initializeCljs(vscode, context);
+
initializeState();
state.setExtensionContext(context);
state.initDepsEdnJackInExecutable();
@@ -218,6 +228,7 @@ async function activate(context: vscode.ExtensionContext) {
// COMMANDS
const commands = {
clearInlineResults: annotations.clearAllEvaluationDecorations,
+ clearReplOutputWebview: clearReplOutputWebview,
clearReplHistory: replHistory.clearHistory,
connect: connector.connectCommand,
connectNonProjectREPL: () => {
@@ -261,10 +272,7 @@ async function activate(context: vscode.ExtensionContext) {
prettyPrintReplaceCurrentForm: edit.prettyPrintReplaceCurrentForm,
printClojureDocsToOutputWindow: clojureDocs.printClojureDocsToOutput,
printClojureDocsToRichComment: clojureDocs.printClojureDocsToRichComment,
- printLastStacktrace: () => {
- outputWindow.printLastStacktrace();
- output.replWindowAppendPrompt();
- },
+ printLastStacktrace: output.printLastStacktrace,
printTextToOutputCommand: clojureDocs.printTextToOutputCommand,
printTextToRichCommentCommand: clojureDocs.printTextToRichCommentCommand,
refresh: refresh.refresh,
@@ -289,6 +297,7 @@ async function activate(context: vscode.ExtensionContext) {
showOutputWindow: outputWindow.revealResultsDoc, // backwards compatibility
showOutputChannel: output.showOutputChannel,
showOutputTerminal: output.showOutputTerminal,
+ showReplOutputWebview: showReplOutputWebviewPanel,
showResultOutputDestination: output.showResultOutputDestination,
showPreviousReplHistoryEntry: replHistory.showPreviousReplHistoryEntry,
startJoyrideReplAndConnect: async () => {
@@ -428,13 +437,15 @@ async function activate(context: vscode.ExtensionContext) {
let contextSettingEditor: vscode.TextEditor = undefined;
let contextSettingCircumstances = undefined;
function contextSettingOnChangeActiveTextEditor(editor: vscode.TextEditor) {
- whenContexts.setCursorContextIfChanged(editor);
- const circumstances = {
- version: editor.document.version,
- active: editor.selection.active,
- };
- contextSettingEditor = editor;
- contextSettingCircumstances = circumstances;
+ if (editor) {
+ whenContexts.setCursorContextIfChanged(editor);
+ const circumstances = {
+ version: editor.document.version,
+ active: editor.selection.active,
+ };
+ contextSettingEditor = editor;
+ contextSettingCircumstances = circumstances;
+ }
}
function contextSettingOnTextDocumentChangeEvent(dce: vscode.TextDocumentChangeEvent) {
if (contextSettingEditor) {
diff --git a/src/results-output/output.ts b/src/results-output/output.ts
index a6441f312..219f24fe4 100644
--- a/src/results-output/output.ts
+++ b/src/results-output/output.ts
@@ -7,6 +7,12 @@ import * as cursorUtil from '../cursor-doc/utilities';
import * as chalk from 'chalk';
import * as ansiRegex from 'ansi-regex';
import * as printer from '../printer';
+import {
+ appendToReplOutputWebview,
+ showReplOutputWebviewPanel,
+ appendStackTraceToReplOutputWebview,
+} from '../../out/cljs-lib/cljs-lib';
+import * as replSession from '../nrepl/repl-session';
const customChalk = new chalk.Instance({ level: 3 });
@@ -51,7 +57,7 @@ export interface AfterAppendCallback {
(insertLocation: vscode.Location, newPosition?: vscode.Location): any;
}
-export type OutputDestination = 'repl-window' | 'output-channel' | 'terminal';
+export type OutputDestination = 'repl-window' | 'output-channel' | 'terminal' | 'webview';
export type OutputDestinationConfiguration = {
evalResults: OutputDestination;
@@ -134,6 +140,9 @@ export function showResultOutputDestination(preserveFocus = true) {
if (getDestinationConfiguration().evalResults === 'terminal') {
return showOutputTerminal(preserveFocus);
}
+ if (getDestinationConfiguration().evalResults === 'webview') {
+ return showReplOutputWebviewPanel();
+ }
return outputWindow.revealResultsDoc(preserveFocus);
}
@@ -154,11 +163,12 @@ function messageContainsAnsi(message: string) {
}
// Used to decide if new result output should be prepended with a newline or not.
-// Also: For non-result output, whether the repl window output should be be printed as line comments.
+// Also: For non-result output, whether the repl window output should be printed as line comments.
const didLastOutputTerminateLine: Record = {
'repl-window': true,
'output-channel': true,
terminal: true,
+ webview: true,
};
let havePrintedLegacyReplWindowOutputMessage = false;
@@ -180,6 +190,7 @@ const lastInfoLineData: Record = {
'repl-window': {},
'output-channel': {},
terminal: {},
+ webview: {},
};
function saveLastInfoLineData(destination: OutputDestination, options: AppendClojureOptions) {
@@ -229,6 +240,11 @@ function appendClojure(
if (after) {
after(undefined, undefined);
}
+ } else if (destination === 'webview') {
+ appendToReplOutputWebview(options, message);
+ if (after) {
+ after(undefined, undefined);
+ }
}
saveLastInfoLineData(destination, options);
}
@@ -287,6 +303,9 @@ function append(options: AppendOptions, message: string, after?: AfterAppendCall
}
return;
}
+ if (destination === 'webview') {
+ appendToReplOutputWebview(options, message);
+ }
}
/**
@@ -377,6 +396,10 @@ function appendLine(options: AppendOptions, message: string, after?: AfterAppend
if (destination === 'terminal') {
append(options, message + '\r\n', after);
}
+ // TODO: Assign these destination strings to variables and use the variables
+ if (destination === 'webview') {
+ appendToReplOutputWebview(options, message);
+ }
}
/**
@@ -452,3 +475,59 @@ export function replWindowAppendPrompt(onAppended?: outputWindow.OnAppendedCallb
didLastOutputTerminateLine['output-window'] = true;
outputWindow.appendPrompt(onAppended);
}
+
+function formatStacktrace(stacktrace: any[]) {
+ return stacktrace
+ .filter((entry) => {
+ return (
+ !entry.flags.includes('dup') &&
+ !['clojure.lang.RestFn', 'clojure.lang.AFn'].includes(entry.class)
+ );
+ })
+ .map((entry) => {
+ const name = entry.var || entry.name;
+ return `${name} (${entry.file}:${entry.line})`;
+ })
+ .join('\n');
+}
+
+function printStackTrace(stacktrace: any[]) {
+ const evalResultsOutputDestination = getDestinationConfiguration().evalResults;
+ switch (evalResultsOutputDestination) {
+ // TODO: Make these strings an enum
+ case 'repl-window':
+ outputWindow.printLastStacktrace();
+ replWindowAppendPrompt();
+ break;
+ case 'webview':
+ appendStackTraceToReplOutputWebview(stacktrace);
+ break;
+ case 'output-channel':
+ outputChannel.appendLine('');
+ outputChannel.appendLine(formatStacktrace(stacktrace));
+ break;
+ case 'terminal':
+ getOutputPTY().write('\n' + formatStacktrace(stacktrace) + '\n');
+ break;
+ default:
+ console.error(
+ 'Printing the last stacktrace is not supported for the configured results output destination:',
+ evalResultsOutputDestination
+ );
+ break;
+ }
+}
+
+export function printLastStacktrace() {
+ const session = replSession.getSession();
+ session
+ .stacktrace()
+ .then((stacktrace) => {
+ if (stacktrace.stacktrace) {
+ printStackTrace(stacktrace.stacktrace);
+ }
+ })
+ .catch((e) => {
+ console.error(`Failed fetching stacktrace: ${e.message}`);
+ });
+}
diff --git a/src/testRunner.ts b/src/testRunner.ts
index d71e94d44..c48970882 100644
--- a/src/testRunner.ts
+++ b/src/testRunner.ts
@@ -9,6 +9,7 @@ import * as namespace from './namespace';
import { getSession, updateReplSessionType } from './nrepl/repl-session';
import * as getText from './util/get-text';
import * as output from './results-output/output';
+import { appendStackTraceToReplOutputWebview } from '../out/cljs-lib/cljs-lib';
const diagnosticCollection = vscode.languages.createDiagnosticCollection('calva');
@@ -205,25 +206,33 @@ async function reportTests(
for (const ns in result.results) {
const resultSet = result.results[ns];
for (const test in resultSet) {
- for (const a of resultSet[test]) {
- const messages = cider.detailedMessage(a);
+ for (const resultsForTest of resultSet[test]) {
+ // TODO: The logic below should be refactored in the future so that output is handled in a separate place
+ // for each type of destination that's configured. That logic should be encapsulated per output destination.
+ const message = cider.detailedMessage(resultsForTest);
- if (a.type == 'error') {
- const stackTrace = await session.testStacktrace(ns, test, a.index);
+ if (resultsForTest.type === 'error') {
+ const stacktrace = await session.testStacktrace(ns, test, resultsForTest.index);
- outputWindow.saveStacktrace(stackTrace.stacktrace);
- outputWindow.appendLine(messages, (_, afterResultLocation) => {
+ outputWindow.saveStacktrace(stacktrace.stacktrace);
+ outputWindow.appendLine(message, (_, afterResultLocation) => {
outputWindow.markLastStacktraceRange(afterResultLocation);
});
- if (output.getDestinationConfiguration().otherOutput !== 'repl-window') {
- output.appendLineOtherOut(messages);
+ const otherOutputDestination = output.getDestinationConfiguration().otherOutput;
+ if (otherOutputDestination !== 'repl-window') {
+ // We don't want to prepend lines with `; ` in output destinations other than the repl-window.
+ // This is just a quick fix to avoid refactoring for now.
+ output.appendLineOtherOut(message.replace(/; /gi, ''));
+ if (otherOutputDestination === 'webview') {
+ appendStackTraceToReplOutputWebview(stacktrace.stacktrace);
+ }
}
- } else if (messages) {
- output.appendLineOtherOut(messages);
+ } else if (message) {
+ output.appendClojureOther(message);
}
- if (a.type === 'fail') {
- recordDiagnostic(a);
+ if (resultsForTest.type === 'fail') {
+ recordDiagnostic(resultsForTest);
}
}
}