diff --git a/notebooks/tap_window.clj b/notebooks/tap_window.clj new file mode 100644 index 000000000..9ff71bb4e --- /dev/null +++ b/notebooks/tap_window.clj @@ -0,0 +1,49 @@ +;; # 🪟 Windows +(ns tap-window + {:nextjournal.clerk/visibility {:code :hide :result :hide} + :nextjournal.clerk/no-cache true} + (:require [nextjournal.clerk :as clerk] + [nextjournal.clerk.tap :as tap])) + +{::clerk/visibility {:code :show}} + +;; Clerk windows are draggable, resizable and dockable containers that are floating on top of other content. Windows make it easy to show arbitary content, independent of a notebook, while still getting all the benefits of Clerk viewers. This can be nice for debugging. For example you could use it to inspect a data structure in one window and show the same data structure as a graph in a second window. + +;; Windows have identity. In order to spawn one, you have to call something like: + +(clerk/window! :my-window {:foo (vec (repeat 2 {:baz (range 30) :fooze (range 40)})) :bar (range 20)}) + +;; This creates a window with a `:my-window` id. The id makes the window addressable and, as such, allows to update its contents from the REPL. For example, you can call … + +(clerk/window! :my-window {:title "A debug window"} (zipmap (range 1000) (map #(* % %) (range 1000)))) + +;; … to replace the contens of `:my-window`. The window itself will not be reinstantiated. The example also shows that `window!` takes an optional second `opts` argument that can be used to give it a custom title. + +;; Windows have a dedicated close button but you can also use the id to close it from the REPL, e.g. + +(clerk/close-window! :my-window) + +;; Finally, there's also special `::clerk/taps` window that doesn't require you to set any content. Instead, it will show you a stream of taps (independant of the notebooks you are working in). So, whenever you `tap>` something, the Taps window will show it when it's open: + +(comment + (clerk/window! ::clerk/taps)) + +;; Mind that windows live outside notebooks and once you spawn one, it shows until you close it again, even if you reload the page or show a different notebook! + +(comment + (clerk/window! :test {:title "My Super-Duper Window"} (range 100)) + (clerk/window! :test (clerk/html [:div.w-8.h-8.bg-green-500])) + (clerk/close-window! :test) + (clerk/close-all-windows!) + (clerk/window! ::clerk/taps) + (tap> (clerk/html [:div.w-8.h-8.bg-green-500])) + (tap> (clerk/vl {:description "A simple bar chart with embedded data." + :data {:values [{:a "A" :b 28} {:a "B" :b 55} {:a "C" :b 43} + {:a "D" :b 91} {:a "E" :b 81} {:a "F" :b 53} + {:a "G" :b 19} {:a "H" :b 87} {:a "I" :b 52}]} + :mark "bar" + :encoding {:x {:field "a" :type "nominal" :axis {:labelAngle 0}} + :y {:field "b" :type "quantitative"}}})) + (tap> 1) + (tap/reset-taps!) + (clerk/window! ::clerk/sci-repl)) diff --git a/scratch_window.clj b/scratch_window.clj new file mode 100644 index 000000000..b47aca4fe --- /dev/null +++ b/scratch_window.clj @@ -0,0 +1,36 @@ +;; # 🪲Debug +(ns scratch-window + {:nextjournal.clerk/visibility {:code :hide :result :hide} + :nextjournal.clerk/no-cache true} + (:require [nextjournal.clerk :as clerk] + [nextjournal.clerk.viewer :as v])) + +(def window-viewer + {:render-fn '(fn [{:keys [vals]} opts] + [nextjournal.clerk.render.window/show + (into [:div] + (map (fn [v] + [:div.mb-4.pb-4.border-b + [nextjournal.clerk.render/inspect-presented v]])) + (:nextjournal/value vals))]) + :transform-fn v/mark-preserve-keys}) + +(defonce !taps (atom '())) + +(defonce taps-setup (add-tap (fn [x] + (swap! !taps conj x) + (clerk/recompute!)))) + +^{::clerk/visibility {:result :show}} +(clerk/with-viewer window-viewer + {:vals @!taps}) + +(comment + (tap> (clerk/html [:div.w-8.h-8.bg-green-500])) + (tap> (clerk/plotly {:data [{:x [1 2 3 4]}]}))) + +(clerk/window! ::clerk/taps) +(clerk/destroy-window ::clerk/taps) +(clerk/list-windows) + +(clerk/window! :test (range 100)) diff --git a/src/nextjournal/clerk.clj b/src/nextjournal/clerk.clj index f56073408..9d113b294 100644 --- a/src/nextjournal/clerk.clj +++ b/src/nextjournal/clerk.clj @@ -12,7 +12,8 @@ [nextjournal.clerk.eval :as eval] [nextjournal.clerk.parser :as parser] [nextjournal.clerk.viewer :as v] - [nextjournal.clerk.webserver :as webserver])) + [nextjournal.clerk.webserver :as webserver] + [nextjournal.clerk.window :as window])) (defonce ^:private !show-filter-fn (atom nil)) (defonce ^:private !last-file (atom nil)) @@ -78,6 +79,10 @@ #_(show! "https://raw.githubusercontent.com/nextjournal/clerk-demo/main/notebooks/rule_30.clj") #_(show! (java.io.StringReader. ";; # In Memory Notebook 👋\n(+ 41 1)")) +(defn window! [& args] (apply window/open! args)) +(defn close-window! [id] (window/close! id)) +(defn close-all-windows! [] (window/close-all!)) + (defn recompute! "Recomputes the currently visible doc, without parsing it." [] diff --git a/src/nextjournal/clerk/render.cljs b/src/nextjournal/clerk/render.cljs index 5780124b4..9344c6ef6 100644 --- a/src/nextjournal/clerk/render.cljs +++ b/src/nextjournal/clerk/render.cljs @@ -17,7 +17,7 @@ [nextjournal.clerk.render.hooks :as hooks] [nextjournal.clerk.render.localstorage :as localstorage] [nextjournal.clerk.render.navbar :as navbar] - [nextjournal.clerk.render.window :as window] + #_[nextjournal.clerk.render.window :as window] [nextjournal.clerk.viewer :as viewer] [reagent.core :as r] [reagent.ratom :as ratom] @@ -138,6 +138,7 @@ (assoc :fragment (subs (.-hash url) 1)))))))) (defn history-push-state [{:as opts :keys [path fragment replace?]}] + (js/console.log :history-push-state opts) (when (not= path (some-> js/history .-state .-path)) (j/call js/history (if replace? :replaceState :pushState) (clj->js opts) "" (str (.. js/document -location -origin) "/" path (when fragment (str "#" fragment)))))) @@ -549,6 +550,7 @@ [:span.cmt-meta tag] (when space? nbsp) value])) (defonce !doc (ratom/atom nil)) +(defonce !windows (ratom/atom {})) (defonce !viewers viewer/!viewers) (defn set-viewers! [scope viewers] @@ -588,8 +590,7 @@ (swap! !state update :desc viewer/merge-presentations more fetch-opts))))} [inspect-presented (:desc @!state)]])) -(defn show-window [& content] - [window/show content]) +(declare clerk-eval) (defn root [] [:<> @@ -601,7 +602,17 @@ [exec-status status])] (when-let [error (get-in @!doc [:nextjournal/value :error])] [:div.fixed.top-0.left-0.w-full.h-full - [inspect-presented error]])]) + [inspect-presented error]]) + #_(when-not (:nextjournal/window-id @!doc) + (into [:<>] + (map (fn [[id state]] + ^{:key id} + [window/show + [render-result state {}] + (-> state + (assoc :id id :on-close #(clerk-eval `(nextjournal.clerk.window/close! ~id))) + (dissoc :nextjournal/presented))])) + @!windows))]) (declare mount) @@ -697,9 +708,14 @@ (if error (reject error) (resolve reply))) (js/console.warn :process-eval-reply!/not-found :eval-id eval-id :keys (keys @!pending-clerk-eval-replies)))) +(defn set-window-state! [{:keys [id state]}] (swap! !windows assoc id state)) +(defn close-window! [{:keys [id]}] (swap! !windows dissoc id)) + (defn ^:export dispatch [{:as msg :keys [type]}] (let [dispatch-fn (get {:patch-state! patch-state! :set-state! set-state! + :set-window-state! set-window-state! + :close-window! close-window! :eval-reply process-eval-reply!} type (fn [_] diff --git a/src/nextjournal/clerk/render/window.cljs b/src/nextjournal/clerk/render/window.cljs index 90466904a..bda933c12 100644 --- a/src/nextjournal/clerk/render/window.cljs +++ b/src/nextjournal/clerk/render/window.cljs @@ -1,6 +1,17 @@ (ns nextjournal.clerk.render.window - (:require [applied-science.js-interop :as j] - [nextjournal.clerk.render.hooks :as hooks])) + (:require ["@codemirror/view" :as cm-view :refer [keymap]] + [applied-science.js-interop :as j] + [clojure.string :as str] + [nextjournal.clerk.render.code :as code] + [nextjournal.clerk.render.hooks :as hooks] + [nextjournal.clerk.sci-env.completions :as completions] + [nextjournal.clojure-mode.keymap :as clojure-mode.keymap] + [nextjournal.clojure-mode.extensions.eval-region :as eval-region] + [sci.core :as sci] + [sci.ctx-store])) + +(defn inspect-fn [] + @(resolve 'nextjournal.clerk.render/inspect)) (defn resizer [{:keys [on-resize on-resize-start on-resize-end] :or {on-resize-start #() on-resize-end #()}}] (let [!direction (hooks/use-state nil) @@ -49,8 +60,9 @@ {:on-mouse-down #(handle-mouse-down :left) :class "w-[4px]"}]])) -(defn header [{:keys [on-drag on-drag-start on-drag-end] :or {on-drag-start #() on-drag-end #()}}] - (let [!mouse-down (hooks/use-state false)] +(defn header [{:keys [id title on-drag on-drag-start on-drag-end on-close] :or {on-drag-start #() on-drag-end #()}}] + (let [!mouse-down (hooks/use-state false) + name (or title id)] (hooks/use-effect (fn [] (let [handle-mouse-up (fn [] (on-drag-end) @@ -63,11 +75,21 @@ (js/addEventListener "mousemove" handle-mouse-move)) #(js/removeEventListener "mousemove" handle-mouse-move))) [!mouse-down on-drag]) - [:div.bg-slate-100.hover:bg-slate-200.dark:bg-slate-800.dark:hover:bg-slate-700.cursor-move.w-full.rounded-t-lg - {:class "h-[14px]" + [:div.bg-slate-100.hover:bg-slate-200.dark:bg-slate-800.dark:hover:bg-slate-700.cursor-move.w-full.rounded-t-lg.flex-shrink-0.leading-none.flex.items-center.justify-between + {:class (if name "h-[24px] " "h-[14px] ") :on-mouse-down (fn [event] (on-drag-start) - (reset! !mouse-down {:start-x (.-screenX event) :start-y (.-screenY event)}))}])) + (reset! !mouse-down {:start-x (.-screenX event) :start-y (.-screenY event)}))} + (when name + [:span.font-sans.font-medium.text-slate-700 + {:class "text-[12px] ml-[8px] "} + (or title id)]) + (when on-close + [:button.text-slate-600.hover:text-slate-900.hover:bg-slate-300.rounded-tr-lg.flex.items-center.justify-center + {:on-click on-close + :class "w-[24px] h-[24px]"} + [:svg {:xmlns "http://www.w3.org/2000/svg" :fill "none" :viewBox "0 0 24 24" :stroke-width "1.5" :stroke "currentColor" :class "w-3 h-3"} + [:path {:stroke-linecap "round" :stroke-linejoin "round" :d "M6 18L18 6M6 6l12 12"}]]])])) (defn resize-top [panel {:keys [top height]} dy] (j/assoc-in! panel [:style :height] (str (- height dy) "px")) @@ -111,67 +133,128 @@ (j/assoc-in! [:style :top] "5px") (j/assoc-in! [:style :left] "5px"))) -(defn show [& content] - (let [!panel-ref (hooks/use-ref nil) - !dragging? (hooks/use-state nil) - !dockable-at (hooks/use-state nil) - !docking-ref (hooks/use-ref nil)] - [:<> - [:div.fixed.border-2.border-dashed.border-indigo-600.border-opacity-70.bg-indigo-600.bg-opacity-30.pointer-events-none.transition-all.rounded-lg - {:class (str "z-[999] " (if-let [side @!dockable-at] - (str "opacity-100 " (case side - :top "left-[5px] top-[5px] right-[5px] h-[33vh]" - :left "left-[5px] top-[5px] bottom-[5px] w-[33vw]" - :bottom "left-[5px] bottom-[5px] right-[5px] h-[33vh]" - :right "right-[5px] top-[5px] bottom-[5px] w-[33vw]")) - "opacity-0 "))}] - [:div.fixed.bg-white.dark:bg-slate-900.shadow-xl.text-slate-800.dark:text-slate-100.rounded-lg.flex.flex-col.hover:ring-2 - {:class (str "z-[1000] " (if @!dragging? "ring-indigo-600 select-none ring-2 " "ring-slate-300 dark:ring-slate-700 ring-1 ")) - :ref !panel-ref - :style {:top 30 :right 30 :width 400 :height 400}} - [resizer {:on-resize (fn [dir dx dy] - (when-let [panel @!panel-ref] - (let [rect (j/lookup (.getBoundingClientRect panel))] - (case dir - :top-left (do (resize-top panel rect dy) - (resize-left panel rect dx)) - :top (resize-top panel rect dy) - :top-right (do (resize-top panel rect dy) - (resize-right panel rect dx)) - :right (resize-right panel rect dx) - :bottom-right (do (resize-bottom panel rect dy) - (resize-right panel rect dx)) - :bottom (resize-bottom panel rect dy) - :bottom-left (do (resize-bottom panel rect dy) - (resize-left panel rect dx)) - :left (resize-left panel rect dx))))) - :on-resize-start #(reset! !dragging? true) - :on-resize-end #(reset! !dragging? false)}] - [header {:on-drag (fn [{:keys [x y dx dy]}] - (when-let [panel @!panel-ref] - (let [{:keys [left top width]} (j/lookup (.getBoundingClientRect panel)) - x-edge-offset 20 - y-edge-offset 10 - vw js/innerWidth - vh js/innerHeight] - (reset! !dockable-at (cond - (zero? x) :left - (>= x (- vw 2)) :right - (<= y 0) :top - (>= y (- vh 2)) :bottom - :else nil)) - (reset! !docking-ref @!dockable-at) - (j/assoc-in! panel [:style :left] (str (min (- vw x-edge-offset) (max (+ x-edge-offset (- width)) (+ left dx))) "px")) - (j/assoc-in! panel [:style :top] (str (min (- vh y-edge-offset) (max y-edge-offset (+ top dy))) "px"))))) - :on-drag-start #(reset! !dragging? true) - :on-drag-end (fn [] - (when-let [side @!docking-ref] - (let [panel @!panel-ref] - (case side - :top (dock-at-top panel) - :right (dock-at-right panel) - :bottom (dock-at-bottom panel) - :left (dock-at-left panel)))) - (reset! !dockable-at nil) - (reset! !docking-ref nil))}] - (into [:div.p-3.flex-auto.overflow-auto] content)]])) +(defn eval-string + ([source] (sci/eval-string* (sci.ctx-store/get-ctx) source)) + ([ctx source] + (when-some [code (not-empty (str/trim source))] + (try {:result (sci/eval-string* ctx code)} + (catch js/Error e + {:error (str (.-message e))}))))) + +(j/defn eval-at-cursor [on-result ^:js {:keys [state]}] + (some->> (eval-region/cursor-node-string state) + (eval-string) + (on-result)) + true) + +(j/defn eval-top-level [on-result ^:js {:keys [state]}] + (some->> (eval-region/top-level-string state) + (eval-string) + (on-result)) + true) + +(j/defn eval-cell [on-result ^:js {:keys [state]}] + (-> (.-doc state) + (str) + (eval-string) + (on-result)) + true) + +(defn sci-extension [{:keys [modifier on-result]}] + (.of cm-view/keymap + (j/lit + [{:key "Mod-Enter" + :run (partial eval-cell on-result)} + {:key (str modifier "-Enter") + :shift (partial eval-top-level on-result) + :run (partial eval-at-cursor on-result)}]))) + +(defn sci-repl [] + (let [!code-str (hooks/use-state "") + !results (hooks/use-state ())] + [:div.flex.flex-col.bg-gray-50 + [:div.w-full.border-t.border-b.border-slate-300.shadow-inner.px-2.py-1.bg-slate-100 + [code/editor !code-str {:extensions #js [(.of keymap clojure-mode.keymap/paredit) + completions/completion-source + (sci-extension {:modifier "Alt" + :on-result #(swap! !results conj {:result % + :evaled-at (js/Date.) + :react-key (gensym)})})]}]] + (into + [:div.w-full.flex-auto.overflow-auto] + (map (fn [{:as r :keys [result evaled-at react-key]}] + ^{:key react-key} + [:div.border-b.px-2.py-2.text-xs.font-mono + [:div.font-mono.text-slate-40.flex-shrink-0.text-right + {:class "text-[9px]"} + (str (first (.. evaled-at toTimeString (split " "))) ":" (.getMilliseconds evaled-at))] + [(inspect-fn) result]])) + @!results)])) + +(defn show + ([content] (show content {})) + ([content {:as opts :keys [css-class]}] + (let [!panel-ref (hooks/use-ref nil) + !dragging? (hooks/use-state nil) + !dockable-at (hooks/use-state nil) + !docking-ref (hooks/use-ref nil)] + [:<> + [:div.fixed.border-2.border-dashed.border-indigo-600.border-opacity-70.bg-indigo-600.bg-opacity-30.pointer-events-none.transition-all.rounded-lg + {:class (str "z-[999] " (if-let [side @!dockable-at] + (str "opacity-100 " (case side + :top "left-[5px] top-[5px] right-[5px] h-[33vh]" + :left "left-[5px] top-[5px] bottom-[5px] w-[33vw]" + :bottom "left-[5px] bottom-[5px] right-[5px] h-[33vh]" + :right "right-[5px] top-[5px] bottom-[5px] w-[33vw]")) + "opacity-0 "))}] + [:div.fixed.bg-white.dark:bg-slate-900.shadow-xl.text-slate-800.dark:text-slate-100.rounded-lg.flex.flex-col.hover:ring-2 + {:class (str "z-[1000] " (if @!dragging? "ring-indigo-600 select-none ring-2 " "ring-slate-300 dark:ring-slate-700 ring-1 ")) + :ref !panel-ref + :style {:top 30 :right 30 :width 400 :height 400}} + [resizer {:on-resize (fn [dir dx dy] + (when-let [panel @!panel-ref] + (let [rect (j/lookup (.getBoundingClientRect panel))] + (case dir + :top-left (do (resize-top panel rect dy) + (resize-left panel rect dx)) + :top (resize-top panel rect dy) + :top-right (do (resize-top panel rect dy) + (resize-right panel rect dx)) + :right (resize-right panel rect dx) + :bottom-right (do (resize-bottom panel rect dy) + (resize-right panel rect dx)) + :bottom (resize-bottom panel rect dy) + :bottom-left (do (resize-bottom panel rect dy) + (resize-left panel rect dx)) + :left (resize-left panel rect dx))))) + :on-resize-start #(reset! !dragging? true) + :on-resize-end #(reset! !dragging? false)}] + [header (merge {:on-drag (fn [{:keys [x y dx dy]}] + (when-let [panel @!panel-ref] + (let [{:keys [left top width]} (j/lookup (.getBoundingClientRect panel)) + x-edge-offset 20 + y-edge-offset 10 + vw js/innerWidth + vh js/innerHeight] + (reset! !dockable-at (cond + (zero? x) :left + (>= x (- vw 2)) :right + (<= y 0) :top + (>= y (- vh 2)) :bottom + :else nil)) + (reset! !docking-ref @!dockable-at) + (j/assoc-in! panel [:style :left] (str (min (- vw x-edge-offset) (max (+ x-edge-offset (- width)) (+ left dx))) "px")) + (j/assoc-in! panel [:style :top] (str (min (- vh y-edge-offset) (max y-edge-offset (+ top dy))) "px"))))) + :on-drag-start #(reset! !dragging? true) + :on-drag-end (fn [] + (when-let [side @!docking-ref] + (let [panel @!panel-ref] + (case side + :top (dock-at-top panel) + :right (dock-at-right panel) + :bottom (dock-at-bottom panel) + :left (dock-at-left panel)))) + (reset! !dockable-at nil) + (reset! !docking-ref nil))} + opts)] + [:div {:class (str "flex-auto " (or css-class "p-3 overflow-auto"))} content]]]))) diff --git a/src/nextjournal/clerk/sci_env.cljs b/src/nextjournal/clerk/sci_env.cljs index 72cfb5426..abe42cda2 100644 --- a/src/nextjournal/clerk/sci_env.cljs +++ b/src/nextjournal/clerk/sci_env.cljs @@ -21,6 +21,7 @@ [nextjournal.clerk.render.context :as view-context] [nextjournal.clerk.render.hooks] [nextjournal.clerk.render.navbar] + [nextjournal.clerk.render.window] [nextjournal.clerk.trim-image] [nextjournal.clerk.viewer :as viewer] [nextjournal.clojure-mode.commands] @@ -160,6 +161,7 @@ 'nextjournal.clerk.render.code 'nextjournal.clerk.render.hooks 'nextjournal.clerk.render.navbar + 'nextjournal.clerk.render.window 'nextjournal.clojure-mode.keymap 'nextjournal.clojure-mode.commands @@ -183,12 +185,17 @@ (defn reconnect-timeout [failed-connection-attempts] (get [0 0 100 500 5000] failed-connection-attempts 10000)) -(defn ^:export connect [ws-url] +(defn ^:export connect [ws-url window-edn] (when (::failed-attempts @render/!doc) (swap! render/!doc assoc ::connection-status "Reconnecting…")) - (let [ws (js/WebSocket. ws-url)] + (let [window (when window-edn + (read-string window-edn)) + ws (js/WebSocket. ws-url)] (set! (.-onmessage ws) onmessage) - (set! (.-onopen ws) (fn [e] (swap! render/!doc dissoc ::connection-status ::failed-attempts))) + (set! (.-onopen ws) (fn [e] + (when window + (.send ws {:type :set-window! :window window})) + (swap! render/!doc dissoc ::connection-status ::failed-attempts))) (set! (.-onclose ws) (fn [e] (let [timeout (reconnect-timeout (::failed-attempts @render/!doc 0))] (swap! render/!doc @@ -198,7 +205,7 @@ (str "Disconnected, reconnecting in " timeout "ms…") "Reconnecting…")) (update ::failed-attempts (fnil inc 0))))) - (js/setTimeout #(connect ws-url) timeout)))) + (js/setTimeout #(connect ws-url window-edn) timeout)))) (set! (.-clerk_ws ^js goog/global) ws) (set! (.-ws_send ^js goog/global) (fn [msg] (.send ws msg))))) diff --git a/src/nextjournal/clerk/sci_env/completions.cljs b/src/nextjournal/clerk/sci_env/completions.cljs new file mode 100644 index 000000000..0262ed523 --- /dev/null +++ b/src/nextjournal/clerk/sci_env/completions.cljs @@ -0,0 +1,127 @@ +(ns nextjournal.clerk.sci-env.completions + (:require ["@codemirror/autocomplete" :as cm-autocomplete] + ["@codemirror/language" :as cm-lang] + [clojure.string :as str] + [goog.object :as gobject] + [sci.core :as sci] + [sci.ctx-store])) + +(defn format [fmt-str x] + (str/replace fmt-str "%s" x)) + +(defn fully-qualified-syms [ctx ns-sym] + (let [syms (sci/eval-string* ctx (format "(keys (ns-map '%s))" ns-sym)) + sym-strs (map #(str "`" %) syms) + sym-expr (str "[" (str/join " " sym-strs) "]") + syms (sci/eval-string* ctx sym-expr) + syms (remove #(str/starts-with? (str %) "nbb.internal") syms)] + syms)) + +(defn- ns-imports->completions [ctx query-ns query] + (let [[_ns-part name-part] (str/split query #"/") + resolved (sci/eval-string* ctx + (pr-str `(let [resolved# (resolve '~query-ns)] + (when-not (var? resolved#) + resolved#))))] + (when resolved + (when-let [[prefix imported] (if name-part + (let [ends-with-dot? (str/ends-with? name-part ".") + fields (str/split name-part #"\.") + fields (if ends-with-dot? + fields + (butlast fields))] + [(str query-ns "/" (when (seq fields) + (let [joined (str/join "." fields)] + (str joined ".")))) + (apply gobject/getValueByKeys resolved + fields)]) + [(str query-ns "/") resolved])] + (let [props (loop [obj imported + props []] + (if obj + (recur (js/Object.getPrototypeOf obj) + (into props (js/Object.getOwnPropertyNames obj))) + props)) + completions (map (fn [k] + [nil (str prefix k)]) props)] + completions))))) + +(defn- match [_alias->ns ns->alias query [sym-ns sym-name qualifier]] + (let [pat (re-pattern query)] + (or (when (and (= :unqualified qualifier) (re-find pat sym-name)) + [sym-ns sym-name]) + (when sym-ns + (or (when (re-find pat (str (get ns->alias (symbol sym-ns)) "/" sym-name)) + [sym-ns (str (get ns->alias (symbol sym-ns)) "/" sym-name)]) + (when (re-find pat (str sym-ns "/" sym-name)) + [sym-ns (str sym-ns "/" sym-name)])))))) + +(defn completions [{:keys [ctx] + ns-str :ns + :as request}] + (js/console.log "request" request) + (try + (let [sci-ns (when ns-str + (sci/find-ns ctx (symbol ns-str)))] + (sci/binding [sci/ns (or sci-ns @sci/ns)] + (if-let [query (or (:symbol request) + (:prefix request))] + (let [has-namespace? (str/includes? query "/") + query-ns (when has-namespace? (some-> (str/split query #"/") + first symbol)) + from-current-ns (fully-qualified-syms ctx (sci/eval-string* ctx "(ns-name *ns*)")) + from-current-ns (map (fn [sym] + [(namespace sym) (name sym) :unqualified]) + from-current-ns) + alias->ns (sci/eval-string* ctx "(let [m (ns-aliases *ns*)] (zipmap (keys m) (map ns-name (vals m))))") + ns->alias (zipmap (vals alias->ns) (keys alias->ns)) + from-aliased-nss (doall (mapcat + (fn [alias] + (let [ns (get alias->ns alias) + syms (sci/eval-string* ctx (format "(keys (ns-publics '%s))" ns))] + (map (fn [sym] + [(str ns) (str sym) :qualified]) + syms))) + (keys alias->ns))) + all-namespaces (->> (sci/eval-string* ctx "(all-ns)") + (map (fn [ns] + [(str ns) nil :qualified]))) + from-imports (when has-namespace? (ns-imports->completions ctx query-ns query)) + fully-qualified-names (when-not from-imports + (when has-namespace? + (let [ns (get alias->ns query-ns query-ns) + syms (sci/eval-string* ctx (format "(and (find-ns '%s) + (keys (ns-publics '%s)))" + ns))] + (map (fn [sym] + [(str ns) (str sym) :qualified]) + syms)))) + svs (concat from-current-ns from-aliased-nss all-namespaces fully-qualified-names) + completions (keep (fn [entry] + (match alias->ns ns->alias query entry)) + svs) + completions (concat completions from-imports) + completions (->> (map (fn [[namespace name]] + (cond-> {"candidate" (str name)} + namespace (assoc "ns" (str namespace)))) + completions) + distinct vec)] + {:completions completions + :status ["done"]}) + {:status ["done"]}))) + (catch :default e + (js/console.error "ERROR" e) + {:completions [] + :status ["done"]}))) + +(defn autocomplete [^js context] + (let [node-before (.. (cm-lang/syntaxTree (.-state context)) (resolveInner (.-pos context) -1)) + text-before (.. context -state (sliceDoc (.-from node-before) (.-pos context)))] + #js {:from (.-from node-before) + :options (clj->js (map + (fn [{:strs [candidate]}] + (doto {:label candidate} prn)) + (:completions (completions {:ctx (sci.ctx-store/get-ctx) :ns "user" :symbol text-before}))))})) + +(def completion-source + (cm-autocomplete/autocompletion #js {:override #js [autocomplete]})) diff --git a/src/nextjournal/clerk/tap.clj b/src/nextjournal/clerk/tap.clj index e0da321ca..8745d0646 100644 --- a/src/nextjournal/clerk/tap.clj +++ b/src/nextjournal/clerk/tap.clj @@ -1,9 +1,7 @@ ;; # 🚰 Tap Inspector (ns nextjournal.clerk.tap {:nextjournal.clerk/visibility {:code :hide :result :hide}} - (:require [clojure.core :as core] - [nextjournal.clerk :as clerk] - [nextjournal.clerk.viewer :as v]) + (:require [nextjournal.clerk.viewer :as v]) (:import (java.time Instant LocalTime ZoneId))) (defn inst->local-time-str [inst] (str (LocalTime/ofInstant inst (ZoneId/systemDefault)))) @@ -25,19 +23,18 @@ [:button.text-xs.rounded-full.px-3.py-1.border-2.font-sans.hover:bg-slate-100.cursor-pointer {:on-click #(nextjournal.clerk.render/clerk-eval `(reset-taps!))} "Clear"]])))) -^{::clerk/sync true ::clerk/viewer switch-view ::clerk/visibility {:result :show}} +^{:nextjournal.clerk/sync true :nextjournal.clerk/viewer switch-view :nextjournal.clerk/visibility {:result :show}} (defonce !view (atom :stream)) - (defonce !taps (atom ())) (defn reset-taps! [] (reset! !taps ()) - (clerk/recompute!)) + ((resolve 'nextjournal.clerk/recompute!))) (defn tapped [x] (swap! !taps conj (record-tap x)) - (clerk/recompute!)) + ((resolve 'nextjournal.clerk/recompute!))) (defonce tap-setup (add-tap (fn [x] ((resolve `tapped) x)))) @@ -45,10 +42,12 @@ (def tap-viewer {:pred (v/get-safe ::val) :render-fn '(fn [{::keys [val tapped-at]} opts] - [:div.border-t.relative.py-3.mt-2 - [:span.absolute.rounded-full.px-2.bg-gray-300.font-mono.top-0 - {:class "left-1/2 -translate-x-1/2 -translate-y-1/2 py-[1px] text-[9px]"} (:nextjournal/value tapped-at)] - [:div.overflow-x-auto [nextjournal.clerk.render/inspect-presented val]]]) + [:div.w-full + [:div.font-sans.bg-slate-50.py-1.text-slate-600.tracking-wide.border-t.border-b + {:class "px-[8px] text-[11px]"} (:nextjournal/value tapped-at)] + [:div.overflow-x-auto.py-2 + {:class "px-[8px]"} + [nextjournal.clerk.render/inspect-presented val]]]) :transform-fn (fn [{:as wrapped-value :nextjournal/keys [value]}] (-> wrapped-value v/mark-preserve-keys @@ -56,26 +55,25 @@ (assoc-in [:nextjournal/render-opts :id] (::key value)) ;; assign custom react key (update-in [:nextjournal/value ::tapped-at] inst->local-time-str)))}) - -^{::clerk/visibility {:result :show} - ::clerk/viewers (v/add-viewers [tap-viewer])} -(clerk/fragment (cond->> @!taps - (= :latest @!view) (take 1))) +^{:nextjournal.clerk/visibility {:result :show} + :nextjournal.clerk/viewers (v/add-viewers [tap-viewer])} +(v/fragment (cond->> @!taps + (= :latest @!view) (take 1))) (comment (last @!taps) (dotimes [_i 5] (tap> (rand-int 1000))) (tap> (shuffle (range (+ 20 (rand-int 200))))) - (tap> (clerk/md "> The purpose of visualization is **insight**, not pictures.")) + (tap> (v/md "> The purpose of visualization is **insight**, not pictures.")) (tap> (v/plotly {:data [{:z [[1 2 3] [3 2 1]] :type "surface"}]})) - (tap> (clerk/html {::clerk/width :full} [:h1.w-full.border-2.border-amber-500.bg-amber-500.h-10])) - (tap> (clerk/table {::clerk/width :full} [[1 2] [3 4]])) - (tap> (clerk/plotly {::clerk/width :full} {:data [{:y [3 1 2]}]})) - (tap> (clerk/image "trees.png")) + (tap> (v/html {:nextjournal.clerk/width :full} [:h1.w-full.border-2.border-amber-500.bg-amber-500.h-10])) + (tap> (v/table {:nextjournal.clerk/width :full} [[1 2] [3 4]])) + (tap> (v/plotly {:nextjournal.clerk/width :full} {:data [{:y [3 1 2]}]})) + (tap> (v/image "trees.png")) (do (require 'rule-30) - (tap> (clerk/with-viewers (clerk/add-viewers rule-30/viewers) rule-30/rule-30))) - (tap> (clerk/with-viewers (clerk/add-viewers rule-30/viewers) rule-30/board)) - (tap> (clerk/html [:h1 "Fin. 👋"])) + (tap> (v/with-viewers (v/add-viewers rule-30/viewers) rule-30/rule-30))) + (tap> (v/with-viewers (v/add-viewers rule-30/viewers) rule-30/board)) + (tap> (v/html [:h1 "Fin. 👋"])) (tap> (reduce (fn [acc _] (vector acc)) :fin (range 200))) (reset-taps!)) diff --git a/src/nextjournal/clerk/view.clj b/src/nextjournal/clerk/view.clj index 6f4ea108e..00a7d89b9 100644 --- a/src/nextjournal/clerk/view.clj +++ b/src/nextjournal/clerk/view.clj @@ -50,7 +50,7 @@ ;; https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions (str/replace s "" "")) -(defn ->html [{:as state :keys [conn-ws? current-path html exclude-js?]}] +(defn ->html [{:as state :keys [conn-ws? current-path html exclude-js? window-id]}] (hiccup/html5 [:head [:meta {:charset "UTF-8"}] @@ -66,4 +66,4 @@ let state = " (-> state v/->edn escape-closing-script-tag pr-str) ".replaceAll('nextjournal.clerk.view/escape-closing-script-tag', 'script') viewer.init(viewer.read_string(state))\n" (when conn-ws? - "viewer.connect(document.location.origin.replace(/^http/, 'ws') + '/_ws')")])])) + (format "viewer.connect(document.location.origin.replace(/^http/, 'ws') + '/_ws', '%s')" (pr-str window-id)))])])) diff --git a/src/nextjournal/clerk/webserver.clj b/src/nextjournal/clerk/webserver.clj index 56d41f162..0d54488d2 100644 --- a/src/nextjournal/clerk/webserver.clj +++ b/src/nextjournal/clerk/webserver.clj @@ -23,8 +23,12 @@ :visibility {:code :hide, :result :show} :result {:nextjournal/value (v/html (help-hiccup))}}]}) -(defonce !clients (atom #{})) +(defonce !clients->sessions (atom {})) (defonce !doc (atom nil)) + +(defonce !sessions (atom {:default !doc})) +#_(reset! !sessions {:default !doc}) + (defonce !last-sender-ch (atom nil)) #_(view/doc->viewer @!doc) @@ -35,15 +39,21 @@ (defn send! [ch msg] (httpkit/send! ch (v/->edn msg))) -(defn broadcast! [msg] - (doseq [ch @!clients] +(defn broadcast! [session msg] + (doseq [ch (keep (fn [[ch ch-session]] + (when (= ch-session session) + ch)) + @!clients->sessions)] (when (not= @!last-sender-ch *sender-ch*) (send! ch {:type :patch-state! :patch [] :effects [(v/->ViewerEval (list 'nextjournal.clerk.render/set-reset-sync-atoms! (not= *sender-ch* ch)))]})) + (let [edn (v/->edn msg)] + (when (str/includes? edn "get-safe/fn--14073") + (spit "msg.edn" (with-out-str (clojure.pprint/pprint edn))))) (httpkit/send! ch (v/->edn msg))) (reset! !last-sender-ch *sender-ch*)) -#_(broadcast! [{:random (rand-int 10000) :range (range 100)}]) +#_(broadcast! nil [{:random (rand-int 10000) :range (range 100)}]) (defn ^:private percent-decode [s] (java.net.URLDecoder/decode s java.nio.charset.StandardCharsets/UTF_8)) @@ -70,8 +80,9 @@ (into {} (comp (filter #(and (map? %) (v/get-safe % :nextjournal/blob-id) (v/get-safe % :nextjournal/presented))) (map (juxt :nextjournal/blob-id :nextjournal/presented))) - (tree-seq coll? seq - (:nextjournal/value presented-doc)))) + (concat (vals @!sessions) + (tree-seq coll? seq + (:nextjournal/value presented-doc))))) #_(blob->presented (meta @!doc)) @@ -111,7 +122,7 @@ (when-let [scheduled-send-status-future (-> doc meta ::!send-status-future)] (future-cancel scheduled-send-status-future))) -(defn present+reset! [doc] +(defn present+reset! [!doc doc] (let [presented (view/doc->viewer doc) sync-vars-old (v/extract-sync-atom-vars @!doc) sync-vars (v/extract-sync-atom-vars doc)] @@ -123,19 +134,26 @@ (reset! !doc (with-meta doc presented)) presented)) -(defn update-doc! [{:as doc :keys [nav-path fragment skip-history?]}] - (broadcast! (if (and (:ns @!doc) (= (:ns @!doc) (:ns doc))) - {:type :patch-state! :patch (editscript/get-edits (editscript/diff (meta @!doc) (present+reset! doc) {:algo :quick}))} - (cond-> {:type :set-state! - :doc (present+reset! doc)} - (and nav-path (not skip-history?)) - (assoc :effects [(v/->ViewerEval (list 'nextjournal.clerk.render/history-push-state - (cond-> {:path nav-path} fragment (assoc :fragment fragment))))]))))) - +(defn update-doc! + ([doc] (update-doc! :default doc)) + ([session {:as doc :keys [nav-path fragment skip-history?]}] + (let [!doc (get @!sessions session)] + (broadcast! session + (if (and (:ns @!doc) (= (:ns @!doc) (:ns doc))) + {:type :patch-state! :patch (editscript/get-edits (editscript/diff (meta @!doc) (present+reset! !doc doc) {:algo :quick}))} + (cond-> {:type :set-state! + :doc (present+reset! !doc doc)} + (and nav-path (not skip-history?)) + (assoc :effects [(v/->ViewerEval (list 'nextjournal.clerk.render/history-push-state + (cond-> {:path nav-path} fragment (assoc :fragment fragment))))]))))))) + +#_(show! {} 'nextjournal.clerk.tap) +#_(show! {} 'nextjournal.clerk.home) #_(update-doc! (help-doc)) -(defn update-error! [ex] - (update-doc! (assoc @!doc :error ex))) +(defn update-error! + ([ex] (update-error! :default ex)) + ([session ex] (update-doc! session (assoc (get @!sessions session) :error ex)))) (defn read-msg [s] (binding [*data-readers* v/data-readers] @@ -148,26 +166,47 @@ #_(pr-str (read-msg "#viewer-eval (resolve 'clojure.core/inc)")) +(defn update-window! [id content] + #_#_ + (swap! !windows assoc id state) + (broadcast! nil {:type :set-window-state! :id id :state state})) + +(defn update-windows! [] + #_ + (doseq [[id state] @!windows] + (update-window! nil state))) + +(defn close-window! [id] + #_#_ + (swap! !windows dissoc id) + (broadcast! nil {:type :close-window! :id id})) + (def ws-handlers - {:on-open (fn [ch] (swap! !clients conj ch)) - :on-close (fn [ch _reason] (swap! !clients disj ch)) + {:on-open (fn [ch] + (swap! !clients->sessions assoc ch :default) + (update-windows!)) + :on-close (fn [ch _reason] (swap! !clients->sessions dissoc ch)) :on-receive (fn [sender-ch edn-string] - (binding [*ns* (or (:ns @!doc) - (create-ns 'user))] - (let [{:as msg :keys [type recompute?]} (read-msg edn-string)] - (case type - :eval (do (send! sender-ch (merge {:type :eval-reply :eval-id (:eval-id msg)} - (try {:reply (eval (:form msg))} - (catch Exception e - {:error (Throwable->map e)})))) - (when recompute? - (eval '(nextjournal.clerk/recompute!)))) - :swap! (when-let [var (resolve (:var-name msg))] - (try - (binding [*sender-ch* sender-ch] - (apply swap! @var (eval (:args msg)))) - (catch Exception ex - (throw (doto (ex-info (str "Clerk cannot `swap!` synced var `" (:var-name msg) "`.") msg ex) update-error!)))))))))}) + (let [session (or (get @!clients->sessions sender-ch) + :default) + !doc (get @!sessions session)] + (binding [*ns* (or (:ns @!doc) + (create-ns 'user))] + (let [{:as msg :keys [type recompute?]} (read-msg edn-string)] + (case type + :set-window! (swap! !clients->sessions assoc sender-ch (:window msg)) + :eval (do (send! sender-ch (merge {:type :eval-reply :eval-id (:eval-id msg)} + (try {:reply (eval (:form msg))} + (catch Exception e + {:error (Throwable->map e)})))) + (when recompute? + (eval '(nextjournal.clerk/recompute!)))) + :swap! (when-let [var (resolve (:var-name msg))] + (try + (binding [*sender-ch* sender-ch] + (apply swap! @var (eval (:args msg)))) + (catch Exception ex + (throw (doto (ex-info (str "Clerk cannot `swap!` synced var `" (:var-name msg) "`.") msg ex) update-error!))))))))))}) #_(do (apply swap! nextjournal.clerk.atom/my-state (eval '[update :counter inc])) @@ -198,13 +237,22 @@ (defn show! [opts file-or-ns] ((resolve 'nextjournal.clerk/show!) opts file-or-ns)) +#_(show! {} 'nextjournal.clerk.home) + (defn navigate! [{:as opts :keys [nav-path]}] (show! opts (->file-or-ns nav-path))) (defn prefetch-request? [req] (= "prefetch" (-> req :headers (get "purpose")))) +(defn extract-window [{:as req :keys [query-string]}] + (some-> (query-string->map query-string) :window edn/read-string)) + +(defn get-doc [req] + (get @!sessions (or (extract-window req) :default))) + (defn serve-notebook [{:as req :keys [uri]}] - (let [nav-path (subs uri 1)] + (let [nav-path (subs uri 1) + !doc (get-doc req)] (cond (prefetch-request? req) {:status 404} @@ -226,42 +274,61 @@ :headers {"Content-Type" "text/plain"} :body (format "Could not find notebook at %s." (pr-str nav-path))})))) +(defn serve-window [{:as req :keys [uri]}] + (if-let [!doc (get @!sessions (extract-window req))] + (do (present+reset! !doc @!doc) + {:status 200 + :headers {"Content-Type" "text/html" "Cache-Control" "no-store"} + :body (view/->html {:doc (meta @!doc) + :window-id (extract-window req) + :resource->url @config/!resource->url + :conn-ws? true})}) + {:status 404 + :body "no-window"})) + +#_(do (swap! !sessions assoc 'tap (atom (nextjournal.clerk.eval/eval-file (clojure.java.io/resource "nextjournal/clerk/tap.clj")))) :done) + + (defn app [{:as req :keys [uri]}] (if (:websocket? req) (httpkit/as-channel req ws-handlers) (try (case (get (re-matches #"/([^/]*).*" uri) 1) - "_blob" (serve-blob @!doc (extract-blob-opts req)) + "_blob" (serve-blob @(get-doc req) (extract-blob-opts req)) ("build" "js" "css") (serve-file uri (str "public" uri)) ("_fs") (serve-file uri (str/replace uri "/_fs/" "")) "_ws" {:status 200 :body "upgrading..."} "favicon.ico" {:status 404} - (serve-notebook req)) + (if (extract-window req) + (serve-window req) + (serve-notebook req))) (catch Throwable e {:status 500 :body (with-out-str (pprint/pprint (Throwable->map e)))})))) #_(nextjournal.clerk/serve! {}) -(defn broadcast-status! [status] +(defn broadcast-status! [session status] ;; avoid editscript diff but use manual patch to just replace `:status` in doc - (broadcast! {:type :patch-state! :patch [[[:status] :r status]]})) + (broadcast! session {:type :patch-state! :patch [[[:status] :r status]]})) (defn broadcast-status-debounced! "Schedules broadcasting a status update after 50 ms. Cancels previously scheduled broadcast, if it exists." - [old-future status] + [old-future session status] (when old-future (future-cancel old-future)) (future (Thread/sleep 50) - (broadcast-status! status))) - -(defn set-status! [status] - (swap! !doc (fn [doc] (-> (or doc (help-doc)) - (vary-meta assoc :status status) - (vary-meta update ::!send-status-future broadcast-status-debounced! status))))) + (broadcast-status! session status))) + +(defn set-status! + ([status] (set-status! :default status)) + ([session status] + (swap! (get @!sessions session) (fn [doc] (-> (or doc (help-doc)) + (vary-meta assoc :status status) + (vary-meta update ::!send-status-future broadcast-status-debounced! session status)))))) #_(clojure.java.browse/browse-url "http://localhost:7777") diff --git a/src/nextjournal/clerk/window.clj b/src/nextjournal/clerk/window.clj new file mode 100644 index 000000000..01ec5caef --- /dev/null +++ b/src/nextjournal/clerk/window.clj @@ -0,0 +1,67 @@ +(ns nextjournal.clerk.window + (:require [nextjournal.clerk.tap :as tap] + [nextjournal.clerk.viewer :as v] + [nextjournal.clerk.webserver :as webserver])) + +(declare open!) +(defonce !taps-view (atom :stream)) +(defn set-view! [x] (reset! !taps-view x) (open! :nextjournal.clerk/taps)) + +(def taps-viewer + {:render-fn '(fn [taps {:as opts :keys [taps-view]}] + [:div.flex.flex-col + [:div.flex.justify-between.items-center.font-sans.border-t.shadow.z-1 + {:class "text-[11px] height-[24px] px-[8px]"} + (into [:div.flex.items-center] + (map (fn [choice] + [:button.transition-all.mr-2.relative + {:class (str "h-[24px] " + (if (= taps-view choice) + "text-indigo-600 font-bold " + "text-slate-500 hover:text-indigo-600 ")) + :on-click #(nextjournal.clerk.render/clerk-eval `(set-view! ~choice))} + (clojure.string/capitalize (name choice))]) + [:stream :latest])) + [:button.text-slate-500.hover:text-indigo-600 + {:on-click #(nextjournal.clerk.render/clerk-eval `(tap/reset-taps!))} + "Clear"]] + (into [:div.overflow-auto + {:style {:height "calc(100% - 40px)"}}] + (nextjournal.clerk.viewer/inspect-children opts) + (cond->> taps (= :latest taps-view) (take 1)))])}) + +(defn open! + ([id] + (case id + :nextjournal.clerk/taps (open! id {:title "🚰 Taps" :css-class "p-0 relative overflow-auto"} + (v/with-viewers (v/add-viewers [tap/tap-viewer]) + (v/with-viewer taps-viewer {:nextjournal/opts {:taps-view @!taps-view}} + @tap/!taps))) + :nextjournal.clerk/sci-repl (open! id {:title "SCI REPL" :css-class "p-0 relative overflow-auto"} + (v/with-viewer {:render-fn 'nextjournal.clerk.render.window/sci-repl + :transform-fn v/mark-presented} nil)))) + ([id content] (open! id {} content)) + ([id opts content] + ;; TODO: consider calling v/transform-result + (webserver/update-window! id content))) + +(add-watch tap/!taps ::tap-watcher (fn [_ _ _ _] (open! :nextjournal.clerk/taps))) + +(defn close! [id] (webserver/close-window! id)) + +(defn close-all! [] + #_(doseq [w (keys @webserver/!windows)] + (close! w))) + +#_(open! :nextjournal.clerk/taps) +#_(doseq [f @@(resolve 'clojure.core/tapset)] (remove-tap f)) +#_(tap> (range 30)) +#_(close! :nextjournal.clerk/taps) +#_(tap> (v/plotly {:data [{:y [1 2 3]}]})) +#_(tap> (v/table [[1 2] [3 4]])) +#_(open! ::my-window {:title "🔭 Rear Window"} (v/table [[1 2] [3 4]])) +#_(open! ::my-window {:title "🔭 Rear Window"} (range 30)) +#_(open! ::my-window {:title "🔭 Rear Window"} (v/plotly {:data [{:y [1 2 3]}]})) +#_(open! ::my-window-2 {:title "🪟"} (range 100)) +#_(close! ::my-window) +#_(close-all!)