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); } } }