mirror of
https://github.com/logseq/logseq.git
synced 2026-05-03 18:36:43 +00:00
1590 lines
67 KiB
Clojure
1590 lines
67 KiB
Clojure
(ns frontend.components.plugins
|
|
(:require [cljs-bean.core :as bean]
|
|
[clojure.string :as string]
|
|
[electron.ipc :as ipc]
|
|
[frontend.components.plugins-settings :as plugins-settings]
|
|
[frontend.components.svg :as svg]
|
|
[frontend.config :as config]
|
|
[frontend.context.i18n :refer [t]]
|
|
[frontend.handler.common.plugin :as plugin-common-handler]
|
|
[frontend.handler.editor :as editor-handler]
|
|
[frontend.handler.notification :as notification]
|
|
[frontend.handler.plugin :as plugin-handler]
|
|
[frontend.handler.plugin-config :as plugin-config-handler]
|
|
[frontend.handler.ui :as ui-handler]
|
|
[frontend.mixins :as mixins]
|
|
[frontend.rum :as rum-utils]
|
|
[frontend.search :as search]
|
|
[frontend.state :as state]
|
|
[frontend.storage :as storage]
|
|
[frontend.ui :as ui]
|
|
[frontend.util :as util]
|
|
[logseq.shui.hooks :as hooks]
|
|
[logseq.shui.ui :as shui]
|
|
[promesa.core :as p]
|
|
[rum.core :as rum]))
|
|
|
|
(declare open-waiting-updates-modal!)
|
|
(defonce PER-PAGE-SIZE 15)
|
|
|
|
(def *dirties-toggle-items (atom {}))
|
|
|
|
(defn- clear-dirties-states!
|
|
[]
|
|
(reset! *dirties-toggle-items {}))
|
|
|
|
(defn render-classic-dropdown-items
|
|
[id items]
|
|
(for [{:keys [hr item title options icon]} items]
|
|
(let [on-click' (:on-click options)]
|
|
(if hr
|
|
(shui/dropdown-menu-separator)
|
|
(shui/dropdown-menu-item
|
|
(assoc options
|
|
:on-click (fn [^js e]
|
|
(when on-click'
|
|
(when-not (false? (on-click' e))
|
|
(shui/popup-hide! id)))))
|
|
(or item
|
|
[:span.flex.items-center.gap-1.w-full
|
|
icon [:div title]]))))))
|
|
|
|
(rum/defcs installed-themes
|
|
<
|
|
(rum/local [] ::themes)
|
|
(rum/local 0 ::cursor)
|
|
(rum/local 0 ::total)
|
|
{:did-mount (fn [state]
|
|
(let [*themes (::themes state)
|
|
*cursor (::cursor state)
|
|
*total (::total state)
|
|
mode (state/sub :ui/theme)
|
|
all-themes (state/sub :plugin/installed-themes)
|
|
themes (->> all-themes
|
|
(filter #(= (:mode %) mode))
|
|
(sort-by #(:name %)))
|
|
no-mode-themes (->> all-themes
|
|
(filter #(= (:mode %) nil))
|
|
(sort-by #(:name %))
|
|
(map-indexed (fn [idx opt] (assoc opt :group-first (zero? idx) :group-desc (if (zero? idx) "light & dark themes" nil)))))
|
|
selected (state/sub :plugin/selected-theme)
|
|
themes (map-indexed (fn [idx opt]
|
|
(let [selected? (= (:url opt) selected)]
|
|
(when selected? (reset! *cursor (+ idx 1)))
|
|
(assoc opt :mode mode :selected selected?))) (concat themes no-mode-themes))
|
|
themes (cons {:name (string/join " " ["Default" (string/capitalize mode) "Theme"])
|
|
:url nil
|
|
:description (string/join " " ["Logseq default" mode "theme."])
|
|
:mode mode
|
|
:selected (nil? selected)
|
|
:group-first true
|
|
:group-desc (str mode " themes")} themes)]
|
|
(reset! *themes themes)
|
|
(reset! *total (count themes))
|
|
state))}
|
|
(mixins/event-mixin
|
|
(fn [state]
|
|
(let [*cursor (::cursor state)
|
|
*total (::total state)
|
|
^js target (rum/dom-node state)]
|
|
(.focus target)
|
|
(mixins/on-key-down
|
|
state {38 ;; up
|
|
(fn [^js _e]
|
|
(reset! *cursor
|
|
(if (zero? @*cursor)
|
|
(dec @*total) (dec @*cursor))))
|
|
40 ;; down
|
|
(fn [^js _e]
|
|
(reset! *cursor
|
|
(if (= @*cursor (dec @*total))
|
|
0 (inc @*cursor))))
|
|
|
|
13 ;; enter
|
|
#(when-let [^js active (.querySelector target ".is-active")]
|
|
(.click active))}))))
|
|
[state]
|
|
(let [*cursor (::cursor state)
|
|
*themes (::themes state)]
|
|
[:div.cp__themes-installed
|
|
{:tab-index -1}
|
|
[:h1.mb-4.text-2xl.p-1 (t :themes)]
|
|
(map-indexed
|
|
(fn [idx opt]
|
|
(let [current-selected? (:selected opt)
|
|
group-first? (:group-first opt)
|
|
plg (get (:plugin/installed-plugins @state/state) (keyword (:pid opt)))]
|
|
[:div
|
|
{:key (str idx (:name opt))}
|
|
(when (and group-first? (not= idx 0)) [:hr.my-2])
|
|
[:div.it.flex.px-3.py-1.5.rounded-sm.justify-between
|
|
{:title (:description opt)
|
|
:class (util/classnames
|
|
[{:is-selected current-selected?
|
|
:is-active (= idx @*cursor)}])
|
|
:on-click #(do (js/LSPluginCore.selectTheme (bean/->js opt))
|
|
(shui/dialog-close!))}
|
|
[:div.flex.items-center.text-xs
|
|
[:div.opacity-60 (str (or (:name plg) "Logseq") " •")]
|
|
[:div.name.ml-1 (:name opt)]]
|
|
(when (or group-first? current-selected?)
|
|
[:div.flex.items-center
|
|
(when group-first? [:small.opacity-60 (:group-desc opt)])
|
|
(when current-selected? [:small.inline-flex.ml-1.opacity-60 (ui/icon "check")])])]]))
|
|
@*themes)]))
|
|
|
|
(rum/defc unpacked-plugin-loader
|
|
[unpacked-pkg-path]
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(let [err-handle
|
|
(fn [^js e]
|
|
(case (keyword (aget e "name"))
|
|
:IllegalPluginPackageError
|
|
(notification/show! "Illegal Logseq plugin package." :error)
|
|
:ExistedImportedPluginPackageError
|
|
(notification/show! (str "Existed plugin package (" (.-message e) ").") :error)
|
|
:default)
|
|
(plugin-handler/reset-unpacked-state))
|
|
reg-handle #(plugin-handler/reset-unpacked-state)]
|
|
(when unpacked-pkg-path
|
|
(doto js/LSPluginCore
|
|
(.once "error" err-handle)
|
|
(.once "registered" reg-handle)
|
|
(.register (bean/->js {:url unpacked-pkg-path}))))
|
|
#(doto js/LSPluginCore
|
|
(.off "error" err-handle)
|
|
(.off "registered" reg-handle))))
|
|
[unpacked-pkg-path])
|
|
|
|
(when unpacked-pkg-path
|
|
[:strong.inline-flex.px-3 "Loading ..."]))
|
|
|
|
(rum/defc category-tabs
|
|
[t total-nums category on-action]
|
|
|
|
[:div.secondary-tabs.categories.flex
|
|
(ui/button
|
|
[:span.flex.items-center
|
|
(ui/icon "puzzle")
|
|
(t :plugins) (when (vector? total-nums) (str " (" (first total-nums) ")"))]
|
|
:intent "link"
|
|
:on-click #(on-action :plugins)
|
|
:class (if (= category :plugins) "active" ""))
|
|
(ui/button
|
|
[:span.flex.items-center
|
|
(ui/icon "palette")
|
|
(t :themes) (when (vector? total-nums) (str " (" (last total-nums) ")"))]
|
|
:intent "link"
|
|
:on-click #(on-action :themes)
|
|
:class (if (= category :themes) "active" ""))])
|
|
|
|
(rum/defc local-markdown-display
|
|
< rum/reactive
|
|
[]
|
|
(let [[content item] (state/sub :plugin/active-readme)]
|
|
[:div.cp__plugins-details
|
|
{:on-click (fn [^js/MouseEvent e]
|
|
(when-let [target (.-target e)]
|
|
(when (and (= (string/lower-case (.-nodeName target)) "a")
|
|
(not (string/blank? (. target getAttribute "href"))))
|
|
(js/apis.openExternal (. target getAttribute "href"))
|
|
(.preventDefault e))))}
|
|
(when-let [repo (:repository item)]
|
|
(when-let [repo (if (string? repo) repo (:url repo))]
|
|
[:div.p-4.rounded-md.bg-base-3
|
|
[:strong [:a.flex.items-center {:target "_blank" :href repo}
|
|
[:span.mr-1 (svg/github {:width 25 :height 25})] repo]]]))
|
|
[:div.p-1.bg-transparent.border-none.ls-block
|
|
{:style {:min-height "60vw"
|
|
:max-width 900}
|
|
:dangerouslySetInnerHTML {:__html content}}]]))
|
|
|
|
(rum/defc remote-readme-display
|
|
[{:keys [repo]} _content]
|
|
(let [src (str (if (string/includes? js/location.host "logseq")
|
|
"./static/" "./") "marketplace.html?repo=" repo)]
|
|
[:iframe.lsp-frame-readme {:src src}]))
|
|
|
|
(defn security-warning
|
|
[]
|
|
(ui/admonition
|
|
:warning
|
|
[:p.text-sm
|
|
(t :plugin/security-warning)]))
|
|
|
|
(defn format-number [num & {:keys [precision] :or {precision 2}}]
|
|
(cond
|
|
(< num 1000) (str num)
|
|
(>= num 1000) (str (.toFixed (/ num 1000) precision) "k")))
|
|
|
|
(rum/defc card-ctls-of-market < rum/static
|
|
[item stat installed? installing-or-updating?]
|
|
[:div.ctl
|
|
[:ul.l.flex.items-center
|
|
;; stars
|
|
[:li.flex.text-sm.items-center.pr-3
|
|
(svg/star 16) [:span.pl-1 (:stargazers_count stat)]]
|
|
|
|
;; downloads
|
|
(when-let [downloads (and stat (:total_downloads stat))]
|
|
(when (and downloads (> downloads 0))
|
|
[:li.flex.text-sm.items-center.pr-3
|
|
(svg/cloud-down 16) [:span.pl-1 (format-number downloads)]]))]
|
|
|
|
[:div.r.flex.items-center
|
|
|
|
[:a.btn
|
|
{:class (util/classnames [{:disabled (or installed? installing-or-updating?)
|
|
:installing installing-or-updating?}])
|
|
:on-click #(plugin-common-handler/install-marketplace-plugin! item)}
|
|
(if installed?
|
|
(t :plugin/installed)
|
|
(if installing-or-updating?
|
|
[:span.flex.items-center [:small svg/loading]
|
|
(t :plugin/installing)]
|
|
(t :plugin/install)))]]])
|
|
|
|
(rum/defc card-ctls-of-installed < rum/static
|
|
[id name url sponsors unpacked? disabled?
|
|
installing-or-updating? has-other-pending?
|
|
new-version item]
|
|
[:div.ctl
|
|
[:div.l
|
|
[:div.de
|
|
[:strong (ui/icon "settings")]
|
|
[:ul.menu-list
|
|
[:li {:on-click #(plugin-handler/open-plugin-settings! id false)} (t :plugin/open-settings)]
|
|
(when (util/electron?)
|
|
[:li {:on-click #(js/apis.openPath url)} (t :plugin/open-package)])
|
|
[:li {:on-click #(plugin-handler/open-report-modal! id name)} (t :plugin/report-security)]
|
|
[:li {:on-click
|
|
#(-> (shui/dialog-confirm!
|
|
[:b (t :plugin/delete-alert name)])
|
|
(p/then (fn []
|
|
(plugin-common-handler/unregister-plugin id)
|
|
|
|
(when (util/electron?)
|
|
(plugin-config-handler/remove-plugin id)))))}
|
|
(t :plugin/uninstall)]]]
|
|
|
|
(when (seq sponsors)
|
|
[:div.de.sponsors
|
|
[:strong (ui/icon "coffee")]
|
|
[:ul.menu-list
|
|
(for [link sponsors]
|
|
[:li {:key link}
|
|
[:a {:href link :target "_blank"}
|
|
[:span.flex.items-center link (ui/icon "external-link")]]])]])]
|
|
|
|
[:div.r.flex.items-center
|
|
(when (and unpacked? (not disabled?))
|
|
[:a.btn
|
|
{:on-click #(js-invoke js/LSPluginCore "reload" id)}
|
|
(t :plugin/reload)])
|
|
|
|
(when (not unpacked?)
|
|
[:div.updates-actions
|
|
[:a.btn
|
|
{:class (util/classnames [{:disabled installing-or-updating?}])
|
|
:on-click #(when-not has-other-pending?
|
|
(plugin-handler/check-or-update-marketplace-plugin!
|
|
(assoc item :only-check (not new-version))
|
|
(fn [^js e] (notification/show! (.toString e) :error))))}
|
|
|
|
(if installing-or-updating?
|
|
(t :plugin/updating)
|
|
(if new-version
|
|
[:span (t :plugin/update) " 👉 " new-version]
|
|
(t :plugin/check-update)))]])
|
|
|
|
(ui/toggle (not disabled?)
|
|
(fn []
|
|
(js-invoke js/LSPluginCore (if disabled? "enable" "disable") id)
|
|
(when (nil? (get @*dirties-toggle-items (keyword id)))
|
|
(swap! *dirties-toggle-items assoc (keyword id) (not disabled?))))
|
|
true)]])
|
|
|
|
(defn get-open-plugin-readme-handler
|
|
[url {:keys [webPkg] :as item} repo]
|
|
#(plugin-handler/open-readme!
|
|
url item (if (or repo webPkg) remote-readme-display local-markdown-display)))
|
|
|
|
(rum/defc plugin-item-card < rum/static
|
|
[t {:keys [id name title version url description author icon iir repo sponsors webPkg] :as item}
|
|
disabled? market? *search-key has-other-pending?
|
|
installing-or-updating? installed? stat coming-update]
|
|
|
|
(let [name (or title name "Untitled")
|
|
web? (not (nil? webPkg))
|
|
unpacked? (and (not web?) (not iir))
|
|
new-version (state/coming-update-new-version? coming-update)]
|
|
[:div.cp__plugins-item-card
|
|
{:key (str "lsp-card-" id)
|
|
:class (util/classnames
|
|
[{:market market?
|
|
:installed installed?
|
|
:updating installing-or-updating?
|
|
:has-new-version new-version}])}
|
|
|
|
[:div.l.link-block.cursor-pointer
|
|
{:on-click (get-open-plugin-readme-handler url item repo)}
|
|
(if (and icon (not (string/blank? icon)))
|
|
[:img.icon {:src (if market? (plugin-handler/pkg-asset id icon) icon)}]
|
|
svg/folder)
|
|
|
|
(when (and (not market?) unpacked?)
|
|
[:span.flex.justify-center.text-xs.text-error.pt-2 (t :plugin/unpacked)])]
|
|
|
|
[:div.r
|
|
[:h3.head.text-xl.font-bold.pt-1.5
|
|
|
|
[:span.l.link-block.cursor-pointer
|
|
{:on-click (get-open-plugin-readme-handler url item repo)}
|
|
name]
|
|
(when (not market?) [:sup.inline-block.px-1.text-xs.opacity-50 version])]
|
|
|
|
[:div.desc.text-xs.opacity-70
|
|
[:p description]
|
|
;;[:small (js/JSON.stringify (bean/->js settings))]
|
|
]
|
|
|
|
;; Author & Identity
|
|
[:div.flag
|
|
[:p.text-xs.pr-2.flex.justify-between
|
|
[:small {:on-click #(when-let [^js el (js/document.querySelector ".cp__plugins-page .search-ctls input")]
|
|
(reset! *search-key (str "@" author))
|
|
(.select el))} author]
|
|
[:small {:on-click #(do
|
|
(notification/show! "Copied!" :success)
|
|
(util/copy-to-clipboard! id))}
|
|
(str "ID: " id)]]]
|
|
|
|
;; Github repo
|
|
[:div.flag.is-top.opacity-50
|
|
(when repo
|
|
[:a.flex {:target "_blank"
|
|
:href (plugin-handler/gh-repo-url repo)}
|
|
(svg/github {:width 16 :height 16})])]
|
|
|
|
(if market?
|
|
;; market ctls
|
|
(card-ctls-of-market item stat installed? installing-or-updating?)
|
|
|
|
;; installed ctls
|
|
(card-ctls-of-installed
|
|
id name url sponsors unpacked? disabled?
|
|
installing-or-updating? has-other-pending? new-version item))]]))
|
|
|
|
(rum/defc panel-tab-search < rum/static
|
|
[search-key *search-key *search-ref]
|
|
[:div.search-ctls
|
|
[:small.absolute.s1
|
|
(ui/icon "search")]
|
|
(when-not (string/blank? search-key)
|
|
[:small.absolute.s2
|
|
{:on-click #(when-let [^js target (rum/deref *search-ref)]
|
|
(reset! *search-key nil)
|
|
(.focus target))}
|
|
(ui/icon "x")])
|
|
(shui/input
|
|
{:placeholder (t :plugin/search-plugin)
|
|
:ref *search-ref
|
|
:auto-focus true
|
|
:on-key-down (fn [^js e]
|
|
(when (= 27 (.-keyCode e))
|
|
(util/stop e)
|
|
(if (string/blank? search-key)
|
|
(some-> (js/document.querySelector ".cp__plugins-page") (.focus))
|
|
(reset! *search-key nil))))
|
|
:on-change #(let [^js target (.-target %)]
|
|
(reset! *search-key (some-> (.-value target) (string/triml))))
|
|
:value (or search-key "")})])
|
|
|
|
(rum/defc panel-tab-developer
|
|
[]
|
|
(ui/button
|
|
(t :plugin/contribute)
|
|
:href "https://github.com/logseq/marketplace"
|
|
:class "contribute"
|
|
:intent "link"
|
|
:target "_blank"))
|
|
|
|
(rum/defc user-proxy-settings-container
|
|
[{:keys [protocol type] :as agent-opts}]
|
|
(let [type (or (not-empty type) (not-empty protocol) "system")
|
|
[opts set-opts!] (rum/use-state agent-opts)
|
|
[testing? set-testing?!] (rum/use-state false)
|
|
*test-input (rum/create-ref)
|
|
disabled? (or (= (:type opts) "system") (= (:type opts) "direct"))]
|
|
[:div.cp__settings-network-proxy-cnt
|
|
[:h1.mb-2.text-2xl.font-bold (t :settings-page/network-proxy)]
|
|
[:div.p-2
|
|
[:p [:label [:strong (t :type)]
|
|
(ui/select [{:label "System" :value "system" :selected (= type "system")}
|
|
{:label "Direct" :value "direct" :selected (= type "direct")}
|
|
{:label "HTTP" :value "http" :selected (= type "http")}
|
|
{:label "SOCKS5" :value "socks5" :selected (= type "socks5")}]
|
|
(fn [_e value]
|
|
(set-opts! (assoc opts :type value :protocol value))))]]
|
|
[:p.flex
|
|
[:label.pr-4
|
|
{:class (if disabled? "opacity-50" nil)}
|
|
[:strong (t :host)]
|
|
[:input.form-input.is-small
|
|
{:value (:host opts)
|
|
:disabled disabled?
|
|
:on-change #(set-opts!
|
|
(assoc opts :host (util/trim-safe (util/evalue %))))}]]
|
|
|
|
[:label
|
|
{:class (if disabled? "opacity-50" nil)}
|
|
[:strong (t :port)]
|
|
[:input.form-input.is-small
|
|
{:value (:port opts) :type "number" :min 1 :max 65535
|
|
:disabled disabled?
|
|
:on-change #(set-opts!
|
|
(assoc opts :port (util/trim-safe (util/evalue %))))}]]]
|
|
|
|
[:hr]
|
|
[:p.flex.items-center.space-x-2
|
|
[:span.w-60
|
|
[:input.form-input.is-small
|
|
{:ref *test-input
|
|
:list "proxy-test-url-datalist"
|
|
:type "url"
|
|
:placeholder "https://"
|
|
:on-change #(set-opts!
|
|
(assoc opts :test (util/trim-safe (util/evalue %))))
|
|
:value (:test opts)}]
|
|
[:datalist#proxy-test-url-datalist
|
|
[:option "https://api.logseq.com/logseq/version"]
|
|
[:option "https://logseq-connectivity-testing-prod.s3.us-east-1.amazonaws.com/logseq-connectivity-testing"]
|
|
[:option "https://www.google.com"]
|
|
[:option "https://s3.amazonaws.com"]
|
|
[:option "https://clients3.google.com/generate_204"]]]
|
|
|
|
(ui/button (if testing? (ui/loading "Testing") "Test URL")
|
|
:intent "logseq"
|
|
:on-click #(let [val (util/trim-safe (.-value (rum/deref *test-input)))]
|
|
(when (and (not testing?) (not (string/blank? val)))
|
|
(set-testing?! true)
|
|
(-> (p/let [result (ipc/ipc :testProxyUrl val opts)]
|
|
(js->clj result :keywordize-keys true))
|
|
(p/then (fn [{:keys [code response-ms]}]
|
|
(notification/clear! :proxy-net-check)
|
|
(notification/show! (str "Success! Status " code " in " response-ms "ms.") :success)))
|
|
(p/catch (fn [e]
|
|
(notification/show! (str e) :error false :proxy-net-check)))
|
|
(p/finally (fn [] (set-testing?! false)))))))]
|
|
|
|
[:p.pt-2
|
|
(ui/button (t :save)
|
|
:on-click (fn []
|
|
(p/let [_ (ipc/ipc :setProxy opts)]
|
|
(state/set-state! [:electron/user-cfgs :settings/agent] opts))))]]]))
|
|
|
|
(rum/defc load-from-web-url-container
|
|
[]
|
|
(let [[url set-url!] (rum/use-state "http://127.0.0.1:8080/")
|
|
[pending? set-pending?] (rum/use-state false)
|
|
handle-submit! (fn []
|
|
(set-pending? true)
|
|
(-> (plugin-handler/load-plugin-from-web-url! url)
|
|
(p/then #(do (notification/show! "New plugin registered!" :success)
|
|
(shui/dialog-close!)))
|
|
(p/catch #(notification/show! (str %) :error))
|
|
(p/finally
|
|
#(set-pending? false))))]
|
|
|
|
[:div.px-4.pt-4.pb-2.rounded-md.flex.flex-col.gap-2
|
|
[:div.flex.flex-col.gap-3
|
|
(shui/input {:placeholder "http://"
|
|
:value url
|
|
:on-change #(set-url! (-> (util/evalue %) (util/trim-safe)))
|
|
:auto-focus true})
|
|
[:span.text-gray-10
|
|
(shui/tabler-icon "info-circle" {:size 13})
|
|
[:span "URLs support both GitHub repositories and local development servers.
|
|
(For examples: https://github.com/xyhp915/logseq-journals-calendar,
|
|
http://localhost:8080/<plugin-dir-root>)"]]]
|
|
[:div.flex.justify-end
|
|
(shui/button {:disabled (or pending? (string/blank? url))
|
|
:on-click handle-submit!}
|
|
(if pending? (ui/loading) "Install"))]]))
|
|
|
|
(rum/defc install-from-github-release-container
|
|
[]
|
|
(let [[url set-url!] (rum/use-state "")
|
|
[opts set-opts!] (rum/use-state {:theme? false :effect? false})
|
|
[pending set-pending!] (rum/use-state false)
|
|
*input (rum/use-ref nil)]
|
|
[:div.p-4.flex.flex-col.pb-0
|
|
(shui/input {:placeholder "GitHub repo url"
|
|
:value url
|
|
:ref *input
|
|
:on-change #(set-url! (util/evalue %))
|
|
:auto-focus true})
|
|
[:div.flex.gap-6.pt-3.items-center.select-none
|
|
[:label.flex.items-center.gap-2
|
|
(shui/checkbox {:checked (:theme? opts)
|
|
:on-checked-change #(set-opts! (assoc opts :theme? %))})
|
|
[:span.opacity-60 "theme?"]]
|
|
[:label.flex.items-center.gap-2
|
|
(shui/checkbox {:checked (:effect? opts)
|
|
:on-checked-change #(set-opts! (assoc opts :effect? %))})
|
|
[:span.opacity-60 "effect?"]]]
|
|
[:div.flex.justify-end.pt-3
|
|
(shui/button
|
|
{:on-click (fn []
|
|
(if (or (string/blank? (util/trim-safe url))
|
|
(not (string/starts-with? url "https://")))
|
|
(.focus (rum/deref *input))
|
|
(let [url (string/replace-first url "https://github.com/" "")
|
|
matched (re-find #"([^\/]+)/([^\/]+)" url)]
|
|
(if-let [id (some-> matched (nth 2))]
|
|
(do
|
|
(set-pending! true)
|
|
(-> #js {:id id :repo (first matched)
|
|
:theme (:theme? opts)
|
|
:effect (:effect? opts)}
|
|
(js/window.logseq.api.__install_plugin)
|
|
(p/then #(shui/dialog-close!))
|
|
(p/catch #(notification/show! (str %) :error))
|
|
(p/finally #(set-pending! false))))
|
|
(notification/show! "Invalid GitHub repo url" :error)))))
|
|
:disabled pending}
|
|
(if pending (ui/loading "Installing") "Install"))]]))
|
|
|
|
(rum/defc auto-check-for-updates-control
|
|
[]
|
|
(let [[enabled, set-enabled!] (rum/use-state (plugin-handler/get-enabled-auto-check-for-updates?))
|
|
text (t :plugin/auto-check-for-updates)]
|
|
|
|
[:div.flex.items-center.justify-between.px-3.py-2
|
|
{:on-click (fn []
|
|
(let [t (not enabled)]
|
|
(set-enabled! t)
|
|
(plugin-handler/set-enabled-auto-check-for-updates t)
|
|
(notification/show!
|
|
[:span text [:strong.pl-1 (if t "ON" "OFF")] "!"]
|
|
(if t :success :info))))}
|
|
[:span.pr-3.opacity-80 text]
|
|
(ui/toggle enabled #() true)]))
|
|
|
|
(rum/defc ^:large-vars/cleanup-todo panel-control-tabs < rum/static
|
|
[search-key *search-key category *category
|
|
sort-by *sort-by filter-by *filter-by total-nums
|
|
selected-unpacked-pkg market? develop-mode?
|
|
reload-market-fn agent-opts]
|
|
|
|
(let [*search-ref (rum/create-ref)]
|
|
[:div.pb-3.flex.justify-between.control-tabs.relative
|
|
[:div.flex.items-center.l
|
|
(category-tabs t total-nums category #(reset! *category %))
|
|
|
|
(when (and develop-mode? (util/electron?) (not market?))
|
|
[:div
|
|
(ui/tooltip
|
|
(ui/button
|
|
(t :plugin/load-unpacked)
|
|
{:icon "upload"
|
|
:intent "link"
|
|
:class "load-unpacked"
|
|
:on-click plugin-handler/load-unpacked-plugin})
|
|
[:div (t :plugin/unpacked-tips)])
|
|
|
|
(when (util/electron?)
|
|
(unpacked-plugin-loader selected-unpacked-pkg))])]
|
|
|
|
[:div.flex.items-center.r
|
|
;; extra info
|
|
(when-let [proxy-val (state/http-proxy-enabled-or-val?)]
|
|
(ui/button
|
|
[:span.flex.items-center.text-indigo-500
|
|
(ui/icon "world-download") proxy-val]
|
|
:small? true
|
|
:intent "link"
|
|
:on-click #(state/pub-event! [:go/proxy-settings agent-opts])))
|
|
|
|
;; search
|
|
(panel-tab-search search-key *search-key *search-ref)
|
|
|
|
;; sorter & filter
|
|
(let [aim-icon #(if (= filter-by %) "check" "circle")
|
|
items (if market?
|
|
[{:title (t :plugin/all)
|
|
:options {:on-click #(reset! *filter-by :default)}
|
|
:icon (ui/icon (aim-icon :default))}
|
|
|
|
{:title (t :plugin/installed)
|
|
:options {:on-click #(reset! *filter-by :installed)}
|
|
:icon (ui/icon (aim-icon :installed))}
|
|
|
|
{:title (t :plugin/not-installed)
|
|
:options {:on-click #(reset! *filter-by :not-installed)}
|
|
:icon (ui/icon (aim-icon :not-installed))}]
|
|
|
|
[{:title (t :plugin/all)
|
|
:options {:on-click #(reset! *filter-by :default)}
|
|
:icon (ui/icon (aim-icon :default))}
|
|
|
|
{:title (t :plugin/enabled)
|
|
:options {:on-click #(reset! *filter-by :enabled)}
|
|
:icon (ui/icon (aim-icon :enabled))}
|
|
|
|
{:title (t :plugin/disabled)
|
|
:options {:on-click #(reset! *filter-by :disabled)}
|
|
:icon (ui/icon (aim-icon :disabled))}
|
|
|
|
{:title (t :plugin/unpacked)
|
|
:options {:on-click #(reset! *filter-by :unpacked)}
|
|
:icon (ui/icon (aim-icon :unpacked))}
|
|
|
|
{:title (t :plugin/update-available)
|
|
:options {:on-click #(reset! *filter-by :update-available)}
|
|
:icon (ui/icon (aim-icon :update-available))}])]
|
|
(ui/button
|
|
(ui/icon "filter")
|
|
:class (str (when-not (contains? #{:default} filter-by) "picked ") "sort-or-filter-by")
|
|
:on-click #(shui/popup-show! (.-target %)
|
|
(fn [{:keys [id]}]
|
|
(render-classic-dropdown-items id items))
|
|
{:as-dropdown? true})
|
|
:variant :ghost))
|
|
|
|
(when market?
|
|
(let [aim-icon #(if (= sort-by %) "check" "circle")
|
|
items [{:title (t :plugin/popular)
|
|
:options {:on-click #(reset! *sort-by :default)}
|
|
:icon (ui/icon (aim-icon :default))}
|
|
|
|
{:title (t :plugin/downloads)
|
|
:options {:on-click #(reset! *sort-by :downloads)}
|
|
:icon (ui/icon (aim-icon :downloads))}
|
|
|
|
{:title (t :plugin/stars)
|
|
:options {:on-click #(reset! *sort-by :stars)}
|
|
:icon (ui/icon (aim-icon :stars))}
|
|
|
|
{:title (t :plugin/title "A - Z")
|
|
:options {:on-click #(reset! *sort-by :letters)}
|
|
:icon (ui/icon (aim-icon :letters))}
|
|
|
|
{:title (t :plugin/date-added)
|
|
:options {:on-click #(reset! *sort-by :addedAt)}
|
|
:icon (ui/icon (aim-icon :addedAt))}]]
|
|
|
|
(ui/button
|
|
(ui/icon "arrows-sort")
|
|
:class (str (when-not (contains? #{:default :popular} sort-by) "picked ") "sort-or-filter-by")
|
|
:on-click #(shui/popup-show! (.-target %)
|
|
(fn [{:keys [id]}]
|
|
(render-classic-dropdown-items id items))
|
|
{:as-dropdown? true})
|
|
:variant :ghost)))
|
|
|
|
;; more - updater
|
|
(let [items (concat (if market?
|
|
[{:title [:span.flex.items-center.gap-1 (ui/icon "rotate-clockwise") (t :plugin/refresh-lists)]
|
|
:options {:on-click #(reload-market-fn)}}]
|
|
[{:title [:span.flex.items-center.gap-1 (ui/icon "rotate-clockwise") (t :plugin/check-all-updates)]
|
|
:options {:on-click #(plugin-handler/user-check-enabled-for-updates! (not= :plugins category))}}])
|
|
|
|
(when (util/electron?)
|
|
[{:title [:span.flex.items-center.gap-1 (ui/icon "world") (t :settings-page/network-proxy)]
|
|
:options {:on-click #(state/pub-event! [:go/proxy-settings agent-opts])}}
|
|
|
|
{:title [:span.flex.items-center.gap-1 (ui/icon "arrow-down-circle") (t :plugin.install-from-file/menu-title)]
|
|
:options {:on-click plugin-config-handler/open-replace-plugins-modal}}])
|
|
|
|
[{:hr true}]
|
|
|
|
(when (state/developer-mode?)
|
|
(if (util/electron?)
|
|
[{:title [:span.flex.items-center.gap-1 (ui/icon "file-code") (t :plugin/open-preferences)]
|
|
:options {:on-click
|
|
#(p/let [root (plugin-handler/get-ls-dotdir-root)]
|
|
(js/apis.openPath (str root "/preferences.json")))}}
|
|
{:title [:span.flex.items-center.whitespace-nowrap.gap-1
|
|
(ui/icon "bug") (t :plugin/open-logseq-dir) [:code "~/.logseq"]]
|
|
:options {:on-click
|
|
#(p/let [root (plugin-handler/get-ls-dotdir-root)]
|
|
(js/apis.openPath root))}}]
|
|
[{:title [:span.flex.items-center.whitespace-nowrap.gap-1
|
|
(ui/icon "plug") (t :plugin/load-from-web-url)]
|
|
:options {:on-click
|
|
#(shui/dialog-open! load-from-web-url-container)}}]))
|
|
|
|
[{:title [:span.flex.items-center.gap-1 (ui/icon "alert-triangle") (t :plugin/report-security)]
|
|
:options {:on-click #(plugin-handler/open-report-modal!)}}]
|
|
|
|
[{:hr true :key "dropdown-more"}
|
|
{:title (auto-check-for-updates-control)}])]
|
|
|
|
(ui/button
|
|
(ui/icon "dots-vertical")
|
|
:class "more-do"
|
|
:on-click #(shui/popup-show! (.-target %)
|
|
(fn [{:keys [id]}]
|
|
(render-classic-dropdown-items id items))
|
|
{:as-dropdown? true
|
|
:align "center"
|
|
:content-props {:side-offset 10}})
|
|
:variant :ghost))
|
|
|
|
;; developer
|
|
(panel-tab-developer)]]))
|
|
|
|
(def plugin-items-list-mixins
|
|
{:did-mount
|
|
(fn [s]
|
|
(when-let [^js el (rum/dom-node s)]
|
|
(when-let [^js el-list (.querySelector el ".cp__plugins-item-lists")]
|
|
(when-let [^js cls (.-classList (.querySelector el ".control-tabs"))]
|
|
(.addEventListener
|
|
el-list "scroll"
|
|
#(if (> (.-scrollTop el-list) 1)
|
|
(.add cls "scrolled")
|
|
(.remove cls "scrolled"))))))
|
|
s)})
|
|
|
|
(rum/defc lazy-items-loader
|
|
[load-more!]
|
|
(let [^js inViewState (ui/useInView #js {:threshold 0})
|
|
in-view? (.-inView inViewState)]
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(load-more!))
|
|
[in-view?])
|
|
|
|
[:div {:ref (.-ref inViewState)}
|
|
[:p.py-1.text-center.opacity-0 (when (.-inView inViewState) "·")]]))
|
|
|
|
(defn weighted-sort-by
|
|
[key pkgs]
|
|
(let [default? (or (nil? key) (= key :default))
|
|
grouped-pkgs (if default?
|
|
(some->> pkgs
|
|
(group-by (fn [{:keys [addedAt]}]
|
|
(and (number? addedAt)
|
|
(< (- (js/Date.now) addedAt)
|
|
;; under 6 days
|
|
(* 1000 60 60 24 6)))))
|
|
(into {}))
|
|
{false pkgs})
|
|
pinned-pkgs (get grouped-pkgs true)
|
|
pkgs (get grouped-pkgs false)
|
|
;; calculate weight
|
|
[key pkgs] (if default?
|
|
(let [decay-factor 0.001
|
|
download-weight 0.8
|
|
star-weight 0.2]
|
|
(letfn [(normalize [vals val]
|
|
(let [min-val (apply min vals)
|
|
max-val (apply max vals)]
|
|
(if (= max-val min-val) 0
|
|
(/ (- val min-val) (- max-val min-val)))))
|
|
(time-diff-in-days [ts]
|
|
(when-let [time-diff (and (number? ts) (- (js/Date.now) ts))]
|
|
(/ time-diff (* 1000 60 60 24))))]
|
|
[:weight
|
|
(let [all-downloads (->> (map :downloads pkgs) (remove #(not (number? %))))
|
|
all-stars (->> (map :stars pkgs) (remove #(not (number? %))))]
|
|
(->> pkgs
|
|
(map (fn [{:keys [downloads stars latestAt] :as pkg}]
|
|
(let [downloads (if (number? downloads) downloads 1)
|
|
stars (if (number? stars) stars 1)
|
|
days-since-latest (time-diff-in-days latestAt)
|
|
decay (js/Math.exp (* -1 decay-factor days-since-latest))
|
|
normalized-downloads (normalize all-downloads downloads)
|
|
normalize-stars (normalize all-stars stars)
|
|
download-score (* normalized-downloads download-weight)
|
|
star-score (* normalize-stars star-weight)]
|
|
(assoc pkg :weight (+ download-score star-score decay)))))))]))
|
|
[key pkgs])]
|
|
(->> (apply sort-by
|
|
(conj
|
|
(case key
|
|
:letters [#(util/safe-lower-case (or (:title %) (:name %)))]
|
|
[key #(compare %2 %1)])
|
|
pkgs))
|
|
(concat pinned-pkgs))))
|
|
|
|
(rum/defcs ^:large-vars/data-var marketplace-plugins
|
|
< rum/static rum/reactive
|
|
plugin-items-list-mixins
|
|
(rum/local false ::fetching)
|
|
(rum/local "" ::search-key)
|
|
(rum/local :plugins ::category)
|
|
(rum/local :default ::sort-by) ;; default (weighted) / downloads / stars / letters / updates / date-added
|
|
(rum/local :default ::filter-by)
|
|
(rum/local nil ::error)
|
|
(rum/local nil ::cached-query-flag)
|
|
(rum/local 1 ::current-page)
|
|
{:did-mount
|
|
(fn [s]
|
|
(let [reload-fn (fn [force-refresh?]
|
|
(when-not @(::fetching s)
|
|
(reset! (::fetching s) true)
|
|
(reset! (::error s) nil)
|
|
(-> (plugin-handler/load-marketplace-plugins force-refresh?)
|
|
(p/then #(plugin-handler/load-marketplace-stats false))
|
|
(p/catch #(do (js/console.error %) (reset! (::error s) %)))
|
|
(p/finally #(reset! (::fetching s) false)))))]
|
|
(reload-fn false)
|
|
(assoc s ::reload (partial reload-fn true))))}
|
|
[state]
|
|
(let [*list-node-ref (rum/create-ref)
|
|
pkgs (state/sub :plugin/marketplace-pkgs)
|
|
stats (state/sub :plugin/marketplace-stats)
|
|
installed-plugins (state/sub :plugin/installed-plugins)
|
|
installing (state/sub :plugin/installing)
|
|
online? (state/sub :network/online?)
|
|
develop-mode? (state/sub :ui/developer-mode?)
|
|
agent-opts (state/sub [:electron/user-cfgs :settings/agent])
|
|
*search-key (::search-key state)
|
|
*category (::category state)
|
|
*sort-by (::sort-by state)
|
|
*filter-by (::filter-by state)
|
|
*cached-query-flag (::cached-query-flag state)
|
|
*current-page (::current-page state)
|
|
*fetching (::fetching state)
|
|
*error (::error state)
|
|
theme-plugins (filter #(:theme %) pkgs)
|
|
normal-plugins (filter #(not (:theme %)) pkgs)
|
|
filtered-pkgs (when (seq pkgs)
|
|
(if (= @*category :themes) theme-plugins normal-plugins))
|
|
total-nums [(count normal-plugins) (count theme-plugins)]
|
|
filtered-pkgs (if (and (seq filtered-pkgs) (not= :default @*filter-by))
|
|
(filter #(apply
|
|
(if (= :installed @*filter-by) identity not)
|
|
[(contains? installed-plugins (keyword (:id %)))])
|
|
filtered-pkgs)
|
|
filtered-pkgs)
|
|
filtered-pkgs (if-not (string/blank? @*search-key)
|
|
(if-let [author (and (string/starts-with? @*search-key "@")
|
|
(subs @*search-key 1))]
|
|
(filter #(= author (:author %)) filtered-pkgs)
|
|
(search/fuzzy-search
|
|
filtered-pkgs @*search-key
|
|
:limit 30
|
|
:extract-fn :title))
|
|
filtered-pkgs)
|
|
filtered-pkgs (map #(if-let [stat (get stats (keyword (:id %)))]
|
|
(let [downloads (:total_downloads stat)
|
|
stars (:stargazers_count stat)
|
|
latest-at (some-> (:updated_at stat) (js/Date.) (.getTime))]
|
|
(assoc %
|
|
:stat stat
|
|
:stars stars
|
|
:latestAt latest-at
|
|
:downloads downloads))
|
|
%) filtered-pkgs)
|
|
sorted-plugins (weighted-sort-by @*sort-by filtered-pkgs)
|
|
|
|
fn-query-flag (fn [] (string/join "_" (map #(str @%) [*filter-by *sort-by *search-key *category])))
|
|
str-query-flag (fn-query-flag)
|
|
_ (when (not= str-query-flag @*cached-query-flag)
|
|
(when-let [^js list-cnt (rum/deref *list-node-ref)]
|
|
(set! (.-scrollTop list-cnt) 0))
|
|
(reset! *current-page 1))
|
|
_ (reset! *cached-query-flag str-query-flag)
|
|
|
|
page-total-items (count sorted-plugins)
|
|
sorted-plugins (if-not (> page-total-items PER-PAGE-SIZE)
|
|
sorted-plugins (take (* @*current-page PER-PAGE-SIZE) sorted-plugins))
|
|
load-more-pages! #(when (> page-total-items PER-PAGE-SIZE)
|
|
(when (< (* PER-PAGE-SIZE @*current-page) page-total-items)
|
|
(reset! *current-page (inc @*current-page))))]
|
|
|
|
[:div.cp__plugins-marketplace
|
|
|
|
(panel-control-tabs
|
|
@*search-key *search-key
|
|
@*category *category
|
|
@*sort-by *sort-by @*filter-by *filter-by
|
|
total-nums nil true develop-mode? (::reload state)
|
|
agent-opts)
|
|
|
|
(cond
|
|
(not online?)
|
|
[:p.flex.justify-center.pt-20.opacity-50 (svg/offline 30)]
|
|
|
|
@*fetching
|
|
[:p.flex.justify-center.py-20 svg/loading]
|
|
|
|
@*error
|
|
[:p.flex.justify-center.pt-20.opacity-50 (t :plugin/remote-error) (.-message @*error)]
|
|
|
|
:else
|
|
[:div.cp__plugins-marketplace-cnt
|
|
{:class (util/classnames [{:has-installing (boolean installing)}])}
|
|
[:div.cp__plugins-item-lists
|
|
{:ref *list-node-ref}
|
|
[:div.cp__plugins-item-lists-inner
|
|
;; items list
|
|
(for [item sorted-plugins]
|
|
(rum/with-key
|
|
(let [pid (keyword (:id item))
|
|
stat (:stat item)]
|
|
(plugin-item-card t item
|
|
(get-in item [:settings :disabled]) true *search-key installing
|
|
(and installing (= (keyword (:id installing)) pid))
|
|
(contains? installed-plugins pid) stat nil))
|
|
(:id item)))]
|
|
|
|
;; items loader
|
|
(when (seq sorted-plugins)
|
|
(lazy-items-loader load-more-pages!))]])]))
|
|
|
|
(rum/defcs ^:large-vars/data-var installed-plugins
|
|
< rum/static rum/reactive
|
|
plugin-items-list-mixins
|
|
(rum/local "" ::search-key)
|
|
(rum/local :default ::filter-by) ;; default / enabled / disabled / unpacked / update-available
|
|
(rum/local :default ::sort-by)
|
|
(rum/local :plugins ::category)
|
|
(rum/local nil ::cached-query-flag)
|
|
(rum/local 1 ::current-page)
|
|
[state]
|
|
(let [*list-node-ref (rum/create-ref)
|
|
installed-plugins' (vals (state/sub [:plugin/installed-plugins]))
|
|
updating (state/sub :plugin/installing)
|
|
develop-mode? (state/sub :ui/developer-mode?)
|
|
selected-unpacked-pkg (state/sub :plugin/selected-unpacked-pkg)
|
|
coming-updates (state/sub :plugin/updates-coming)
|
|
agent-opts (state/sub [:electron/user-cfgs :settings/agent])
|
|
*filter-by (::filter-by state)
|
|
*sort-by (::sort-by state)
|
|
*search-key (::search-key state)
|
|
*category (::category state)
|
|
*cached-query-flag (::cached-query-flag state)
|
|
*current-page (::current-page state)
|
|
default-filter-by? (= :default @*filter-by)
|
|
theme-plugins (filter #(:theme %) installed-plugins')
|
|
normal-plugins (filter #(not (:theme %)) installed-plugins')
|
|
filtered-plugins (when (seq installed-plugins')
|
|
(if (= @*category :themes) theme-plugins normal-plugins))
|
|
total-nums [(count normal-plugins) (count theme-plugins)]
|
|
filtered-plugins (if-not default-filter-by?
|
|
(filter (fn [it]
|
|
(let [disabled (get-in it [:settings :disabled])]
|
|
(case @*filter-by
|
|
:enabled (not disabled)
|
|
:disabled disabled
|
|
:unpacked (not (:iir it))
|
|
:update-available (state/plugin-update-available? (:id it))
|
|
true))) filtered-plugins)
|
|
filtered-plugins)
|
|
filtered-plugins (if-not (string/blank? @*search-key)
|
|
(if-let [author (and (string/starts-with? @*search-key "@")
|
|
(subs @*search-key 1))]
|
|
(filter #(= author (:author %)) filtered-plugins)
|
|
(search/fuzzy-search
|
|
filtered-plugins @*search-key
|
|
:limit 30
|
|
:extract-fn :name))
|
|
filtered-plugins)
|
|
sorted-plugins (if default-filter-by?
|
|
(->> filtered-plugins
|
|
(reduce #(let [disabled? (get-in %2 [:settings :disabled])
|
|
old-dirty (get @*dirties-toggle-items (keyword (:id %2)))
|
|
k (if (if (boolean? old-dirty) (not old-dirty) disabled?) 1 0)]
|
|
(update %1 k conj %2)) [[] []])
|
|
(#(update % 0 (fn [coll] (sort-by :iir coll))))
|
|
(flatten))
|
|
(do
|
|
(clear-dirties-states!)
|
|
filtered-plugins))
|
|
|
|
fn-query-flag (fn [] (string/join "_" (map #(str @%) [*filter-by *sort-by *search-key *category])))
|
|
str-query-flag (fn-query-flag)
|
|
_ (when (not= str-query-flag @*cached-query-flag)
|
|
(when-let [^js list-cnt (rum/deref *list-node-ref)]
|
|
(set! (.-scrollTop list-cnt) 0))
|
|
(reset! *current-page 1))
|
|
_ (reset! *cached-query-flag str-query-flag)
|
|
|
|
page-total-items (count sorted-plugins)
|
|
sorted-plugins (if-not (> page-total-items PER-PAGE-SIZE)
|
|
sorted-plugins (take (* @*current-page PER-PAGE-SIZE) sorted-plugins))
|
|
load-more-pages! #(when (> page-total-items PER-PAGE-SIZE)
|
|
(when (< (* PER-PAGE-SIZE @*current-page) page-total-items)
|
|
(reset! *current-page (inc @*current-page))))]
|
|
|
|
[:div.cp__plugins-installed
|
|
(panel-control-tabs
|
|
@*search-key *search-key
|
|
@*category *category
|
|
@*sort-by *sort-by
|
|
@*filter-by *filter-by
|
|
total-nums selected-unpacked-pkg
|
|
false develop-mode? nil
|
|
agent-opts)
|
|
|
|
[:div.cp__plugins-item-lists.pb-6
|
|
{:ref *list-node-ref}
|
|
[:div.cp__plugins-item-lists-inner
|
|
(for [item sorted-plugins]
|
|
(rum/with-key
|
|
(let [pid (keyword (:id item))]
|
|
(plugin-item-card t item
|
|
(get-in item [:settings :disabled]) false *search-key updating
|
|
(and updating (= (keyword (:id updating)) pid))
|
|
true nil (get coming-updates pid)))
|
|
(:id item)))]
|
|
|
|
(if (seq sorted-plugins)
|
|
(lazy-items-loader load-more-pages!)
|
|
[:div.flex.items-center.justify-center.py-28.flex-col.gap-2.opacity-30
|
|
(shui/tabler-icon "list-search" {:size 40})
|
|
[:span.text-sm "Nothing Found."]])]]))
|
|
|
|
(rum/defcs waiting-coming-updates
|
|
< rum/reactive
|
|
{:will-mount (fn [s] (state/reset-unchecked-update) s)}
|
|
[_s]
|
|
(let [_ (state/sub :plugin/updates-coming)
|
|
downloading? (state/sub :plugin/updates-downloading?)
|
|
unchecked (state/sub :plugin/updates-unchecked)
|
|
updates (state/all-available-coming-updates)]
|
|
|
|
[:div.cp__plugins-waiting-updates
|
|
[:h1.mb-4.text-2xl.p-1 (t :plugin/found-n-updates (count updates))]
|
|
|
|
(if (seq updates)
|
|
;; lists
|
|
[:ul
|
|
{:class (when downloading? "downloading")}
|
|
(for [it updates
|
|
:let [k (str "lsp-it-" (:id it))
|
|
c? (not (contains? unchecked (:id it)))
|
|
notes (util/trim-safe (:latest-notes it))]]
|
|
[:li.flex.items-center
|
|
{:key k
|
|
:class (when c? "checked")}
|
|
|
|
[:label.flex-1
|
|
{:for k}
|
|
(shui/checkbox
|
|
{:id k
|
|
:default-checked c?
|
|
:on-checked-change (fn [checked?]
|
|
(when-not downloading?
|
|
(state/set-unchecked-update (:id it) (not checked?))))})
|
|
[:strong.px-3 (:title it)
|
|
[:sup (str (:version it) " 👉 " (:latest-version it))]]]
|
|
|
|
[:div.px-4
|
|
(when-not (string/blank? notes)
|
|
(ui/tooltip [:span.opacity-30.hover:opacity-80 (ui/icon "info-circle")] [:p notes]))]])]
|
|
|
|
;; all done
|
|
[:div.py-4 [:strong.text-4xl (str "\uD83C\uDF89 " (t :plugin/all-updated))]])
|
|
|
|
;; actions
|
|
(when (seq updates)
|
|
[:div.pt-5.flex.justify-end
|
|
(ui/button
|
|
(if downloading?
|
|
[:span (ui/loading (t :plugin/updates-downloading))]
|
|
[:span.flex.items-center (ui/icon "download") (t :plugin/update-all-selected)])
|
|
|
|
:on-click
|
|
#(when-not downloading?
|
|
(plugin-handler/open-updates-downloading)
|
|
(if-let [n (state/get-next-selected-coming-update)]
|
|
(plugin-handler/check-or-update-marketplace-plugin!
|
|
(assoc n :only-check false)
|
|
(fn [^js e] (notification/show! (.toString e) :error)))
|
|
(plugin-handler/close-updates-downloading)))
|
|
|
|
:disabled
|
|
(or downloading?
|
|
(and (seq unchecked)
|
|
(= (count unchecked) (count updates)))))])]))
|
|
|
|
(rum/defc plugins-from-file
|
|
< rum/reactive
|
|
[plugins]
|
|
[:div.cp__plugins-fom-file
|
|
[:h1.mb-4.text-2xl.p-1 (t :plugin.install-from-file/title)]
|
|
(if (seq plugins)
|
|
[:div
|
|
[:div.mb-2.text-xl (t :plugin.install-from-file/notice)]
|
|
;; lists
|
|
[:ul
|
|
(for [it (:install plugins)
|
|
:let [k (str "lsp-it-" (name (:id it)))]]
|
|
[:li.flex.items-center
|
|
{:key k}
|
|
[:label.flex-1
|
|
{:for k}
|
|
[:strong.px-3 (str (name (:id it)) " " (:version it))]]])]
|
|
|
|
;; actions
|
|
[:div.pt-5
|
|
(ui/button [:span (t :plugin/install)]
|
|
:on-click #(do
|
|
(plugin-config-handler/replace-plugins plugins)
|
|
(shui/dialog-close! "ls-plugins-from-file-modal")))]]
|
|
;; all done
|
|
[:div.py-4 [:strong.text-xl (str "\uD83C\uDF89 " (t :plugin.install-from-file/success))]])])
|
|
|
|
(defn open-select-theme!
|
|
[]
|
|
(shui/dialog-open! installed-themes
|
|
{:align :top}))
|
|
|
|
(rum/defc hook-ui-slot < rum/static
|
|
([type payload] (hook-ui-slot type payload nil #(plugin-handler/hook-plugin-app type % nil)))
|
|
([type payload opts callback]
|
|
(let [rs (util/rand-str 8)
|
|
id (str "slot__" rs)
|
|
*el-ref (rum/use-ref nil)]
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(let [timer (js/setTimeout #(callback {:type type :slot id :payload payload}) 50)]
|
|
#(js/clearTimeout timer)))
|
|
[id])
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(let [el (rum/deref *el-ref)]
|
|
#(when-let [uis (seq (.querySelectorAll el "[data-injected-ui]"))]
|
|
(doseq [^js el uis]
|
|
(when-let [id (.-injectedUi (.-dataset el))]
|
|
(js/LSPluginCore._forceCleanInjectedUI id))))))
|
|
[])
|
|
|
|
[:div.lsp-hook-ui-slot
|
|
(merge opts {:id id
|
|
:ref *el-ref
|
|
:on-pointer-down (fn [e] (util/stop-propagation e))})])))
|
|
|
|
(rum/defc hook-block-slot < rum/static
|
|
[type block]
|
|
(hook-ui-slot type {} nil #(plugin-handler/hook-plugin-block-slot block %)))
|
|
|
|
(rum/defc ui-item-renderer
|
|
[pid type {:keys [key template prefix]}]
|
|
(let [*el (rum/use-ref nil)
|
|
uni #(str prefix "injected-ui-item-" %)
|
|
^js pl (js/LSPluginCore.registeredPlugins.get (name pid))]
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(when-let [^js el (rum/deref *el)]
|
|
(js/LSPlugin.pluginHelpers.setupInjectedUI.call
|
|
pl #js {:slot (.-id el) :key key :template template} #js {})))
|
|
[template])
|
|
|
|
(if-not (nil? pl)
|
|
[:div
|
|
{:id (uni (str (name key) "-" (name pid)))
|
|
:title key
|
|
:class (uni (name type))
|
|
:ref *el}]
|
|
[:<>])))
|
|
|
|
(rum/defc toolbar-plugins-manager-list
|
|
[updates-coming items]
|
|
(let [badge-updates? (and (not (plugin-handler/get-auto-checking?))
|
|
(seq (state/all-available-coming-updates updates-coming)))
|
|
items (fn []
|
|
(->> (concat
|
|
(for [[_ {:keys [key pinned?] :as opts} pid] items
|
|
:let [pkey (str (name pid) ":" key)]]
|
|
{:title key
|
|
:item [:div.flex.items-center.item-wrap
|
|
(ui-item-renderer pid :toolbar (assoc opts :prefix "pl-" :key (str "pl-" key)))
|
|
[:span {:style {:padding-left "2px"}} key]
|
|
[:span.pin.flex.items-center.opacity-60
|
|
{:class (util/classnames [{:pinned pinned?}])}
|
|
(ui/icon (if pinned? "pinned" "pin"))]]
|
|
:options {:on-click (fn [^js e]
|
|
(let [^js target (.-target e)
|
|
user-btn? (boolean (.closest target "div[data-injected-ui]"))]
|
|
(when-not user-btn?
|
|
(plugin-handler/op-pinned-toolbar-item! pkey (if pinned? :remove :add)))
|
|
true))}})
|
|
[{:hr true}
|
|
{:title (t :plugins)
|
|
:options {:on-click #(plugin-handler/goto-plugins-dashboard!)
|
|
:class "extra-item mt-2"}
|
|
:icon (ui/icon "apps")}
|
|
|
|
{:title (t :themes)
|
|
:options {:on-click #(plugin-handler/show-themes-modal!)
|
|
:class "extra-item"}
|
|
:icon (ui/icon "palette")}
|
|
|
|
{:title (t :settings)
|
|
:options {:on-click #(plugin-handler/goto-plugins-settings!)
|
|
:class "extra-item"}
|
|
:icon (ui/icon "adjustments")}
|
|
|
|
(when badge-updates?
|
|
{:title [:div.flex.items-center.space-x-5.leading-none
|
|
[:span (t :plugin/found-updates)] (ui/point "bg-red-700" 5 {:style {:margin-top 2}})]
|
|
:options {:on-click #(open-waiting-updates-modal!)
|
|
:class "extra-item"}
|
|
:icon (ui/icon "download")})]
|
|
|
|
[{:hr true :key "dropdown-more"}
|
|
{:title (auto-check-for-updates-control)}])
|
|
(remove nil?)))]
|
|
|
|
[:div.toolbar-plugins-manager
|
|
{:on-pointer-down
|
|
(fn [^js e]
|
|
(shui/popup-show! (.-target e)
|
|
(fn [{:keys [id]}]
|
|
(render-classic-dropdown-items id (items)))
|
|
{:as-dropdown? true
|
|
:content-props {:class "toolbar-plugins-manager-content"}}))}
|
|
|
|
(shui/button-ghost-icon :puzzle
|
|
{:class "flex relative toolbar-plugins-manager-trigger"}
|
|
(when badge-updates?
|
|
(ui/point "bg-red-600.top-1.right-1.absolute" 4 {:style {:margin-right 2 :margin-top 2}})))]))
|
|
|
|
(rum/defc header-ui-items-list-wrap
|
|
[children]
|
|
(let [*wrap-el (rum/use-ref nil)]
|
|
[:div.list-wrap {:ref *wrap-el} children]))
|
|
|
|
(rum/defcs hook-ui-items < rum/reactive
|
|
< {:key-fn #(identity "plugin-hook-items")}
|
|
"type of :toolbar, :pagebar"
|
|
[_state type]
|
|
(when (state/sub [:plugin/installed-ui-items])
|
|
(let [toolbar? (= :toolbar type)
|
|
pinned-items (state/sub [:plugin/preferences :pinnedToolbarItems])
|
|
pinned-items (and (sequential? pinned-items) (into #{} pinned-items))
|
|
items (state/get-plugins-ui-items-with-type type)
|
|
items (sort-by #(:key (second %)) items)]
|
|
|
|
(when-let [items (and (seq items)
|
|
(if toolbar?
|
|
(map #(assoc-in % [1 :pinned?]
|
|
(let [[_ {:keys [key]} pid] %
|
|
pkey (str (name pid) ":" key)]
|
|
(contains? pinned-items pkey)))
|
|
items)
|
|
items))]
|
|
|
|
[:div.ui-items-container
|
|
{:data-type (name type)}
|
|
|
|
[:<>
|
|
(header-ui-items-list-wrap
|
|
(for [[_ {:keys [key pinned?] :as opts} pid] items]
|
|
(when (or (not toolbar?)
|
|
(not (set? pinned-items)) pinned?)
|
|
(rum/with-key (ui-item-renderer pid type opts) key))))
|
|
|
|
;; manage plugin buttons
|
|
(when toolbar?
|
|
(let [updates-coming (state/sub :plugin/updates-coming)]
|
|
(toolbar-plugins-manager-list updates-coming items)))]]))))
|
|
|
|
(rum/defc hook-ui-fenced-code
|
|
[block content {:keys [render edit] :as _opts}]
|
|
|
|
(let [[content1 set-content1!] (rum/use-state content)
|
|
[editor-active? set-editor-active!] (rum/use-state (string/blank? content))
|
|
*cm (rum/use-ref nil)
|
|
*el (rum/use-ref nil)]
|
|
|
|
(hooks/use-effect!
|
|
#(set-content1! content)
|
|
[content])
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(some-> (rum/deref *el)
|
|
(.closest ".ui-fenced-code-wrap")
|
|
(.-classList)
|
|
(#(if editor-active?
|
|
(.add % "is-active")
|
|
(.remove % "is-active"))))
|
|
(when-let [cm (rum/deref *cm)]
|
|
(.refresh cm)
|
|
(.focus cm)
|
|
(.setCursor cm (.lineCount cm) (count (.getLine cm (.lastLine cm))))))
|
|
[editor-active?])
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(let [t (js/setTimeout
|
|
#(when-let [^js cm (some-> (rum/deref *el)
|
|
(.closest ".ui-fenced-code-wrap")
|
|
(.querySelector ".CodeMirror")
|
|
(.-CodeMirror))]
|
|
(rum/set-ref! *cm cm)
|
|
(doto cm
|
|
(.on "change" (fn []
|
|
(some-> cm (.getDoc) (.getValue) (set-content1!))))))
|
|
;; wait for the cm loaded
|
|
1000)]
|
|
#(js/clearTimeout t)))
|
|
[])
|
|
|
|
[:div.ui-fenced-code-result
|
|
{:on-pointer-down (fn [e] (when (false? edit) (util/stop e)))
|
|
:class (util/classnames [{:not-edit (false? edit)}])
|
|
:ref *el}
|
|
[:<>
|
|
[:span.actions
|
|
{:on-pointer-down #(util/stop %)}
|
|
(ui/button (ui/icon "square-toggle-horizontal" {:size 14})
|
|
:on-click #(set-editor-active! (not editor-active?)))
|
|
(ui/button (ui/icon "source-code" {:size 14})
|
|
:on-click #(editor-handler/edit-block! block (count content1)))]
|
|
(when (fn? render)
|
|
(js/React.createElement render #js {:content content1}))]]))
|
|
|
|
(rum/defc plugins-page
|
|
[]
|
|
|
|
(let [[active set-active!] (rum/use-state :installed)
|
|
market? (= active :marketplace)
|
|
*el-ref (rum/create-ref)]
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(state/load-app-user-cfgs)
|
|
#(clear-dirties-states!))
|
|
[])
|
|
|
|
(hooks/use-effect!
|
|
#(clear-dirties-states!)
|
|
[market?])
|
|
|
|
[:div.cp__plugins-page
|
|
{:ref *el-ref
|
|
:class (when-not (util/electron?) "web-platform")
|
|
:tab-index "-1"}
|
|
|
|
[:h1 (t :plugins)]
|
|
|
|
(when (util/electron?)
|
|
[:<>
|
|
(security-warning)
|
|
[:hr.my-4]])
|
|
|
|
[:div.tabs.flex.items-center.justify-center
|
|
[:div.tabs-inner.flex.items-center
|
|
(shui/button {:on-click #(set-active! :installed)
|
|
:class (when (not market?) "active")
|
|
:size :sm
|
|
:variant :text}
|
|
(t :plugin/installed))
|
|
|
|
(shui/button {:on-click #(set-active! :marketplace)
|
|
:class (when market? "active")
|
|
:size :sm
|
|
:variant :text}
|
|
(shui/tabler-icon "apps")
|
|
(t :plugin/marketplace))]]
|
|
|
|
[:div.panels
|
|
(if market?
|
|
(marketplace-plugins)
|
|
(installed-plugins))]]))
|
|
|
|
(def *updates-sub-content-timer (atom nil))
|
|
(def *updates-sub-content (atom nil))
|
|
|
|
(defn set-updates-sub-content!
|
|
[content duration]
|
|
(reset! *updates-sub-content content)
|
|
|
|
(when (> duration 0)
|
|
(some-> @*updates-sub-content-timer (js/clearTimeout))
|
|
(->> (js/setTimeout #(reset! *updates-sub-content nil) duration)
|
|
(reset! *updates-sub-content-timer))))
|
|
|
|
(rum/defc updates-notifications-impl
|
|
[check-pending? auto-checking? online?]
|
|
(let [[uid, set-uid] (rum/use-state nil)
|
|
[sub-content, _set-sub-content!] (rum-utils/use-atom *updates-sub-content)
|
|
notify! (fn [content status]
|
|
(if auto-checking?
|
|
(println (t :plugin/list-of-updates) content)
|
|
(let [cb #(plugin-handler/cancel-user-checking!)]
|
|
(try
|
|
(set-uid (notification/show! content status false uid nil cb))
|
|
(catch js/Error _
|
|
(set-uid (notification/show! content status false nil nil cb)))))))]
|
|
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(if check-pending?
|
|
(notify!
|
|
[:div
|
|
[:div (t :plugin/checking-for-updates)]
|
|
(when sub-content [:p.opacity-60 sub-content])]
|
|
(ui/loading ""))
|
|
(when uid (notification/clear! uid))))
|
|
[check-pending? sub-content])
|
|
|
|
(hooks/use-effect!
|
|
;; scheduler for auto updates
|
|
(fn []
|
|
(when online?
|
|
(let [last-updates (storage/get :lsp-last-auto-updates)]
|
|
(when (and (not (false? last-updates))
|
|
(or (true? last-updates)
|
|
(not (number? last-updates))
|
|
;; interval 12 hours
|
|
(> (- (js/Date.now) last-updates) (* 60 60 12 1000))))
|
|
(let [update-timer (js/setTimeout
|
|
(fn []
|
|
(plugin-handler/auto-check-enabled-for-updates!)
|
|
(storage/set :lsp-last-auto-updates (js/Date.now)))
|
|
(if (util/electron?) 3000 (* 60 1000)))]
|
|
#(js/clearTimeout update-timer))))))
|
|
[online?])
|
|
|
|
[:<>]))
|
|
|
|
(rum/defcs updates-notifications < rum/reactive
|
|
[_]
|
|
(let [updates-pending (state/sub :plugin/updates-pending)
|
|
online? (state/sub :network/online?)
|
|
auto-checking? (state/sub :plugin/updates-auto-checking?)
|
|
check-pending? (boolean (seq updates-pending))]
|
|
(updates-notifications-impl check-pending? auto-checking? online?)))
|
|
|
|
(rum/defcs focused-settings-content
|
|
< rum/reactive
|
|
(rum/local (state/sub :plugin/focused-settings) ::cache)
|
|
[_state title]
|
|
(let [*cache (::cache _state)
|
|
focused (state/sub :plugin/focused-settings)
|
|
nav? (state/sub :plugin/navs-settings?)
|
|
_ (state/sub :plugin/installed-plugins)
|
|
_ (js/setTimeout #(reset! *cache focused) 100)]
|
|
|
|
[:div.cp__plugins-settings.cp__settings-main
|
|
[:div.cp__settings-inner.md:flex
|
|
{:class (util/classnames [{:no-aside (not nav?)}])}
|
|
(when nav?
|
|
[:aside.md:w-64 {:style {:min-width "10rem"}}
|
|
[:header.cp__settings-header
|
|
[:h1.cp__settings-modal-title (or title (t :settings-of-plugins))]]
|
|
(let [plugins (plugin-handler/get-enabled-plugins-if-setting-schema)]
|
|
[:ul.settings-plugin-list
|
|
(for [{:keys [id name title icon]} plugins]
|
|
[:li
|
|
{:key id :class (util/classnames [{:active (= id focused)}])}
|
|
[:a.flex.items-center.settings-plugin-item
|
|
{:data-id id
|
|
:on-click #(do (state/set-state! :plugin/focused-settings id))}
|
|
(if (and icon (not (string/blank? icon)))
|
|
[:img.icon {:src icon}]
|
|
svg/folder)
|
|
[:strong.flex-1 (or title name)]]])])])
|
|
|
|
[:article
|
|
[:div.panel-wrap
|
|
{:data-id focused}
|
|
(when-let [^js pl (and focused (= @*cache focused)
|
|
(plugin-handler/get-plugin-inst focused))]
|
|
(ui/catch-error
|
|
[:p.warning.text-lg.mt-5 "Settings schema Error!"]
|
|
(plugins-settings/settings-container
|
|
(bean/->clj (.-settingsSchema pl)) pl)))]]]]))
|
|
|
|
(rum/defc custom-js-installer
|
|
[{:keys [t current-repo db-restoring?]}]
|
|
(hooks/use-effect!
|
|
(fn []
|
|
(when (and (not db-restoring?)
|
|
(not util/nfs?))
|
|
(ui-handler/exec-js-if-exists-&-allowed! t)))
|
|
[current-repo db-restoring?])
|
|
nil)
|
|
|
|
(rum/defc perf-tip-content
|
|
[pid name url]
|
|
[:div
|
|
[:span.block.whitespace-normal
|
|
"This plugin "
|
|
[:strong.text-error "#" name]
|
|
" takes too long to load, affecting the application startup time and
|
|
potentially causing other plugins to fail to load."]
|
|
|
|
[:path.opacity-50
|
|
[:small [:span.pr-1 (ui/icon "folder")] url]]
|
|
|
|
[:p
|
|
(ui/button "Disable now"
|
|
:small? true
|
|
:on-click
|
|
(fn []
|
|
(-> (js/LSPluginCore.disable pid)
|
|
(p/then #(do
|
|
(notification/clear! pid)
|
|
(notification/show!
|
|
[:span "The plugin "
|
|
[:strong.text-error "#" name]
|
|
" is disabled."] :success
|
|
true nil 3000 nil)))
|
|
(p/catch #(js/console.error %)))))]])
|
|
|
|
(defn open-plugins-modal!
|
|
[]
|
|
(shui/dialog-open!
|
|
(plugins-page)
|
|
{:label :plugins-dashboard
|
|
:align :start}))
|
|
|
|
(defn open-waiting-updates-modal!
|
|
[]
|
|
(shui/dialog-open!
|
|
(fn []
|
|
(waiting-coming-updates))
|
|
{:center? true}))
|
|
|
|
(defn open-plugins-from-file-modal!
|
|
[plugins]
|
|
(shui/dialog-open!
|
|
(fn []
|
|
(plugins-from-file plugins))
|
|
{:id "ls-plugins-from-file-modal"}))
|
|
|
|
(defn open-focused-settings-modal!
|
|
[title]
|
|
(shui/dialog-open!
|
|
(fn []
|
|
[:div.settings-modal.of-plugins
|
|
(focused-settings-content title)])
|
|
{:label "plugin-settings-modal"
|
|
:align :start
|
|
:id "ls-focused-settings-modal"}))
|
|
|
|
(defn hook-custom-routes
|
|
[routes]
|
|
(cond-> routes
|
|
config/lsp-enabled?
|
|
(concat (some->> (plugin-handler/get-route-renderers)
|
|
(mapv (fn [custom-route]
|
|
(when-let [{:keys [name path render]} custom-route]
|
|
(when (not (string/blank? path))
|
|
[path {:name name :view (fn [r] (render r custom-route))}]))))
|
|
(remove nil?)))))
|
|
|
|
(defn hook-daemon-renderers
|
|
[]
|
|
(when-let [rs (seq (plugin-handler/get-daemon-renderers))]
|
|
[:div.lsp-daemon-container
|
|
(for [{:keys [key _pid render]} rs]
|
|
(when (fn? render)
|
|
[:div.lsp-daemon-container-card {:data-key key} (render)]))]))
|