浏览代码

Feat: sync progress for electron (#6662)

* fix: state shouldn't be returned in ipc

* feat: download && upload processing

* enhance(ui):  interaction of show password checkbox

* feat: time left

* feat: download progress

* Set download batch size to 100

* improve(ui): progress pane of file sync indicator

* improve(ui): progress pie of each file state

* improve(ui): progress pie of each file state

* improve(ui): progress pie of each downloading file

* fix: add last changed time

* enhance: time left

* fix: total needs to be larger than finished

* fix: wait for update-graphs-txid!

* enhance: show in-progress files first

* chore: ui polish

* improve(ui): persist stauts of sync files list toggle switch

* fix(ui): visibility of sync now button on mobile

* chore: remove ios static out after sync

* fix: debounce clicking on sync icon

* fix: repos not refreshed after unlink or delete

* enhance: automatically save page-metadata.edn to avoid sync when restart

* improve(ui): sync now shortcut for file sync progress pane

* enhance: data transfer icons

* fix: stop sync if switched to another graph

* fix: can't switch

* enhance: sort files first before uploading or downloading

* fix: clear current graph uuid when sync stops

* fix: separate progress by graphs

* fix: check files only in the current progress

* fix: prevent multiple sync managers for the same graph

* fix: remove redundant files watchers

* enhance(sync): re-exec remote->local-full-sync when exception

re-exec remote->local-full-sync when <update-local-files return exceptions

* enhance(sync): re-exec remote->local-full-sync when exception

re-exec remote->local-full-sync when <update-local-files return exceptions

* fix(sync): set-progress-callback, update rsapi

* fix(sync): uploading progress bar

Co-authored-by: Tienson Qin <[email protected]>
Co-authored-by: charlie <[email protected]>
Co-authored-by: rcmerci <[email protected]>
Andelf 3 年之前
父节点
当前提交
ae114afbd8

+ 5 - 0
gulpfile.js

@@ -111,6 +111,11 @@ const common = {
       })
     })
 
+    cp.execSync(`rm -rf ios/App/App/public/static/out`, {
+      stdio: 'inherit'
+    })
+
+
     cp.execSync(`npx cap run ${mode} --external`, {
       stdio: 'inherit',
       env: Object.assign(process.env, {

+ 1 - 1
package.json

@@ -87,7 +87,7 @@
         "@logseq/react-tweet-embed": "1.3.1-1",
         "@sentry/react": "^6.18.2",
         "@sentry/tracing": "^6.18.2",
-        "@tabler/icons": "1.54.0",
+        "@tabler/icons": "^1.96.0",
         "@tippyjs/react": "4.2.5",
         "aes-js": "3.1.2",
         "bignumber.js": "^9.0.2",

+ 8 - 1
resources/css/common.css

@@ -14,6 +14,9 @@
   --ls-left-sidebar-width: 246px;
   --ls-left-sidebar-sm-width: 70%;
   --ls-left-sidebar-nav-btn-size: 38px;
+  --ls-color-file-sync-error: #ff0000;
+  --ls-color-file-sync-pending: #ffbb4d;
+  --ls-color-file-sync-idle: #04b404;
 }
 
 @media (prefers-color-scheme: dark) {
@@ -85,6 +88,8 @@ html[data-theme='dark'] {
   --ls-search-icon-color: var(--ls-link-text-color);
   --ls-a-chosen-bg: var(--ls-secondary-background-color);
   --ls-right-sidebar-code-bg-color: #04303c;
+  --ls-pie-bg-color: #01303b;
+  --ls-pie-fg-color: #0b5869;
   --color-level-1: var(--ls-secondary-background-color);
   --color-level-2: var(--ls-tertiary-background-color);
   --color-level-3: var(--ls-quaternary-background-color);
@@ -145,6 +150,8 @@ html[data-theme='light'] {
   --ls-search-icon-color: var(--ls-icon-color);
   --ls-a-chosen-bg: #f7f7f7;
   --ls-right-sidebar-code-bg-color: var(--ls-secondary-background-color);
+  --ls-pie-bg-color: #e1e1e1;
+  --ls-pie-fg-color: #0a4a5d;
   --color-level-1: var(--ls-secondary-background-color);
   --color-level-2: var(--ls-tertiary-background-color);
   --color-level-3: var(--ls-quaternary-background-color);
@@ -902,7 +909,7 @@ button.menu:focus {
   background-color: var(--ls-menu-hover-color, #f4f5f7);
 }
 
-.menu-links-wrapper {
+.menu-links-wrapper, .menu-links-outer {
   @apply py-2 rounded-md shadow-lg overflow-y-auto;
 
   max-height: calc(100vh - 100px) !important;

+ 1 - 1
resources/package.json

@@ -37,7 +37,7 @@
     "https-proxy-agent": "5.0.0",
     "@sentry/electron": "2.5.1",
     "posthog-js": "1.10.2",
-    "@logseq/rsapi": "0.0.44",
+    "@logseq/rsapi": "0.0.46",
     "electron-deeplink": "1.0.10",
     "abort-controller": "3.0.0"
   },

+ 16 - 1
src/electron/electron/file_sync_rsapi.cljs

@@ -1,11 +1,17 @@
 (ns electron.file-sync-rsapi
-  (:require ["@logseq/rsapi" :as rsapi]))
+  (:require ["@logseq/rsapi" :as rsapi]
+            [electron.logger :as logger]
+            [electron.window :as window]
+            [cljs-bean.core :as bean]))
 
 (defn key-gen [] (rsapi/keygen))
 
 (defn set-env [graph-uuid env private-key public-key]
   (rsapi/setEnv graph-uuid env private-key public-key))
 
+(defn set-progress-callback [callback]
+  (rsapi/setProgressCallback callback))
+
 (defn get-local-files-meta [graph-uuid base-path file-paths]
   (rsapi/getLocalFilesMeta graph-uuid base-path (clj->js file-paths)))
 
@@ -41,3 +47,12 @@
 
 (defn decrypt-with-passphrase [passphrase data]
   (rsapi/ageDecryptWithPassphrase passphrase data))
+
+(defonce progress-notify-chan "file-sync-progress")
+(set-progress-callback (fn [error progress-info]
+                         (when-not error
+                           (doseq [^js win (window/get-all-windows)]
+                             (when-not (.isDestroyed win)
+                               (.. win -webContents
+                                   (send progress-notify-chan (bean/->js progress-info)))))
+                           (logger/info "sync progess" progress-info))))

+ 1 - 1
src/electron/electron/handler.cljs

@@ -67,7 +67,7 @@
                             (string/replace "\\" "_"))
             recycle-dir (str repo-dir "/logseq/.recycle")
             _           (fs-extra/ensureDirSync recycle-dir)
-            new-path    (str recycle-dir "/" file-name)] 
+            new-path    (str recycle-dir "/" file-name)]
         (fs/renameSync path new-path)
         (logger/debug ::unlink "recycle to" new-path))
       (catch :default e

+ 6 - 0
src/main/electron/listener.cljs

@@ -49,6 +49,12 @@
                          (when (file-sync-handler/enable-sync?)
                            (sync/file-watch-handler type payload)))))
 
+  (js/window.apis.on "file-sync-progress"
+                     (fn [data]
+                       (let [payload (bean/->clj data)]
+                         (state/set-state! [:file-sync/progress (:graphUUID payload) (:file payload)] payload)
+                         nil)))
+
   (js/window.apis.on "notification"
                      (fn [data]
                        (let [{:keys [type payload]} (bean/->clj data)

+ 5 - 4
src/main/frontend/components/encryption.cljs

@@ -54,11 +54,12 @@
 (rum/defc show-password-cp
   [*show-password?]
   [:div.flex.flex-row.items-center
-   [:label {:for "show-password"}
-    (ui/checkbox {:checked? @*show-password?
+   [:label.px-1 {:for "show-password"}
+    (ui/checkbox {:checked?  @*show-password?
                   :on-change (fn [e]
-                               (reset! *show-password? (util/echecked? e)))})]
-   [:span.text-sm.ml-1.opacity-80 "Show password"]])
+                               (reset! *show-password? (util/echecked? e)))
+                  :id        "show-password"})
+    [:span.text-sm.ml-1.opacity-80.select-none.px-1 "Show password"]]])
 
 (rum/defcs ^:large-vars/cleanup-todo input-password-inner < rum/reactive
   (rum/local "" ::password)

+ 307 - 110
src/main/frontend/components/file_sync.cljs

@@ -23,10 +23,14 @@
             [frontend.ui :as ui]
             [frontend.util :as util]
             [frontend.util.fs :as fs-util]
+            [frontend.storage :as storage]
             [logseq.graph-parser.config :as gp-config]
             [promesa.core :as p]
             [reitit.frontend.easy :as rfe]
-            [rum.core :as rum]))
+            [rum.core :as rum]
+            [cljs-time.core :as t]
+            [cljs-time.coerce :as tc]
+            [goog.functions :refer [debounce]]))
 
 (declare maybe-onboarding-show)
 (declare open-icloud-graph-clone-picker)
@@ -131,15 +135,15 @@
                 (when-let [GraphUUID (get (async/<! (file-sync-handler/create-graph graph-name)) 2)]
                   (async/<! (fs-sync/sync-start))
                   (state/set-state! [:ui/loading? :graph/create-remote?] false)
-                 ;; update existing repo
-                 (state/set-repos! (map (fn [r]
-                                          (if (= (:url r) repo)
-                                            (assoc r
-                                                   :GraphUUID GraphUUID
-                                                   :GraphName graph-name
-                                                   :remote? true)
-                                            r))
-                                     (state/get-repos))))))))]
+                  ;; update existing repo
+                  (state/set-repos! (map (fn [r]
+                                           (if (= (:url r) repo)
+                                             (assoc r
+                                                    :GraphUUID GraphUUID
+                                                    :GraphName graph-name
+                                                    :remote? true)
+                                             r))
+                                         (state/get-repos))))))))]
 
     [:div.cp__file-sync-related-normal-modal
      [:div.flex.justify-center.pb-4 [:span.icon-wrap (ui/icon "cloud-upload" {:size 20})]]
@@ -161,8 +165,156 @@
       (ui/button "Cancel" :background "gray" :class "opacity-50" :on-click close-fn)
       (ui/button "Create remote graph" :on-click on-confirm)]]))
 
-(rum/defcs ^:large-vars/cleanup-todo indicator < rum/reactive
-  < {:key-fn #(identity "file-sync-indicator")}
+(rum/defc indicator-progress-pie
+  [percentage]
+
+  (let [*el (rum/use-ref nil)]
+    (rum/use-effect!
+     #(when-let [^js el (rum/deref *el)]
+        (set! (.. el -style -backgroundImage)
+              (util/format "conic-gradient(var(--ls-pie-fg-color) %s%, var(--ls-pie-bg-color) %s%)" percentage percentage)))
+     [percentage])
+    [:span.cp__file-sync-indicator-progress-pie {:ref *el}]))
+
+(rum/defc last-synced-cp < rum/reactive
+  []
+  (let [last-synced-at (state/sub [:file-sync/last-synced-at (state/get-current-repo)])
+        last-synced-at (if last-synced-at
+                         (util/time-ago (tc/from-long (* last-synced-at 1000)))
+                         "just now")]
+    [:div.cl
+     [:span.opacity-60 "Last change was"]
+     [:span.pl-1 last-synced-at]]))
+
+(rum/defc sync-now
+  []
+  (ui/button "Sync now"
+             :class "block cursor-pointer"
+             :small? true
+             :on-click #(async/offer! fs-sync/immediately-local->remote-chan true)))
+
+(def *last-calculated-time (atom nil))
+
+(rum/defc ^:large-vars/cleanup-todo indicator-progress-pane
+  [sync-state sync-progress
+   {:keys [idle? syncing? no-active-files? online? history-files? queuing?]}]
+
+  (rum/use-effect!
+   (fn []
+     #(reset! *last-calculated-time nil))
+   [])
+
+  (let [uploading-files        (:current-local->remote-files sync-state)
+        downloading-files      (:current-remote->local-files sync-state)
+        uploading?             (seq uploading-files)
+        downloading?           (seq downloading-files)
+
+        progressing?           (or uploading? downloading?)
+
+        full-upload-files      (:full-local->remote-files sync-state)
+        full-download-files    (:full-remote->local-files sync-state)
+        calc-progress-total    #(cond
+                                  uploading? (count full-upload-files)
+                                  downloading? (count full-download-files)
+                                  :else 0)
+        calc-progress-finished (fn []
+                                 (let [current-sync-files (set
+                                                           (->> (or (seq full-upload-files) (seq full-download-files))
+                                                                (map :path)))]
+                                   (count (filter #(and (= (:percent (second %)) 100)
+                                                        (contains? current-sync-files (first %))) sync-progress))))
+        calc-time-left         (fn [] (let [last-calculated-at (:calculated-at @*last-calculated-time)
+                                            now                (tc/to-epoch (t/now))]
+                                        (if (and last-calculated-at (< (- now last-calculated-at) 10))
+                                          (:result @*last-calculated-time)
+                                          (let [result (file-sync-handler/calculate-time-left sync-state sync-progress)]
+                                            (reset! *last-calculated-time {:calculated-at now
+                                                                           :result        result})
+                                            result))))
+
+        p-total                (if syncing? (calc-progress-total) 0)
+        p-finished             (if syncing? (calc-progress-finished) 0)
+        tip-b&p                (if (and syncing? progressing?)
+                                 [[:span (util/format "%s of %s files" p-finished p-total)]
+                                  [:div.progress-bar [:i {:style
+                                                          {:width (str (if (> p-total 0)
+                                                                         (* (/ p-finished p-total) 100) 0) "%")}}]]]
+                                 [[:span.opacity-60 "all file edits"]
+                                  (last-synced-cp)])
+        *el-ref                (rum/use-ref nil)
+        [list-active?, set-list-active?] (rum/use-state
+                                          (-> (storage/get :ui/file-sync-active-file-list?)
+                                              (#(if (nil? %) true %))))]
+
+    (rum/use-effect!
+     (fn []
+       (when-let [^js outer-class-list
+                  (some-> (rum/deref *el-ref)
+                          (.closest ".menu-links-outer")
+                          (.-classList))]
+         (->> "is-list-active"
+              (#(if list-active?
+                  (.add outer-class-list %)
+                  (.remove outer-class-list %))))
+         (storage/set :ui/file-sync-active-file-list? list-active?)))
+     [list-active?])
+
+    [:div.cp__file-sync-indicator-progress-pane
+     {:ref *el-ref
+      :class (when (and syncing? progressing?) "is-progress-active")}
+     (let [idle-&-no-active? (and idle? no-active-files?)]
+       [:div.a
+        [:div.al
+         [:strong
+          {:class (when idle-&-no-active? "is-no-active")}
+          (cond
+            (not online?) (ui/icon "wifi-off")
+            uploading? (ui/icon "arrow-up")
+            downloading? (ui/icon "arrow-down")
+            :else (ui/icon "thumb-up"))]
+         [:span
+          (cond
+            (not online?) "Currently having connection issues..."
+            idle-&-no-active? "Everything is synced!"
+            syncing? "Currently syncing your graph..."
+            :else "Waiting..."
+            )]]
+        [:div.ar
+         (when queuing? (sync-now))]])
+
+     [:div.b.dark:text-gray-200
+      [:div.bl
+       [:span.flex.items-center
+        (if no-active-files?
+          [:span.opacity-100.pr-1 "Successfully processed"]
+          [:span.opacity-60.pr-1 "Processed"])]
+
+       (first tip-b&p)]
+
+      [:div.br
+       [:small.opacity-50
+        (when syncing?
+          (calc-time-left))]]]
+
+     [:div.c
+      (second tip-b&p)
+      (when (or history-files? (not no-active-files?))
+        [:span.inline-flex.ml-1.active:opacity-50
+         {:on-click #(set-list-active? (not list-active?))}
+         (if list-active?
+           (ui/icon "chevron-up" {:style {:font-size 24}})
+           (ui/icon "chevron-left" {:style {:font-size 24}}))])]]))
+
+(defn- sort-files
+  [progress files]
+  (sort-by (fn [f]
+             (let [percent (or (:percent (get progress f)) 0)]
+               (if (= percent 100) -1 percent)))
+           > files))
+
+(rum/defcs ^:large-vars/cleanup-todo indicator <
+  rum/reactive
+  {:key-fn #(identity "file-sync-indicator")}
   {:will-mount   (fn [state]
                    (let [unsub-fn (file-sync-handler/setup-file-sync-event-listeners)]
                      (assoc state ::unsub-events unsub-fn)))
@@ -170,77 +322,89 @@
                    (apply (::unsub-events state) nil)
                    state)}
   [_state]
-  (let [_                      (state/sub :auth/id-token)
-        current-repo           (state/get-current-repo)
-        creating-remote-graph? (state/sub [:ui/loading? :graph/create-remote?])
-        sync-state             (state/sub [:file-sync/sync-state current-repo])
-        _                      (rum/react file-sync-handler/refresh-file-sync-component)
-        synced-file-graph?     (file-sync-handler/synced-file-graph? current-repo)
-        uploading-files        (:current-local->remote-files sync-state)
-        downloading-files      (:current-remote->local-files sync-state)
-        queuing-files          (:queued-local->remote-files sync-state)
-
-        status                 (:state sync-state)
-        status                 (or (nil? status) (keyword (name status)))
-        off?                   (fs-sync/sync-off? sync-state)
-        full-syncing?          (contains? #{:local->remote-full-sync :remote->local-full-sync} status)
-        syncing?               (or full-syncing? (contains? #{:local->remote :remote->local} status))
-        idle?                  (contains? #{:idle} status)
-        need-password?         (and (contains? #{:need-password} status)
-                                    (not (fs-sync/graph-encrypted?)))
-        queuing?               (and idle? (boolean (seq queuing-files)))
-        no-active-files?       (empty? (concat downloading-files queuing-files uploading-files))
-        create-remote-graph-fn #(when (and current-repo (not (config/demo-graph? current-repo)))
-                                  (let [graph-name
-                                        (js/decodeURI (util/node-path.basename current-repo))
-
-                                        confirm-fn
-                                        (fn [close-fn]
-                                          (create-remote-graph-panel current-repo graph-name close-fn))]
-
-                                    (state/set-modal! confirm-fn {:center? true :close-btn? false})))
-        turn-on                (fn []
-                                 (when-not (file-sync-handler/current-graph-sync-on?)
-                                   (async/go
-                                     (async/<! (p->c (persist-var/-load fs-sync/graphs-txid)))
-                                     (cond
-                                       @*beta-unavailable?
-                                       (state/pub-event! [:file-sync/onboarding-tip :unavailable])
-
-                                       ;; current graph belong to other user, do nothing
-                                       (and (first @fs-sync/graphs-txid)
-                                            (not (fs-sync/check-graph-belong-to-current-user (user-handler/user-uuid)
-                                                                                             (first @fs-sync/graphs-txid))))
-                                       nil
-
-                                       (and synced-file-graph?
-                                            (fs-sync/graph-sync-off? current-repo)
-                                            (second @fs-sync/graphs-txid)
-                                            (async/<! (fs-sync/<check-remote-graph-exists (second @fs-sync/graphs-txid))))
-                                       (fs-sync/sync-start)
-
-                                       ;; remote graph already has been deleted, clear repos first, then create-remote-graph
-                                       synced-file-graph?      ; <check-remote-graph-exists -> false
-                                       (do (state/set-repos!
-                                            (map (fn [r]
-                                                   (if (= (:url r) current-repo)
-                                                     (dissoc r :GraphUUID :GraphName :remote?)
-                                                     r))
-                                              (state/get-repos)))
-                                           (create-remote-graph-fn))
-
-                                       (second @fs-sync/graphs-txid) ; sync not started yet
-                                       nil
-
-                                       :else
-                                       (create-remote-graph-fn)))))]
-
+  (let [_                       (state/sub :auth/id-token)
+        online?                 (state/sub :network/online?)
+        enabled-progress-panel? (util/electron?)
+        current-repo            (state/get-current-repo)
+        creating-remote-graph?  (state/sub [:ui/loading? :graph/create-remote?])
+        sync-state              (state/sub [:file-sync/sync-state current-repo])
+        sync-progress           (state/sub [:file-sync/progress (second @fs-sync/graphs-txid)])
+        _                       (rum/react file-sync-handler/refresh-file-sync-component)
+        synced-file-graph?      (file-sync-handler/synced-file-graph? current-repo)
+        uploading-files         (sort-files sync-progress (:current-local->remote-files sync-state))
+        downloading-files       (sort-files sync-progress (:current-remote->local-files sync-state))
+        queuing-files           (:queued-local->remote-files sync-state)
+        history-files           (:history sync-state)
+        status                  (:state sync-state)
+        status                  (or (nil? status) (keyword (name status)))
+        off?                    (fs-sync/sync-off? sync-state)
+        full-syncing?           (contains? #{:local->remote-full-sync :remote->local-full-sync} status)
+        syncing?                (or full-syncing? (contains? #{:local->remote :remote->local} status))
+        idle?                   (contains? #{:idle} status)
+        need-password?          (and (contains? #{:need-password} status)
+                                     (not (fs-sync/graph-encrypted?)))
+        queuing?                (and idle? (boolean (seq queuing-files)))
+        no-active-files?        (empty? (concat downloading-files queuing-files uploading-files))
+        create-remote-graph-fn  #(when (and current-repo (not (config/demo-graph? current-repo)))
+                                   (let [graph-name
+                                         (js/decodeURI (util/node-path.basename current-repo))
+
+                                         confirm-fn
+                                         (fn [close-fn]
+                                           (create-remote-graph-panel current-repo graph-name close-fn))]
+
+                                     (state/set-modal! confirm-fn {:center? true :close-btn? false})))
+        turn-on                 (->
+                                 (fn []
+                                   (when-not (file-sync-handler/current-graph-sync-on?)
+                                     (async/go
+                                       (async/<! (p->c (persist-var/-load fs-sync/graphs-txid)))
+                                       (cond
+                                         @*beta-unavailable?
+                                         (state/pub-event! [:file-sync/onboarding-tip :unavailable])
+
+                                         ;; current graph belong to other user, do nothing
+                                         (and (first @fs-sync/graphs-txid)
+                                              (not (fs-sync/check-graph-belong-to-current-user (user-handler/user-uuid)
+                                                                                               (first @fs-sync/graphs-txid))))
+                                         nil
+
+                                         (and synced-file-graph?
+                                              (fs-sync/graph-sync-off? current-repo)
+                                              (second @fs-sync/graphs-txid)
+                                              (async/<! (fs-sync/<check-remote-graph-exists (second @fs-sync/graphs-txid))))
+                                         (do
+                                           (prn "sync start")
+                                           (fs-sync/sync-start))
+
+                                         ;; remote graph already has been deleted, clear repos first, then create-remote-graph
+                                         synced-file-graph?  ; <check-remote-graph-exists -> false
+                                         (do (state/set-repos!
+                                              (map (fn [r]
+                                                     (if (= (:url r) current-repo)
+                                                       (dissoc r :GraphUUID :GraphName :remote?)
+                                                       r))
+                                                (state/get-repos)))
+                                             (create-remote-graph-fn))
+
+                                         (second @fs-sync/graphs-txid) ; sync not started yet
+                                         nil
+
+                                         :else
+                                         (create-remote-graph-fn)))))
+                                 (debounce 1500))]
     (if creating-remote-graph?
       (ui/loading "")
       [:div.cp__file-sync-indicator
+       {:class (util/classnames
+                [{:is-enabled-progress-pane enabled-progress-panel?
+                  :has-active-files         (not no-active-files?)}
+                 (str "status-of-" (and (keyword? status) (name status)))])}
        (when (and (not config/publishing?)
                   (user-handler/logged-in?))
+
          (ui/dropdown-with-links
+          ;; trigger
           (fn [{:keys [toggle-fn]}]
             (if (not off?)
               [:a.button.cloud.on
@@ -256,18 +420,24 @@
                {:on-click turn-on}
                (ui/icon "cloud-off" {:size ui/icon-size})]))
 
+          ;; links
           (cond-> []
             synced-file-graph?
             (concat
              (if (and no-active-files? idle?)
-               [{:item [:div.flex.justify-center.w-full.py-2
-                        [:span.opacity-60 "Everything is synced!"]]
-                 :as-link? false}]
-               (if need-password?
-                 [{:title   [:div.file-item.flex
-                             (ui/icon "lock")
-                             [:span.pl-1 "Password is required"]]
+               [(when-not (util/electron?)
+                  {:item     [:div.flex.justify-center.w-full.py-2
+                              [:span.opacity-60 "Everything is synced!"]]
+                   :as-link? false})]
+
+               (cond
+                 need-password?
+                 [{:title   [:div.file-item
+                             (ui/icon "lock") "Password is required"]
                    :options {:on-click fs-sync/sync-need-password!}}]
+
+                 ;; head of upcoming sync
+                 (not no-active-files?)
                  [{:title   [:div.file-item.is-first ""]
                    :options {:class "is-first-placeholder"}}]))
 
@@ -275,9 +445,18 @@
                                    {:key (str "downloading-" f)}
                                    (js/decodeURIComponent f)]
                            :key   (str "downloading-" f)
-                           :icon  (ui/icon "arrow-narrow-down")}) downloading-files)
+                           :icon  (if enabled-progress-panel?
+                                    (let [progress (get sync-progress f)
+                                          percent (or (:percent progress) 0)]
+                                      (if (and (number? percent)
+                                               (< percent 100))
+                                        (indicator-progress-pie percent)
+                                        (ui/icon "circle-check")))
+                                    (ui/icon "arrow-narrow-down"))
+                           }) downloading-files)
+
              (map (fn [e] (let [icon (case (.-type e)
-                                       "add"    "plus"
+                                       "add" "plus"
                                        "unlink" "minus"
                                        "edit")
                                 path (fs-sync/relative-path e)]
@@ -286,40 +465,60 @@
                                      (js/decodeURIComponent path)]
                              :key   (str "queue-" path)
                              :icon  (ui/icon icon)})) (take 10 queuing-files))
+
              (map (fn [f] {:title [:div.file-item
                                    {:key (str "uploading-" f)}
                                    (js/decodeURIComponent f)]
                            :key   (str "uploading-" f)
-                           :icon  (ui/icon "arrow-up")}) uploading-files)
-
-             (when sync-state
+                           :icon  (if enabled-progress-panel?
+                                    (let [progress (get sync-progress f)
+                                          percent (or (:percent progress) 0)]
+                                      (if (and (number? percent)
+                                               (< percent 100))
+                                        (indicator-progress-pie percent)
+                                        (ui/icon "circle-check")))
+                                    (ui/icon "arrow-up"))
+                           }) uploading-files)
+
+             (when (seq history-files)
                (map-indexed (fn [i f] (:time f)
-                              (let [path       (:path f)
-                                    ext        (string/lower-case (util/get-file-ext path))
+                              (let [path        (:path f)
+                                    ext         (string/lower-case (util/get-file-ext path))
                                     _supported? (gp-config/mldoc-support? ext)
-                                    full-path  (util/node-path.join (config/get-repo-dir current-repo) path)
-                                    page-name  (db/get-file-page full-path)]
+                                    full-path   (util/node-path.join (config/get-repo-dir current-repo) path)
+                                    page-name   (db/get-file-page full-path)]
                                 {:title [:div.files-history.cursor-pointer
-                                         {:key i :class (when (= i 0) "is-first")
+                                         {:key      i :class (when (= i 0) "is-first")
                                           :on-click (fn []
                                                       (if page-name
                                                         (rfe/push-state :page {:name page-name})
                                                         (rfe/push-state :file {:path full-path})))}
                                          [:span.file-sync-item (js/decodeURIComponent (:path f))]
                                          [:div.opacity-50 (ui/humanity-time-ago (:time f) nil)]]}))
-                            (take 10 (:history sync-state))))))
+                            (take 10 history-files)))))
 
-          {:links-header
+          ;; options
+          {:outer-header
            [:<>
-            (when (and synced-file-graph? queuing?)
-              [:div.head-ctls
-               (ui/button "Sync now"
-                          :class "block cursor-pointer"
-                          :small? true
-                          :on-click #(async/offer! fs-sync/immediately-local->remote-chan true))])
-
-                                        ;(when config/dev?
-                                        ;  [:strong.debug-status (str status)])
+            (when (util/electron?)
+              (indicator-progress-pane
+               sync-state sync-progress
+               {:idle?            idle?
+                :syncing?         syncing?
+                :need-password?   need-password?
+                :full-sync?       full-syncing?
+                :online?          online?
+                :queuing?         queuing?
+                :no-active-files? no-active-files?
+                :history-files?   (seq history-files)}))
+
+            (when (and
+                   (not enabled-progress-panel?)
+                   synced-file-graph? queuing?)
+              [:div.head-ctls (sync-now)])
+
+            ;(when config/dev?
+            ;  [:strong.debug-status (str status)])
             ]}))])))
 
 (rum/defc pick-local-graph-for-sync [graph]
@@ -369,9 +568,7 @@
                                                          (nil? (second info))
                                                          (not= (second info) (:GraphUUID graph))))
                                             (if (js/confirm "This directory is not empty, are you sure to sync the remote graph to it? Make sure to back up the directory first.")
-                                              (do
-                                                (state/set-state! :graph/remote-binding? true)
-                                                (p/resolved nil))
+                                              (p/resolved nil)
                                               (throw (js/Error. nil)))))))
 
                             ;; cancel pick a directory
@@ -416,7 +613,7 @@
        [:div.p-4 (ui/loading "Loading...")]
        (for [version version-files]
          (let [version-uuid (get-version-key version)
-               local?      (some? (:relative-path version))]
+               local?       (some? (:relative-path version))]
            [:div.version-list-item {:key version-uuid}
             [:a.item-link.block.fade-link.flex.justify-between
              {:title    version-uuid
@@ -551,7 +748,7 @@
                  :disabled loading?
                  :on-click (fn []
                              (set-loading? true)
-                             (let [result (:user/info @state/state)
+                             (let [result  (:user/info @state/state)
                                    ex-time (:ExpireTime result)]
                                (if (and (number? ex-time)
                                         (< (* ex-time 1000) (js/Date.now)))

+ 145 - 14
src/main/frontend/components/file_sync.css

@@ -1,9 +1,5 @@
 .cp__file-sync {
   &-indicator {
-    --ls-color-file-sync-error: #ff0000;
-    --ls-color-file-sync-pending: #ffbb4d;
-    --ls-color-file-sync-idle: #04b404;
-
     a.cloud {
       position: relative;
       opacity: 1 !important;
@@ -68,12 +64,14 @@
       width: 90vw;
       position: fixed;
       right: 5vw;
-      border-radius: 4px;
 
       .head-ctls {
         position: absolute;
-        top: 2px;
-        right: 5px;
+        right: 10px;
+        transform: translateY(8px);
+      }
+
+      .cp__file-sync-indicator-progress-pane {
       }
 
       @screen md {
@@ -101,20 +99,21 @@
           padding: 0 !important;
           user-select: none;
           pointer-events: none;
+          margin-top: 5px;
 
           &:hover {
             background-color: unset !important;
           }
         }
-      }
 
-      i.ti {
-        transform: translate(0);
-        margin-right: 5px;
+        i.ti {
+          transform: translate(0);
+          margin-right: 5px;
+        }
       }
 
       .files-history.is-first, .file-item.is-first {
-        margin-top: 30px;
+        margin-top: 40px;
         position: relative;
 
         &:before {
@@ -142,13 +141,133 @@
       }
     }
 
+    .menu-links-outer {
+      @apply py-0;
+    }
+
     .menu-links-wrapper {
       padding: 4px 0;
-      max-height: 60vh !important;
+      max-height: calc(70vh - 120px) !important;
       overflow-y: auto;
+
+      .title-wrap {
+        flex: 1;
+      }
+    }
+
+    &.is-enabled-progress-pane {
+      .head-ctls {
+        display: none;
+      }
+
+      .menu-links-outer {
+        &.is-list-active {
+          .menu-links-wrapper, .head-ctls {
+            display: block !important;
+          }
+
+          .menu-links-wrapper {
+            overflow-y: auto;
+          }
+        }
+      }
+
+      .menu-links-wrapper {
+        @apply p-0 hidden overflow-hidden;
+
+        > .menu-link:last-child {
+          padding-bottom: 16px;
+        }
+      }
     }
   }
 
+  &-indicator-progress-pane {
+    @apply px-4 pt-4 pb-3 w-full overflow-hidden;
+
+    -webkit-font-smoothing: antialiased;
+    background-color: var(--ls-quaternary-background-color);
+
+    .ti {
+      @apply translate-y-0;
+    }
+
+    > .a {
+      @apply flex justify-between text-gray-200;
+
+      > .al {
+        @apply flex text-base flex-1 w-0.5 items-center space-x-2 font-semibold;
+
+        > strong {
+          @apply flex items-center justify-center font-extralight text-gray-800;
+
+          background-color: var(--ls-color-file-sync-pending);
+          border-radius: 4px;
+          height: 24px;
+          width: 24px;
+
+          &.is-no-active {
+            background-color: var(--ls-color-file-sync-idle);
+          }
+        }
+
+        > span {
+          @apply w-full overflow-hidden overflow-ellipsis pr-2;
+        }
+      }
+
+      > .ar {
+        @apply relative;
+
+        top: -4px;
+      }
+    }
+
+    > .b {
+      @apply flex items-center justify-between pt-2 pb-3 text-sm;
+
+      .bl {
+        @apply flex items-center leading-none pt-2;
+      }
+    }
+
+    > .c {
+      @apply flex items-center text-gray-200 relative;
+
+      top: -3px;
+
+      .cl {
+        @apply text-sm leading-6 flex flex-1 items-center overflow-hidden;
+      }
+
+      .progress-bar {
+        @apply flex flex-1 items-center overflow-hidden relative;
+
+        height: 7px;
+        top: 1px;
+        border-radius: 4px;
+        background-color: var(--ls-secondary-background-color);
+
+        i {
+          transition: width .5s;
+          height: 100%;
+          width: 0;
+          border-radius: 4px;
+          background-color: var(--ls-link-text-color);
+        }
+      }
+    }
+  }
+
+  &-indicator-progress-pie {
+    width: 16px;
+    height: 16px;
+    border-radius: 50%;
+    display: inline-block;
+    margin-right: 2px;
+    margin-bottom: -1px;
+  }
+
   &-page-histories {
     @apply flex;
 
@@ -463,6 +582,18 @@ html[data-theme='light'] {
         background-color: #e1e1e1;
       }
     }
+
+    &-indicator-progress-pane {
+      background-color: var(--ls-tertiary-background-color);
+
+      > .a, > .c {
+        @apply text-gray-700;
+      }
+
+      .progress-bar {
+        background-color: var(--ls-quaternary-background-color);
+      }
+    }
   }
 }
 
@@ -474,4 +605,4 @@ html:not(.is-electron) {
       }
     }
   }
-}
+}

+ 336 - 251
src/main/frontend/fs/sync.cljs

@@ -27,7 +27,8 @@
             [frontend.encrypt :as encrypt]
             [medley.core :refer [dedupe-by]]
             [rum.core :as rum]
-            [promesa.core :as p]))
+            [promesa.core :as p]
+            [lambdaisland.glogi :as log]))
 
 ;;; ### Commentary
 ;; file-sync related local files/dirs:
@@ -143,6 +144,7 @@
 (s/def ::event #{:created-local-version-file
                  :finished-local->remote
                  :finished-remote->local
+                 :start
                  :pause
                  :resume
                  :exception-decrypt-failed
@@ -152,6 +154,10 @@
 
 (s/def ::sync-event (s/keys :req-un [::event ::data]))
 
+(defonce download-batch-size 100)
+(defonce upload-batch-size 20)
+(def ^:private current-sm-graph-uuid (atom nil))
+
 ;;; ### configs in config.edn
 ;; - :file-sync/ignore-files
 
@@ -178,13 +184,14 @@
 (def graphs-txid (persist-var/persist-var nil "graphs-txid"))
 
 (declare assert-local-txid<=remote-txid)
-(defn update-graphs-txid!
+(defn <update-graphs-txid!
   [latest-txid graph-uuid user-uuid repo]
   {:pre [(int? latest-txid) (>= latest-txid 0)]}
-  (persist-var/-reset-value! graphs-txid [user-uuid graph-uuid latest-txid] repo)
-  (p/let [_ (persist-var/persist-save graphs-txid)]
-    (state/pub-event! [:graph/refresh]))
-  (when (state/developer-mode?) (assert-local-txid<=remote-txid)))
+  (-> (p/let [_ (persist-var/-reset-value! graphs-txid [user-uuid graph-uuid latest-txid] repo)
+              _ (persist-var/persist-save graphs-txid)]
+        (state/pub-event! [:graph/refresh])
+        (when (state/developer-mode?) (assert-local-txid<=remote-txid)))
+      p->c))
 
 (defn clear-graphs-txid! [repo]
   (persist-var/-reset-value! graphs-txid nil repo)
@@ -592,10 +599,7 @@
   FileTxn
   (-checksum [this] (.-checksum this)))
 
-
-
-
-(defn- sort-file-metatdata-fn
+(defn- sort-file-metadata-fn
   ":recent-days-range > :favorite-pages > small-size pages > ...
   :recent-days-range : [<min-inst-ms> <max-inst-ms>]
 "
@@ -605,7 +609,8 @@
   (let [favorite-pages* (set favorite-pages)]
     (fn [^FileMetadata item]
       (let [path (relative-path item)
-            journal? (string/starts-with? path "journals/")
+            journal? (string/starts-with? path
+                                          (str (config/get-journals-directory) "/"))
             journal-day
             (when journal?
               (try
@@ -623,11 +628,18 @@
                    (second recent-days-range)))
           journal-day
 
+          (string/includes? path "logseq/")
+          9999
+
+          (string/includes? path "content.")
+          10000
+
           (contains? favorite-pages* path)
           (count path)
 
           :else
           (- (.-size item)))))))
+
 ;;; ### APIs
 ;; `RSAPI` call apis through rsapi package, supports operations on files
 
@@ -752,10 +764,8 @@
   (<update-local-files [this graph-uuid base-path filepaths]
     (println "update-local-files" graph-uuid base-path filepaths)
     (go
-      (let [token (<! (<get-token this))
-            r (<! (<retry-rsapi
-                   #(p->c (ipc/ipc "update-local-files" graph-uuid base-path filepaths token))))]
-        r)))
+      (let [token (<! (<get-token this))]
+        (<! (p->c (ipc/ipc "update-local-files" graph-uuid base-path filepaths token))))))
   (<download-version-files [this graph-uuid base-path filepaths]
     (go
       (let [token (<! (<get-token this))
@@ -847,13 +857,11 @@
 
   (<update-local-files [this graph-uuid base-path filepaths]
     (go
-      (let [token (<! (<get-token this))
-            r (<! (<retry-rsapi
-                   #(p->c (.updateLocalFiles mobile-util/file-sync (clj->js {:graphUUID graph-uuid
-                                                                             :basePath base-path
-                                                                             :filePaths filepaths
-                                                                             :token token})))))]
-        r)))
+      (let [token (<! (<get-token this))]
+        (<! (p->c (.updateLocalFiles mobile-util/file-sync (clj->js {:graphUUID graph-uuid
+                                                                     :basePath base-path
+                                                                     :filePaths filepaths
+                                                                     :token token})))))))
 
   (<download-version-files [this graph-uuid base-path filepaths]
     (go
@@ -1099,6 +1107,7 @@
                             (map
                              #(hash-map :checksum (:checksum %)
                                         :encrypted-path (remove-user-graph-uuid-prefix (:Key %))
+                                        :size (:Size %)
                                         :last-modified (:LastModified %))
                              objs))
                      (when-not (empty? next-continuation-token)
@@ -1113,7 +1122,7 @@
               (let [encrypted-path->path-map (zipmap encrypted-path-list* path-list-or-exp)]
                 (set
                  (mapv
-                  #(->FileMetadata nil
+                  #(->FileMetadata (:size %)
                                    (:checksum %)
                                    (get encrypted-path->path-map (:encrypted-path %))
                                    (:encrypted-path %)
@@ -1444,7 +1453,7 @@
               r)))))))
 
 (defn apply-filetxns-partitions
-  "won't call update-graphs-txid! when *txid is nil"
+  "won't call <update-graphs-txid! when *txid is nil"
   [*sync-state user-uuid graph-uuid base-path filetxns-partitions repo *txid *stopped *paused]
   (assert (some? *sync-state))
 
@@ -1476,7 +1485,7 @@
               ;; update local-txid
               (when *txid
                 (reset! *txid latest-txid)
-                (update-graphs-txid! latest-txid graph-uuid user-uuid repo))
+                (<! (<update-graphs-txid! latest-txid graph-uuid user-uuid repo)))
               (recur (next filetxns-partitions*)))))))))
 
 (defmulti need-sync-remote? (fn [v] (cond
@@ -1542,6 +1551,17 @@
            :path path
            :checksum checksum}))
 
+  ILookup
+  (-lookup [o k] (-lookup o k nil))
+  (-lookup [_ k not-found]
+    (case k
+      :type type
+      :dir  dir
+      :path path
+      :stat stat
+      :checksum checksum
+      not-found))
+
   IPrintWithWriter
   (-pr-writer [_ w _opts]
     (write-all w (str {:type type :base-path dir :path path :size (:size stat) :checksum checksum}))))
@@ -1947,7 +1967,9 @@
   {:post [(s/valid? ::sync-state %)]}
   {:current-syncing-graph-uuid  nil
    :state                       ::starting
+   :full-local->remote-files    #{}
    :current-local->remote-files #{}
+   :full-remote->local-files    #{}
    :current-remote->local-files #{}
    :queued-local->remote-files  #{}
    :recent-remote->local-files  #{}
@@ -2006,6 +2028,16 @@
   {:post [(s/valid? ::sync-state %)]}
   (update sync-state :recent-remote->local-files set/difference items))
 
+(defn sync-state-reset-full-local->remote-files
+  [sync-state events]
+  {:post [(s/valid? ::sync-state %)]}
+  (assoc sync-state :full-local->remote-files events))
+
+(defn sync-state-reset-full-remote->local-files
+  [sync-state events]
+  {:post [(s/valid? ::sync-state %)]}
+  (assoc sync-state :full-remote->local-files events))
+
 (defn- add-history-items
   [history paths now]
   (sequence
@@ -2072,20 +2104,26 @@
     (go
       (let [partitioned-filetxns
             (sequence (filepath+checksum-coll->partitioned-filetxns
-                       10 graph-uuid user-uuid)
+                       download-batch-size graph-uuid user-uuid)
                       relative-filepath+checksum-coll)
             r
             (if (empty? (flatten partitioned-filetxns))
               {:succ true}
-              (<! (apply-filetxns-partitions
-                   *sync-state user-uuid graph-uuid base-path partitioned-filetxns repo
-                   nil *stopped *paused)))]
+              (do
+                (put-sync-event! {:event :start
+                                  :data  {:type :full-remote->local
+                                          :graph-uuid graph-uuid
+                                          :full-sync? true
+                                          :epoch      (tc/to-epoch (t/now))}})
+                (<! (apply-filetxns-partitions
+                    *sync-state user-uuid graph-uuid base-path partitioned-filetxns repo
+                    nil *stopped *paused))))]
         (cond
           (instance? ExceptionInfo r) {:unknown r}
           @*stopped                   {:stop true}
           @*paused                    {:pause true}
           :else
-          (do (update-graphs-txid! latest-txid graph-uuid user-uuid repo)
+          (do (<! (<update-graphs-txid! latest-txid graph-uuid user-uuid repo))
               (reset! *txid latest-txid)
               {:succ true})))))
 
@@ -2103,12 +2141,17 @@
                         {:need-remote->local-full-sync true})
 
                     (when (pos-int? latest-txid)
-                      (let [partitioned-filetxns (transduce (diffs->partitioned-filetxns 10)
+                      (let [partitioned-filetxns (transduce (diffs->partitioned-filetxns download-batch-size)
                                                             (completing (fn [r i] (conj r (reverse i)))) ;reverse
                                                             '()
                                                             (reverse diff-txns))]
+                        (put-sync-event! {:event :start
+                                          :data  {:type :remote->local
+                                                  :graph-uuid graph-uuid
+                                                  :full-sync? false
+                                                  :epoch      (tc/to-epoch (t/now))}})
                         (if (empty? (flatten partitioned-filetxns))
-                          (do (update-graphs-txid! latest-txid graph-uuid user-uuid repo)
+                          (do (<! (<update-graphs-txid! latest-txid graph-uuid user-uuid repo))
                               (reset! *txid latest-txid)
                               {:succ true})
                           (<! (apply-filetxns-partitions
@@ -2140,9 +2183,10 @@
                 recent-10-days-range  ((juxt #(tc/to-long (t/minus % (t/days 10))) #(tc/to-long %)) (t/today))
                 sorted-diff-remote-files
                 (sort-by
-                 (sort-file-metatdata-fn :recent-days-range recent-10-days-range) > diff-remote-files)
+                 (sort-file-metadata-fn :recent-days-range recent-10-days-range) > diff-remote-files)
                 latest-txid           (:TXId (<! (<get-remote-graph remoteapi nil graph-uuid)))]
             (println "[full-sync(remote->local)]" (count sorted-diff-remote-files) "files need to sync")
+            (swap! *sync-state #(sync-state-reset-full-remote->local-files % sorted-diff-remote-files))
             (<! (.sync-files-remote->local!
                  this (map (juxt relative-path -checksum)
                            sorted-diff-remote-files)
@@ -2240,188 +2284,193 @@
                          ^:mutable rate *txid ^:mutable remote->local-syncer stop-chan *stopped *paused
                          ;; control chans
                          private-immediately-local->remote-chan private-recent-edited-chan]
-    Object
-    (filter-file-change-events-fn [_]
-      (fn [^FileChangeEvent e]
-        (go (and (instance? FileChangeEvent e)
-                 (if-let [mtime (:mtime (.-stat e))]
-                   ;; if mtime is not nil, it should be after (- now 1min)
-                   ;; ignore events too early
-                   (> (* 1000 mtime) (tc/to-long (t/minus (t/now) (t/minutes 1))))
-                   true)
-                 (or (string/starts-with? (.-dir e) base-path)
-                     (string/starts-with? (str "file://" (.-dir e)) base-path)) ; valid path prefix
-                 (not (ignored? e))     ;not ignored
-                 ;; download files will also trigger file-change-events, ignore them
-                 (let [r (not (contains? (:recent-remote->local-files @*sync-state)
-                                         (<! (<file-change-event=>recent-remote->local-file-item
-                                              graph-uuid e))))]
-                   (when (and (true? r)
-                              (seq (:recent-remote->local-files @*sync-state)))
-                     (println :debug (:recent-remote->local-files @*sync-state) e))
-                   r)))))
-
-    (set-remote->local-syncer! [_ s] (set! remote->local-syncer s))
-
-    ILocal->RemoteSync
-    (setup-local->remote! [_]
-      (async/tap immediately-local->remote-mult private-immediately-local->remote-chan)
-      (async/tap recent-edited-mult private-recent-edited-chan))
-
-    (stop-local->remote! [_]
-      (async/untap immediately-local->remote-mult private-immediately-local->remote-chan)
-      (async/untap recent-edited-mult private-recent-edited-chan)
-      (async/close! stop-chan)
-      (vreset! *stopped true))
-
-    (<ratelimit [this from-chan]
-      (let [<fast-filter-e-fn (.filter-file-change-events-fn this)]
-        (util/<ratelimit
-         from-chan rate
-         :filter-fn
-         (fn [e]
-           (go
-             (and (rsapi-ready? rsapi graph-uuid)
-                  (<! (<fast-filter-e-fn e))
-                  (do
-                    (swap! *sync-state sync-state--add-queued-local->remote-files e)
-                    (let [v (<! (<filter-local-changes-pred e base-path graph-uuid))]
-                      (when-not v
-                        (swap! *sync-state sync-state--remove-queued-local->remote-files e))
-                      v)))))
-         :flush-fn #(swap! *sync-state sync-state-reset-queued-local->remote-files)
-         :stop-ch stop-chan
-         :distinct-coll? true
-         :flush-now-ch private-immediately-local->remote-chan
-         :refresh-timeout-ch private-recent-edited-chan)))
-
-    (<sync-local->remote! [_ es]
-      (if (empty? es)
-        (go {:succ true})
-        (let [type         (.-type ^FileChangeEvent (first es))
-              es->paths-xf (comp
-                            (map #(relative-path %))
-                            (remove ignored?))]
-          (go
-            (let [es*   (<! (<filter-checksum-not-consistent graph-uuid es))
-                  _     (when (not= (count es*) (count es))
-                          (println :debug :filter-checksum-changed
-                                   (mapv relative-path (set/difference (set es) (set es*)))))
-                  es**  (filter-too-huge-files es*)
-                  _     (when (not= (count es**) (count es*))
-                          (println :debug :filter-too-huge-files
-                                   (mapv relative-path (set/difference (set es*) (set es**)))))
-                  paths (sequence es->paths-xf es**)
-                  _     (println :sync-local->remote type paths)
-                  r     (if (empty? paths)
-                          (go @*txid)
-                          (case type
-                            ("add" "change")
-                            (<with-pause (<update-remote-files rsapi graph-uuid base-path paths @*txid) *paused)
-
-                            "unlink"
-                            (<with-pause (<delete-remote-files rsapi graph-uuid base-path paths @*txid) *paused)))
-                  _               (swap! *sync-state sync-state--add-current-local->remote-files paths)
-                  r*              (<! r)
-                  [succ? paused?] ((juxt number? :pause) r*)
-                  _               (swap! *sync-state sync-state--remove-current-local->remote-files paths succ?)]
-              (cond
-                (need-sync-remote? r*)
-                (do (println :need-sync-remote r*)
-                    {:need-sync-remote true})
-
-                (need-reset-local-txid? r*) ;; TODO: this cond shouldn't be true,
-                ;; but some potential bugs cause local-txid > remote-txid
-                (let [remote-txid (:TXId (<! (<get-remote-graph remoteapi nil graph-uuid)))]
-                  (update-graphs-txid! remote-txid graph-uuid user-uuid repo)
-                  (reset! *txid remote-txid)
-                  {:succ true})
-
-                (graph-has-been-deleted? r*)
-                (do (println :graph-has-been-deleted r*)
-                    {:graph-has-been-deleted true})
-
-                paused?
-                {:pause true}
-
-                succ?                   ; succ
+  Object
+  (filter-file-change-events-fn [_]
+    (fn [^FileChangeEvent e]
+      (go (and (instance? FileChangeEvent e)
+               (if-let [mtime (:mtime (.-stat e))]
+                 ;; if mtime is not nil, it should be after (- now 1min)
+                 ;; ignore events too early
+                 (> (* 1000 mtime) (tc/to-long (t/minus (t/now) (t/minutes 1))))
+                 true)
+               (or (string/starts-with? (.-dir e) base-path)
+                   (string/starts-with? (str "file://" (.-dir e)) base-path)) ; valid path prefix
+               (not (ignored? e))     ;not ignored
+               ;; download files will also trigger file-change-events, ignore them
+               (not (contains? (:recent-remote->local-files @*sync-state)
+                               (<! (<file-change-event=>recent-remote->local-file-item
+                                    graph-uuid e))))))))
+
+  (set-remote->local-syncer! [_ s] (set! remote->local-syncer s))
+
+  ILocal->RemoteSync
+  (setup-local->remote! [_]
+    (async/tap immediately-local->remote-mult private-immediately-local->remote-chan)
+    (async/tap recent-edited-mult private-recent-edited-chan))
+
+  (stop-local->remote! [_]
+    (async/untap immediately-local->remote-mult private-immediately-local->remote-chan)
+    (async/untap recent-edited-mult private-recent-edited-chan)
+    (async/close! stop-chan)
+    (vreset! *stopped true))
+
+  (<ratelimit [this from-chan]
+    (let [<fast-filter-e-fn (.filter-file-change-events-fn this)]
+      (util/<ratelimit
+       from-chan rate
+       :filter-fn
+       (fn [e]
+         (go
+           (and (rsapi-ready? rsapi graph-uuid)
+                (<! (<fast-filter-e-fn e))
                 (do
-                  (println "sync-local->remote! update txid" r*)
-                  ;; persist txid
-                  (update-graphs-txid! r* graph-uuid user-uuid repo)
-                  (reset! *txid r*)
-                  {:succ true})
+                  (swap! *sync-state sync-state--add-queued-local->remote-files e)
+                  (let [v (<! (<filter-local-changes-pred e base-path graph-uuid))]
+                    (when-not v
+                      (swap! *sync-state sync-state--remove-queued-local->remote-files e))
+                    v)))))
+       :flush-fn #(swap! *sync-state sync-state-reset-queued-local->remote-files)
+       :stop-ch stop-chan
+       :distinct-coll? true
+       :flush-now-ch private-immediately-local->remote-chan
+       :refresh-timeout-ch private-recent-edited-chan)))
+
+  (<sync-local->remote! [_ es]
+    (if (empty? es)
+      (go {:succ true})
+      (let [type         (.-type ^FileChangeEvent (first es))
+            es->paths-xf (comp
+                          (map #(relative-path %))
+                          (remove ignored?))]
+        (go
+          (let [es*   (<! (<filter-checksum-not-consistent graph-uuid es))
+                _     (when (not= (count es*) (count es))
+                        (println :debug :filter-checksum-changed
+                                 (mapv relative-path (set/difference (set es) (set es*)))))
+                es**  (filter-too-huge-files es*)
+                _     (when (not= (count es**) (count es*))
+                        (println :debug :filter-too-huge-files
+                                 (mapv relative-path (set/difference (set es*) (set es**)))))
+                paths (sequence es->paths-xf es**)
+                _     (println :sync-local->remote type paths)
+                r     (if (empty? paths)
+                        (go @*txid)
+                        (case type
+                          ("add" "change")
+                          (<with-pause (<update-remote-files rsapi graph-uuid base-path paths @*txid) *paused)
+
+                          "unlink"
+                          (<with-pause (<delete-remote-files rsapi graph-uuid base-path paths @*txid) *paused)))
+                _               (swap! *sync-state sync-state--add-current-local->remote-files paths)
+                r*              (<! r)
+                [succ? paused?] ((juxt number? :pause) r*)
+                _               (swap! *sync-state sync-state--remove-current-local->remote-files paths succ?)]
+            (cond
+              (need-sync-remote? r*)
+              (do (println :need-sync-remote r*)
+                  {:need-sync-remote true})
+
+              (need-reset-local-txid? r*) ;; TODO: this cond shouldn't be true,
+              ;; but some potential bugs cause local-txid > remote-txid
+              (let [remote-txid (:TXId (<! (<get-remote-graph remoteapi nil graph-uuid)))]
+                (<! (<update-graphs-txid! remote-txid graph-uuid user-uuid repo))
+                (reset! *txid remote-txid)
+                {:succ true})
+
+              (graph-has-been-deleted? r*)
+              (do (println :graph-has-been-deleted r*)
+                  {:graph-has-been-deleted true})
+
+              paused?
+              {:pause true}
+
+              succ?                   ; succ
+              (do
+                (println "sync-local->remote! update txid" r*)
+                ;; persist txid
+                (<! (<update-graphs-txid! r* graph-uuid user-uuid repo))
+                (reset! *txid r*)
+                {:succ true})
 
-                :else
-                (do
-                  (println "sync-local->remote unknown:" r*)
-                  {:unknown r*})))))))
-
-    (<sync-local->remote-all-files! [this]
-      (go
-        (let [remote-all-files-meta-c      (<get-remote-all-files-meta remoteapi graph-uuid)
-              local-all-files-meta-c       (<get-local-all-files-meta rsapi graph-uuid base-path)
-              deletion-logs-c              (<get-deletion-logs remoteapi graph-uuid @*txid)
-              remote-all-files-meta-or-exp (<! remote-all-files-meta-c)
-              deletion-logs                (<! deletion-logs-c)]
-          (if (or (storage-exceed-limit? remote-all-files-meta-or-exp)
-                  (sync-stop-when-api-flying? remote-all-files-meta-or-exp)
-                  (decrypt-exp? remote-all-files-meta-or-exp))
-            (do (put-sync-event! {:event :exception-decrypt-failed
-                                  :data  {:graph-uuid graph-uuid
-                                          :exp        remote-all-files-meta-or-exp
-                                          :epoch      (tc/to-epoch (t/now))}})
-                {:stop true})
-            (let [remote-all-files-meta remote-all-files-meta-or-exp
-                  local-all-files-meta  (<! local-all-files-meta-c)
-                  {local-all-files-meta :keep delete-local-files :delete}
-                  (filter-local-files-in-deletion-logs local-all-files-meta deletion-logs)
-                  diff-local-files      (diff-file-metadata-sets local-all-files-meta remote-all-files-meta)
-                  change-events
-                  (sequence
-                   (comp
-                    ;; convert to FileChangeEvent
-                    (map #(->FileChangeEvent "change" base-path (.get-normalized-path ^FileMetadata %)
-                                             {:size (:size %)} (:etag %)))
-                    (remove ignored?))
-                   diff-local-files)
-                  change-events-partitions
-                  (sequence
-                   ;; partition FileChangeEvents
-                   (partition-file-change-events 10)
-                   (distinct-file-change-events change-events))]
-              (println "[full-sync(local->remote)]"
-                       (count (flatten change-events-partitions)) "files need to sync and"
-                       (count delete-local-files) "local files need to delete")
-              ;; 1. delete local files
-              (loop [[f & fs] delete-local-files]
-                (when f
-                  (let [relative-p (relative-path f)]
-                    (when-not (<! (<local-file-not-exist? graph-uuid rsapi base-path relative-p))
-                      (let [fake-recent-remote->local-file-item {:remote->local-type :delete
-                                                                 :checksum nil
-                                                                 :path relative-p}]
-                        (swap! *sync-state sync-state--add-recent-remote->local-files
-                               [fake-recent-remote->local-file-item])
-                        (<! (<delete-local-files rsapi graph-uuid base-path [(relative-path f)]))
-                        (go (<! (timeout 5000))
-                            (swap! *sync-state sync-state--remove-recent-remote->local-files
-                                   [fake-recent-remote->local-file-item])))))
-                  (recur fs)))
-
-              ;; 2. upload local files
-              (loop [es-partitions change-events-partitions]
-                (if @*stopped
-                  {:stop true}
-                  (if (empty? es-partitions)
-                    {:succ true}
-                    (let [{:keys [succ need-sync-remote graph-has-been-deleted unknown] :as r}
-                          (<! (<sync-local->remote! this (first es-partitions)))]
-                      (s/assert ::sync-local->remote!-result r)
-                      (cond
-                        succ
-                        (recur (next es-partitions))
-                        (or need-sync-remote graph-has-been-deleted unknown) r)))))))))))
+              :else
+              (do
+                (println "sync-local->remote unknown:" r*)
+                {:unknown r*})))))))
+
+  (<sync-local->remote-all-files! [this]
+    (go
+      (let [remote-all-files-meta-c      (<get-remote-all-files-meta remoteapi graph-uuid)
+            local-all-files-meta-c       (<get-local-all-files-meta rsapi graph-uuid base-path)
+            deletion-logs-c              (<get-deletion-logs remoteapi graph-uuid @*txid)
+            remote-all-files-meta-or-exp (<! remote-all-files-meta-c)
+            deletion-logs                (<! deletion-logs-c)]
+        (if (or (storage-exceed-limit? remote-all-files-meta-or-exp)
+                (sync-stop-when-api-flying? remote-all-files-meta-or-exp)
+                (decrypt-exp? remote-all-files-meta-or-exp))
+          (do (put-sync-event! {:event :exception-decrypt-failed
+                                :data  {:graph-uuid graph-uuid
+                                        :exp        remote-all-files-meta-or-exp
+                                        :epoch      (tc/to-epoch (t/now))}})
+              {:stop true})
+          (let [remote-all-files-meta remote-all-files-meta-or-exp
+                local-all-files-meta  (<! local-all-files-meta-c)
+                {local-all-files-meta :keep delete-local-files :delete}
+                (filter-local-files-in-deletion-logs local-all-files-meta deletion-logs)
+                recent-10-days-range  ((juxt #(tc/to-long (t/minus % (t/days 10))) #(tc/to-long %)) (t/today))
+                diff-local-files      (->> (diff-file-metadata-sets local-all-files-meta remote-all-files-meta)
+                                           (sort-by (sort-file-metadata-fn :recent-days-range recent-10-days-range) >))
+                change-events
+                (sequence
+                 (comp
+                  ;; convert to FileChangeEvent
+                  (map #(->FileChangeEvent "change" base-path (.get-normalized-path ^FileMetadata %)
+                                           {:size (:size %)} (:etag %)))
+                  (remove ignored?))
+                 diff-local-files)
+                distinct-change-events (distinct-file-change-events change-events)
+                _ (swap! *sync-state #(sync-state-reset-full-local->remote-files % distinct-change-events))
+                change-events-partitions
+                (sequence
+                 ;; partition FileChangeEvents
+                 (partition-file-change-events upload-batch-size)
+                 distinct-change-events)]
+            (println "[full-sync(local->remote)]"
+                     (count (flatten change-events-partitions)) "files need to sync and"
+                     (count delete-local-files) "local files need to delete")
+            (put-sync-event! {:event :start
+                              :data  {:type :full-local->remote
+                                      :graph-uuid graph-uuid
+                                      :full-sync? true
+                                      :epoch      (tc/to-epoch (t/now))}})
+            ;; 1. delete local files
+            (loop [[f & fs] delete-local-files]
+              (when f
+                (let [relative-p (relative-path f)]
+                  (when-not (<! (<local-file-not-exist? graph-uuid rsapi base-path relative-p))
+                    (let [fake-recent-remote->local-file-item {:remote->local-type :delete
+                                                               :checksum nil
+                                                               :path relative-p}]
+                      (swap! *sync-state sync-state--add-recent-remote->local-files
+                             [fake-recent-remote->local-file-item])
+                      (<! (<delete-local-files rsapi graph-uuid base-path [(relative-path f)]))
+                      (go (<! (timeout 5000))
+                          (swap! *sync-state sync-state--remove-recent-remote->local-files
+                                 [fake-recent-remote->local-file-item])))))
+                (recur fs)))
+
+            ;; 2. upload local files
+            (loop [es-partitions change-events-partitions]
+              (if @*stopped
+                {:stop true}
+                (if (empty? es-partitions)
+                  {:succ true}
+                  (let [{:keys [succ need-sync-remote graph-has-been-deleted unknown] :as r}
+                        (<! (<sync-local->remote! this (first es-partitions)))]
+                    (s/assert ::sync-local->remote!-result r)
+                    (cond
+                      succ
+                      (recur (next es-partitions))
+                      (or need-sync-remote graph-has-been-deleted unknown) r)))))))))))
 
 ;;; ### put all stuff together
 
@@ -2454,7 +2503,7 @@
         ::local->remote-full-sync
         (<! (.full-sync this))
         ::remote->local-full-sync
-        (<! (.remote->local-full-sync this nil))
+        (<! (.remote->local-full-sync this args))
         ::pause
         (<! (.pause this))
         ::stop
@@ -2541,8 +2590,8 @@
 
   (pause [this]
     (put-sync-event! {:event :pause
-                      :data {:graph-uuid graph-uuid
-                             :epoch (tc/to-epoch (t/now))}})
+                      :data  {:graph-uuid graph-uuid
+                              :epoch      (tc/to-epoch (t/now))}})
     (go-loop []
       (let [{:keys [resume]} (<! ops-chan)]
         (if resume
@@ -2563,9 +2612,9 @@
               ;; if resume-state = nil, try a remote->local to sync recent diffs
               (offer! private-remote->local-sync-chan true))
             (put-sync-event! {:event :resume
-                              :data {:graph-uuid graph-uuid
-                                     :resume-state resume-state
-                                     :epoch (tc/to-epoch (t/now))}})
+                              :data  {:graph-uuid   graph-uuid
+                                      :resume-state resume-state
+                                      :epoch        (tc/to-epoch (t/now))}})
             (<! (.schedule this ::idle nil :resume)))
           (recur)))))
 
@@ -2615,11 +2664,11 @@
           unknown
           (do
             (put-sync-event! {:event :local->remote-full-sync-failed
-                              :data {:graph-uuid graph-uuid
-                                     :epoch (tc/to-epoch (t/now))}})
+                              :data  {:graph-uuid graph-uuid
+                                      :epoch      (tc/to-epoch (t/now))}})
             (.schedule this ::idle nil nil))))))
 
-  (remote->local-full-sync [this _next-state]
+  (remote->local-full-sync [this _]
     (go
       (let [{:keys [succ unknown stop pause]}
             (<! (<sync-remote->local-all-files! remote->local-syncer))]
@@ -2638,9 +2687,11 @@
           unknown
           (do
             (put-sync-event! {:event :remote->local-full-sync-failed
-                              :data {:graph-uuid graph-uuid
-                                     :epoch (tc/to-epoch (t/now))}})
-            (.schedule this ::idle nil nil))))))
+                              :data  {:graph-uuid graph-uuid
+                                      :exp        unknown
+                                      :epoch      (tc/to-epoch (t/now))}})
+            ;; if any exception occurred, re-exec remote->local-full-sync
+            (.schedule this ::remote->local-full-sync nil nil))))))
 
   (remote->local [this _next-state {remote-val :remote}]
     (go
@@ -2678,8 +2729,14 @@
     (assert (some? local-changes) local-changes)
     (go
       (let [distincted-local-changes (distinct-file-change-events local-changes)
+            _ (swap! *sync-state #(sync-state-reset-full-local->remote-files % distincted-local-changes))
             change-events-partitions
-            (sequence (partition-file-change-events 10) distincted-local-changes)
+            (sequence (partition-file-change-events upload-batch-size) distincted-local-changes)
+            _ (put-sync-event! {:event :start
+                                :data  {:type       :local->remote
+                                        :graph-uuid graph-uuid
+                                        :full-sync? false
+                                        :epoch      (tc/to-epoch (t/now))}})
             {:keys [succ need-sync-remote graph-has-been-deleted unknown stop pause]}
             (loop [es-partitions change-events-partitions]
               (cond
@@ -2741,9 +2798,11 @@
         (debug/pprint ["stop sync-manager, graph-uuid" graph-uuid "base-path" base-path])
         (swap! *sync-state sync-state--update-state ::stop)
         (loop []
-          (when (not= ::stop state)
-            (<! (timeout 100))
-            (recur))))))
+          (if (not= ::stop state)
+            (do
+              (<! (timeout 100))
+              (recur))
+            (reset! current-sm-graph-uuid nil))))))
 
   IStopped?
   (-stopped? [_]
@@ -2769,21 +2828,25 @@
     (->SyncManager graph-uuid base-path *sync-state local->remote-syncer remote->local-syncer remoteapi-with-stop
                    nil *txid nil nil nil *stopped? *paused? nil (chan 1) (chan 1) (chan 1) (chan 1) (chan 1))))
 
-(def ^:private current-sm-graph-uuid (atom nil))
-
 (defn sync-manager-singleton
   [user-uuid graph-uuid base-path repo txid *sync-state]
   (when-not @current-sm-graph-uuid
     (reset! current-sm-graph-uuid graph-uuid)
     (sync-manager user-uuid graph-uuid base-path repo txid *sync-state)))
 
+(defn clear-graph-progress!
+  [graph-uuid]
+  (state/set-state! [:file-sync/progress graph-uuid] {}))
+
 (defn <sync-stop []
   (go
     (when-let [sm ^SyncManager (state/get-file-sync-manager)]
       (println "[SyncManager" (:graph-uuid sm) "]" "stopping")
       (<! (-stop! sm))
+
       (println "[SyncManager" (:graph-uuid sm) "]" "stopped")
-      (state/set-file-sync-manager nil))
+      (state/set-file-sync-manager nil)
+      (clear-graph-progress! (:graph-uuid sm)))
     (reset! current-sm-graph-uuid nil)))
 
 (defn sync-need-password!
@@ -2840,11 +2903,14 @@
 
 (declare network-online-cursor)
 
+;; Prevent starting of multiple sync managers
+(def *sync-starting? (atom {}))
 (defn sync-start []
   (let [*sync-state                 (atom (sync-state))
         current-user-uuid           (user/user-uuid)
         repo                        (state/get-current-repo)]
     (go
+      (<! (<sync-stop))
       (when (and (graph-sync-off? repo) @network-online-cursor)
         (<! (p->c (persist-var/-load graphs-txid)))
         (let [[user-uuid graph-uuid txid] @graphs-txid]
@@ -2852,25 +2918,35 @@
                      (user/logged-in?)
                      repo
                      (not (config/demo-graph? repo)))
-            (when-some [sm (sync-manager-singleton current-user-uuid graph-uuid
-                                                   (config/get-repo-dir repo) repo
-                                                   txid *sync-state)]
-              (when (check-graph-belong-to-current-user current-user-uuid user-uuid)
-                (if-not (<! (<check-remote-graph-exists graph-uuid)) ; remote graph has been deleted
-                  (clear-graphs-txid! repo)
-                  (do
-                    (state/set-file-sync-state repo @*sync-state)
-                    (state/set-file-sync-manager sm)
-
-                    ;; update global state when *sync-state changes
-                    (add-watch *sync-state ::update-global-state
-                               (fn [_ _ _ n]
-                                 (state/set-file-sync-state repo n)))
-
-                    (.start sm)
-
-                    (offer! remote->local-full-sync-chan true)
-                    (offer! full-sync-chan true)))))))))))
+            (try
+              (when-not (get @*sync-starting? graph-uuid)
+                (swap! *sync-starting? assoc graph-uuid true)
+                (clear-graph-progress! graph-uuid)
+
+                (when-some [sm (sync-manager-singleton current-user-uuid graph-uuid
+                                                       (config/get-repo-dir repo) repo
+                                                       txid *sync-state)]
+                  (when (check-graph-belong-to-current-user current-user-uuid user-uuid)
+                    (if-not (<! (<check-remote-graph-exists graph-uuid)) ; remote graph has been deleted
+                      (clear-graphs-txid! repo)
+                      (do
+                        (state/set-file-sync-state repo @*sync-state)
+                        (state/set-file-sync-manager sm)
+
+                        ;; update global state when *sync-state changes
+                        (add-watch *sync-state ::update-global-state
+                                   (fn [_ _ _ n]
+                                     (state/set-file-sync-state repo n)))
+
+                        (.start sm)
+
+                        (offer! remote->local-full-sync-chan true)
+                        (offer! full-sync-chan true)
+                        (swap! *sync-starting? assoc graph-uuid false))))))
+              (catch :default e
+                (prn "Sync start error: ")
+                (log/error :exception e)
+                (swap! *sync-starting? assoc graph-uuid false)))))))))
 
 ;;; ### some add-watches
 
@@ -2927,6 +3003,15 @@
   (<get-local-all-files-meta rsapi graph-uuid
                              (config/get-repo-dir (state/get-current-repo)))
   (def base-path (config/get-repo-dir (state/get-current-repo)))
+
+  ;; upload
+  (def full-upload-files (:full-local->remote-files (state/sub [:file-sync/sync-state (state/get-current-repo)])))
+
+  ;; queued
+  (:queued-local->remote-files (state/sub [:file-sync/sync-state (state/get-current-repo)]))
+
+  ;; download
+  (:current-remote->local-files (state/sub [:file-sync/sync-state (state/get-current-repo)]))
   )
 
 

+ 2 - 0
src/main/frontend/handler.cljs

@@ -23,6 +23,7 @@
             [frontend.handler.user :as user-handler]
             [frontend.handler.repo-config :as repo-config-handler]
             [frontend.handler.global-config :as global-config-handler]
+            [frontend.handler.metadata :as metadata-handler]
             [frontend.idb :as idb]
             [frontend.mobile.util :as mobile-util]
             [frontend.modules.instrumentation.core :as instrument]
@@ -227,6 +228,7 @@
   (persist-var/load-vars)
   (user-handler/restore-tokens-from-localstorage)
   (user-handler/refresh-tokens-loop)
+  (metadata-handler/run-set-page-metadata-job!)
   (js/setTimeout instrument! (* 60 1000)))
 
 (defn stop! []

+ 8 - 9
src/main/frontend/handler/events.cljs

@@ -155,14 +155,13 @@
           (repo-handler/persist-db! current-repo persist-db-noti-m)
           (repo-handler/broadcast-persist-db! graph))))
      (repo-handler/restore-and-setup-repo! graph)
-     (graph-switch graph))))
+     (graph-switch graph)
+     state/set-state! :sync-graph/init? false)))
 
 (defmethod handle :graph/switch [[_ graph opts]]
-  (if (or @outliner-file/*writes-finished?
-          (:graph/remote-binding? @state/state))
-    (do
-      (state/set-state! :graph/remote-binding? false)
-      (graph-switch-on-persisted graph opts))
+  (if (or (not (false? (get @outliner-file/*writes-finished? graph)))
+          (:sync-graph/init? @state/state))
+    (graph-switch-on-persisted graph opts)
     (notification/show!
      "Please wait seconds until all changes are saved for the current graph."
      :warning)))
@@ -663,9 +662,9 @@
                        {:content (str "The directory " dir " has been back, you can edit your graph now.")
                         :status :success
                         :clear? true}])
-    (state/update-state! :file/unlinked-dirs (fn [dirs] (disj dirs dir))))
-  (when (= dir (config/get-repo-dir repo))
-    (fs/watch-dir! dir)))
+    (state/update-state! :file/unlinked-dirs (fn [dirs] (disj dirs dir)))
+    (when (= dir (config/get-repo-dir repo))
+      (fs/watch-dir! dir))))
 
 (defmethod handle :file/alter [[_ repo path content]]
   (p/let [_ (file-handler/alter-file repo path content {:from-disk? true})]

+ 52 - 14
src/main/frontend/handler/file_sync.cljs

@@ -10,7 +10,9 @@
             [frontend.handler.notification :as notification]
             [frontend.state :as state]
             [frontend.handler.user :as user]
-            [frontend.fs :as fs]))
+            [frontend.fs :as fs]
+            [cljs-time.coerce :as tc]
+            [cljs-time.core :as t]))
 
 (def *beta-unavailable? (volatile! false))
 
@@ -42,7 +44,7 @@
       (if (and (not (instance? ExceptionInfo r))
                (string? r))
         (let [tx-info [0 r (user/user-uuid) (state/get-current-repo)]]
-          (apply sync/update-graphs-txid! tx-info)
+          (<! (apply sync/<update-graphs-txid! tx-info))
           (swap! refresh-file-sync-component not) tx-info)
         (do
           (state/set-state! [:ui/loading? :graph/create-remote?] false)
@@ -90,10 +92,13 @@
   (state/set-state! :file-sync/remote-graphs {:loading false :graphs nil}))
 
 (defn init-graph [graph-uuid]
-  (let [repo (state/get-current-repo)
-        user-uuid (user/user-uuid)]
-    (sync/update-graphs-txid! 0 graph-uuid user-uuid repo)
-    (swap! refresh-file-sync-component not)))
+  (go
+    (let [repo (state/get-current-repo)
+          user-uuid (user/user-uuid)]
+      (state/set-state! :sync-graph/init? true)
+      (<! (sync/<update-graphs-txid! 0 graph-uuid user-uuid repo))
+      (swap! refresh-file-sync-component not)
+      (state/pub-event! [:graph/switch repo {:persist? false}]))))
 
 (defn download-version-file
   ([graph-uuid file-uuid version-uuid]
@@ -173,7 +178,6 @@
           " to "
           (config/get-string-repo-dir (config/get-local-dir local)))
      :warning)
-
     (init-graph (:GraphUUID graph))
     (state/close-modal!)))
 
@@ -181,20 +185,54 @@
   []
   (let [c     (async/chan 1)
         p     sync/sync-events-publication
-        topic :finished-local->remote]
-
-    (async/sub p topic c)
+        topics [:finished-local->remote :finished-remote->local :start]]
+    (doseq [topic topics]
+      (async/sub p topic c))
 
     (async/go-loop []
-      (let [{:keys [data]} (async/<! c)]
+      (let [{:keys [event data]} (async/<! c)]
+        (case event
+          (list :finished-local->remote :finished-remote->local)
+          (do
+            (sync/clear-graph-progress! (second @sync/graphs-txid))
+            (state/set-state! :file-sync/start {})
+            (state/set-state! [:file-sync/last-synced-at (state/get-current-repo)]
+                              (:epoch data)))
+
+          :start
+          (state/set-state! :file-sync/start data)
+
+          nil)
+
         (when (and (:file-change-events data)
                    (= :page (state/get-current-route)))
-          (state/pub-event!
-           [:file-sync/maybe-onboarding-show :sync-history])))
+          (state/pub-event! [:file-sync/maybe-onboarding-show :sync-history])))
       (recur))
 
-    #(async/unsub p topic c)))
+    #(doseq [topic topics]
+       (async/unsub p topic c))))
 
 (defn reset-user-state! []
   (vreset! *beta-unavailable? false)
   (state/set-state! :file-sync/onboarding-state nil))
+
+(defn calculate-time-left
+  "This assumes that the network speed is stable which could be wrong sometimes."
+  [sync-state progressing]
+  (let [start-time (get-in @state/state [:file-sync/start :epoch])
+        now (tc/to-epoch (t/now))
+        diff-seconds (- now start-time)
+        finished (reduce + (map (comp :progress second) progressing))
+        local->remote-files (:full-local->remote-files sync-state)
+        remote->local-files (:full-remote->local-files sync-state)
+        total (if (seq remote->local-files)
+                (reduce + (map (fn [m] (or (:size m) 0)) remote->local-files))
+                (reduce + (map #(:size (.-stat %)) local->remote-files)))
+        mins (int (/ (* (/ total finished) diff-seconds) 60))]
+    (if (or (zero? total) (zero? finished))
+      "waiting"
+      (cond
+        (zero? mins) "soon"
+        (= mins 1) "1 min left"
+        (> mins 30) "calculating..."
+        :else (str mins " mins left")))))

+ 9 - 0
src/main/frontend/handler/metadata.cljs

@@ -84,3 +84,12 @@
 (defn update-properties!
   [properties-tx]
   (set-metadata! :block/properties #(handler-properties! % properties-tx)))
+
+(defn run-set-page-metadata-job!
+  []
+  (js/setInterval
+   (fn []
+     (when-let [repo (state/get-current-repo)]
+       (when (state/input-idle? repo :diff 3000)
+         (set-pages-metadata! repo))))
+   (* 1000 60 10)))

+ 3 - 2
src/main/frontend/handler/repo.cljs

@@ -359,8 +359,9 @@
                         (when (= (state/get-current-repo) url)
                           (state/set-current-repo! (:url (first (state/get-repos)))))))]
     (when (or (config/local-db? url) (= url "local"))
-      (p/let [_ (idb/clear-local-db! url)] ; clear file handles
-        (delete-db-f)))))
+      (-> (p/let [_ (idb/clear-local-db! url)] ; clear file handles
+            )
+          (p/finally delete-db-f)))))
 
 (defn start-repo-db-if-not-exists!
   [repo]

+ 0 - 1
src/main/frontend/handler/web/nfs.cljs

@@ -203,7 +203,6 @@
                                     (state/add-repo! {:url repo :nfs? true})
                                     (state/set-loading-files! repo false)
                                     (when ok-handler (ok-handler {:url repo}))
-                                    (fs/watch-dir! dir-name)
                                     (db/persist-if-idle! repo))))))))
                 (p/catch (fn [error]
                            (log/error :nfs/load-files-error repo)

+ 17 - 7
src/main/frontend/modules/outliner/file.cljs

@@ -10,7 +10,9 @@
             [frontend.util :as util]
             [goog.object :as gobj]
             [lambdaisland.glogi :as log]
-            [frontend.state :as state]))
+            [frontend.state :as state]
+            [cljs-time.core :as t]
+            [cljs-time.coerce :as tc]))
 
 (def batch-write-interval 1000)
 
@@ -54,16 +56,24 @@
     (when-let [repo (state/get-current-repo)]
       (if (:graph/importing @state/state) ; write immediately
         (write-files! [[repo page-db-id]])
-        (async/put! (state/get-file-write-chan) [repo page-db-id])))))
+        (async/put! (state/get-file-write-chan) [repo page-db-id (tc/to-long (t/now))])))))
 
-(def *writes-finished? (atom true))
+(def *writes-finished? (atom {}))
 
 (defn <ratelimit-file-writes!
   []
   (util/<ratelimit (state/get-file-write-chan) batch-write-interval
                  :filter-fn
-                 #(do (reset! *writes-finished? false) true)
+                 (fn [[repo _ time]]
+                   (swap! *writes-finished? assoc repo {:time time
+                                                        :value false})
+                   true)
                  :flush-fn
-                 #(do
-                    (write-files! %)
-                    (reset! *writes-finished? true))))
+                 (fn [col]
+                   (let [start-time (tc/to-long (t/now))
+                         repos (distinct (map first col))]
+                     (write-files! col)
+                     (doseq [repo repos]
+                       (let [last-write-time (get-in @*writes-finished? [repo :time])]
+                         (when (> start-time last-write-time)
+                           (swap! *writes-finished? assoc repo {:value true}))))))))

+ 7 - 3
src/main/frontend/state.cljs

@@ -244,6 +244,11 @@
 
      :ui/loading?                           {}
      :file-sync/set-remote-graph-password-result {}
+     ;; graph-uuid -> {file-path -> payload}
+     :file-sync/progress                    {}
+     :file-sync/start                       {}
+     ;; graph -> epoch
+     :file-sync/last-synced-at              {}
      :feature/enable-sync?                  (storage/get :logseq-sync-enabled)
 
      :file/rename-event-chan                (async/chan 100)
@@ -716,9 +721,8 @@ Similar to re-frame subscriptions"
   [repo]
   (swap! state update-in [:me :repos]
          (fn [repos]
-           (->> (remove #(= (:url repo)
-                            (:url %))
-                        repos)
+           (->> (remove #(or (= (:url repo) (:url %))
+                             (= (:GraphUUID repo) (:GraphUUID %))) repos)
                 (util/distinct-by :url)))))
 
 (defn set-timestamp-block!

+ 48 - 33
src/main/frontend/ui.cljs

@@ -143,44 +143,59 @@
 
 (rum/defc menu-link
   [options child shortcut]
-  [:a.flex.justify-between.px-4.py-2.text-sm.transition.ease-in-out.duration-150.cursor.menu-link
-   options
-   [:span child]
-   (when shortcut
-     [:span.ml-1 (render-keyboard-shortcut shortcut)])])
+  (if (:only-child? options)
+    [:div.menu-link
+     (dissoc options :only-child?) child]
+    [:a.flex.justify-between.px-4.py-2.text-sm.transition.ease-in-out.duration-150.cursor.menu-link
+     options
+     [:span child]
+     (when shortcut
+       [:span.ml-1 (render-keyboard-shortcut shortcut)])]))
 
 (rum/defc dropdown-with-links
-  [content-fn links {:keys [links-header links-footer] :as opts}]
+  [content-fn links
+   {:keys [outer-header outer-footer links-header links-footer] :as opts}]
+
   (dropdown
    content-fn
    (fn [{:keys [close-fn]}]
-     [:.menu-links-wrapper
-      (when links-header links-header)
-
-      (for [{:keys [options title icon key hr hover-detail item _as-link?]} (if (fn? links) (links) links)]
-        (let [new-options
-              (merge options
-                     (cond->
-                       {:title    hover-detail
-                        :on-click (fn [e]
-                                    (when-not (false? (when-let [on-click-fn (:on-click options)]
-                                                        (on-click-fn e)))
-                                      (close-fn)))}
-                       key
-                       (assoc :key key)))
-              child (if hr
-                      nil
-                      (or item
-                          [:div.flex.items-center
-                           (when icon icon)
-                           [:div {:style {:margin-right "8px"
-                                          :margin-left  "4px"}} title]]))]
-          (if hr
-            [:hr.menu-separator {:key "dropdown-hr"}]
-            (rum/with-key
-              (menu-link new-options child nil)
-              title))))
-      (when links-footer links-footer)])
+     (let [links-children
+           (let [links (if (fn? links) (links) links)
+                 links (remove nil? links)]
+             (for [{:keys [options title icon key hr hover-detail item _as-link?]} links]
+               (let [new-options
+                           (merge options
+                                  (cond->
+                                    {:title    hover-detail
+                                     :on-click (fn [e]
+                                                 (when-not (false? (when-let [on-click-fn (:on-click options)]
+                                                                     (on-click-fn e)))
+                                                   (close-fn)))}
+                                    key
+                                    (assoc :key key)))
+                     child (if hr
+                             nil
+                             (or item
+                                 [:div.flex.items-center
+                                  (when icon icon)
+                                  [:div.title-wrap {:style {:margin-right "8px"
+                                                            :margin-left  "4px"}} title]]))]
+                 (if hr
+                   [:hr.menu-separator {:key "dropdown-hr"}]
+                   (rum/with-key
+                    (menu-link new-options child nil)
+                    title)))))
+
+           wrapper-children
+           [:.menu-links-wrapper
+            (when links-header links-header)
+            links-children
+            (when links-footer links-footer)]]
+
+       (if (or outer-header outer-footer)
+         [:.menu-links-outer
+          outer-header wrapper-children outer-footer]
+         wrapper-children)))
    opts))
 
 (defn button

+ 4 - 4
yarn.lock

@@ -821,10 +821,10 @@
   dependencies:
     defer-to-connect "^1.0.1"
 
-"@tabler/icons@1.54.0":
-  version "1.54.0"
-  resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.54.0.tgz#c8cf4e777e61b19004d0e21443c9a7fae31d70c4"
-  integrity sha512-X0SjUMWlu6IWsWIZP6gtMZhi9Q7pO2+BQ9vex28rOu+gtym7fZjnDXWG0okzVhtt4mlOwJ2BHQllRky29lsn7Q==
+"@tabler/icons@^1.96.0":
+  version "1.96.0"
+  resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-1.96.0.tgz#ff3cb1a5ddc38b96f54a54b9c0b5e1ed88c24da7"
+  integrity sha512-ZAQ64CHi5sOFQE7COoBmZbhIBtTX+4XNuiVZsr8kcqJX1bmrw6dFU0W9ONJamt/2TO997uVfSqxr2o0WZqZr/g==
 
 "@tailwindcss/custom-forms@^0.2.1":
   version "0.2.1"