Просмотр исходного кода

Merge pull request #1179 from logseq/feat/electron

Desktop app support
Tienson Qin 5 лет назад
Родитель
Сommit
b9a60aee09
83 измененных файлов с 3072 добавлено и 1185 удалено
  1. 1 0
      .gitignore
  2. 21 1
      README.md
  3. 1 1
      deps.edn
  4. 34 1
      gulpfile.js
  5. 16 3
      package.json
  6. 2 2
      resources/css/common.css
  7. 39 39
      resources/css/inter.css
  8. 2 2
      resources/css/style.css
  9. 25 0
      resources/dev.html
  10. 107 0
      resources/electron-dev.html
  11. 106 0
      resources/electron.html
  12. 42 0
      resources/forge.config.js
  13. BIN
      resources/icons/logseq.icns
  14. BIN
      resources/icons/logseq.ico
  15. BIN
      resources/icons/logseq.png
  16. BIN
      resources/icons/logseq_big_sur.icns
  17. BIN
      resources/icons/logseq_big_sur.png
  18. BIN
      resources/img/dmg-bg.png
  19. 120 0
      resources/js/preload.js
  20. 2 2
      resources/js/worker.js
  21. 33 0
      resources/package.json
  22. 7 0
      shadow-cljs.edn
  23. 5 0
      src/dev-cljs/shadow/user.clj
  24. 122 0
      src/electron/electron/core.cljs
  25. 145 0
      src/electron/electron/handler.cljs
  26. 119 0
      src/electron/electron/updater.cljs
  27. 11 0
      src/electron/electron/utils.cljs
  28. 9 0
      src/main/electron/ipc.cljs
  29. 7 1
      src/main/frontend/commands.cljs
  30. 13 3
      src/main/frontend/components/block.cljs
  31. 1 1
      src/main/frontend/components/diff.cljs
  32. 102 89
      src/main/frontend/components/editor.cljs
  33. 3 3
      src/main/frontend/components/file.cljs
  34. 109 107
      src/main/frontend/components/header.cljs
  35. 5 0
      src/main/frontend/components/header.css
  36. 3 3
      src/main/frontend/components/journal.cljs
  37. 13 7
      src/main/frontend/components/page.cljs
  38. 11 5
      src/main/frontend/components/repo.cljs
  39. 30 27
      src/main/frontend/components/right_sidebar.cljs
  40. 94 50
      src/main/frontend/components/settings.cljs
  41. 44 0
      src/main/frontend/components/settings.css
  42. 7 1
      src/main/frontend/components/sidebar.css
  43. 43 1
      src/main/frontend/components/svg.cljs
  44. 13 10
      src/main/frontend/components/theme.cljs
  45. 105 2
      src/main/frontend/components/theme.css
  46. 19 19
      src/main/frontend/components/widgets.cljs
  47. 43 2
      src/main/frontend/config.cljs
  48. 1 1
      src/main/frontend/core.cljs
  49. 0 14
      src/main/frontend/date.cljs
  50. 3 2
      src/main/frontend/db.cljs
  51. 20 5
      src/main/frontend/db/model.cljs
  52. 0 15
      src/main/frontend/db/react.cljs
  53. 1 2
      src/main/frontend/db/utils.cljs
  54. 0 3
      src/main/frontend/db_schema.cljs
  55. 8 2
      src/main/frontend/dicts.cljs
  56. 83 260
      src/main/frontend/fs.cljs
  57. 40 0
      src/main/frontend/fs/bfs.cljs
  58. 197 0
      src/main/frontend/fs/nfs.cljs
  59. 49 0
      src/main/frontend/fs/node.cljs
  60. 14 0
      src/main/frontend/fs/protocol.cljs
  61. 68 0
      src/main/frontend/fs/watcher_handler.cljs
  62. 19 18
      src/main/frontend/git.cljs
  63. 6 2
      src/main/frontend/handler.cljs
  64. 30 40
      src/main/frontend/handler/common.cljs
  65. 1 1
      src/main/frontend/handler/config.cljs
  66. 4 101
      src/main/frontend/handler/dnd.cljs
  67. 14 17
      src/main/frontend/handler/draw.cljs
  68. 72 74
      src/main/frontend/handler/editor.cljs
  69. 2 4
      src/main/frontend/handler/extract.cljs
  70. 46 20
      src/main/frontend/handler/file.cljs
  71. 3 2
      src/main/frontend/handler/git.cljs
  72. 38 37
      src/main/frontend/handler/image.cljs
  73. 20 12
      src/main/frontend/handler/page.cljs
  74. 32 30
      src/main/frontend/handler/repo.cljs
  75. 149 120
      src/main/frontend/handler/web/nfs.cljs
  76. 1 0
      src/main/frontend/page.cljs
  77. 1 1
      src/main/frontend/publishing.cljs
  78. 20 5
      src/main/frontend/state.cljs
  79. 5 2
      src/main/frontend/ui.cljs
  80. 21 0
      src/main/frontend/ui.css
  81. 19 7
      src/main/frontend/util.cljc
  82. 12 0
      src/main/frontend/utils.js
  83. 439 6
      yarn.lock

+ 1 - 0
.gitignore

@@ -28,3 +28,4 @@ report.html
 strings.csv
 
 .calva
+resources/electron.js

+ 21 - 1
README.md

@@ -16,7 +16,7 @@ Use it to organize your todo list, to write your journals, or to record your uni
 ## Why Logseq?
 
 [Logseq](https://logseq.com) is a platform for knowledge sharing and management. It focuses on privacy, longevity, and user control.
-Notice: the backend code will not be open-sourced for security reasons and other potential risks. 
+Notice: the backend code will not be open-sourced for security reasons and other potential risks.
 
 The server will never store or analyze your private notes. Your data are plain text files and we currently support both Markdown and Emacs Org mode (more to be added soon).
 
@@ -103,6 +103,26 @@ Run Clojure tests. (Note: `.cljc` files may be tested both by ClojureScript, and
 clj -Mtest-clj
 ```
 
+## Desktop app development
+
+### 1. Compile to JavaScript
+
+``` bash
+yarn watch
+```
+
+### 2. Open the debug app
+
+``` bash
+yarn debug-electron
+```
+
+### 3. Build a release
+
+``` bash
+yarn release-electron
+```
+
 ## Alternative: Docker based development environment
 
 ### 1. Fetch sources

+ 1 - 1
deps.edn

@@ -33,7 +33,7 @@
   expound/expound             {:mvn/version "0.8.6"}
   lambdaisland/glogi          {:mvn/version "1.0.74"}}
 
- :aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/"]
+ :aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"]
                   :extra-deps  {org.clojure/clojurescript   {:mvn/version "1.10.764"}
                                 binaryage/devtools          {:mvn/version "1.0.2"}
                                 org.clojure/tools.namespace {:mvn/version "0.2.11"}

+ 34 - 1
gulpfile.js

@@ -1,4 +1,5 @@
 const fs = require('fs')
+const cp = require('child_process')
 const path = require('path')
 const gulp = require('gulp')
 const postcss = require('gulp-postcss')
@@ -62,7 +63,7 @@ const css = {
 
 const common = {
   clean () {
-    return del(outputPath)
+    return del(['./static/**/*', '!./static/yarn.lock', '!./static/node_modules'])
   },
 
   syncResourceFile () {
@@ -74,6 +75,38 @@ const common = {
   }
 }
 
+exports.electron = () => {
+  if (!fs.existsSync(path.join(outputPath, 'node_modules'))) {
+    cp.execSync('yarn', {
+      cwd: outputPath,
+      stdio: 'inherit'
+    })
+  }
+
+  cp.execSync('yarn electron:dev', {
+    cwd: outputPath,
+    stdio: 'inherit'
+  })
+}
+
+exports.electronMaker = () => {
+  cp.execSync('yarn release', {
+    stdio: 'inherit'
+  })
+
+  if (!fs.existsSync(path.join(outputPath, 'node_modules'))) {
+    cp.execSync('yarn', {
+      cwd: outputPath,
+      stdio: 'inherit'
+    })
+  }
+
+  cp.execSync('yarn electron:make', {
+    cwd: outputPath,
+    stdio: 'inherit'
+  })
+}
+
 exports.clean = common.clean
 exports.watch = gulp.parallel(common.keepSyncResourceFile, css.watchCSS)
 exports.build = gulp.series(common.clean, common.syncResourceFile, css.buildCSS)

+ 16 - 3
package.json

@@ -2,6 +2,7 @@
     "name": "logseq",
     "version": "0.0.1",
     "private": true,
+    "main": "static/electron.js",
     "devDependencies": {
         "@tailwindcss/ui": "0.7.2",
         "@types/gulp": "^4.0.7",
@@ -25,19 +26,25 @@
     },
     "scripts": {
         "watch": "run-p gulp:build gulp:watch cljs:watch",
+        "electron-watch": "run-p gulp:build gulp:watch cljs:electron-watch",
         "release": "run-s gulp:build cljs:release",
         "watch-app": "run-p gulp:watch cljs:watch-app",
         "release-app": "run-s gulp:build cljs:release-app",
         "release-publishing": "run-s gulp:build cljs:release-publishing",
         "dev-release-app": "run-s gulp:build cljs:dev-release-app",
+        "dev-electron-app": "gulp electron",
+        "release-electron": "run-p cljs:electron-release && cd ./static && yarn electron:make",
+        "debug-electron": "cd static/ && yarn electron:debug",
         "clean": "gulp clean",
         "test": "run-s cljs:test cljs:run-test",
         "report": "run-s cljs:report",
         "style:lint": "stylelint \"src/**/*.css\" ",
         "gulp:watch": "gulp watch",
         "gulp:build": "cross-env NODE_ENV=production gulp build",
-        "cljs:watch": "clojure -M:cljs watch app publishing",
-        "cljs:release": "clojure -M:cljs release app publishing",
+        "cljs:watch": "clojure -M:cljs watch app publishing electron",
+        "cljs:electron-watch": "clojure -M:cljs watch app electron",
+        "cljs:release": "clojure -M:cljs release app publishing electron",
+        "cljs:electron-release": "clojure -M:cljs release app --config-merge '{:release {:asset-path \"./js\"}}' publishing electron",
         "cljs:test": "clojure -A:test compile test",
         "cljs:run-test": "node static/tests.js",
         "cljs:watch-app": "clojure -M:cljs watch app",
@@ -45,23 +52,29 @@
         "cljs:release-publishing": "clojure -M:cljs release publishing",
         "cljs:dev-release-app": "clojure -M:cljs release app --config-merge '{:closure-defines {frontend.config/DEV-RELEASE true}}'",
         "cljs:debug": "clojure -M:cljs release app --debug",
-        "cljs:report": "clojure -M:cljs run shadow.cljs.build-report app report.html"
+        "cljs:report": "clojure -M:cljs run shadow.cljs.build-report app report.html",
+        "cljs:build-electron": "clojure -A:cljs compile app electron"
     },
     "dependencies": {
+        "chokidar": "^3.5.1",
         "codemirror": "^5.58.1",
         "diff": "5.0.0",
         "diff-match-patch": "^1.0.5",
+        "electron": "^11.2.0",
+        "fs": "^0.0.1-security",
         "fuzzysort": "^1.1.4",
         "gulp-cached": "^1.1.1",
         "ignore": "^5.1.8",
         "jszip": "^3.5.0",
         "mldoc": "^0.3.7",
         "mousetrap": "^1.6.5",
+        "path": "^0.12.7",
         "react": "^17.0.1",
         "react-dom": "^17.0.1",
         "react-resize-context": "^3.0.0",
         "react-textarea-autosize": "^8.0.1",
         "react-transition-group": "^4.3.0",
+        "url": "^0.11.0",
         "yargs-parser": "^20.2.4"
     }
 }

+ 2 - 2
resources/css/common.css

@@ -61,8 +61,8 @@ html[data-theme=dark] {
   --ls-page-blockquote-border-color: var(--ls-border-color);
   --ls-page-inline-code-color: var(--ls-primary-text-color);
   --ls-page-inline-code-bg-color: #01222a;
-  --ls-scrollbar-foreground-color: rgba(255, 255, 255, 0.1);
-  --ls-scrollbar-background-color: rgba(255, 255, 255, 0.05);
+  --ls-scrollbar-foreground-color: rgba(255, 255, 255, 0.05);
+  --ls-scrollbar-background-color: rgba(30, 60, 67, 0.9);
   --ls-scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.2);
   --ls-head-text-color: var(--ls-link-text-color);
   --ls-icon-color: var(--ls-link-text-color);

+ 39 - 39
resources/css/inter.css

@@ -3,16 +3,16 @@
   font-style:  normal;
   font-weight: 100;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-Thin.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-Thin.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-Thin.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-Thin.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 100;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-ThinItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-ThinItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-ThinItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-ThinItalic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -20,16 +20,16 @@
   font-style:  normal;
   font-weight: 200;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-ExtraLight.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-ExtraLight.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-ExtraLight.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-ExtraLight.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 200;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-ExtraLightItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-ExtraLightItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-ExtraLightItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-ExtraLightItalic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -37,16 +37,16 @@
   font-style:  normal;
   font-weight: 300;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-Light.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-Light.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-Light.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-Light.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 300;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-LightItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-LightItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-LightItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-LightItalic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -54,16 +54,16 @@
   font-style:  normal;
   font-weight: 400;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-Regular.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-Regular.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-Regular.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-Regular.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 400;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-Italic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-Italic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-Italic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-Italic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -71,16 +71,16 @@
   font-style:  normal;
   font-weight: 500;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-Medium.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-Medium.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-Medium.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-Medium.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 500;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-MediumItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-MediumItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-MediumItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-MediumItalic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -88,16 +88,16 @@
   font-style:  normal;
   font-weight: 600;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-SemiBold.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-SemiBold.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-SemiBold.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-SemiBold.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 600;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-SemiBoldItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-SemiBoldItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-SemiBoldItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-SemiBoldItalic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -105,16 +105,16 @@
   font-style:  normal;
   font-weight: 700;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-Bold.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-Bold.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-Bold.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-Bold.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 700;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-BoldItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-BoldItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-BoldItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-BoldItalic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -122,16 +122,16 @@
   font-style:  normal;
   font-weight: 800;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-ExtraBold.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-ExtraBold.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-ExtraBold.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-ExtraBold.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 800;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-ExtraBoldItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-ExtraBoldItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-ExtraBoldItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-ExtraBoldItalic.woff?v=3.15") format("woff");
 }
 
 @font-face {
@@ -139,16 +139,16 @@
   font-style:  normal;
   font-weight: 900;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-Black.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-Black.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-Black.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-Black.woff?v=3.15") format("woff");
 }
 @font-face {
   font-family: 'Inter';
   font-style:  italic;
   font-weight: 900;
   font-display: swap;
-  src: url("/static/fonts/inter/Inter-BlackItalic.woff2?v=3.15") format("woff2"),
-       url("/static/fonts/inter/Inter-BlackItalic.woff?v=3.15") format("woff");
+  src: url("../fonts/inter/Inter-BlackItalic.woff2?v=3.15") format("woff2"),
+       url("../fonts/inter/Inter-BlackItalic.woff?v=3.15") format("woff");
 }
 
 /* -------------------------------------------------------
@@ -166,7 +166,7 @@ Usage:
   font-display: swap;
   font-style: normal;
   font-named-instance: 'Regular';
-  src: url("/static/fonts/inter/Inter-roman.var.woff2?v=3.15") format("woff2");
+  src: url("../fonts/inter/Inter-roman.var.woff2?v=3.15") format("woff2");
 }
 @font-face {
   font-family: 'Inter var';
@@ -174,7 +174,7 @@ Usage:
   font-display: swap;
   font-style: italic;
   font-named-instance: 'Italic';
-  src: url("/static/fonts/inter/Inter-italic.var.woff2?v=3.15") format("woff2");
+  src: url("../fonts/inter/Inter-italic.var.woff2?v=3.15") format("woff2");
 }
 
 
@@ -196,5 +196,5 @@ explicitly, e.g.
   font-weight: 100 900;
   font-display: swap;
   font-style: oblique 0deg 10deg;
-  src: url("/static/fonts/inter/Inter.var.woff2?v=3.15") format("woff2");
+  src: url("../fonts/inter/Inter.var.woff2?v=3.15") format("woff2");
 }

+ 2 - 2
resources/css/style.css

@@ -9,6 +9,6 @@
 @import "./table.css";
 @import "./datepicker.css";
 @import "./highlight.css";
-@import "../../static/css/tailwind.core.css"; /* Build by gulp. Check `_buildTailwind` for more detail */
+@import "./tailwind.core.css"; /* Build by gulp. Check `_buildTailwind` for more detail */
 @import "./common.css";
-@import "../../static/css/tailwind.build.css"; /* Build by gulp. Check `_buildTailwind` for more detail */
+@import "./tailwind.build.css"; /* Build by gulp. Check `_buildTailwind` for more detail */

+ 25 - 0
resources/dev.html

@@ -0,0 +1,25 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport"
+        content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+  <meta http-equiv="X-UA-Compatible" content="ie=edge">
+  <title>Electron Development Entries</title>
+</head>
+<body>
+<div style="padding: 50px; text-align: center;">
+  <h1>
+    Development Mode :)
+  </h1>
+  <h3>
+    <a href="http://localhost:3000">
+      http://localhost:3000
+    </a> <br> <br>
+    <a href="http://localhost:3001">
+      http://localhost:3001
+    </a>
+  </h3>
+</div>
+</body>
+</html>

+ 107 - 0
resources/electron-dev.html

@@ -0,0 +1,107 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <meta content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" name="viewport">
+  <link href="./css/style.css" rel="stylesheet" type="text/css">
+  <link href="./css/tailwind.build.css" rel="stylesheet" type="text/css">
+  <link href="./img/logo.png" rel="shortcut icon" type="image/png">
+  <link href="./img/logo.png" rel="shortcut icon" sizes="192x192">
+  <link href="./img/logo.png" rel="apple-touch-icon">
+  <meta content="Logseq" name="apple-mobile-web-app-title">
+  <meta content="yes" name="apple-mobile-web-app-capable">
+  <meta content="yes" name="apple-touch-fullscreen">
+  <meta content="black-translucent" name="apple-mobile-web-app-status-bar-style">
+  <meta content="yes" name="mobile-web-app-capable">
+  <meta content="summary" name="twitter:card">
+  <meta content="A local-first knowledge base which can be synced using Git." name="twitter:description">
+  <meta content="@logseq" name="twitter:site">
+  <meta content="A local-first knowledge base." name="twitter:title">
+  <meta content="https://asset.logseq.com/static/img/logo.png" name="twitter:image:src">
+  <meta content="A local-first knowledge base which can be synced using Git." name="twitter:image:alt">
+  <meta content="A local-first knowledge base." property="og:title">
+  <meta content="site" property="og:type">
+  <meta content="https://logseq.com" property="og:url">
+  <meta content="https://asset.logseq.com/static/img/logo.png" property="og:image">
+  <meta content="A local-first knowledge base which can be synced using Git." property="og:description">
+  <title>Logseq: A local-first knowledge base</title>
+  <meta content="logseq" property="og:site_name">
+  <meta content="A local-first knowledge base which can be synced using Git." name="description">
+</head>
+<body>
+<div id="root">
+  <svg class="ls-center" fill="none" height="300" viewbox="0 0 300 300" width="300">
+    <g filter="url(#filter0_d)">
+      <path class="fade-in one"
+            d="M85.2474 196.999C78.9469 195.427 75.5941 186.78 77.7586 177.685C79.9232 168.589 86.7856 162.49 93.0861 164.061C99.3866 165.632 102.739 174.279 100.575 183.375C98.4102 192.47 91.5479 198.57 85.2474 196.999Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M159.307 218.517C159.091 225.031 149.797 229.996 138.548 229.605C127.298 229.214 118.354 223.616 118.57 217.102C118.786 210.587 128.081 205.623 139.33 206.014C150.579 206.404 159.523 212.002 159.307 218.517Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M96.8481 135.55C101.197 138.758 100.722 147.042 95.7864 154.053C90.8513 161.065 83.3252 164.149 78.9764 160.941C74.6276 157.734 75.103 149.45 80.0381 142.438C84.9732 135.426 92.4993 132.343 96.8481 135.55Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M114.449 221.038C111.977 230.437 99.6731 236.491 86.9668 234.559C74.2605 232.626 65.9638 223.44 68.4357 214.04C70.9075 204.641 83.2119 198.587 95.9182 200.52C108.625 202.452 116.921 211.638 114.449 221.038Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M85.8103 132.35C75.571 131.027 67.8608 120.196 68.589 108.16C69.3173 96.123 78.2083 87.438 88.4476 88.7613C98.6869 90.0845 106.397 100.915 105.669 112.951C104.941 124.988 96.0496 133.673 85.8103 132.35Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M209.177 221.12C206.705 230.519 194.401 236.573 181.694 234.641C168.988 232.708 160.691 223.522 163.163 214.123C165.635 204.723 177.939 198.669 190.646 200.602C203.352 202.534 211.649 211.72 209.177 221.12Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M135.635 151.371C129.334 149.799 125.982 141.152 128.146 132.057C130.311 122.961 137.173 116.862 143.474 118.433C149.774 120.004 153.127 128.651 150.962 137.747C148.798 146.842 141.935 152.942 135.635 151.371Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M209.694 172.889C209.478 179.403 200.184 184.368 188.935 183.977C177.686 183.586 168.742 177.988 168.958 171.473C169.174 164.959 178.468 159.995 189.717 160.386C200.966 160.776 209.91 166.374 209.694 172.889Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M147.236 89.9221C151.584 93.1296 151.109 101.414 146.174 108.425C141.239 115.437 133.713 118.521 129.364 115.313C125.015 112.106 125.49 103.822 130.426 96.81C135.361 89.7984 142.887 86.7146 147.236 89.9221Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M164.837 175.41C162.365 184.809 150.061 190.863 137.354 188.931C124.648 186.998 116.351 177.812 118.823 168.412C121.295 159.013 133.599 152.959 146.306 154.892C159.012 156.824 167.309 166.01 164.837 175.41Z"
+            fill="white"></path>
+      <path class="fade-in three"
+            d="M136.198 86.7217C125.958 85.3985 118.248 74.5682 118.977 62.5316C119.705 50.4949 128.596 41.81 138.835 43.1332C149.074 44.4564 156.785 55.2867 156.056 67.3234C155.328 79.36 146.437 88.045 136.198 86.7217Z"
+            fill="white"></path>
+      <path class="fade-in three"
+            d="M259.564 175.492C257.092 184.891 244.788 190.945 232.082 189.013C219.375 187.08 211.079 177.894 213.551 168.495C216.023 159.095 228.327 153.041 241.033 154.974C253.739 156.906 262.036 166.092 259.564 175.492Z"
+            fill="white"></path>
+    </g>
+    <defs>
+      <filter color-interpolation-filters="sRGB" filterunits="userSpaceOnUse" height="200" id="filter0_d" width="200"
+              x="64" y="43">
+        <feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
+        <feColorMatrix in="SourceAlpha" type="matrix"
+                       values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"></feColorMatrix>
+        <feOffset dy="4"></feOffset>
+        <feGaussianBlur stddeviation="2"></feGaussianBlur>
+        <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"></feColorMatrix>
+        <feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"></feBlend>
+        <feBlend in2="effect1_dropShadow" in="SourceGraphic" mode="normal" result="shape"></feBlend>
+      </filter>
+    </defs>
+  </svg>
+</div>
+<script>window.user = null</script>
+<script src="./js/magic_portal.js"></script>
+<script>let worker = new Worker('./js/worker.js')
+const portal = new MagicPortal(worker);
+(async () => {
+  const git = await portal.get('git')
+  window.git = git
+  const fs = await portal.get('fs')
+  window.fs = fs
+  const pfs = await portal.get('pfs')
+  window.pfs = pfs
+  const gitHttp = await portal.get('gitHttp')
+  window.gitHttp = gitHttp
+  const workerThread = await portal.get('workerThread')
+  window.workerThread = workerThread
+})()
+</script>
+<script src="./js/main.js"></script>
+<script src="./js/highlight.min.js"></script>
+</body>
+</html>

+ 106 - 0
resources/electron.html

@@ -0,0 +1,106 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="utf-8">
+  <meta content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" name="viewport">
+  <link href="./css/style.css" rel="stylesheet" type="text/css">
+  <link href="./img/logo.png" rel="shortcut icon" type="image/png">
+  <link href="./img/logo.png" rel="shortcut icon" sizes="192x192">
+  <link href="./img/logo.png" rel="apple-touch-icon">
+  <meta content="Logseq" name="apple-mobile-web-app-title">
+  <meta content="yes" name="apple-mobile-web-app-capable">
+  <meta content="yes" name="apple-touch-fullscreen">
+  <meta content="black-translucent" name="apple-mobile-web-app-status-bar-style">
+  <meta content="yes" name="mobile-web-app-capable">
+  <meta content="summary" name="twitter:card">
+  <meta content="A local-first knowledge base which can be synced using Git." name="twitter:description">
+  <meta content="@logseq" name="twitter:site">
+  <meta content="A local-first knowledge base." name="twitter:title">
+  <meta content="https://asset.logseq.com/static/img/logo.png" name="twitter:image:src">
+  <meta content="A local-first knowledge base which can be synced using Git." name="twitter:image:alt">
+  <meta content="A local-first knowledge base." property="og:title">
+  <meta content="site" property="og:type">
+  <meta content="https://logseq.com" property="og:url">
+  <meta content="https://asset.logseq.com/static/img/logo.png" property="og:image">
+  <meta content="A local-first knowledge base which can be synced using Git." property="og:description">
+  <title>Logseq: A local-first knowledge base</title>
+  <meta content="logseq" property="og:site_name">
+  <meta content="A local-first knowledge base which can be synced using Git." name="description">
+</head>
+<body>
+<div id="root">
+  <svg class="ls-center" fill="none" height="300" viewbox="0 0 300 300" width="300">
+    <g filter="url(#filter0_d)">
+      <path class="fade-in one"
+            d="M85.2474 196.999C78.9469 195.427 75.5941 186.78 77.7586 177.685C79.9232 168.589 86.7856 162.49 93.0861 164.061C99.3866 165.632 102.739 174.279 100.575 183.375C98.4102 192.47 91.5479 198.57 85.2474 196.999Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M159.307 218.517C159.091 225.031 149.797 229.996 138.548 229.605C127.298 229.214 118.354 223.616 118.57 217.102C118.786 210.587 128.081 205.623 139.33 206.014C150.579 206.404 159.523 212.002 159.307 218.517Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M96.8481 135.55C101.197 138.758 100.722 147.042 95.7864 154.053C90.8513 161.065 83.3252 164.149 78.9764 160.941C74.6276 157.734 75.103 149.45 80.0381 142.438C84.9732 135.426 92.4993 132.343 96.8481 135.55Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M114.449 221.038C111.977 230.437 99.6731 236.491 86.9668 234.559C74.2605 232.626 65.9638 223.44 68.4357 214.04C70.9075 204.641 83.2119 198.587 95.9182 200.52C108.625 202.452 116.921 211.638 114.449 221.038Z"
+            fill="white"></path>
+      <path class="fade-in one"
+            d="M85.8103 132.35C75.571 131.027 67.8608 120.196 68.589 108.16C69.3173 96.123 78.2083 87.438 88.4476 88.7613C98.6869 90.0845 106.397 100.915 105.669 112.951C104.941 124.988 96.0496 133.673 85.8103 132.35Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M209.177 221.12C206.705 230.519 194.401 236.573 181.694 234.641C168.988 232.708 160.691 223.522 163.163 214.123C165.635 204.723 177.939 198.669 190.646 200.602C203.352 202.534 211.649 211.72 209.177 221.12Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M135.635 151.371C129.334 149.799 125.982 141.152 128.146 132.057C130.311 122.961 137.173 116.862 143.474 118.433C149.774 120.004 153.127 128.651 150.962 137.747C148.798 146.842 141.935 152.942 135.635 151.371Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M209.694 172.889C209.478 179.403 200.184 184.368 188.935 183.977C177.686 183.586 168.742 177.988 168.958 171.473C169.174 164.959 178.468 159.995 189.717 160.386C200.966 160.776 209.91 166.374 209.694 172.889Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M147.236 89.9221C151.584 93.1296 151.109 101.414 146.174 108.425C141.239 115.437 133.713 118.521 129.364 115.313C125.015 112.106 125.49 103.822 130.426 96.81C135.361 89.7984 142.887 86.7146 147.236 89.9221Z"
+            fill="white"></path>
+      <path class="fade-in two"
+            d="M164.837 175.41C162.365 184.809 150.061 190.863 137.354 188.931C124.648 186.998 116.351 177.812 118.823 168.412C121.295 159.013 133.599 152.959 146.306 154.892C159.012 156.824 167.309 166.01 164.837 175.41Z"
+            fill="white"></path>
+      <path class="fade-in three"
+            d="M136.198 86.7217C125.958 85.3985 118.248 74.5682 118.977 62.5316C119.705 50.4949 128.596 41.81 138.835 43.1332C149.074 44.4564 156.785 55.2867 156.056 67.3234C155.328 79.36 146.437 88.045 136.198 86.7217Z"
+            fill="white"></path>
+      <path class="fade-in three"
+            d="M259.564 175.492C257.092 184.891 244.788 190.945 232.082 189.013C219.375 187.08 211.079 177.894 213.551 168.495C216.023 159.095 228.327 153.041 241.033 154.974C253.739 156.906 262.036 166.092 259.564 175.492Z"
+            fill="white"></path>
+    </g>
+    <defs>
+      <filter color-interpolation-filters="sRGB" filterunits="userSpaceOnUse" height="200" id="filter0_d" width="200"
+              x="64" y="43">
+        <feFlood flood-opacity="0" result="BackgroundImageFix"></feFlood>
+        <feColorMatrix in="SourceAlpha" type="matrix"
+                       values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"></feColorMatrix>
+        <feOffset dy="4"></feOffset>
+        <feGaussianBlur stddeviation="2"></feGaussianBlur>
+        <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"></feColorMatrix>
+        <feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"></feBlend>
+        <feBlend in2="effect1_dropShadow" in="SourceGraphic" mode="normal" result="shape"></feBlend>
+      </filter>
+    </defs>
+  </svg>
+</div>
+<script>window.user = null</script>
+<script src="./js/magic_portal.js"></script>
+<script>let worker = new Worker('./js/worker.js')
+const portal = new MagicPortal(worker);
+(async () => {
+  const git = await portal.get('git')
+  window.git = git
+  const fs = await portal.get('fs')
+  window.fs = fs
+  const pfs = await portal.get('pfs')
+  window.pfs = pfs
+  const gitHttp = await portal.get('gitHttp')
+  window.gitHttp = gitHttp
+  const workerThread = await portal.get('workerThread')
+  window.workerThread = workerThread
+})()
+</script>
+<script src="./js/main.js"></script>
+<script src="./js/highlight.min.js"></script>
+</body>
+</html>

+ 42 - 0
resources/forge.config.js

@@ -0,0 +1,42 @@
+const path = require('path')
+
+module.exports = {
+  packagerConfig: {
+    icon: './icons/logseq_big_sur.icns'
+  },
+
+  makers: [
+    {
+      'name': '@electron-forge/maker-squirrel',
+      'config': {
+        'name': 'Logseq'
+      }
+    },
+    {
+      name: '@electron-forge/maker-dmg',
+      config: {
+        background: './img/dmg-bg.png',
+        format: 'ULFO',
+        icon: './icons/logseq_big_sur.icns',
+        name: 'Logseq'
+      }
+    },
+    {
+      name: '@electron-forge/maker-zip',
+      platforms: ['darwin', 'linux']
+    }
+  ],
+
+  publishers: [
+    {
+      name: '@electron-forge/publisher-github',
+      config: {
+        repository: {
+          owner: 'logseq',
+          name: 'logseq'
+        },
+        prerelease: true
+      }
+    }
+  ]
+}

BIN
resources/icons/logseq.icns


BIN
resources/icons/logseq.ico


BIN
resources/icons/logseq.png


BIN
resources/icons/logseq_big_sur.icns


BIN
resources/icons/logseq_big_sur.png


BIN
resources/img/dmg-bg.png


+ 120 - 0
resources/js/preload.js

@@ -0,0 +1,120 @@
+const fs = require('fs')
+const path = require('path')
+const { ipcRenderer, contextBridge, shell, clipboard, BrowserWindow } = require('electron')
+
+const IS_MAC = process.platform === 'darwin'
+const IS_WIN32 = process.platform === 'win32'
+
+function getFilePathFromClipboard () {
+  if (IS_WIN32) {
+    const rawFilePath = clipboard.read('FileNameW')
+    return rawFilePath.replace(new RegExp(String.fromCharCode(0), 'g'), '')
+  } else if (IS_MAC) {
+    return clipboard.read('public.file-url').replace('file://', '')
+  } else{
+    return clipboard.readText()
+  }
+}
+
+function isClipboardHasImage () {
+  return !clipboard.readImage().isEmpty()
+}
+
+contextBridge.exposeInMainWorld('apis', {
+  doAction: async (arg) => {
+    return await ipcRenderer.invoke('main', arg)
+  },
+
+  on: (channel, callback) => {
+    const newCallback = (_, data) => callback(data)
+    ipcRenderer.on(channel, newCallback)
+  },
+
+  checkForUpdates: async (...args) => {
+    await ipcRenderer.invoke('check-for-updates', ...args)
+  },
+
+  setUpdatesCallback (cb) {
+    if (typeof cb !== 'function') return
+
+    const channel = 'updates-callback'
+    ipcRenderer.removeAllListeners(channel)
+    ipcRenderer.on(channel, cb)
+  },
+
+  installUpdatesAndQuitApp () {
+    ipcRenderer.invoke('install-updates', true)
+  },
+
+  async openExternal (url, options) {
+    await shell.openExternal(url, options)
+  },
+
+  async openPath (path) {
+    await shell.openPath(path)
+  },
+
+  showItemInFolder (fullpath) {
+    shell.showItemInFolder(fullpath)
+  },
+
+  /**
+   * When from is empty. The resource maybe from
+   * client paste or screenshoot.
+   * @param repoPathRoot
+   * @param to
+   * @param from?
+   * @returns {Promise<void>}
+   */
+  async copyFileToAssets (repoPathRoot, to, from) {
+    if (from && fs.statSync(from).isDirectory()) {
+      throw new Error('not support copy directory')
+    }
+
+    const dest = path.join(repoPathRoot, to)
+    const assetsRoot = path.dirname(dest)
+
+    if (!/assets$/.test(assetsRoot)) {
+      throw new Error('illegal assets dirname')
+    }
+
+    await fs.promises.mkdir(assetsRoot, { recursive: true })
+
+    from = decodeURIComponent(from || getFilePathFromClipboard())
+
+    if (from) {
+      // console.debug('copy file: ', from, dest)
+      await fs.promises.copyFile(from, dest)
+      return path.basename(from)
+    }
+
+    // support image
+    // console.debug('read image: ', from, dest)
+    const nImg = clipboard.readImage()
+
+    if (nImg && !nImg.isEmpty()) {
+      const rawExt = path.extname(dest)
+      return await fs.promises.writeFile(
+        dest.replace(rawExt, '.png'),
+        nImg.toPNG()
+      )
+    }
+  },
+
+  toggleMaxOrMinActiveWindow (isToggleMin = false) {
+    ipcRenderer.invoke('toggle-max-or-min-active-win', isToggleMin)
+  },
+
+  /**
+   * internal
+   * @param type
+   * @param args
+   * @private
+   */
+  async _callApplication (type, ...args) {
+    return await ipcRenderer.invoke('call-application', type, ...args)
+  },
+
+  getFilePathFromClipboard,
+  isClipboardHasImage
+})

+ 2 - 2
resources/js/worker.js

@@ -1,10 +1,10 @@
 importScripts(
   // Batched optimization
-  "/static/js/lightning-fs.min.js?v=0.0.2.3",
+  "./lightning-fs.min.js?v=0.0.2.3",
   "https://cdn.jsdelivr.net/npm/[email protected]/index.umd.min.js",
   "https://cdn.jsdelivr.net/npm/[email protected]/http/web/index.umd.js",
   // Fixed a bug
-  "/static/js/magic_portal.js"
+  "./magic_portal.js"
 );
 
 const detect = () => {

+ 33 - 0
resources/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "logseq",
+  "version": "0.0.1",
+  "main": "electron.js",
+  "author": "logseq",
+  "description": "A privacy-first, open-source platform for knowledge sharing and management.",
+  "scripts": {
+    "electron:dev": "electron-forge start",
+    "electron:debug": "electron-forge start --inspect-electron",
+    "electron:make": "electron-forge make",
+    "electron:publish:github": "electron-forge publish"
+  },
+  "config": {
+    "forge": "./forge.config.js"
+  },
+  "dependencies": {
+    "electron-log": "^4.3.1",
+    "electron-squirrel-startup": "^1.0.0",
+    "update-electron-app": "^2.0.1",
+    "node-fetch": "^2.6.1",
+    "open": "^7.3.1",
+    "chokidar": "^3.5.1"
+  },
+  "devDependencies": {
+    "@electron-forge/cli": "^6.0.0-beta.54",
+    "@electron-forge/maker-deb": "^6.0.0-beta.54",
+    "@electron-forge/maker-dmg": "^6.0.0-beta.54",
+    "@electron-forge/maker-rpm": "^6.0.0-beta.54",
+    "@electron-forge/maker-squirrel": "^6.0.0-beta.54",
+    "@electron-forge/maker-zip": "^6.0.0-beta.54",
+    "electron": "11.2.0"
+  }
+}

+ 7 - 0
shadow-cljs.edn

@@ -33,6 +33,13 @@
     :watch-path   "static"
     :preloads     [devtools.preload]}}
 
+  :electron {:target :node-script
+             :output-to "static/electron.js"
+             :main electron.core/main
+             :devtools
+             {:before-load electron.core/stop
+              :after-load electron.core/start}}
+
   :test
   {:target :node-test
    :output-to "static/tests.js"

+ 5 - 0
src/dev-cljs/shadow/user.clj

@@ -5,3 +5,8 @@
   []
   (api/watch :app)
   (api/repl :app))
+
+(defn electron-repl
+  []
+  (api/watch :electron)
+  (api/repl :electron))

+ 122 - 0
src/electron/electron/core.cljs

@@ -0,0 +1,122 @@
+(ns electron.core
+  (:require [electron.handler :as handler]
+            [electron.updater :refer [init-updater]]
+            [electron.utils :refer [mac? win32? prod? dev? log open]]
+            [clojure.string :as string]
+            ["fs" :as fs]
+            ["path" :as path]
+            ["electron" :refer [BrowserWindow app protocol ipcMain] :as electron]))
+
+(def ROOT_PATH (path/join js/__dirname ".."))
+(def MAIN_WINDOW_ENTRY (str "file://" (path/join js/__dirname (if dev? "electron-dev.html" "electron.html"))))
+
+(defonce *setup-fn (volatile! nil))
+(defonce *teardown-fn (volatile! nil))
+
+;; Handle creating/removing shortcuts on Windows when installing/uninstalling.
+(when (js/require "electron-squirrel-startup") (.quit app))
+
+(defn create-main-window
+  "create main app window"
+  []
+  (let [win-opts {:width         980
+                  :height        700
+                  :frame         win32?
+                  :titleBarStyle (if mac? "hidden" nil)
+                  :webPreferences
+                  {:nodeIntegration         false
+                   :nodeIntegrationInWorker false
+                   :contextIsolation        true
+                   :spellcheck              true
+                   :preload                 (path/join js/__dirname "js/preload.js")}}
+        url MAIN_WINDOW_ENTRY
+        win (BrowserWindow. (clj->js win-opts))]
+    (when win32? (.removeMenu win))
+    (.loadURL win url)
+    (when dev? (.. win -webContents (openDevTools)))
+    win))
+
+(defn setup-updater! [^js win]
+  ;; manual updater
+  (init-updater {:repo   "logseq/logseq"
+                 :logger log
+                 :win    win}))
+
+(defn setup-interceptor! []
+  (.registerFileProtocol
+   protocol "assets"
+   (fn [^js request callback]
+     (let [url (.-url request)
+           path (string/replace url "assets://" "")]
+       (callback #js {:path path}))))
+  #(.unregisterProtocol protocol "assets"))
+
+(defn setup-app-manager!
+  [^js win]
+  (let [toggle-win-channel "toggle-max-or-min-active-win"
+        call-app-channel "call-application"]
+    (doto ipcMain
+      (.handle toggle-win-channel
+               (fn [_ toggle-min?]
+                 (when-let [active-win (.getFocusedWindow BrowserWindow)]
+                   (if toggle-min?
+                     (if (.isMinimized active-win)
+                       (.restore active-win)
+                       (.minimize active-win))
+                     (if (.isMaximized active-win)
+                       (.unmaximize active-win)
+                       (.maximize active-win))))))
+      (.handle call-app-channel
+               (fn [_ type & args]
+                 (try
+                   (js-invoke app type args)
+                   (catch js/Error e
+                     (js/console.error e))))))
+    (.. win -webContents (on "new-window"
+                             (fn [e url]
+                               (.. log (info "new-window" url))
+                               (open url)
+                               (.preventDefault e))))
+    #(do (.removeHandler ipcMain toggle-win-channel)
+         (.removeHandler ipcMain call-app-channel))))
+
+(defn main
+  []
+  (.on app "window-all-closed" #(when-not mac? (.quit app)))
+  (.on app "ready"
+       (fn []
+         (let [^js win (create-main-window)
+               *win (atom win)
+               *quitting? (atom false)]
+
+           (.. log (info (str "Logseq App(" (.getVersion app) ") Starting... ")))
+
+           (vreset! *setup-fn
+                    (fn []
+                      (let [t0 (setup-updater! win)
+                            t1 (setup-interceptor!)
+                            t2 (setup-app-manager! win)
+                            tt (handler/set-ipc-handler! win)]
+
+                        (vreset! *teardown-fn
+                                 #(doseq [f [t0 t1 t2 tt]]
+                                    (and f (f)))))))
+
+           ;; setup effects
+           (@*setup-fn)
+
+           ;; main window events
+           (.on win "close" #(if (or @*quitting? (not mac?))
+                               (reset! *win nil)
+                               (do (.preventDefault ^js/Event %)
+                                   (.hide win))))
+           (.on app "before-quit" #(reset! *quitting? true))
+           (.on app "activate" #(if @*win (.show win)))))))
+
+(defn start []
+  (js/console.log "Main - start")
+  (when @*setup-fn (@*setup-fn)))
+
+(defn stop []
+  (js/console.log "Main - stop")
+  (when @*teardown-fn (@*teardown-fn)))

+ 145 - 0
src/electron/electron/handler.cljs

@@ -0,0 +1,145 @@
+(ns electron.handler
+  (:require ["electron" :refer [ipcMain dialog app]]
+            [cljs-bean.core :as bean]
+            ["fs" :as fs]
+            ["path" :as path]
+            ["chokidar" :as watcher]
+            [promesa.core :as p]
+            [goog.object :as gobj]
+            [clojure.string :as string]
+            [electron.utils :as utils]))
+
+(defmulti handle (fn [_window args] (keyword (first args))))
+
+(defmethod handle :mkdir [_window [_ dir]]
+  (fs/mkdirSync dir))
+
+(defn- readdir
+  [dir]
+  (->> (tree-seq
+        (fn [f] (.isDirectory (fs/statSync f) ()))
+        (fn [d] (map #(.join path d %) (fs/readdirSync d)))
+        dir)
+       (doall)
+       (vec)))
+
+(defmethod handle :readdir [_window [_ dir]]
+  (readdir dir))
+
+(defmethod handle :unlink [_window [_ path]]
+  (fs/unlinkSync path))
+
+(defn- read-file
+  [path]
+  (.toString (fs/readFileSync path)))
+(defmethod handle :readFile [_window [_ path]]
+  (read-file path))
+
+(defmethod handle :writeFile [_window [_ path content]]
+  (fs/writeFileSync path content))
+
+(defmethod handle :rename [_window [_ old-path new-path]]
+  (fs/renameSync old-path new-path))
+
+(defmethod handle :stat [_window [_ path]]
+  (fs/statSync path))
+
+(defn- fix-win-path!
+  [path]
+  (when path
+    (if utils/win32?
+      (string/replace path "\\" "/")
+      path)))
+
+(defn- get-files
+  [path]
+  (let [result (->> (map
+                     (fn [path]
+                       (let [stat (fs/statSync path)]
+                         (when-not (.isDirectory stat)
+                           {:path (fix-win-path! path)
+                            :content (read-file path)
+                            :stat stat})))
+                     (readdir path))
+                    (remove nil?))]
+    (vec (cons {:path (fix-win-path! path)} result))))
+
+;; TODO: Is it going to be slow if it's a huge directory
+(defmethod handle :openDir [window _messages]
+  (let [result (.showOpenDialogSync dialog (bean/->js
+                                            {:properties ["openDirectory"]}))
+        path (first result)]
+    (get-files path)))
+
+(defmethod handle :getFiles [window [_ path]]
+  (get-files path))
+
+(defn- get-file-ext
+  [file]
+  (last (string/split file #"\.")))
+
+(defonce file-watcher-chan "file-watcher")
+(defn send-file-watcher! [^js win type payload]
+  (.. win -webContents
+      (send file-watcher-chan
+            (bean/->js {:type type :payload payload}))))
+
+(defn watch-dir!
+  [win dir]
+  (let [watcher (.watch watcher dir
+                        (clj->js
+                         {:ignored (fn [path]
+                                     (some #(string/starts-with? path (str dir "/" %))
+                                           ["." "assets" "node_modules"]))
+                          ;; :ignoreInitial true
+                          :persistent true
+                          :awaitWriteFinish true}))]
+    (.on watcher "add"
+         (fn [path]
+           (send-file-watcher! win "add"
+                               {:dir (fix-win-path! dir)
+                                :path (fix-win-path! path)
+                                :content (read-file path)
+                                :stat (fs/statSync path)})))
+    (.on watcher "change"
+         (fn [path]
+           (send-file-watcher! win "change"
+                               {:dir (fix-win-path! dir)
+                                :path (fix-win-path! path)
+                                :content (read-file path)
+                                :stat (fs/statSync path)})))
+    (.on watcher "unlink"
+         (fn [path]
+           (send-file-watcher! win "unlink"
+                               {:dir (fix-win-path! dir)
+                                :path (fix-win-path! path)})))
+    (.on watcher "error"
+         (fn [path]
+           (println "Watch error happened: "
+                    {:path path})))
+
+    (.on app "quit" #(.close watcher))
+
+    true))
+
+(defmethod handle :addDirWatcher [window [_ dir]]
+  (when dir
+    (watch-dir! window dir)))
+
+(defmethod handle :default [args]
+  (println "Error: no ipc handler for: " (bean/->js args)))
+
+(defn set-ipc-handler! [window]
+  (let [main-channel "main"]
+    (.handle ipcMain main-channel
+             (fn [event args-js]
+               (try
+                 (let [message (bean/->clj args-js)]
+                   (bean/->js (handle window message)))
+                 (catch js/Error e
+                   (when-not (contains? #{"mkdir" "stat"} (nth args-js 0))
+                     (println "IPC error: " {:event event
+                                            :args args-js}
+                             e))
+                   e))))
+    #(.removeHandler ipcMain main-channel)))

+ 119 - 0
src/electron/electron/updater.cljs

@@ -0,0 +1,119 @@
+(ns electron.updater
+  (:require [electron.utils :refer [mac? win32? prod? open fetch]]
+            [frontend.version :refer [version]]
+            [clojure.string :as string]
+            [promesa.core :as p]
+            [cljs-bean.core :as bean]
+            ["os" :as os]
+            ["fs" :as fs]
+            ["path" :as path]
+            ["electron" :refer [ipcMain app]]))
+
+(def *update-ready-to-install (atom nil))
+(def *update-pending (atom nil))
+
+;Event: 'error'
+;Event: 'checking-for-update'
+;Event: 'update-available'
+;Event: 'update-not-available'
+;Event: 'download-progress'
+;Event: 'update-downloaded'
+;Event: 'completed'
+
+(defn get-latest-artifact-info
+  [repo]
+  (let [;endpoint "https://update.electronjs.org/xyhp915/cljs-todo/darwin-x64/0.0.4"
+        endpoint (str "https://update.electronjs.org/" repo "/" (if mac? "darwin" "win32") "-x64/" version)]
+    (p/catch
+     (p/let [res (fetch endpoint)
+             status (.-status res)
+             text (.text res)]
+       (if (.-ok res)
+         (let [info (if-not (string/blank? text) (js/JSON.parse text))]
+           (bean/->clj info))
+         (throw (js/Error. (str "[" status "] " text)))))
+     (fn [e]
+       (js/console.warn "[update server error] " e)
+       (throw e)))))
+
+(defn check-for-updates
+  [{:keys           [repo ^js logger ^js win]
+    [auto-download] :args}]
+  (let [debug (partial (.-warn logger) "[updater]")
+        emit (fn [type payload]
+               (.. win -webContents
+                   (send "updates-callback" (bean/->js {:type type :payload payload}))))]
+    (debug "check for updates #" repo version)
+    (p/create
+     (fn [resolve reject]
+       (emit "checking-for-update" nil)
+       (-> (p/let
+            [artifact (get-latest-artifact-info repo)
+             url (if-not artifact (do (emit "update-not-available" nil) (throw nil)) (:url artifact))
+             _ (if url (emit "update-available" (bean/->js artifact)) (throw (js/Error. "download url not exists")))
+               ;; start download FIXME: user's preference about auto download
+             _ (when-not auto-download (throw nil))
+             ^js dl-res (fetch url)
+             _ (if-not (.-ok dl-res) (throw (js/Error. "download resource not available")))
+             dest-info (p/create
+                        (fn [resolve1 reject1]
+                          (let [headers (. dl-res -headers)
+                                total-size (js/parseInt (.get headers "content-length"))
+                                body (.-body dl-res)
+                                start-at (.now js/Date)
+                                *downloaded (atom 0)
+                                dest-basename (path/basename url)
+                                tmp-dest-file (path/join (os/tmpdir) (str dest-basename ".pending"))
+                                dest-file (.createWriteStream fs tmp-dest-file)]
+                            (doto body
+                              (.on "data" (fn [chunk]
+                                            (let [downloaded (+ @*downloaded (.-length chunk))
+                                                  percent (.toFixed (/ (* 100 downloaded) total-size) 2)
+                                                  elapsed (/ (- (js/Date.now) start-at) 1000)]
+                                              (.write dest-file chunk)
+                                              (emit "download-progress" {:total      total-size
+                                                                         :downloaded downloaded
+                                                                         :percent    percent
+                                                                         :elapsed    elapsed})
+                                              (reset! *downloaded downloaded))))
+                              (.on "error" (fn [e]
+                                             (reject1 e)))
+                              (.on "end" (fn [e]
+                                           (.close dest-file)
+                                           (let [dest-file (string/replace tmp-dest-file ".pending" "")]
+                                             (fs/renameSync tmp-dest-file dest-file)
+                                             (resolve1 (merge artifact {:dest-file dest-file})))))))))]
+             (reset! *update-ready-to-install dest-info)
+             (emit "update-downloaded" dest-info)
+             (resolve nil))
+           (p/catch
+            (fn [e]
+              (if e
+                (do
+                  (emit "error" e)
+                  (reject e))
+                (resolve nil))))
+           (p/finally
+             (fn []
+               (emit "completed" nil))))))))
+
+(defn init-updater
+  [{:keys [repo logger ^js win] :as opts}]
+  (let [check-channel "check-for-updates"
+        install-channel "install-updates"
+        check-listener (fn [e & args]
+                         (when-not @*update-pending
+                           (reset! *update-pending true)
+                           (p/finally
+                             (check-for-updates (merge opts {:args args}))
+                             #(reset! *update-pending nil))))
+        install-listener (fn [e quit-app?]
+                           (when-let [dest-file (:dest-file @*update-ready-to-install)]
+                             (open dest-file)
+                             (and quit-app? (js/setTimeout #(.quit app) 1000))))]
+    (.handle ipcMain check-channel check-listener)
+    (.handle ipcMain install-channel install-listener)
+    #(do
+       (.removeHandler ipcMain install-channel)
+       (.removeHandler ipcMain check-channel)
+       (reset! *update-pending nil))))

+ 11 - 0
src/electron/electron/utils.cljs

@@ -0,0 +1,11 @@
+(ns electron.utils)
+
+(defonce mac? (= (.-platform js/process) "darwin"))
+(defonce win32? (= (.-platform js/process) "win32"))
+
+(defonce prod? (= js/process.env.NODE_ENV "production"))
+(defonce dev? (not prod?))
+(defonce log (js/require "electron-log"))
+
+(defonce open (js/require "open"))
+(defonce fetch (js/require "node-fetch"))

+ 9 - 0
src/main/electron/ipc.cljs

@@ -0,0 +1,9 @@
+(ns electron.ipc
+  (:require [cljs-bean.core :as bean]
+            [promesa.core :as p]))
+
+;; TODO: handle errors
+(defn ipc
+  [& args]
+  (p/let [result (js/window.apis.doAction (bean/->js args))]
+    result))

+ 7 - 1
src/main/frontend/commands.cljs

@@ -3,6 +3,7 @@
             [frontend.date :as date]
             [frontend.state :as state]
             [frontend.search :as search]
+            [frontend.config :as config]
             [clojure.string :as string]
             [goog.dom :as gdom]
             [goog.object :as gobj]
@@ -123,7 +124,12 @@
                   [:editor/search-template]]]
      ;; same as link
      ["Image Link" link-steps]
-     (when (state/logged?)
+     (cond
+       (and (util/electron?) (config/local-db? (state/get-current-repo)))
+
+       ["Upload an asset" [[:editor/click-hidden-file-input :id]]]
+
+       (state/logged?)
        ["Upload an image" [[:editor/click-hidden-file-input :id]]])
      ["Embed Youtube Video" [[:editor/input "{{youtube }}" {:last-pattern slash
                                                             :backward-pos 2}]]]

+ 13 - 3
src/main/frontend/components/block.cljs

@@ -226,7 +226,7 @@
   (let [src (::src state)
         granted? (state/sub [:nfs/user-granted? (state/get-current-repo)])]
 
-    (when granted?
+    (when (or granted? (util/electron?))
       (p/then (editor-handler/make-asset-url href) #(reset! src %)))
 
     (when @src
@@ -376,6 +376,12 @@
            label
            original-page-name))])))
 
+(rum/defc asset-reference
+  [title path]
+  (let [repo-path (config/get-repo-dir (state/get-current-repo))
+        full-path (str repo-path (string/replace path "../" "/"))]
+    [:a.asset-ref {:target "_blank" :href full-path} (or title path)]))
+
 (rum/defc page-reference < rum/reactive
   [html-export? s config label]
   (let [show-brackets? (state/show-brackets?)
@@ -622,6 +628,7 @@
 
           (= \# (first s))
           (->elem :a {:href (str "#" (mldoc/anchorLink (subs s 1)))} (map-inline config label))
+
           ;; FIXME: same headline, see more https://orgmode.org/manual/Internal-Links.html
           (and (= \* (first s))
                (not= \* (last s)))
@@ -631,6 +638,9 @@
           (->elem :a {:href s}
                   (map-inline config label))
 
+          (and (util/electron?) (config/local-asset? s))
+          (asset-reference (second (first label)) s)
+
           :else
           (page-reference html-export? s config label))
 
@@ -894,13 +904,13 @@
      [:a (if (not dummy?)
            {:href (rfe/href :page {:name uuid})
             :on-click (fn [e]
-                        (.preventDefault e)
                         (when (gobj/get e "shiftKey")
                           (state/sidebar-add-block!
                            (state/get-current-repo)
                            (:db/id block)
                            :block
-                           block)))})
+                           block)
+                          (util/stop e)))})
       [:span.bullet-container.cursor
        {:id (str "dot-" uuid)
         :draggable true

+ 1 - 1
src/main/frontend/components/diff.cljs

@@ -57,7 +57,7 @@
      [:div.cp__diff-file-header
       [:a.mr-2 {:on-click (fn [] (toggle-collapse? path))}
        (if collapse?
-         (svg/arrow-right)
+         (svg/arrow-right-2)
          (svg/arrow-down))]
       [:span.cp__diff-file-header-content {:style {:word-break "break-word"}}
        path]

+ 102 - 89
src/main/frontend/components/editor.cljs

@@ -58,7 +58,7 @@
                        (editor-handler/insert-command! id command-steps
                                                        format
                                                        {:restore? restore-slash?})))
-        :class "black"}))))
+        :class     "black"}))))
 
 (rum/defc block-commands < rum/reactive
   [id format]
@@ -71,7 +71,7 @@
                      (editor-handler/insert-command! id (get (into {} matched) chosen)
                                                      format
                                                      {:last-pattern commands/angle-bracket}))
-        :class "black"}))))
+        :class     "black"}))))
 
 (rum/defc page-search < rum/reactive
   {:will-unmount (fn [state] (reset! editor-handler/*selected-text nil) state)}
@@ -108,7 +108,7 @@
                                                                    page-ref-text
                                                                    format
                                                                    {:last-pattern (str "[[" (if @editor-handler/*selected-text "" q))
-                                                                    :postfix-fn (fn [s] (util/replace-first "]]" s ""))}))))
+                                                                    :postfix-fn   (fn [s] (util/replace-first "]]" s ""))}))))
               non-exist-page-handler (fn [_state]
                                        (state/set-editor-show-page-search! false)
                                        (if (state/org-mode-file-link? (state/get-current-repo))
@@ -128,9 +128,9 @@
           (ui/auto-complete
            matched-pages
            {:on-chosen chosen-handler
-            :on-enter non-exist-page-handler
+            :on-enter  non-exist-page-handler
             :empty-div [:div.text-gray-500.pl-4.pr-4 "Search for a page"]
-            :class "black"}))))))
+            :class     "black"}))))))
 
 (rum/defc block-search < rum/reactive
   {:will-unmount (fn [state] (reset! editor-handler/*selected-text nil) state)}
@@ -157,7 +157,7 @@
                                                                  (util/format "((%s))" uuid-string)
                                                                  format
                                                                  {:last-pattern (str "((" (if @editor-handler/*selected-text "" q))
-                                                                  :postfix-fn (fn [s] (util/replace-first "))" s ""))})
+                                                                  :postfix-fn   (fn [s] (util/replace-first "))" s ""))})
 
                                  ;; Save it so it'll be parsed correctly in the future
                                  (editor-handler/set-block-property! (:block/uuid chosen)
@@ -171,12 +171,12 @@
                                         (util/cursor-move-forward input 2))]
           (ui/auto-complete
            matched-blocks
-           {:on-chosen chosen-handler
-            :on-enter non-exist-block-handler
-            :empty-div [:div.text-gray-500.pl-4.pr-4 "Search for a block"]
+           {:on-chosen   chosen-handler
+            :on-enter    non-exist-block-handler
+            :empty-div   [:div.text-gray-500.pl-4.pr-4 "Search for a block"]
             :item-render (fn [{:block/keys [content]}]
                            (subs content 0 64))
-            :class "black"}))))))
+            :class       "black"}))))))
 
 (rum/defc template-search < rum/reactive
   {:will-unmount (fn [state] (reset! editor-handler/*selected-text nil) state)}
@@ -224,12 +224,12 @@
                                   (state/set-editor-show-template-search! false))]
           (ui/auto-complete
            matched-templates
-           {:on-chosen chosen-handler
-            :on-enter non-exist-handler
-            :empty-div [:div.text-gray-500.pl-4.pr-4 "Search for a template"]
+           {:on-chosen   chosen-handler
+            :on-enter    non-exist-handler
+            :empty-div   [:div.text-gray-500.pl-4.pr-4 "Search for a template"]
             :item-render (fn [[template _block-db-id]]
                            template)
-            :class "black"}))))))
+            :class       "black"}))))))
 
 (rum/defc mobile-bar < rum/reactive
   [parent-state parent-id]
@@ -253,17 +253,17 @@
     {:on-click #(commands/simple-insert!
                  parent-id "[[]]"
                  {:backward-pos 2
-                  :check-fn (fn [_ _ new-pos]
-                              (reset! commands/*slash-caret-pos new-pos)
-                              (commands/handle-step [:editor/search-page]))})}
+                  :check-fn     (fn [_ _ new-pos]
+                                  (reset! commands/*slash-caret-pos new-pos)
+                                  (commands/handle-step [:editor/search-page]))})}
     "[[]]"]
    [:button.font-extrabold.bottom-action.-mt-1
     {:on-click #(commands/simple-insert!
                  parent-id "(())"
                  {:backward-pos 2
-                  :check-fn (fn [_ _ new-pos]
-                              (reset! commands/*slash-caret-pos new-pos)
-                              (commands/handle-step [:editor/search-block]))})}
+                  :check-fn     (fn [_ _ new-pos]
+                                  (reset! commands/*slash-caret-pos new-pos)
+                                  (commands/handle-step [:editor/search-block]))})}
     "(())"]])
 
 (rum/defcs input < rum/reactive
@@ -277,7 +277,7 @@
             (let [input-value (get state ::input-value)
                   input-option (get @state/state :editor/show-input)]
               (when (seq @input-value)
-                ;; no new line input
+                                   ;; no new line input
                 (util/stop e)
                 (let [[_id on-submit] (:rum/args state)
                       {:keys [pos]} @*slash-caret-pos
@@ -307,11 +307,11 @@
               [:input.form-input.block.w-full.pl-2.sm:text-sm.sm:leading-5
                (merge
                 (cond->
-                 {:key (str "modal-input-" (name id))
-                  :id (str "modal-input-" (name id))
-                  :type (or type "text")
-                  :on-change (fn [e]
-                               (swap! input-value assoc id (util/evalue e)))
+                 {:key           (str "modal-input-" (name id))
+                  :id            (str "modal-input-" (name id))
+                  :type          (or type "text")
+                  :on-change     (fn [e]
+                                   (swap! input-value assoc id (util/evalue e)))
                   :auto-complete (if (util/chrome?) "chrome-off" "off")}
                   placeholder
                   (assoc :placeholder placeholder))
@@ -358,31 +358,31 @@
     (when-let [pos (rum/react pos)]
       (ui/css-transition
        {:class-names "fade"
-        :timeout {:enter 500
-                  :exit 300}}
+        :timeout     {:enter 500
+                      :exit  300}}
        (absolute-modal cp set-default-width? pos)))))
 
 (rum/defc image-uploader < rum/reactive
   {:did-mount    (fn [state]
                    (let [[id format] (:rum/args state)]
-                     (add-watch editor-handler/*image-pending-file ::pending-image
+                     (add-watch editor-handler/*asset-pending-file ::pending-asset
                                 (fn [_ _ _ f]
                                   (reset! *slash-caret-pos (util/get-caret-pos (gdom/getElement id)))
-                                  (editor-handler/upload-image id #js[f] format editor-handler/*image-uploading? true))))
+                                  (editor-handler/upload-asset id #js[f] format editor-handler/*asset-uploading? true))))
                    state)
    :will-unmount (fn [state]
-                   (remove-watch editor-handler/*image-pending-file ::pending-image))}
+                   (remove-watch editor-handler/*asset-pending-file ::pending-asset))}
   [id format]
   [:div.image-uploader
    [:input
-    {:id "upload-file"
-     :type "file"
+    {:id        "upload-file"
+     :type      "file"
      :on-change (fn [e]
                   (let [files (.-files (.-target e))]
-                    (editor-handler/upload-image id files format editor-handler/*image-uploading? false)))
-     :hidden true}]
-   (when-let [uploading? (util/react editor-handler/*image-uploading?)]
-     (let [processing (util/react editor-handler/*image-uploading-process)]
+                    (editor-handler/upload-asset id files format editor-handler/*asset-uploading? false)))
+     :hidden    true}]
+   (when-let [uploading? (util/react editor-handler/*asset-uploading?)]
+     (let [processing (util/react editor-handler/*asset-uploading-process)]
        (transition-cp
         [:div.flex.flex-row.align-center.rounded-md.shadow-sm.bg-base-2.px-1.py-1
          (ui/loading
@@ -435,21 +435,21 @@
                             (profile
                              "Insert block"
                              (editor-handler/insert-new-block! state))))))))))
-         ;; up
+                          ;; up
          38 (fn [state e]
               (when (and
                      (not (gobj/get e "ctrlKey"))
                      (not (gobj/get e "metaKey"))
                      (not (editor-handler/in-auto-complete? input)))
                 (editor-handler/on-up-down state e true)))
-         ;; down
+                          ;; down
          40 (fn [state e]
               (when (and
                      (not (gobj/get e "ctrlKey"))
                      (not (gobj/get e "metaKey"))
                      (not (editor-handler/in-auto-complete? input)))
                 (editor-handler/on-up-down state e false)))
-         ;; backspace
+                          ;; backspace
          8  (fn [state e]
               (let [node (gdom/getElement input-id)
                     current-pos (:pos (util/get-caret-pos node))
@@ -465,7 +465,7 @@
                   nil
 
                   (and (zero? current-pos)
-                       ;; not the top block in a block page
+                                        ;; not the top block in a block page
                        (not (and page
                                  (util/uuid-string? page)
                                  (= (medley/uuid page) block-id))))
@@ -483,7 +483,7 @@
                     (reset! *angle-bracket-caret-pos nil)
                     (reset! *show-block-commands false))
 
-                  ;; pair
+                                   ;; pair
                   (and
                    deleted
                    (contains?
@@ -506,13 +506,13 @@
                       :else
                       nil))
 
-                  ;; deleting hashtag
+                                   ;; deleting hashtag
                   (and (= deleted "#") (state/get-editor-show-page-search-hashtag?))
                   (state/set-editor-show-page-search-hashtag! false)
 
                   :else
                   nil)))
-         ;; tab
+                          ;; tab
          9  (fn [state e]
               (let [input-id (state/get-edit-input-id)
                     input (and input-id (gdom/getElement id))
@@ -616,18 +616,18 @@
           (let [k (gobj/get e "key")
                 format (:format (get-state state))]
             (when-not (state/get-editor-show-input)
-              (when (and @*show-commands (not= key-code 191))     ; not /
+              (when (and @*show-commands (not= key-code 191)) ; not /
                 (let [matched-commands (editor-handler/get-matched-commands input)]
                   (if (seq matched-commands)
                     (do
                       (reset! *show-commands true)
                       (reset! *matched-commands matched-commands))
                     (reset! *show-commands false))))
-              (when (and @*show-block-commands (not= key-code 188))     ; not <
+              (when (and @*show-block-commands (not= key-code 188)) ; not <
                 (let [matched-block-commands (editor-handler/get-matched-block-commands input)]
                   (if (seq matched-block-commands)
                     (cond
-                      (= key-code 9)      ;tab
+                      (= key-code 9)       ;tab
                       (when @*show-block-commands
                         (util/stop e)
                         (editor-handler/insert-command! input-id
@@ -639,41 +639,41 @@
                       (reset! *matched-block-commands matched-block-commands))
                     (reset! *show-block-commands false))))
               (editor-handler/close-autocomplete-if-outside input))))))))
-  {:did-mount (fn [state]
-                (let [[{:keys [dummy? format block-parent-id]} id] (:rum/args state)
-                      content (get-in @state/state [:editor/content id])
-                      input (gdom/getElement id)]
-                  (when block-parent-id
-                    (state/set-editing-block-dom-id! block-parent-id))
-                  (if (= :indent-outdent (state/get-editor-op))
-                    (when input
-                      (when-let [pos (state/get-edit-pos)]
-                        (util/set-caret-pos! input pos)))
-                    (editor-handler/restore-cursor-pos! id content dummy?))
-
-                  (when input
-                    (dnd/subscribe!
-                     input
-                     :upload-images
-                     {:drop (fn [e files]
-                              (editor-handler/upload-image id files format editor-handler/*image-uploading? true))}))
-
-                  ;; Here we delay this listener, otherwise the click to edit event will trigger a outside click event,
-                  ;; which will hide the editor so no way for editing.
-                  (js/setTimeout #(keyboards-handler/esc-save! state) 100)
-
-                  (when-let [element (gdom/getElement id)]
-                    (.focus element)))
-                state)
-   :did-remount (fn [_old-state state]
-                  (keyboards-handler/esc-save! state)
-                  state)
+  {:did-mount    (fn [state]
+                   (let [[{:keys [dummy? format block-parent-id]} id] (:rum/args state)
+                         content (get-in @state/state [:editor/content id])
+                         input (gdom/getElement id)]
+                     (when block-parent-id
+                       (state/set-editing-block-dom-id! block-parent-id))
+                     (if (= :indent-outdent (state/get-editor-op))
+                       (when input
+                         (when-let [pos (state/get-edit-pos)]
+                           (util/set-caret-pos! input pos)))
+                       (editor-handler/restore-cursor-pos! id content dummy?))
+
+                     (when input
+                       (dnd/subscribe!
+                        input
+                        :upload-images
+                        {:drop (fn [e files]
+                                 (editor-handler/upload-asset id files format editor-handler/*asset-uploading? true))}))
+
+                                    ;; Here we delay this listener, otherwise the click to edit event will trigger a outside click event,
+                                    ;; which will hide the editor so no way for editing.
+                     (js/setTimeout #(keyboards-handler/esc-save! state) 100)
+
+                     (when-let [element (gdom/getElement id)]
+                       (.focus element)))
+                   state)
+   :did-remount  (fn [_old-state state]
+                   (keyboards-handler/esc-save! state)
+                   state)
    :will-unmount (fn [state]
                    (let [{:keys [id value format block repo dummy? config]} (get-state state)
                          file? (:file? config)]
                      (when-let [input (gdom/getElement id)]
-                       ;; (.removeEventListener input "paste" (fn [event]
-                       ;;                                       (append-paste-doc! format event)))
+                                      ;; (.removeEventListener input "paste" (fn [event]
+                                      ;;                                       (append-paste-doc! format event)))
                        (let [s (str "cljs-drag-n-drop." :upload-images)
                              a (gobj/get input s)
                              timer (:timer a)]
@@ -699,8 +699,8 @@
                          (editor-handler/save-block! (get-state state) value))))
                    state)}
   [state {:keys [on-hide dummy? node format block block-parent-id]
-          :or {dummy? false}
-          :as option} id config]
+          :or   {dummy? false}
+          :as   option} id config]
   (let [content (state/get-edit-content)]
     [:div.editor-inner {:class (if block "block-editor" "non-block-editor")}
      (when config/mobile? (mobile-bar state id))
@@ -742,20 +742,33 @@
                             (when-let [handled
                                        (let [pick-one-allowed-item
                                              (fn [items]
-                                               (when (and items (.-length items))
-                                                 (let [files (. (js/Array.from items) (filter #(= (.-kind %) "file")))
-                                                       it (gobj/get files 0) ;;; TODO: support multiple files
-                                                       mime (and it (.-type it))]
-                                                   (cond
-                                                     (contains? #{"image/jpeg" "image/png" "image/jpg" "image/gif"} mime) [:image (. it getAsFile)]))))
+                                               (if (util/electron?)
+                                                 (let [existed-file-path (js/window.apis.getFilePathFromClipboard)
+                                                       existed-file-path (if (and
+                                                                              (string? existed-file-path)
+                                                                              (not util/mac?)
+                                                                              (not util/win32?)) ; FIXME: linuxcx
+                                                                           (when (re-find #"^(/[^/ ]*)+/?$" existed-file-path)
+                                                                             existed-file-path)
+                                                                           existed-file-path)
+                                                       has-file-path? (not (string/blank? existed-file-path))
+                                                       has-image? (js/window.apis.isClipboardHasImage)]
+                                                   (if (or has-image? has-file-path?)
+                                                     [:asset (js/File. #js[] (if has-file-path? existed-file-path "image.png"))]))
+
+                                                 (when (and items (.-length items))
+                                                   (let [files (. (js/Array.from items) (filter #(= (.-kind %) "file")))
+                                                         it (gobj/get files 0) ;;; TODO: support multiple files
+                                                         mime (and it (.-type it))]
+                                                     (cond
+                                                       (contains? #{"image/jpeg" "image/png" "image/jpg" "image/gif"} mime) [:asset (. it getAsFile)])))))
                                              clipboard-data (gobj/get e "clipboardData")
                                              items (or (.-items clipboard-data)
                                                        (.-files clipboard-data))
                                              picked (pick-one-allowed-item items)]
-                                         (when (and picked (get picked 1))
+                                         (if (get picked 1)
                                            (match picked
-                                             [:image file] (editor-handler/set-image-pending-file file))
-                                           true))]
+                                             [:asset file] (editor-handler/set-asset-pending-file file))))]
                               (util/stop e)))
        :auto-focus        false})
 

+ 3 - 3
src/main/frontend/components/file.cljs

@@ -71,7 +71,7 @@
   (let [path (get-path state)
         format (format/get-format path)
         page (db/get-file-page path)
-        config? (= path (str config/app-name "/" config/config-file))]
+        config? (= path (config/get-config-path))]
     (rum/with-context [[tongue] i18n/*tongue-context*]
       [:div.file {:id (str "file-" path)}
        [:h1.title
@@ -81,14 +81,14 @@
           [:a.bg-base-2.p-1.ml-1 {:style {:border-radius 4}
                                   :href (rfe/href :page {:name page})
                                   :on-click (fn [e]
-                                              (.preventDefault e)
                                               (when (gobj/get e "shiftKey")
                                                 (when-let [page (db/entity [:page/name (string/lower-case page)])]
                                                   (state/sidebar-add-block!
                                                    (state/get-current-repo)
                                                    (:db/id page)
                                                    :page
-                                                   {:page page}))))}
+                                                   {:page page}))
+                                                (util/stop e)))}
            page]])
 
        (when (and page (not (string/starts-with? page "logseq/")))

+ 109 - 107
src/main/frontend/components/header.cljs

@@ -22,7 +22,7 @@
 (rum/defc logo < rum/reactive
   [{:keys [white?]}]
   [:a.cp__header-logo
-   {:href "/"
+   {:href     (rfe/href :home)
     :on-click (fn []
                 (util/scroll-to-top)
                 (state/set-journals-length! 1))}
@@ -31,6 +31,29 @@
      [:img.cp__header-logo-img {:src logo}]
      (svg/logo (not white?)))])
 
+(rum/defc login
+  [logged?]
+  (rum/with-context [[t] i18n/*tongue-context*]
+    (when (and (not logged?)
+               (not config/publishing?))
+
+      (ui/dropdown-with-links
+       (fn [{:keys [toggle-fn]}]
+         [:a {:on-click toggle-fn}
+          [:span.ml-1.text-sm (t :login)]])
+       (let [list [{:title (t :login-google)
+                    :url (str config/website "/login/google")}
+                   {:title (t :login-github)
+                    :url (str config/website "/login/github")}]]
+         (mapv
+          (fn [{:keys [title url]}]
+            {:title title
+             :options
+             {:on-click
+              (fn [_] (set! (.-href js/window.location) url))}})
+          list))
+       nil))))
+
 (rum/defc left-menu-button < rum/reactive
   [{:keys [on-click]}]
   [:button#left-menu.cp__header-left-menu
@@ -45,93 +68,80 @@
 
 (rum/defc dropdown-menu < rum/reactive
   [{:keys [me current-repo t default-home]}]
-  (let [projects (state/sub [:me :projects])]
+  (let [projects (state/sub [:me :projects])
+        logged? (state/logged?)]
     (ui/dropdown-with-links
      (fn [{:keys [toggle-fn]}]
-       [:button.max-w-xs.flex.items-center.text-sm.rounded-full.focus:outline-none.focus:shadow-outline.h-7.w-7.ml-2
+       [:a.cp__right-menu-button
         {:on-click toggle-fn}
-        (if-let [avatar (:avatar me)]
-          [:img#avatar.h-7.w-7.rounded-full
-           {:src avatar
-            :on-error (fn [this]
-                        (let [elem (gdom/getElement "avatar")]
-                          (gobj/set elem "src" (config/asset-uri "/static/img/broken-avatar.png"))))}]
-          [:div.h-7.w-7.rounded-full.bg-base-2.opacity-70.hover:opacity-100 {:style {:padding 1.5}}
-           [:a svg/user]])])
-     (let [logged? (:name me)]
-       (->>
-        [(when current-repo
-           {:title (t :graph)
-            :options {:href (rfe/href :graph)}
-            :icon svg/graph-sm})
-
-         (when (or logged? (and (nfs/supported?) current-repo))
-           {:title (t :all-graphs)
-            :options {:href (rfe/href :repos)}
-            :icon svg/repos-sm})
-
-         (when current-repo
-           {:title (t :all-pages)
-            :options {:href (rfe/href :all-pages)}
-            :icon svg/pages-sm})
-
-         (when current-repo
-           {:title (t :all-files)
-            :options {:href (rfe/href :all-files)}
-            :icon svg/folder-sm})
-
-         (when (and default-home current-repo)
-           {:title (t :all-journals)
-            :options {:href (rfe/href :all-journals)}
-            :icon svg/calendar-sm})
-
-         (when (project-handler/get-current-project current-repo projects)
-           {:title (t :my-publishing)
-            :options {:href (rfe/href :my-publishing)}})
-
-         (when-let [project (and current-repo
-                                 (project-handler/get-current-project current-repo projects))]
-           (let [link (str config/website "/" project)]
-             {:title (str (t :go-to) "/" project)
-              :options {:href link
-                        :target "_blank"}
-              :icon svg/external-link}))
-
-         {:title (t :settings)
-          :options {:href (rfe/href :settings)}
-          :icon svg/settings-sm}
-
-         (when current-repo
-           {:title (t :export)
-            :options {:on-click (fn []
-                                  (export/export-repo-as-html! current-repo))}
-            :icon nil})
-         (when current-repo
-           {:title (t :import)
-            :options {:href (rfe/href :import)}
-            :icon svg/import-sm})
-         {:title [:div.flex-row.flex.justify-between.items-center
-                  [:span (t :join-community)]]
-          :options {:href "https://discord.gg/KpN4eHY"
-                    :title (t :discord-title)
-                    :target "_blank"}
-          :icon svg/discord}
-         {:title [:div.flex-row.flex.justify-between.items-center
-                  [:span (t :sponsor-us)]]
-          :options {:href "https://opencollective.com/logseq"
-                    :target "_blank"}}
-         (when logged?
-           {:title (t :sign-out)
-            :options {:on-click user-handler/sign-out!}
-            :icon svg/logout-sm})]
-        (remove nil?)))
-     {})))
-
-(rum/defc right-menu-button < rum/reactive
-  []
-  [:a.cp__right-menu-button
-   {:on-click state/toggle-sidebar-open?!}
-   (svg/menu nil)])
+        (svg/horizontal-dots nil)])
+     (->>
+      [{:title (t :help/toggle-right-sidebar)
+        :options {:on-click state/toggle-sidebar-open?!}}
+
+       (when current-repo
+         {:title (t :graph)
+          :options {:href (rfe/href :graph)}
+          :icon svg/graph-sm})
+
+       (when (or logged? (and (nfs/supported?) current-repo))
+         {:title (t :all-graphs)
+          :options {:href (rfe/href :repos)}
+          :icon svg/repos-sm})
+
+       (when current-repo
+         {:title (t :all-pages)
+          :options {:href (rfe/href :all-pages)}
+          :icon svg/pages-sm})
+
+       (when current-repo
+         {:title (t :all-files)
+          :options {:href (rfe/href :all-files)}
+          :icon svg/folder-sm})
+
+       (when (and default-home current-repo)
+         {:title (t :all-journals)
+          :options {:href (rfe/href :all-journals)}
+          :icon svg/calendar-sm})
+
+       (when (project-handler/get-current-project current-repo projects)
+         {:title (t :my-publishing)
+          :options {:href (rfe/href :my-publishing)}})
+
+       (when-let [project (and current-repo
+                               (project-handler/get-current-project current-repo projects))]
+         (let [link (str config/website "/" project)]
+           {:title (str (t :go-to) "/" project)
+            :options {:href link
+                      :target "_blank"}
+            :icon svg/external-link}))
+
+       {:title (t :settings)
+        :options {:href (rfe/href :settings)}
+        :icon svg/settings-sm}
+
+       (when current-repo
+         {:title (t :export)
+          :options {:on-click (fn []
+                                (export/export-repo-as-html! current-repo))}
+          :icon nil})
+       (when current-repo
+         {:title (t :import)
+          :options {:href (rfe/href :import)}
+          :icon svg/import-sm})
+       {:title [:div.flex-row.flex.justify-between.items-center
+                [:span (t :join-community)]]
+        :options {:href "https://discord.gg/KpN4eHY"
+                  :title (t :discord-title)
+                  :target "_blank"}
+        :icon svg/discord}
+       (when logged?
+         {:title (t :sign-out)
+          :options {:on-click user-handler/sign-out!}
+          :icon svg/logout-sm})]
+      (remove nil?))
+     {:links-footer (when (and (util/electron?) (not logged?))
+                      [:div.px-2.py-2 (login logged?)])})))
 
 (rum/defc header
   < rum/reactive
@@ -141,37 +151,31 @@
                    (remove #(= (:url %) config/local-repo)))]
     (rum/with-context [[t] i18n/*tongue-context*]
       [:div.cp__header#head
+       {:on-double-click #(when (util/electron?) (js/window.apis.toggleMaxOrMinActiveWindow))}
        (left-menu-button {:on-click (fn []
                                       (open-fn)
                                       (state/set-left-sidebar-open! true))})
 
        (logo {:white? white?})
 
+       (when (util/electron?)
+         [:a.mr-1.opacity-50.hover:opacity-100.it
+          {:style {:margin-left -10}
+           :title "Go Back" :on-click #(js/window.history.back)} (svg/arrow-left)])
+
+       (when (util/electron?)
+         [:a.opacity-50.hover:opacity-100.it
+          {:style {:margin-right 15}
+           :title "Go Forward" :on-click #(js/window.history.forward)} (svg/arrow-right)])
+
        (if current-repo
          (search/search)
          [:div.flex-1])
 
        (new-block-mode)
 
-       (when (and (not logged?)
-                  (not config/publishing?))
-
-         (ui/dropdown-with-links
-          (fn [{:keys [toggle-fn]}]
-            [:a {:on-click toggle-fn}
-             [:span.ml-1.text-sm (t :login)]])
-          (let [list [{:title (t :login-google)
-                       :url "/login/google"}
-                      {:title (t :login-github)
-                       :url "/login/github"}]]
-            (mapv
-             (fn [{:keys [title url]}]
-               {:title title
-                :options
-                {:on-click
-                 (fn [_] (set! (.-href js/window.location) url))}})
-             list))
-          nil))
+       (when-not (util/electron?)
+         (login logged?))
 
        (repo/sync-status current-repo)
 
@@ -199,6 +203,4 @@
                          :default-home default-home}))
 
        [:a#download-as-html.hidden]
-       [:a#download-as-zip.hidden]
-
-       (right-menu-button)])))
+       [:a#download-as-zip.hidden]])))

+ 5 - 0
src/main/frontend/components/header.css

@@ -10,6 +10,11 @@
   width: 100%;
   top: 0;
   left: 0;
+
+  user-select: none;
+  .it svg {
+      transform: scale(0.8);
+  }
 }
 
 .cp__header-left-menu {

+ 3 - 3
src/main/frontend/components/journal.cljs

@@ -1,5 +1,6 @@
 (ns frontend.components.journal
   (:require [rum.core :as rum]
+            [reitit.frontend.easy :as rfe]
             [frontend.util :as util :refer-macros [profile]]
             [frontend.config :as config]
             [frontend.date :as date]
@@ -77,16 +78,15 @@
     [:div.flex-1.journal.page {:class (if intro? "intro" "")}
      (ui/foldable
       [:a.initial-color.title
-       {:href (str "/page/" encoded-page-name)
+       {:href     (rfe/href :page {:name encoded-page-name})
         :on-click (fn [e]
-                    (.preventDefault e)
                     (when (gobj/get e "shiftKey")
                       (when-let [page (db/pull [:page/name (string/lower-case title)])]
                         (state/sidebar-add-block!
                          (state/get-current-repo)
                          (:db/id page)
                          :page
-                         {:page page
+                         {:page     page
                           :journal? true}))))}
        [:h1.title
         (util/capitalize-all title)]]

+ 13 - 7
src/main/frontend/components/page.cljs

@@ -202,7 +202,7 @@
          [:ul.mt-2
           (for [[original-name name] pages]
             [:li {:key (str "tagged-page-" name)}
-             [:a {:href (str "/page/" (util/encode-str name))}
+             [:a {:href (rfe/href :page {:name name})}
               original-name]])])]])))
 
 (defonce last-route (atom :home))
@@ -283,6 +283,11 @@
                              :options {:on-click (fn [] (page-handler/handle-add-page-to-contents! page-original-name))}}
                             {:title (t :page/rename)
                              :options {:on-click #(state/set-modal! (rename-page-dialog page-name))}}
+                            (when (and file-path (util/electron?))
+                              [{:title   (t :page/open-in-finder)
+                                :options {:on-click #(js/window.apis.showItemInFolder file-path)}}
+                               {:title (t :page/open-with-default-app)
+                                :options {:on-click #(js/window.apis.openPath file-path)}}])
                             {:title (t :page/delete)
                              :options {:on-click #(state/set-modal! (delete-page-dialog page-name))}}
                             {:title   (t :page/action-publish)
@@ -304,8 +309,8 @@
                                                                              (page-handler/publish-page! page-name project/add-project))}})
                                                     (when-not published?
                                                       {:title   (t :page/publish-as-slide)
-                                                      :options {:on-click (fn []
-                                                                            (page-handler/publish-page-as-slide! page-name project/add-project))}})
+                                                       :options {:on-click (fn []
+                                                                             (page-handler/publish-page-as-slide! page-name project/add-project))}})
                                                     {:title   (t (if public? :page/make-private :page/make-public))
                                                      :options {:background (if public? "gray" "indigo")
                                                                :on-click (fn []
@@ -326,6 +331,7 @@
                                                                     :on-click #(.writeText js/navigator.clipboard page-data))]
                                                         :success
                                                         false)))}})]
+                           (flatten)
                            (remove nil?))]
                 (when (seq links)
                   (ui/dropdown-with-links
@@ -373,11 +379,12 @@
                  {:key "page-file"}
                  [:span.opacity-50 {:style {:margin-top 2}} (t :file/file)]
                  [:a.bg-base-2.px-1.ml-1.mr-3 {:style {:border-radius 4
-                                                       :word-break "break-word"}
-                                               :href (str "/file/" (util/url-encode file-path))}
+                                                       :word-break    "break-word"}
+                                               :href  (rfe/href :file {:path (util/url-encode file-path)})}
                   file-path]
 
                  (when (and (not config/mobile?)
+                            (not (util/electron?))
                             (not journal?))
                    (presentation repo page))])]
 
@@ -387,7 +394,7 @@
                    [:div.text-sm.ml-1.mb-4 {:key "page-file"}
                     [:span.opacity-50 "Alias: "]
                     (for [item alias]
-                      [:a.ml-1.mr-1 {:href (str "/page/" (util/encode-str item))}
+                      [:a.ml-1.mr-1 {:href (rfe/href :page {:name (util/encode-str item)})}
                        item])])))
 
              (when (and block? (not sidebar?))
@@ -477,7 +484,6 @@
                (let [encoded-page (util/encode-str page)]
                  [:tr {:key encoded-page}
                   [:td [:a {:on-click (fn [e]
-                                        (.preventDefault e)
                                         (let [repo (state/get-current-repo)
                                               page (db/pull repo '[*] [:page/name (string/lower-case page)])]
                                           (when (gobj/get e "shiftKey")

+ 11 - 5
src/main/frontend/components/repo.cljs

@@ -45,7 +45,7 @@
               (ui/button
                (t :open-a-directory)
                :on-click nfs-handler/ls-dir-files)])
-           (when (state/logged?)
+           (when (and (state/logged?) (not (util/electron?)))
              (ui/button
               "Add another git repo"
               :href (rfe/href :repo-add nil {:graph-types "github"})))]
@@ -65,7 +65,7 @@
                              :on-click (fn []
                                          (if local?
                                            (nfs-handler/rebuild-index! url
-                                                                 repo-handler/create-today-journal!)
+                                                                       repo-handler/create-today-journal!)
                                            (repo-handler/rebuild-index! url))
                                          (js/setTimeout
                                           (fn []
@@ -97,7 +97,7 @@
              [:a
               {:on-click #(nfs-handler/refresh! repo
                                                 repo-handler/create-today-journal!)
-               :title (str "Sync files with the local directory: " (config/get-local-dir repo) ".\nVersion: "
+               :title (str "Import files from the local directory: " (config/get-local-dir repo) ".\nVersion: "
                            version/version)}
               svg/refresh]])
           (let [changed-files (state/sub [:repo/changed-files repo])
@@ -199,7 +199,11 @@
           (ui/dropdown-with-links
            (fn [{:keys [toggle-fn]}]
              [:a#repo-switch {:on-click toggle-fn}
-              [:span (get-repo-name current-repo)]
+              (let [repo-name (get-repo-name current-repo)
+                    repo-name (if (util/electron?)
+                                (last (string/split repo-name #"/"))
+                                repo-name)]
+                [:span repo-name])
               [:span.dropdown-caret.ml-1 {:style {:border-top-color "#6b7280"}}]])
            (mapv
             (fn [{:keys [id url]}]
@@ -221,7 +225,9 @@
           (and current-repo (not local-repo?))
           (let [repo-name (get-repo-name current-repo)]
             (if (config/local-db? current-repo)
-              repo-name
+              (if (util/electron?)
+                (last (string/split repo-name #"/"))
+                repo-name)
               [:a
                {:href current-repo
                 :target "_blank"}

+ 30 - 27
src/main/frontend/components/right_sidebar.cljs

@@ -143,7 +143,7 @@
                                                        :slide? true
                                                        :sidebar? true
                                                        :page-name page-name})]
-      [[:a {:href (str "/page/" (util/url-encode page-name))}
+      [[:a {:href {:href (rfe/href :page {:name page-name})}}
         (db-model/get-page-original-name page-name)]
        [:div.ml-2.slide.mt-2
         (slide/slide sections)]])
@@ -222,32 +222,35 @@
        {:class (if sidebar-open? "is-open")}
        (if sidebar-open?
          [:div.cp__right-sidebar-inner
-          [:div.cp__right-sidebar-settings.hide-scrollbar {:key "right-sidebar-settings"}
-           [:div.ml-4.text-sm
-            [:a.cp__right-sidebar-settings-btn {:on-click (fn [e]
-                                                            (state/sidebar-add-block! repo "contents" :contents nil))}
-             (t :right-side-bar/contents)]]
-
-           [:div.ml-4.text-sm
-            [:a.cp__right-sidebar-settings-btn {:on-click (fn [_e]
-                                                            (state/sidebar-add-block! repo "recent" :recent nil))}
-
-             (t :right-side-bar/recent)]]
-
-           [:div.ml-4.text-sm
-            [:a.cp__right-sidebar-settings-btn {:on-click (fn []
-                                                            (when-let [page (get-current-page)]
-                                                              (state/sidebar-add-block!
-                                                               repo
-                                                               (str "page-graph-" page)
-                                                               :page-graph
-                                                               page)))}
-             (t :right-side-bar/page)]]
-
-           [:div.ml-4.text-sm
-            [:a.cp__right-sidebar-settings-btn {:on-click (fn [_e]
-                                                            (state/sidebar-add-block! repo "help" :help nil))}
-             (t :right-side-bar/help)]]]
+          [:div.flex.flex-row.justify-between.items-center
+           [:div.cp__right-sidebar-settings.hide-scrollbar {:key "right-sidebar-settings"}
+            [:div.ml-4.text-sm
+             [:a.cp__right-sidebar-settings-btn {:on-click (fn [e]
+                                                             (state/sidebar-add-block! repo "contents" :contents nil))}
+              (t :right-side-bar/contents)]]
+
+            [:div.ml-4.text-sm
+             [:a.cp__right-sidebar-settings-btn {:on-click (fn [_e]
+                                                             (state/sidebar-add-block! repo "recent" :recent nil))}
+
+              (t :right-side-bar/recent)]]
+
+            [:div.ml-4.text-sm
+             [:a.cp__right-sidebar-settings-btn {:on-click (fn []
+                                                             (when-let [page (get-current-page)]
+                                                               (state/sidebar-add-block!
+                                                                repo
+                                                                (str "page-graph-" page)
+                                                                :page-graph
+                                                                page)))}
+              (t :right-side-bar/page)]]
+
+            [:div.ml-4.text-sm
+             [:a.cp__right-sidebar-settings-btn {:on-click (fn [_e]
+                                                             (state/sidebar-add-block! repo "help" :help nil))}
+              (t :right-side-bar/help)]]]
+           [:a.close-arrow.opacity-50.hover:opacity-100 {:on-click state/toggle-sidebar-open?!}
+            (svg/big-arrow-right)]]
 
           (for [[idx [repo db-id block-type block-data]] (medley/indexed blocks)]
             (rum/with-key

+ 94 - 50
src/main/frontend/components/settings.cljs

@@ -1,6 +1,7 @@
 (ns frontend.components.settings
   (:require [rum.core :as rum]
             [frontend.ui :as ui]
+            [frontend.components.svg :as svg]
             [frontend.handler.notification :as notification]
             [frontend.handler.user :as user-handler]
             [frontend.handler.ui :as ui-handler]
@@ -8,12 +9,14 @@
             [frontend.handler.config :as config-handler]
             [frontend.handler.page :as page-handler]
             [frontend.state :as state]
+            [frontend.version :refer [version]]
             [frontend.util :as util]
             [frontend.config :as config]
             [frontend.dicts :as dicts]
             [clojure.string :as string]
             [goog.object :as gobj]
-            [frontend.context.i18n :as i18n]))
+            [frontend.context.i18n :as i18n]
+            [reitit.frontend.easy :as rfe]))
 
 (rum/defcs set-email < (rum/local "" ::email)
   [state]
@@ -73,6 +76,41 @@
     [:div.max-w-lg.rounded-md.sm:max-w-xs
      (ui/toggle state on-toggle)]]])
 
+(rum/defcs app-updater < rum/reactive
+  [state]
+  (let [update-pending? (state/sub :electron/updater-pending?)
+        {:keys [type payload]} (state/sub :electron/updater)]
+    [:div.cp__settings-app-updater
+     [:button.ui__button_base.is-logseq.check-update
+      {:disabled update-pending?
+       :on-click #(js/window.apis.checkForUpdates false)}
+      (if update-pending? "Checking ..." "Check for updates")]
+     (when-not (or update-pending?
+                   (string/blank? type))
+       [:div.update-state
+        (case type
+          "update-not-available"
+          [:p "😀 Your app is up-to-date!"]
+
+          "update-available"
+          (let [{:keys [name url]} payload]
+            [:p (str "Found new release ")
+             [:a.link
+              {:on-click
+               (fn [e]
+                 (js/window.apis.openExternal url)
+                 (util/stop e))}
+              svg/external-link name " 🎉"]])
+
+          "error"
+          [:p "⚠️ Oops, Something Went Wrong!" [:br] " Please check out the "
+           [:a.link
+            {:on-click
+             (fn [e]
+               (js/window.apis.openExternal "https://github.com/logseq/logseq/releases")
+               (util/stop e))}
+            svg/external-link " release channel"]])])]))
+
 (rum/defcs settings < rum/reactive
   []
   (let [preferred-format (state/get-preferred-format)
@@ -137,9 +175,9 @@
               (:label language)])]]]]
 
        [:div.pl-1
-        ;; config.edn
+                        ;; config.edn
         (when current-repo
-          [:a {:href (str "/file/" (util/url-encode (str config/app-name "/" config/config-file)))}
+          [:a {:href (rfe/href :file {:path (config/get-config-path)})}
            (t :settings-page/edit-config-edn)])
 
         [:hr]
@@ -194,12 +232,12 @@
                    (let [value (not enable-timetracking?)]
                      (config-handler/set-config! :feature/enable-timetracking? value))))
 
-         ;; (toggle "enable_block_time"
-         ;;         (t :settings-page/enable-block-time)
-         ;;         enable-block-time?
-         ;;         (fn []
-         ;;           (let [value (not enable-block-time?)]
-         ;;             (config-handler/set-config! :feature/enable-block-time? value))))
+                         ;; (toggle "enable_block_time"
+                         ;;         (t :settings-page/enable-block-time)
+                         ;;         enable-block-time?
+                         ;;         (fn []
+                         ;;           (let [value (not enable-block-time?)]
+                         ;;             (config-handler/set-config! :feature/enable-block-time? value))))
 
          (toggle "enable_journals"
                  (t :settings-page/enable-journals)
@@ -210,48 +248,45 @@
 
          (when (not enable-journals?)
            [:div.mt-6.sm:mt-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start.sm:pt-5
-             [:label.block.text-sm.font-medium.leading-5.sm:mt-px.sm:pt-2.opacity-70
-              {:for "default page"}
-              (t :settings-page/home-default-page)]
-             [:div.mt-1.sm:mt-0.sm:col-span-2
-              [:div.max-w-lg.rounded-md.shadow-sm.sm:max-w-xs
-               [:input#home-default-page.form-input.block.w-full.transition.duration-150.ease-in-out.sm:text-sm.sm:leading-5
-                {:default-value (state/sub-default-home-page)
-                 :on-blur (fn [event]
-                            (let [value (util/evalue event)]
-                              (cond
-                                (string/blank? value)
-                                (let [home (get (state/get-config) :default-home {})
-                                      new-home (dissoc home :page)]
-                                  (config-handler/set-config! :default-home new-home)
-                                  (notification/show! "Home default page updated successfully!" :success))
+            [:label.block.text-sm.font-medium.leading-5.sm:mt-px.sm:pt-2.opacity-70
+             {:for "default page"}
+             (t :settings-page/home-default-page)]
+            [:div.mt-1.sm:mt-0.sm:col-span-2
+             [:div.max-w-lg.rounded-md.shadow-sm.sm:max-w-xs
+              [:input#home-default-page.form-input.block.w-full.transition.duration-150.ease-in-out.sm:text-sm.sm:leading-5
+               {:default-value (state/sub-default-home-page)
+                :on-blur       (fn [event]
+                                 (let [value (util/evalue event)]
+                                   (cond
+                                     (string/blank? value)
+                                     (let [home (get (state/get-config) :default-home {})
+                                           new-home (dissoc home :page)]
+                                       (config-handler/set-config! :default-home new-home)
+                                       (notification/show! "Home default page updated successfully!" :success))
 
-                                (page-handler/page-exists? (string/lower-case value))
-                                (let [home (get (state/get-config) :default-home {})
-                                      new-home (assoc home :page value)]
-                                  (config-handler/set-config! :default-home new-home)
-                                  (notification/show! "Home default page updated successfully!" :success))
+                                     (page-handler/page-exists? (string/lower-case value))
+                                     (let [home (get (state/get-config) :default-home {})
+                                           new-home (assoc home :page value)]
+                                       (config-handler/set-config! :default-home new-home)
+                                       (notification/show! "Home default page updated successfully!" :success))
 
-                                :else
-                                (notification/show! "Please make sure the page exists!" :warning))))}]]]])
+                                     :else
+                                     (notification/show! "Please make sure the page exists!" :warning))))}]]]])
 
          (when (string/starts-with? current-repo "https://")
            (toggle "enable_git_auto_push"
-                  "Enable Git auto push"
-                  enable-git-auto-push?
-                  (fn []
-                    (let [value (not enable-git-auto-push?)]
-                      (config-handler/set-config! :git-auto-push value)))))
-
-
-         [:hr]
+                   "Enable Git auto push"
+                   enable-git-auto-push?
+                   (fn []
+                     (let [value (not enable-git-auto-push?)]
+                       (config-handler/set-config! :git-auto-push value))))) [:hr]
 
          (when logged?
            [:div
             (ui/admonition
              :important
              [:p (t :settings-page/dont-use-other-peoples-proxy-servers)
-              [:a {:href "https://github.com/isomorphic-git/cors-proxy"
+              [:a {:href   "https://github.com/isomorphic-git/cors-proxy"
                    :target "_blank"}
                "https://github.com/isomorphic-git/cors-proxy"]])
             [:div.mt-6.sm:mt-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start.sm:pt-5
@@ -262,19 +297,28 @@
               [:div.max-w-lg.rounded-md.shadow-sm.sm:max-w-xs
                [:input#pat.form-input.block.w-full.transition.duration-150.ease-in-out.sm:text-sm.sm:leading-5
                 {:default-value cors-proxy
-                 :on-blur (fn [event]
-                            (when-let [server (util/evalue event)]
-                              (user-handler/set-cors! server)
-                              (notification/show! "Custom CORS proxy updated successfully!" :success)))
-                 :on-key-press (fn [event]
-                                 (let [k (gobj/get event "key")]
-                                   (if (= "Enter" k)
-                                     (when-let [server (util/evalue event)]
-                                       (user-handler/set-cors! server)
-                                       (notification/show! "Custom CORS proxy updated successfully!" :success)))))}]]]]
+                 :on-blur       (fn [event]
+                                  (when-let [server (util/evalue event)]
+                                    (user-handler/set-cors! server)
+                                    (notification/show! "Custom CORS proxy updated successfully!" :success)))
+                 :on-key-press  (fn [event]
+                                  (let [k (gobj/get event "key")]
+                                    (if (= "Enter" k)
+                                      (when-let [server (util/evalue event)]
+                                        (user-handler/set-cors! server)
+                                        (notification/show! "Custom CORS proxy updated successfully!" :success)))))}]]]]
 
             [:hr]])
 
+         [:div.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start.sm:pt-5
+          [:label.block.text-sm.font-medium.leading-5.sm:mt-px.sm:pt-2.opacity-70
+           (t :settings-page/current-version)]
+          [:div.mt-1.sm:mt-0.sm:col-span-2
+           [:p version]
+           (if (util/electron?) (app-updater))]]
+
+         [:hr]
+
          [:div.sm:grid.sm:grid-cols-3.sm:gap-4.sm:items-start.sm:pt-5
           [:label.block.text-sm.font-medium.leading-5.sm:mt-px.sm:pt-2.opacity-70
            {:for "developer_mode"}

+ 44 - 0
src/main/frontend/components/settings.css

@@ -0,0 +1,44 @@
+.cp__settings {
+  &-app-updater {
+    min-height: 20px;
+    position: relative;
+    margin-bottom: -10px;
+
+    button.check-update {
+      position: absolute;
+      right: 0;
+      top: -45px;
+
+      &:disabled {
+        cursor: progress;
+      }
+    }
+
+    .update-state {
+      padding: 15px;
+      background-color: var(--ls-secondary-background-color);
+      border-radius: 4px;
+
+      > p {
+        margin: 0;
+      }
+
+      .link {
+        font-size: 16px;
+        line-height: 1em;
+        letter-spacing: 1px;
+
+        svg {
+          display: inline-block;
+          position: relative;
+          top: -1px;
+          margin-right: 2px;
+        }
+
+        &:hover {
+          text-decoration: underline;
+        }
+      }
+    }
+  }
+}

+ 7 - 1
src/main/frontend/components/sidebar.css

@@ -80,7 +80,7 @@
 }
 
 .cp__sidebar-main-content {
-  padding: 6rem 1.5rem;
+  padding: 5rem 1.5rem;
   max-width: var(--ls-main-content-max-width);
   min-height: 100vh;
   flex: 1;
@@ -138,12 +138,14 @@
 
   &-inner {
     padding: 15px;
+    padding-top: 0;
   }
 
   &-settings {
     @apply flex flex-row mb-2;
     margin: -15px;
     margin-bottom: 0;
+    margin-top: 0;
     overflow: auto;
 
     &-btn {
@@ -153,6 +155,10 @@
     }
   }
 
+  .close-arrow svg {
+      transform: scale(0.8);
+  }
+
   &.is-open {
     display: block;
     width: 40%;

+ 43 - 1
src/main/frontend/components/svg.cljs

@@ -15,7 +15,7 @@
     {:d "M5 11L0 6l1.5-1.5L5 8.25 8.5 4.5 10 6l-5 5z"
      :fill-rule "evenodd"}]])
 
-(rum/defc arrow-right
+(rum/defc arrow-right-2
   []
   [:svg
    {:aria-hidden "true"
@@ -29,6 +29,26 @@
     {:d "M7.5 8l-5 5L1 11.5 4.75 8 1 4.5 2.5 3l5 5z"
      :fill-rule "evenodd"}]])
 
+(rum/defc arrow-left
+  []
+  [:svg.w-6.h-6
+   {:viewbox "0 0 24 24", :stroke "currentColor", :fill "none"}
+   [:path
+    {:d "M15 19l-7-7 7-7",
+     :stroke-width "2",
+     :stroke-linejoin "round",
+     :stroke-linecap "round"}]])
+
+(rum/defc arrow-right
+  []
+  [:svg.w-6.h-6
+   {:viewbox "0 0 24 24", :stroke "currentColor", :fill "none"}
+   [:path
+    {:d "M9 5l7 7-7 7",
+     :stroke-width "2",
+     :stroke-linejoin "round",
+     :stroke-linecap "round"}]])
+
 (rum/defc big-arrow-right
   []
   [:svg
@@ -83,6 +103,22 @@
    [:path.opacity-75 {:fill "currentColor"
                       :d "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"}]])
 
+(defonce minus
+  [:svg.w-6.h-6
+   {:viewbox "0 0 24 24", :stroke "currentColor", :fill "none"}
+   [:path
+    {:d               "M20 12H4"
+     :stroke-width    "2"
+     :stroke-linejoin "round"
+     :stroke-linecap  "round"}]])
+
+(defonce rectangle
+  [:svg.w-6.h-6
+   {:viewbox "0 0 24 24", :stroke "currentColor", :fill "none"}
+   [:path
+    {:d               "M3.16580358,18.5038125 L20.5529464,18.5038125 C22.6525178,18.5038125 23.7072321,17.4593839 23.7072321,15.3902411 L23.7072321,3.12495537 C23.7072321,1.0558125 22.6525178,0.0113839219 20.5529464,0.0113839219 L3.16580358,0.0113839219 C1.07651787,0.0118125 0.0115178672,1.04638392 0.0115178672,3.12495537 L0.0115178672,15.3906696 C0.0115178672,17.4696696 1.07651787,18.5042411 3.16580358,18.5042411 L3.16580358,18.5038125 Z M3.19580358,16.8868125 C2.19123216,16.8868125 1.62894642,16.3545268 1.62894642,15.3096696 L1.62894642,3.20638392 C1.62894642,2.16152679 2.19123213,1.62924108 3.19580358,1.62924108 L20.5229464,1.62924108 C21.5172321,1.62924108 22.0898036,2.16152679 22.0898036,3.20638392 L22.0898036,15.3092411 C22.0898036,16.3540982 21.5172322,16.8863839 20.5229464,16.8863839 L3.19580358,16.8868125 Z"
+     :stroke-width    "2"}]])
+
 (defn- hero-icon
   ([d]
    (hero-icon d {}))
@@ -182,6 +218,9 @@
 (defn vertical-dots
   [options]
   (hero-icon "M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" options))
+(defn horizontal-dots
+  [options]
+  (hero-icon "M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" options))
 (def external-link
   [:svg {:fill "none", :view-box "0 0 24 24", :height "21", :width "21"
          :stroke "currentColor"}
@@ -441,3 +480,6 @@
 
 (def online
   (hero-icon "M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"))
+
+(def collapse-right
+  (hero-icon "M4 6h16M4 12h16m-7 6h7"))

+ 13 - 10
src/main/frontend/components/theme.cljs

@@ -1,17 +1,20 @@
 (ns frontend.components.theme
-  (:require [rum.core :as rum]))
+  (:require [rum.core :as rum]
+            [frontend.util :as util]
+            [frontend.version :refer [version]]
+            [frontend.components.svg :as svg]))
 
 (rum/defc container
   [{:keys [theme on-click] :as props} child]
-  rum/use-effect! (let [doc js/document.documentElement
-                        cls (.-classList doc)]
-                    (.setAttribute doc "data-theme" (if (= theme "white") "light" theme))
-                    (if (= theme "dark")                    ;; for tailwind dark mode
-                      (.add cls "dark")
-                      (.remove cls "dark")))
-
-  [theme]
+  (rum/use-effect!
+   #(let [doc js/document.documentElement
+          cls (.-classList doc)]
+      (.setAttribute doc "data-theme" (if (= theme "white") "light" theme))
+      (if (= theme "dark")                                 ;; for tailwind dark mode
+        (.add cls "dark")
+        (.remove cls "dark")))
+   [theme])
   [:div
-   {:class (str theme "-theme")
+   {:class    (str theme "-theme")
     :on-click on-click}
    child])

+ 105 - 2
src/main/frontend/components/theme.css

@@ -10,7 +10,7 @@
   --ls-z-index-level-5: 99999;
 }
 
-html:not(.is-mac) {
+html {
   ::-webkit-scrollbar-thumb {
     background-color: var(--ls-scrollbar-foreground-color);
   }
@@ -26,7 +26,6 @@ html:not(.is-mac) {
   ::-webkit-scrollbar {
     width: 8px;
     height: 8px;
-    -webkit-border-radius: 100px;
   }
 
   ::-webkit-scrollbar-thumb {
@@ -91,3 +90,107 @@ html[data-theme=light] {
     display: none;
   }
 }
+
+html.is-electron {
+  --frame-top-height: 24px;
+
+  .theme-inner {
+  }
+
+  .cp__header {
+    height: 2.6rem;
+    background-color: var(--ls-primary-background-color);
+    top: 0;
+  }
+
+  &.is-mac {
+    .cp__header {
+      height: calc(2.2rem + var(--frame-top-height));
+      padding-top: var(--frame-top-height);
+
+      &-logo {
+        height: var(--frame-top-height);
+      }
+
+      &:before {
+        content: " ";
+        position: fixed;
+        top: 0;
+        left: 0;
+        z-index: 8;
+        -webkit-app-region: drag;
+        width: 100%;
+        height: var(--frame-top-height);
+      }
+    }
+
+    .cp__right-sidebar {
+      top: 4rem;
+    }
+  }
+
+  #search {
+    -webkit-app-region: drag;
+
+    #search-wrapper {
+      -webkit-app-region: no-drag;
+    }
+  }
+
+  .ls-window-frame-title-bar {
+    background-color: var(--ls-primary-background-color);
+    position: fixed;
+    left: 0;
+    right: 0;
+    z-index: 9;
+    height: var(--frame-top-height);
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    user-select: none;
+    -webkit-app-region: drag;
+
+    & > .l {
+      display: flex;
+    }
+
+    & > .r {
+      & > .inner {
+        display: flex;
+      }
+    }
+
+    & > .c {
+      font-size: .7rem;
+    }
+
+    a.it {
+      padding: 0 2px;
+      cursor: pointer;
+      -webkit-app-region: no-drag;
+
+      &:hover {
+        background-color: var(--ls-secondary-background-color);
+      }
+
+      &:active {
+        background-color: var(--ls-primary-background-color);
+      }
+
+      svg {
+        transform: scale(.6);
+        color: var(--ls-primary-text-color);
+        cursor: pointer;
+      }
+
+      &.maximize {
+        svg {
+          transform: scale(.5) translateY(2px) translateX(1px);
+          opacity: .7;
+        }
+      }
+    }
+  }
+}
+
+

+ 19 - 19
src/main/frontend/components/widgets.cljs

@@ -59,24 +59,24 @@
                           (reset! branch (util/evalue e)))}]]]]
 
         (ui/button
-          (t :git/add-repo-prompt-confirm)
-          :on-click
-          (fn []
-            (let [branch (string/trim @branch)]
-              (if (string/blank? branch)
-                (notification/show!
-                 [:p.text-gray-700.dark:text-gray-300 "Please input a branch, make sure it's matched with your setting on Github."]
-                 :error
-                 false)
-                (let [repo (util/lowercase-first @repo)]
-                  (if (util/starts-with? repo "https://github.com/")
-                    (let [repo (string/replace repo ".git" "")]
-                      (repo-handler/create-repo! repo branch))
+         (t :git/add-repo-prompt-confirm)
+         :on-click
+         (fn []
+           (let [branch (string/trim @branch)]
+             (if (string/blank? branch)
+               (notification/show!
+                [:p.text-gray-700.dark:text-gray-300 "Please input a branch, make sure it's matched with your setting on Github."]
+                :error
+                false)
+               (let [repo (util/lowercase-first @repo)]
+                 (if (util/starts-with? repo "https://github.com/")
+                   (let [repo (string/replace repo ".git" "")]
+                     (repo-handler/create-repo! repo branch))
 
-                    (notification/show!
-                     [:p.text-gray-700.dark:text-gray-300 "Please input a valid repo url, e.g. https://github.com/username/repo"]
-                     :error
-                     false)))))))]])))
+                   (notification/show!
+                    [:p.text-gray-700.dark:text-gray-300 "Please input a valid repo url, e.g. https://github.com/username/repo"]
+                    :error
+                    false)))))))]])))
 
 (rum/defcs add-local-directory
   []
@@ -109,9 +109,9 @@
         generate-f (fn [x]
                      (case x
                        :github
-                       (when github-authed?
+                       (when (and github-authed? (not (util/electron?)))
                          (rum/with-key (add-github-repo)
-                                       "add-github-repo"))
+                           "add-github-repo"))
 
                        :local
                        (rum/with-key (add-local-directory)

+ 43 - 2
src/main/frontend/config.cljs

@@ -31,8 +31,10 @@
 
 (defn asset-uri
   [path]
-  (if dev? path
-      (str asset-domain path)))
+  (if (util/file-protocol?)
+    (string/replace path "/static/" "./")
+    (if dev? path
+        (str asset-domain path))))
 
 (goog-define GITHUB_APP_NAME "logseq-test")
 
@@ -299,3 +301,42 @@
 (defn get-local-dir
   [s]
   (string/replace s local-db-prefix ""))
+
+(defn get-local-repo
+  [dir]
+  (str local-db-prefix dir))
+
+(defn get-repo-dir
+  [repo-url]
+  (if (and (util/electron?) (local-db? repo-url))
+    (get-local-dir repo-url)
+    (str "/"
+         (->> (take-last 2 (string/split repo-url #"/"))
+              (string/join "_")))))
+
+(defn get-repo-path
+  [repo-url path]
+  (if (and (util/electron?) (local-db? repo-url))
+    path
+    (str (get-repo-dir repo-url) "/" path)))
+
+(defn get-file-path
+  [repo-url relative-path]
+  (if (and (util/electron?) (local-db? repo-url))
+    (str (get-repo-dir repo-url) "/" relative-path)
+    relative-path))
+
+(defn get-config-path
+  ([]
+   (get-config-path (state/get-current-repo)))
+  ([repo]
+   (when repo
+     (get-file-path repo (str app-name "/" config-file)))))
+
+(defn get-custom-css-path
+  ([]
+   (get-custom-css-path (state/get-current-repo)))
+  ([repo]
+   (when repo
+     (get-file-path repo
+                    (str app-name "/" custom-css-file)))))

+ 1 - 1
src/main/frontend/core.cljs

@@ -16,7 +16,7 @@
    (rf/router routes/routes {})
    route/set-route-match!
    ;; set to false to enable HistoryAPI
-   {:use-fragment false}))
+   {:use-fragment true}))
 
 (defn display-welcome-message
   []

+ 0 - 14
src/main/frontend/date.cljs

@@ -129,20 +129,6 @@
                  :minute "2-digit"
                  :hour12 false}))))
 
-(defn journals-path
-  [year month preferred-format]
-  (let [month (if (< month 10) (str "0" month) month)
-        format (string/lower-case (name preferred-format))
-        format (if (= format "markdown") "md" format)]
-    (str "journals/" year "_" month "." format)))
-
-(defn current-journal-path
-  [preferred-format]
-  (when preferred-format
-    (let [{:keys [year month]} (get-date)
-          preferred-format preferred-format]
-      (journals-path year month preferred-format))))
-
 (defn valid?
   [s]
   (some

+ 3 - 2
src/main/frontend/db.cljs

@@ -50,10 +50,11 @@
   get-page-properties-content get-page-referenced-blocks get-page-referenced-pages get-page-unlinked-references
   get-pages get-pages-relation get-pages-that-mentioned-page get-public-pages get-tag-pages
   journal-page? local-native-fs? mark-repo-as-cloned! page-alias-set page-blocks-transform pull-block
-  set-file-last-modified-at! transact-files-db! with-block-refs-count get-modified-pages page-empty? get-alias-source-page]
+  set-file-last-modified-at! transact-files-db! with-block-refs-count get-modified-pages page-empty? get-alias-source-page
+  set-file-content!]
 
  [frontend.db.react
-  get-current-marker get-current-page get-current-priority get-handler-keys set-file-content! set-key-value
+  get-current-marker get-current-page get-current-priority get-handler-keys set-key-value
   transact-react! remove-key! remove-q! remove-query-component! add-q! add-query-component! clear-query-state!
   clear-query-state-without-refs-and-embeds! get-block-blocks-cache-atom get-page-blocks-cache-atom kv q
   query-state query-components query-entity-in-component remove-custom-query! set-new-result! sub-key-value]

+ 20 - 5
src/main/frontend/db/model.cljs

@@ -203,7 +203,7 @@
 (defn set-file-last-modified-at!
   [repo path last-modified-at]
   (when (and repo path last-modified-at)
-    (when-let [conn (conn/get-files-conn repo)]
+    (when-let [conn (conn/get-conn repo false)]
       (d/transact! conn
                    [{:file/path path
                      :file/last-modified-at last-modified-at}]))))
@@ -211,7 +211,7 @@
 (defn get-file-last-modified-at
   [repo path]
   (when (and repo path)
-    (when-let [conn (conn/get-files-conn repo)]
+    (when-let [conn (conn/get-conn repo false)]
       (-> (d/entity (d/db conn) [:file/path path])
           :file/last-modified-at))))
 
@@ -258,7 +258,8 @@
 
 (defn get-custom-css
   []
-  (get-file "logseq/custom.css"))
+  (when-let [repo (state/get-current-repo)]
+    (get-file (config/get-file-path repo "logseq/custom.css"))))
 
 (defn get-file-no-sub
   ([path]
@@ -342,7 +343,7 @@
 (defn sort-blocks
   [blocks]
   (let [pages-ids (map (comp :db/id :block/page) blocks)
-        pages (db-utils/pull-many '[:db/id :page/last-modified-at :page/name :page/original-name] pages-ids)
+        pages (db-utils/pull-many '[:db/id :page/name :page/original-name] pages-ids)
         pages-map (reduce (fn [acc p] (assoc acc (:db/id p) p)) {} pages)
         blocks (map
                 (fn [block]
@@ -696,7 +697,7 @@
   [file ast]
   ;; headline
   (let [ast (map first ast)]
-    (if (util/starts-with? file "pages/contents.")
+    (if (string/includes? file "pages/contents.")
       "Contents"
       (let [first-block (last (first (filter block/heading-block? ast)))
             property-name (when (and (= "Properties" (ffirst ast))
@@ -1188,3 +1189,17 @@
         tx-data (map (fn [page-id] [:db/retract page-id :page/alias]) page-ids)]
     (when (seq tx-data)
       (db-utils/transact! repo tx-data))))
+
+(defn set-file-content!
+  [repo path content]
+  (when (and repo path)
+    (let [tx-data {:file/path path
+                   :file/content content}
+          tx-data (if (config/local-db? repo)
+                    (dissoc tx-data :file/last-modified-at)
+                    tx-data)]
+      (react/transact-react!
+       repo
+       [tx-data]
+       {:key [:file/content path]
+        :files-db? true}))))

+ 0 - 15
src/main/frontend/db/react.cljs

@@ -339,18 +339,3 @@
      (-> (q repo-url [:kv key] {} key key)
          react
          key))))
-
-(defn set-file-content!
-  [repo path content]
-  (when (and repo path)
-    (let [tx-data {:file/path path
-                   :file/content content
-                   :file/last-modified-at (util/time-ms)}
-          tx-data (if (config/local-db? repo)
-                    (dissoc tx-data :file/last-modified-at)
-                    tx-data)]
-      (transact-react!
-       repo
-       [tx-data]
-       {:key [:file/content path]
-        :files-db? true}))))

+ 1 - 2
src/main/frontend/db/utils.cljs

@@ -35,8 +35,7 @@
 (defn group-by-page
   [blocks]
   (some->> blocks
-           (group-by :block/page)
-           (sort-by (fn [[p _blocks]] (:page/last-modified-at p)) >)))
+           (group-by :block/page)))
 
 (defn get-tx-id [tx-report]
   (get-in tx-report [:tempids :db/current-tx]))

+ 0 - 3
src/main/frontend/db_schema.cljs

@@ -5,7 +5,6 @@
 (def files-db-schema
   {:file/path {:db/unique :db.unique/identity}
    :file/content {}
-   :file/last-modified-at {}
    :file/size {}
    :file/handle {}})
 
@@ -50,8 +49,6 @@
                      :db/cardinality :db.cardinality/many}
    :page/journal?   {}
    :page/journal-day {}
-   :page/created-at {}
-   :page/last-modified-at {}
 
    ;; block
    :block/uuid   {:db/unique      :db.unique/identity}

+ 8 - 2
src/main/frontend/dicts.cljs

@@ -222,6 +222,8 @@ title: How to take dummy notes?
         :page/re-index "Re-index this page"
         :page/copy-to-json "Copy the whole page as JSON"
         :page/rename "Rename page"
+        :page/open-in-finder "Open in directory"
+        :page/open-with-default-app "Open with default app"
         :page/action-publish "Publish"
         :page/make-public "Publish it when exporting to an html file"
         :page/make-private "Make it private"
@@ -291,6 +293,7 @@ title: How to take dummy notes?
         :settings-page/enable-developer-mode "Enable developer mode"
         :settings-page/disable-developer-mode "Disable developer mode"
         :settings-page/developer-mode-desc "Developer mode helps contributors and extension developers test their integration with Logseq more efficient."
+        :settings-page/current-version "Current version"
         :logseq "Logseq"
         :dot-mode "Dot mode"
         :on "ON"
@@ -708,6 +711,8 @@ title: How to take dummy notes?
            :page/re-index "对此页面重新建立索引"
            :page/copy-to-json "将整页以 JSON 格式复制"
            :page/rename "重命名本页"
+           :page/open-in-finder "打开文件对应目录"
+           :page/open-with-default-app "用默认应用打开文件"
            :page/action-publish "发布"
            :page/make-public "导出 HTML 时发布本页面"
            :page/make-private "导出 HTML 时取消发布本页面"
@@ -774,6 +779,7 @@ title: How to take dummy notes?
            :settings-page/enable-developer-mode "启用开发者模式"
            :settings-page/disable-developer-mode "禁用开发者模式"
            :settings-page/developer-mode-desc "开发者模式帮助贡献者和扩展开发者更有效地测试他们与 Logseq 的集成。"
+           :settings-page/current-version "当前版本"
            :logseq "Logseq"
            :dot-mode "点模式"
            :on "已打开"
@@ -803,7 +809,7 @@ title: How to take dummy notes?
            :sponsor-us "赞助我们!"
            :discord-title "我们的 Discord 社群!"
            :sign-out "登出"
-           :help-shortcut-title "点此查看快捷方式和更多游泳帮助"
+           :help-shortcut-title "点此查看快捷方式和更多有用帮助"
            :loading "加载中"
            :cloning "Clone 中"
            :parsing-files "正在解析文件"
@@ -1059,7 +1065,7 @@ title: How to take dummy notes?
              :join-community "加入社區"
              :discord-title "我們的 Discord 社群!"
              :sign-out "登出"
-             :help-shortcut-title "點此查看快捷方式和更多游泳幫助"
+             :help-shortcut-title "點此查看快捷方式和更多有用幫助"
              :loading "加載中"
              :cloning "Clone 中"
              :parsing-files "正在解析文件"

+ 83 - 260
src/main/frontend/fs.cljs

@@ -1,263 +1,85 @@
 (ns frontend.fs
   (:require [frontend.util :as util :refer-macros [profile]]
             [frontend.config :as config]
-            [frontend.state :as state]
             [clojure.string :as string]
-            [frontend.idb :as idb]
-            [frontend.db :as db]
-            [frontend.handler.common :as common-handler]
             [promesa.core :as p]
-            [goog.object :as gobj]
-            [clojure.set :as set]
             [lambdaisland.glogi :as log]
-            ["/frontend/utils" :as utils]))
+            [frontend.fs.protocol :as protocol]
+            [frontend.fs.nfs :as nfs]
+            [frontend.fs.bfs :as bfs]
+            [frontend.fs.node :as node]
+            [cljs-bean.core :as bean]
+            [frontend.state :as state]))
 
-;; We need to cache the file handles in the memory so that
-;; the browser will not keep asking permissions.
-(defonce nfs-file-handles-cache (atom {}))
-
-(defn get-nfs-file-handle
-  [handle-path]
-  (get @nfs-file-handles-cache handle-path))
-
-(defn add-nfs-file-handle!
-  [handle-path handle]
-  (swap! nfs-file-handles-cache assoc handle-path handle))
-
-(defn remove-nfs-file-handle!
-  [handle-path]
-  (swap! nfs-file-handles-cache dissoc handle-path))
-
-;; TODO:
-;; We need to support several platforms:
-;; 1. Chrome native file system API (lighting-fs wip)
-;; 2. IndexedDB (lighting-fs)
-;; 3. NodeJS
-#_(defprotocol Fs
-    (mkdir! [this dir])
-    (readdir! [this dir])
-    (unlink! [this path opts])
-    (rename! [this old-path new-path])
-    (rmdir! [this dir])
-    (read-file [dir path option])
-    (write-file! [dir path content])
-    (stat [dir path]))
+(defonce nfs-record (nfs/->Nfs))
+(defonce bfs-record (bfs/->Bfs))
+(defonce node-record (node/->Node))
 
 (defn local-db?
   [dir]
   (and (string? dir)
        (config/local-db? (subs dir 1))))
 
-(defn mkdir
+(defn get-fs
   [dir]
-  (cond
-    (local-db? dir)
-    (let [[root new-dir] (rest (string/split dir "/"))
-          root-handle (str "handle/" root)]
-      (->
-       (p/let [handle (idb/get-item root-handle)
-               _ (when handle (common-handler/verify-permission nil handle true))]
-         (when (and handle new-dir
-                    (not (string/blank? new-dir)))
-           (p/let [handle (.getDirectoryHandle ^js handle new-dir
-                                               #js {:create true})
-                   handle-path (str root-handle "/" new-dir)
-                   _ (idb/set-item! handle-path handle)]
-             (add-nfs-file-handle! handle-path handle)
-             (println "Stored handle: " (str root-handle "/" new-dir)))))
-       (p/catch (fn [error]
-                  (js/console.debug "mkdir error: " error ", dir: " dir)
-                  (throw error)))))
+  (let [bfs-local? (or (string/starts-with? dir "/local")
+                       (string/starts-with? dir "local"))
+        current-repo (state/get-current-repo)
+        git-repo? (and current-repo
+                       (string/starts-with? current-repo "https://"))]
+    (cond
+      (and (util/electron?) (not bfs-local?) (not git-repo?))
+      node-record
 
-    (and dir js/window.pfs)
-    (js/window.pfs.mkdir dir)
+      (local-db? dir)
+      nfs-record
 
-    :else
-    (println (str "mkdir " dir " failed"))))
+      :else
+      bfs-record)))
 
-(defn readdir
+(defn mkdir!
   [dir]
-  (cond
-    (local-db? dir)
-    (let [prefix (str "handle/" dir)
-          cached-files (keys @nfs-file-handles-cache)]
-      (p/resolved
-       (->> (filter #(string/starts-with? % (str prefix "/")) cached-files)
-            (map (fn [path]
-                   (string/replace path prefix ""))))))
-
-    (and dir js/window.pfs)
-    (js/window.pfs.readdir dir)
+  (protocol/mkdir! (get-fs dir) dir))
 
-    :else
-    nil))
+(defn readdir
+  [dir]
+  (protocol/readdir (get-fs dir) dir))
 
-(defn unlink
+(defn unlink!
   [path opts]
-  (cond
-    (local-db? path)
-    (let [[dir basename] (util/get-dir-and-basename path)
-          handle-path (str "handle" path)]
-      (->
-       (p/let [handle (idb/get-item (str "handle" dir))
-               _ (idb/remove-item! handle-path)]
-         (when handle
-           (.removeEntry ^js handle basename))
-         (remove-nfs-file-handle! handle-path))
-       (p/catch (fn [error]
-                  (log/error :unlink/path {:path path
-                                           :error error})))))
-
-    :else
-    (p/let [stat (js/window.pfs.stat path)]
-      (if (= (.-type stat) "file")
-        (js/window.pfs.unlink path opts)
-        (p/rejected "Unlinking a directory is not allowed")))))
+  (protocol/unlink! (get-fs path) path opts))
 
-(defn rmdir
-  "Remove the directory recursively."
+(defn rmdir!
+  "Remove the directory recursively.
+   Warning: only run it for browser cache."
   [dir]
-  (cond
-    (local-db? dir)
-    nil
-
-    :else
-    (js/window.workerThread.rimraf dir)))
+  (protocol/rmdir! (get-fs dir) dir))
 
 (defn read-file
-  ([dir path]
-   (read-file dir path (clj->js {:encoding "utf8"})))
-  ([dir path option]
-   (cond
-     (local-db? dir)
-     (let [handle-path (str "handle" dir "/" path)]
-       (p/let [handle (idb/get-item handle-path)
-               local-file (and handle (.getFile handle))]
-         (and local-file (.text local-file))))
-
-     :else
-     (js/window.pfs.readFile (str dir "/" path) option))))
-
-(defn nfs-saved-handler
-  [repo path file]
-  (when-let [last-modified (gobj/get file "lastModified")]
-    ;; TODO: extract
-    (let [path (if (= \/ (first path))
-                 (subs path 1)
-                 path)]
-      (db/set-file-last-modified-at! repo path last-modified))))
-
-(defn write-file
-  ([repo dir path content]
-   (write-file repo dir path content nil))
-  ([repo dir path content {:keys [old-content last-modified-at]}]
-   (->
-    (cond
-      (local-db? dir)
-      (let [parts (string/split path "/")
-            basename (last parts)
-            sub-dir (->> (butlast parts)
-                         (remove string/blank?)
-                         (string/join "/"))
-            sub-dir-handle-path (str "handle/"
-                                     (subs dir 1)
-                                     (if sub-dir
-                                       (str "/" sub-dir)))
-            handle-path (if (= "/" (last sub-dir-handle-path))
-                          (subs sub-dir-handle-path 0 (dec (count sub-dir-handle-path)))
-                          sub-dir-handle-path)
-            basename-handle-path (str handle-path "/" basename)]
-        (p/let [file-handle (idb/get-item basename-handle-path)]
-          (when file-handle
-            (add-nfs-file-handle! basename-handle-path file-handle))
-          (if file-handle
-            (p/let [local-file (.getFile file-handle)
-                    local-content (.text local-file)
-                    local-last-modified-at (gobj/get local-file "lastModified")
-                    current-time (util/time-ms)
-                    new? (> current-time local-last-modified-at)
-                    new-created? (nil? last-modified-at)
-                    not-changed? (= last-modified-at local-last-modified-at)
-                    format (-> (util/get-file-ext path)
-                               (config/get-file-format))
-                    pending-writes (state/get-write-chan-length)]
-              ;; (println {:last-modified-at last-modified-at
-              ;;           :local-last-modified-at local-last-modified-at
-              ;;           :not-changed? not-changed?
-              ;;           :new-created? new-created?
-              ;;           :pending-writes pending-writes
-              ;;           :local-content local-content
-              ;;           :old-content old-content
-              ;;           :new? new?})
-              (if (and local-content old-content new?
-                       (or
-                        (> pending-writes 0)
-                        not-changed?
-                        new-created?))
-                (do
-                  (p/let [_ (common-handler/verify-permission repo file-handle true)
-                          _ (utils/writeFile file-handle content)
-                          file (.getFile file-handle)]
-                    (when file
-                      (nfs-saved-handler repo path file))))
-                (do
-                  (js/alert (str "The file has been modified in your local disk! File path: " path
-                                 ", save your changes and click the refresh button to reload it.")))))
-            ;; create file handle
-            (->
-             (p/let [handle (idb/get-item handle-path)]
-               (if handle
-                 (do
-                   (p/let [_ (common-handler/verify-permission repo handle true)
-                           file-handle (.getFileHandle ^js handle basename #js {:create true})
-                           _ (idb/set-item! basename-handle-path file-handle)
-                           _ (utils/writeFile file-handle content)
-                           file (.getFile file-handle)]
-                     (when file
-                       (nfs-saved-handler repo path file))))
-                 (println "Error: directory handle not exists: " handle-path)))
-             (p/catch (fn [error]
-                        (println "Write local file failed: " {:path path})
-                        (js/console.error error)))))))
-
-      js/window.pfs
-      (js/window.pfs.writeFile (str dir "/" path) content)
-
-      :else
-      nil)
-    (p/catch (fn [error]
-               (log/error :file/write-failed? {:dir dir
-                                               :path path
-                                               :error error})
-               ;; Disable this temporarily
-               ;; (js/alert "Current file can't be saved! Please copy its content to your local file system and click the refresh button.")
-)))))
-
-(defn rename
+  [dir path]
+  (protocol/read-file (get-fs dir) dir path))
+
+(defn write-file!
+  [repo dir path content opts]
+  (->
+   (protocol/write-file! (get-fs dir) repo dir path content opts)
+   (p/catch (fn [error]
+              (log/error :file/write-failed? {:dir dir
+                                              :path path
+                                              :error error})
+              ;; Disable this temporarily
+              ;; (js/alert "Current file can't be saved! Please copy its content to your local file system and click the refresh button.")
+))))
+
+(defn rename!
   [repo old-path new-path]
   (cond
     ; See https://github.com/isomorphic-git/lightning-fs/issues/41
     (= old-path new-path)
     (p/resolved nil)
 
-    (local-db? old-path)
-    ;; create new file
-    ;; delete old file
-    (p/let [[dir basename] (util/get-dir-and-basename old-path)
-            [_ new-basename] (util/get-dir-and-basename new-path)
-            parts (->> (string/split new-path "/")
-                       (remove string/blank?))
-            dir (str "/" (first parts))
-            new-path (->> (rest parts)
-                          (string/join "/"))
-            handle (idb/get-item (str "handle" old-path))
-            file (.getFile handle)
-            content (.text file)
-            _ (write-file repo dir new-path content)]
-      (unlink old-path nil))
-
     :else
-    (js/window.pfs.rename old-path new-path)))
+    (protocol/rename! (get-fs old-path) repo old-path new-path)))
 
 (defn stat
   [dir path]
@@ -267,32 +89,40 @@
                              (subs path 1)
                              path))
                       "")]
-    (cond
-      (local-db? dir)
-      (if-let [file (get-nfs-file-handle (str "handle/"
-                                              (string/replace-first dir "/" "")
-                                              append-path))]
-        (p/let [file (.getFile file)]
-          (let [get-attr #(gobj/get file %)]
-            {:file/last-modified-at (get-attr "lastModified")
-             :file/size (get-attr "size")
-             :file/type (get-attr "type")}))
-        (p/rejected "File not exists"))
-
-      :else
-      (do
-        (js/window.pfs.stat (str dir append-path))))))
+    (protocol/stat (get-fs dir) dir path)))
+
+(defn open-dir
+  [ok-handler]
+  (let [record (if (util/electron?) node-record nfs-record)]
+    (p/let [result (protocol/open-dir record ok-handler)]
+      (if (util/electron?)
+        (let [[dir & paths] (bean/->clj result)]
+          [(:path dir) paths])
+        result))))
+
+(defn get-files
+  [path-or-handle ok-handler]
+  (let [record (if (util/electron?) node-record nfs-record)]
+    (p/let [result (protocol/get-files record path-or-handle ok-handler)]
+      (if (util/electron?)
+        (let [result (bean/->clj result)]
+          (rest result))
+        result))))
+
+(defn watch-dir!
+  [dir]
+  (protocol/watch-dir! node-record dir))
 
 (defn mkdir-if-not-exists
   [dir]
-  (when dir
-    (let [local? (config/local-db? dir)]
-      (when (or local? js/window.pfs)
-        (util/p-handle
-         (stat dir nil)
-         (fn [_stat])
-         (fn [error]
-           (mkdir dir)))))))
+  (->
+   (when dir
+     (util/p-handle
+      (stat dir nil)
+      (fn [_stat])
+      (fn [error]
+        (mkdir! dir))))
+   (p/catch (fn [_error] nil))))
 
 (defn create-if-not-exists
   ([repo dir path]
@@ -302,11 +132,11 @@
                 path
                 (str "/" path))]
      (->
-      (p/let [_ (stat dir path)]
+      (p/let [stat (stat dir path)]
         true)
       (p/catch
        (fn [_error]
-         (p/let [_ (write-file repo dir path initial-content)]
+         (p/let [_ (write-file! repo dir path initial-content nil)]
            false)))))))
 
 (defn file-exists?
@@ -315,10 +145,3 @@
    (stat dir path)
    (fn [_stat] true)
    (fn [_e] false)))
-
-(defn check-directory-permission!
-  [repo]
-  (when (config/local-db? repo)
-    (p/let [handle (idb/get-item (str "handle/" repo))]
-      (when handle
-        (common-handler/verify-permission repo handle true)))))

+ 40 - 0
src/main/frontend/fs/bfs.cljs

@@ -0,0 +1,40 @@
+(ns frontend.fs.bfs
+  (:require [frontend.fs.protocol :as protocol]
+            [frontend.util :as util]
+            [clojure.string :as string]
+            [promesa.core :as p]))
+
+(defrecord Bfs []
+  protocol/Fs
+  (mkdir! [this dir]
+    (when (and js/window.pfs (not (util/electron?)))
+      (->
+       (js/window.pfs.mkdir dir)
+       (p/catch (fn [error] (println "Mkdir error: " error))))))
+  (readdir [this dir]
+    (when js/window.pfs
+      (js/window.pfs.readdir dir)))
+  (unlink! [this path opts]
+    (when js/window.pfs
+      (p/let [stat (js/window.pfs.stat path)]
+        (if (= (.-type stat) "file")
+          (js/window.pfs.unlink path opts)
+          (p/rejected "Unlinking a directory is not allowed")))))
+  (rmdir! [this dir]
+    (js/window.workerThread.rimraf dir))
+  (read-file [this dir path]
+    (let [option (clj->js {:encoding "utf8"})]
+      (js/window.pfs.readFile (str dir "/" path) option)))
+  (write-file! [this repo dir path content opts]
+    (when-not (util/electron?)
+      (js/window.pfs.writeFile (str dir "/" path) content)))
+  (rename! [this repo old-path new-path]
+    (js/window.pfs.rename old-path new-path))
+  (stat [this dir path]
+    (js/window.pfs.stat (str dir path)))
+  (open-dir [this ok-handler]
+    nil)
+  (get-files [this path-or-handle ok-handler]
+    nil)
+  (watch-dir! [this dir]
+    nil))

+ 197 - 0
src/main/frontend/fs/nfs.cljs

@@ -0,0 +1,197 @@
+(ns frontend.fs.nfs
+  (:require [frontend.fs.protocol :as protocol]
+            [frontend.util :as util]
+            [clojure.string :as string]
+            [frontend.idb :as idb]
+            [promesa.core :as p]
+            [lambdaisland.glogi :as log]
+            [goog.object :as gobj]
+            [frontend.db :as db]
+            [frontend.config :as config]
+            [frontend.state :as state]
+            ["/frontend/utils" :as utils]))
+
+;; We need to cache the file handles in the memory so that
+;; the browser will not keep asking permissions.
+(defonce nfs-file-handles-cache (atom {}))
+
+(defn get-nfs-file-handle
+  [handle-path]
+  (get @nfs-file-handles-cache handle-path))
+
+(defn add-nfs-file-handle!
+  [handle-path handle]
+  (swap! nfs-file-handles-cache assoc handle-path handle))
+
+(defn remove-nfs-file-handle!
+  [handle-path]
+  (swap! nfs-file-handles-cache dissoc handle-path))
+
+(defn nfs-saved-handler
+  [repo path file]
+  (when-let [last-modified (gobj/get file "lastModified")]
+    ;; TODO: extract
+    (let [path (if (= \/ (first path))
+                 (subs path 1)
+                 path)]
+      ;; Bad code
+      (db/set-file-last-modified-at! repo path last-modified))))
+
+(defn verify-permission
+  [repo handle read-write?]
+  (let [repo (or repo (state/get-current-repo))]
+    (p/then
+     (utils/verifyPermission handle read-write?)
+     (fn []
+       (state/set-state! [:nfs/user-granted? repo] true)
+       true))))
+
+(defn check-directory-permission!
+  [repo]
+  (when (config/local-db? repo)
+    (p/let [handle (idb/get-item (str "handle/" repo))]
+      (when handle
+        (verify-permission repo handle true)))))
+
+(defrecord Nfs []
+  protocol/Fs
+  (mkdir! [this dir]
+    (let [[root new-dir] (rest (string/split dir "/"))
+          root-handle (str "handle/" root)]
+      (->
+       (p/let [handle (idb/get-item root-handle)
+               _ (when handle (verify-permission nil handle true))]
+         (when (and handle new-dir
+                    (not (string/blank? new-dir)))
+           (p/let [handle (.getDirectoryHandle ^js handle new-dir
+                                               #js {:create true})
+                   handle-path (str root-handle "/" new-dir)
+                   _ (idb/set-item! handle-path handle)]
+             (add-nfs-file-handle! handle-path handle)
+             (println "Stored handle: " (str root-handle "/" new-dir)))))
+       (p/catch (fn [error]
+                  (js/console.debug "mkdir error: " error ", dir: " dir)
+                  (throw error))))))
+
+  (readdir [this dir]
+    (let [prefix (str "handle/" dir)
+          cached-files (keys @nfs-file-handles-cache)]
+      (p/resolved
+       (->> (filter #(string/starts-with? % (str prefix "/")) cached-files)
+            (map (fn [path]
+                   (string/replace path prefix "")))))))
+
+  (unlink! [this path opts]
+    (let [[dir basename] (util/get-dir-and-basename path)
+          handle-path (str "handle" path)]
+      (->
+       (p/let [handle (idb/get-item (str "handle" dir))
+               _ (idb/remove-item! handle-path)]
+         (when handle
+           (.removeEntry ^js handle basename))
+         (remove-nfs-file-handle! handle-path))
+       (p/catch (fn [error]
+                  (log/error :unlink/path {:path path
+                                           :error error}))))))
+
+  (rmdir! [this dir]
+    nil)
+
+  (read-file [this dir path]
+    (let [handle-path (str "handle" dir "/" path)]
+      (p/let [handle (idb/get-item handle-path)
+              local-file (and handle (.getFile handle))]
+        (and local-file (.text local-file)))))
+
+  (write-file! [this repo dir path content opts]
+    (let [{:keys [old-content last-modified-at]} opts
+          parts (string/split path "/")
+          basename (last parts)
+          sub-dir (->> (butlast parts)
+                       (remove string/blank?)
+                       (string/join "/"))
+          sub-dir-handle-path (str "handle/"
+                                   (subs dir 1)
+                                   (if sub-dir
+                                     (str "/" sub-dir)))
+          handle-path (if (= "/" (last sub-dir-handle-path))
+                        (subs sub-dir-handle-path 0 (dec (count sub-dir-handle-path)))
+                        sub-dir-handle-path)
+          basename-handle-path (str handle-path "/" basename)]
+      (p/let [file-handle (idb/get-item basename-handle-path)]
+        (when file-handle
+          (add-nfs-file-handle! basename-handle-path file-handle))
+        (if file-handle
+          (p/let [local-file (.getFile file-handle)
+                  local-content (.text local-file)
+                  local-last-modified-at (gobj/get local-file "lastModified")
+                  current-time (util/time-ms)
+                  new? (> current-time local-last-modified-at)
+                  new-created? (nil? last-modified-at)
+                  not-changed? (= last-modified-at local-last-modified-at)
+                  format (-> (util/get-file-ext path)
+                             (config/get-file-format))
+                  pending-writes (state/get-write-chan-length)]
+            (if (and local-content old-content new?
+                     (or
+                      (> pending-writes 0)
+                      not-changed?
+                      new-created?))
+              (do
+                (p/let [_ (verify-permission repo file-handle true)
+                        _ (utils/writeFile file-handle content)
+                        file (.getFile file-handle)]
+                  (when file
+                    (nfs-saved-handler repo path file))))
+              (do
+                (js/alert (str "The file has been modified in your local disk! File path: " path
+                               ", save your changes and click the refresh button to reload it.")))))
+           ;; create file handle
+          (->
+           (p/let [handle (idb/get-item handle-path)]
+             (if handle
+               (do
+                 (p/let [_ (verify-permission repo handle true)
+                         file-handle (.getFileHandle ^js handle basename #js {:create true})
+                         _ (idb/set-item! basename-handle-path file-handle)
+                         _ (utils/writeFile file-handle content)
+                         file (.getFile file-handle)]
+                   (when file
+                     (nfs-saved-handler repo path file))))
+               (println "Error: directory handle not exists: " handle-path)))
+           (p/catch (fn [error]
+                      (println "Write local file failed: " {:path path})
+                      (js/console.error error))))))))
+
+  (rename! [this repo old-path new-path]
+    (p/let [[dir basename] (util/get-dir-and-basename old-path)
+            [_ new-basename] (util/get-dir-and-basename new-path)
+            parts (->> (string/split new-path "/")
+                       (remove string/blank?))
+            dir (str "/" (first parts))
+            new-path (->> (rest parts)
+                          (string/join "/"))
+            handle (idb/get-item (str "handle" old-path))
+            file (.getFile handle)
+            content (.text file)
+            _ (protocol/write-file! this repo dir new-path content nil)]
+      (protocol/unlink! this old-path nil)))
+  (stat [this dir path]
+    (if-let [file (get-nfs-file-handle (str "handle/"
+                                            (string/replace-first dir "/" "")
+                                            path))]
+      (p/let [file (.getFile file)]
+        (let [get-attr #(gobj/get file %)]
+          {:file/last-modified-at (get-attr "lastModified")
+           :file/size (get-attr "size")
+           :file/type (get-attr "type")}))
+      (p/rejected "File not exists")))
+  (open-dir [this ok-handler]
+    (utils/openDirectory #js {:recursive true}
+                         ok-handler))
+  (get-files [this path-or-handle ok-handler]
+    (utils/getFiles path-or-handle true ok-handler))
+
+  ;; TODO:
+  (watch-dir! [this dir]
+    nil))

+ 49 - 0
src/main/frontend/fs/node.cljs

@@ -0,0 +1,49 @@
+(ns frontend.fs.node
+  (:require [frontend.fs.protocol :as protocol]
+            [frontend.util :as util]
+            [clojure.string :as string]
+            [promesa.core :as p]
+            [electron.ipc :as ipc]
+            [cljs-bean.core :as bean]))
+
+(defn concat-path
+  [dir path]
+  (cond
+    (nil? path)
+    dir
+
+    (string/starts-with? path dir)
+    path
+
+    :else
+    (str (string/replace dir #"/$" "")
+         (when path
+           (str "/" (string/replace path #"^/" ""))))))
+
+(defrecord Node []
+  protocol/Fs
+  (mkdir! [this dir]
+    (ipc/ipc "mkdir" dir))
+  (readdir [this dir]                   ; recursive
+    (ipc/ipc "readdir" dir))
+  (unlink! [this path _opts]
+    (ipc/ipc "unlink" path))
+  (rmdir! [this dir]
+    nil)
+  (read-file [this dir path]
+    (let [path (concat-path dir path)]
+      (ipc/ipc "readFile" path)))
+  (write-file! [this repo dir path content _opts]
+    (let [path (concat-path dir path)]
+      (ipc/ipc "writeFile" path content)))
+  (rename! [this repo old-path new-path]
+    (ipc/ipc "rename" old-path new-path))
+  (stat [this dir path]
+    (let [path (concat-path dir path)]
+      (ipc/ipc "stat" path)))
+  (open-dir [this ok-handler]
+    (ipc/ipc "openDir" {}))
+  (get-files [this path-or-handle ok-handler]
+    (ipc/ipc "getFiles" path-or-handle))
+  (watch-dir! [this dir]
+    (ipc/ipc "addDirWatcher" dir)))

+ 14 - 0
src/main/frontend/fs/protocol.cljs

@@ -0,0 +1,14 @@
+(ns frontend.fs.protocol)
+
+(defprotocol Fs
+  (mkdir! [this dir])
+  (readdir [this dir])
+  (unlink! [this path opts])
+  (rmdir! [this dir])
+  (read-file [this dir path])
+  (write-file! [this repo dir path content opts])
+  (rename! [this repo old-path new-path])
+  (stat [this dir path])
+  (open-dir [this ok-handler])
+  (get-files [this path-or-handle ok-handler])
+  (watch-dir! [this dir]))

+ 68 - 0
src/main/frontend/fs/watcher_handler.cljs

@@ -0,0 +1,68 @@
+(ns frontend.fs.watcher-handler
+  (:require [clojure.core.async :as async]
+            [lambdaisland.glogi :as log]
+            [frontend.handler.file :as file-handler]
+            [frontend.handler.page :as page-handler]
+            [frontend.handler.notification :as notification]
+            [frontend.handler.route :as route-handler]
+            [cljs-time.coerce :as tc]
+            [frontend.config :as config]
+            [cljs-bean.core :as bean]
+            [frontend.db :as db]
+            [frontend.state :as state]
+            [clojure.string :as string]))
+
+(defn handle-changed!
+  [type {:keys [dir path content stat] :as payload}]
+  (when dir
+    (let [repo (config/get-local-repo dir)
+          {:keys [mtime]} stat
+          mtime (tc/to-long mtime)]
+      (cond
+        (= "add" type)
+        (let [db-content (db/get-file path)]
+          (when (and (not= content db-content)
+                     ;; Avoid file overwrites
+                     ;; 1. create a new page which writes a new file
+                     ;; 2. add some new content
+                     ;; 3. file watcher notified it with the old content
+                     ;; 4. old content will overwrites the new content in step 2
+                     (not (and db-content
+                               (string/starts-with? db-content content))))
+            (file-handler/alter-file repo path content {:re-render-root? true})))
+
+        (and (= "change" type)
+             (nil? (db/get-file path)))
+        (println "Can't get file in the db: " path)
+
+        (and (= "change" type)
+             (not= content (db/get-file path))
+             (when-let [last-modified-at (db/get-file-last-modified-at repo path)]
+               (> mtime last-modified-at)))
+        (file-handler/alter-file repo path content {:re-render-root? true})
+
+        (= "unlink" type)
+        (when-let [page-name (db/get-file-page path)]
+          (page-handler/delete!
+           page-name
+           (fn []
+             (notification/show! (str "Page " page-name " was deleted on disk.")
+                                 :success)
+             (when (= (state/get-current-page) page-name)
+               ;; redirect to home
+               (route-handler/redirect-to-home!)))))
+
+        (contains? #{"add" "change" "unlink"} type)
+        nil
+
+        :else
+        (log/error :fs/watcher-no-handler {:type type
+                                           :payload payload})))))
+
+(defn run-dirs-watcher!
+  []
+  ;; TODO: move "file-watcher" to electron.ipc.channels
+  (js/window.apis.on "file-watcher"
+                     (fn [data]
+                       (let [{:keys [type payload]} (bean/->clj data)]
+                         (handle-changed! type payload)))))

+ 19 - 18
src/main/frontend/git.cljs

@@ -2,6 +2,7 @@
   (:refer-clojure :exclude [clone merge])
   (:require [promesa.core :as p]
             [frontend.util :as util]
+            [frontend.config :as config]
             [clojure.string :as string]
             [clojure.set :as set]
             [frontend.state :as state]
@@ -30,7 +31,7 @@
 
 (defn clone
   [repo-url token]
-  (js/window.workerThread.clone (util/get-repo-dir repo-url)
+  (js/window.workerThread.clone (config/get-repo-dir repo-url)
                                 repo-url
                                 (get-cors-proxy repo-url)
                                 1
@@ -40,12 +41,12 @@
 
 (defn list-files
   [repo-url]
-  (js/window.workerThread.listFiles (util/get-repo-dir repo-url)
+  (js/window.workerThread.listFiles (config/get-repo-dir repo-url)
                                     (state/get-default-branch repo-url)))
 
 (defn fetch
   [repo-url token]
-  (js/window.workerThread.fetch (util/get-repo-dir repo-url)
+  (js/window.workerThread.fetch (config/get-repo-dir repo-url)
                                 repo-url
                                 (get-cors-proxy repo-url)
                                 100
@@ -55,23 +56,23 @@
 
 (defn merge
   [repo-url]
-  (js/window.workerThread.merge (util/get-repo-dir repo-url)
+  (js/window.workerThread.merge (config/get-repo-dir repo-url)
                                 (state/get-default-branch repo-url)))
 
 (defn checkout
   [repo-url]
-  (js/window.workerThread.checkout (util/get-repo-dir repo-url)
+  (js/window.workerThread.checkout (config/get-repo-dir repo-url)
                                    (state/get-default-branch repo-url)))
 
 (defn log
   [repo-url depth]
-  (js/window.workerThread.log (util/get-repo-dir repo-url)
+  (js/window.workerThread.log (config/get-repo-dir repo-url)
                               (state/get-default-branch repo-url)
                               depth))
 
 (defn pull
   [repo-url token]
-  (js/window.workerThread.pull (util/get-repo-dir repo-url)
+  (js/window.workerThread.pull (config/get-repo-dir repo-url)
                                (get-cors-proxy repo-url)
                                (state/get-default-branch repo-url)
                                (get-username)
@@ -80,12 +81,12 @@
 (defn add
   [repo-url file]
   (when js/window.git
-    (js/window.workerThread.add (util/get-repo-dir repo-url)
+    (js/window.workerThread.add (config/get-repo-dir repo-url)
                                 file)))
 
 (defn remove-file
   [repo-url file]
-  (js/window.workerThread.remove (util/get-repo-dir repo-url)
+  (js/window.workerThread.remove (config/get-repo-dir repo-url)
                                  file))
 
 (defn rename
@@ -100,7 +101,7 @@
    (commit repo-url message nil))
   ([repo-url message parent]
    (let [{:keys [name email]} (:me @state/state)]
-     (js/window.workerThread.commit (util/get-repo-dir repo-url)
+     (js/window.workerThread.commit (config/get-repo-dir repo-url)
                                     message
                                     name
                                     email
@@ -109,7 +110,7 @@
 (defn add-all
   "Equivalent to `git add --all`. Returns changed files."
   [repo-url]
-  (p/let [repo-dir (util/get-repo-dir repo-url)
+  (p/let [repo-dir (config/get-repo-dir repo-url)
 
           ; statusMatrix will return `[]` rather than raising an error if the repo directory does
           ; not exist. So checks whether repo-dir exists before proceeding.
@@ -141,14 +142,14 @@
 
 (defn read-commit
   [repo-url oid]
-  (js/window.workerThread.readCommit (util/get-repo-dir repo-url)
+  (js/window.workerThread.readCommit (config/get-repo-dir repo-url)
                                      oid))
 
 
 ;; FIXME: not working
 ;; (defn descendent?
 ;;   [repo-url oid ancestor]
-;;   (js/window.workerThread.isDescendent (util/get-repo-dir repo-url)
+;;   (js/window.workerThread.isDescendent (config/get-repo-dir repo-url)
 ;;                                        oid
 ;;                                        ancestor))
 
@@ -166,7 +167,7 @@
   ([repo-url token]
    (push repo-url token false))
   ([repo-url token force?]
-   (js/window.workerThread.push (util/get-repo-dir repo-url)
+   (js/window.workerThread.push (config/get-repo-dir repo-url)
                                 (get-cors-proxy repo-url)
                                 (state/get-default-branch repo-url)
                                 force?
@@ -188,7 +189,7 @@
 (defn get-diffs
   [repo-url hash-1 hash-2]
   (and js/window.git
-       (let [dir (util/get-repo-dir repo-url)]
+       (let [dir (config/get-repo-dir repo-url)]
          (p/let [diffs (js/window.workerThread.getFileStateChanges hash-1 hash-2 dir)
                  diffs (cljs-bean.core/->clj diffs)
                  diffs (remove #(= (:type %) "equal") diffs)
@@ -217,16 +218,16 @@
 
 (defn read-blob
   [repo-url oid path]
-  (js/window.workerThread.readBlob (util/get-repo-dir repo-url)
+  (js/window.workerThread.readBlob (config/get-repo-dir repo-url)
                                    oid
                                    path))
 ;; (resolve-ref (state/get-current-repo) "refs/remotes/origin/master")
 (defn resolve-ref
   [repo-url ref]
-  (js/window.workerThread.resolveRef (util/get-repo-dir repo-url) ref))
+  (js/window.workerThread.resolveRef (config/get-repo-dir repo-url) ref))
 
 (defn write-ref!
   [repo-url oid]
-  (js/window.workerThread.writeRef (util/get-repo-dir repo-url)
+  (js/window.workerThread.writeRef (config/get-repo-dir repo-url)
                                    (state/get-default-branch repo-url)
                                    oid))

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

@@ -19,6 +19,7 @@
             [frontend.handler.ui :as ui-handler]
             [frontend.handler.export :as export-handler]
             [frontend.handler.web.nfs :as nfs]
+            [frontend.fs.watcher-handler :as fs-watcher-handler]
             [frontend.ui :as ui]
             [goog.object :as gobj]
             [frontend.idb :as idb]
@@ -105,7 +106,8 @@
                                (fn []
                                  (js/console.error "Failed to request GitHub app tokens."))))
 
-                            (watch-for-date!)))
+                            (watch-for-date!)
+                            (file-handler/watch-for-local-dirs!)))
                          (p/catch (fn [error]
                                     (log/error :db/restore-failed error))))))]
     ;; clear this interval
@@ -156,4 +158,6 @@
     (reset! db/*sync-search-indice-f search/sync-search-indice!)
     (db/run-batch-txs!)
     (file-handler/run-writes-chan!)
-    (editor-handler/periodically-save!)))
+    (editor-handler/periodically-save!)
+    (when (util/electron?)
+      (fs-watcher-handler/run-dirs-watcher!))))

+ 30 - 40
src/main/frontend/handler/common.cljs

@@ -35,7 +35,7 @@
           (gobj/get js/window "workerThread")
           (gobj/get js/window.workerThread "getChangedFiles"))
      (->
-      (p/let [files (js/window.workerThread.getChangedFiles (util/get-repo-dir repo))
+      (p/let [files (js/window.workerThread.getChangedFiles (config/get-repo-dir repo))
               files (bean/->clj files)]
         (->
          (p/let [remote-latest-commit (get-remote-ref repo)
@@ -68,12 +68,11 @@
             diffs (git/get-diffs repo local-oid remote-oid)]
       (println {:local-oid local-oid
                 :remote-oid remote-oid
-                :diffs diffs})))
-  )
+                :diffs diffs}))))
 
 (defn get-config
   [repo-url]
-  (db/get-file repo-url (str config/app-name "/" config/config-file)))
+  (db/get-file repo-url (config/get-config-path)))
 
 (defn reset-config!
   [repo-url content]
@@ -91,20 +90,20 @@
   [ok-handler error-handler]
   (let [repos (state/get-repos)
         installation-ids (->> (map :installation_id repos)
-                           (remove nil?)
-                           (distinct))]
+                              (remove nil?)
+                              (distinct))]
     (when (or (seq repos)
-            (seq installation-ids))
+              (seq installation-ids))
       (util/post (str config/api "refresh_github_token")
-        {:installation-ids installation-ids
-         :repos repos}
-        (fn [result]
-          (state/set-github-installation-tokens! result)
-          (when ok-handler (ok-handler)))
-        (fn [error]
-          (log/error :token/http-request-failed error)
-          (js/console.dir error)
-          (when error-handler (error-handler)))))))
+                 {:installation-ids installation-ids
+                  :repos repos}
+                 (fn [result]
+                   (state/set-github-installation-tokens! result)
+                   (when ok-handler (ok-handler)))
+                 (fn [error]
+                   (log/error :token/http-request-failed error)
+                   (js/console.dir error)
+                   (when error-handler (error-handler)))))))
 
 (defn- get-github-token*
   [repo]
@@ -114,7 +113,7 @@
           (state/get-github-token repo)]
       (spec/validate :repos/repo token-state)
       (if (and (map? token-state)
-            (string? expires_at))
+               (string? expires_at))
         (let [expires-at (tf/parse (tf/formatters :date-time-no-ms) expires_at)
               now (t/now)
               expired? (t/after? now expires-at)]
@@ -129,26 +128,17 @@
   ([repo]
    (when-not (config/local-db? repo)
      (js/Promise.
-       (fn [resolve reject]
-         (let [{:keys [expired? token exist?]} (get-github-token* repo)
-               valid-token? (and exist? (not expired?))]
-           (if valid-token?
-             (resolve token)
-             (request-app-tokens!
-               (fn []
-                 (let [{:keys [expired? token exist?] :as token-m} (get-github-token* repo)
-                       valid-token? (and exist? (not expired?))]
-                   (if valid-token?
-                     (resolve token)
-                     (do (log/error :token/failed-get-token token-m)
-                         (reject)))))
-               nil))))))))
-
-(defn verify-permission
-  [repo handle read-write?]
-  (let [repo (or repo (state/get-current-repo))]
-    (p/then
-      (utils/verifyPermission handle read-write?)
-      (fn []
-        (state/set-state! [:nfs/user-granted? repo] true)
-        true))))
+      (fn [resolve reject]
+        (let [{:keys [expired? token exist?]} (get-github-token* repo)
+              valid-token? (and exist? (not expired?))]
+          (if valid-token?
+            (resolve token)
+            (request-app-tokens!
+             (fn []
+               (let [{:keys [expired? token exist?] :as token-m} (get-github-token* repo)
+                     valid-token? (and exist? (not expired?))]
+                 (if valid-token?
+                   (resolve token)
+                   (do (log/error :token/failed-get-token token-m)
+                       (reject)))))
+             nil))))))))

+ 1 - 1
src/main/frontend/handler/config.cljs

@@ -9,7 +9,7 @@
 (defn set-config!
   [k v]
   (when-let [repo (state/get-current-repo)]
-    (let [path (str config/app-name "/" config/config-file)]
+    (let [path (config/get-config-path)]
       (when-let [config (db/get-file-no-sub path)]
         (let [config (try
                        (rewrite/parse-string config)

+ 4 - 101
src/main/frontend/handler/dnd.cljs

@@ -277,22 +277,13 @@
                              bottom-area))
           after-blocks (->> (compute-after-blocks-in-same-file repo target-block to-block direction top? nested? target-child? target-file original-top-block-start-pos block-changes)
                             (remove nil?))
-          path (:file/path (db/entity repo (:db/id (:block/file to-block))))
-          modified-time (let [modified-at (tc/to-long (t/now))]
-                          (->
-                           [[:db/add (:db/id (:block/page target-block)) :page/last-modified-at modified-at]
-                            [:db/add (:db/id (:block/page to-block)) :page/last-modified-at modified-at]
-                            [:db/add (:db/id (:block/file target-block)) :file/last-modified-at modified-at]
-                            [:db/add (:db/id (:block/file to-block)) :file/last-modified-at modified-at]]
-                           distinct
-                           vec))]
+          path (:file/path (db/entity repo (:db/id (:block/file to-block))))]
       (profile
        "Move block in the same file: "
        (repo-handler/transact-react-and-alter-file!
         repo
         (concat
-         after-blocks
-         modified-time)
+         after-blocks)
         {:key :block/change
          :data block-changes}
         [[path new-file-content]]))
@@ -327,14 +318,6 @@
                                 (utf8/substring to-file-content 0 separate-pos)
                                 target-content
                                 (utf8/substring to-file-content separate-pos))))
-        modified-time (let [modified-at (tc/to-long (t/now))]
-                        (->
-                         [[:db/add (:db/id (:block/page target-block)) :page/last-modified-at modified-at]
-                          [:db/add (:db/id (:block/page to-block)) :page/last-modified-at modified-at]
-                          [:db/add (:db/id (:block/file target-block)) :file/last-modified-at modified-at]
-                          [:db/add (:db/id (:block/file to-block)) :file/last-modified-at modified-at]]
-                         distinct
-                         vec))
         target-after-blocks (rebuild-dnd-blocks repo target-file target-child?
                                                 (get-start-pos target-block)
                                                 block-changes nil {:delete? true})
@@ -363,92 +346,12 @@
       repo
       (concat
        target-after-blocks
-       to-after-blocks
-       modified-time)
+       to-after-blocks)
       {:key :block/change
        :data (conj block-changes target-block)}
       [[target-file-path new-target-file-content]
        [to-file-path new-to-file-content]]))))
 
-(defn- move-block-in-different-repos
-  [target-block-repo to-block-repo target-block to-block top-block bottom-block nested? top? target-child? direction target-content target-file original-top-block-start-pos block-changes]
-  (let [target-file (db/entity target-block-repo (:db/id (:block/file target-block)))
-        target-file-path (:file/path target-file)
-        target-file-content (db/get-file target-block-repo target-file-path)
-        to-file (db/entity to-block-repo (:db/id (:block/file to-block)))
-        to-file-path (:file/path to-file)
-        target-block-end-pos (block-handler/get-block-end-pos-rec target-block-repo target-block)
-        to-block-start-pos (get-start-pos to-block)
-        to-block-end-pos (block-handler/get-block-end-pos-rec to-block-repo to-block)
-        new-target-file-content (utf8/delete! target-file-content
-                                              (get-start-pos target-block)
-                                              target-block-end-pos)
-        to-file-content (utf8/encode (db/get-file to-block-repo to-file-path))
-        new-to-file-content (let [separate-pos (cond nested?
-                                                     (get-end-pos to-block)
-                                                     top?
-                                                     to-block-start-pos
-                                                     :else
-                                                     to-block-end-pos)]
-                              (string/trim
-                               (util/join-newline
-                                (utf8/substring to-file-content 0 separate-pos)
-                                target-content
-                                (utf8/substring to-file-content separate-pos))))
-        target-delete-tx (map (fn [id]
-                                [:db.fn/retractEntity [:block/uuid id]])
-                              (block-handler/get-block-ids target-block))
-        [target-modified-time to-modified-time]
-        (let [modified-at (tc/to-long (t/now))]
-          [[[:db/add (:db/id (:block/page target-block)) :page/last-modified-at modified-at]
-            [:db/add (:db/id (:block/file target-block)) :file/last-modified-at modified-at]]
-           [[:db/add (:db/id (:block/page to-block)) :page/last-modified-at modified-at]
-            [:db/add (:db/id (:block/file to-block)) :file/last-modified-at modified-at]]])
-        target-after-blocks (rebuild-dnd-blocks target-block-repo target-file target-child?
-                                                (get-start-pos target-block)
-                                                block-changes nil {:delete? true})
-        to-after-blocks (cond
-                          top?
-                          (rebuild-dnd-blocks to-block-repo to-file target-child?
-                                              (get-start-pos to-block)
-                                              block-changes
-                                              nil
-                                              {:same-file? false})
-
-                          :else
-                          (let [offset-block-id (if nested?
-                                                  (:block/uuid to-block)
-                                                  (last (block-handler/get-block-ids to-block)))
-                                offset-end-pos (get-end-pos
-                                                (db/entity to-block-repo [:block/uuid offset-block-id]))]
-                            (rebuild-dnd-blocks to-block-repo to-file target-child?
-                                                offset-end-pos
-                                                block-changes
-                                                nil
-                                                {:same-file? false})))]
-    (profile
-     "[Target file] Move block between different files: "
-     (repo-handler/transact-react-and-alter-file!
-      target-block-repo
-      (concat
-       target-delete-tx
-       target-after-blocks
-       target-modified-time)
-      {:key :block/change
-       :data [(dissoc target-block :block/children)]}
-      [[target-file-path new-target-file-content]]))
-
-    (profile
-     "[Destination file] Move block between different files: "
-     (repo-handler/transact-react-and-alter-file!
-      to-block-repo
-      (concat
-       to-after-blocks
-       to-modified-time)
-      {:key :block/change
-       :data [block-changes]}
-      [[to-file-path new-to-file-content]]))))
-
 (defn move-block
   "There can be at least 3 possible situations:
   1. Move a block in the same file (either top-to-bottom or bottom-to-top).
@@ -510,7 +413,7 @@
 
           ;; different repos
           :else
-          (move-block-in-different-repos target-block-repo to-block-repo target-block to-block top-block bottom-block nested? top? target-child? direction target-content target-file original-top-block-start-pos block-changes))
+          nil)
 
         (when (state/get-git-auto-push?)
           (doseq [repo (->> #{target-block-repo to-block-repo}

+ 14 - 17
src/main/frontend/handler/draw.cljs

@@ -48,32 +48,29 @@
 (defn create-draws-directory!
   [repo]
   (when repo
-    (let [repo-dir (util/get-repo-dir repo)]
-     (util/p-handle
-      (fs/mkdir (str repo-dir (str "/" config/default-draw-directory)))
-      (fn [_result] nil)
-      (fn [_error] nil)))))
+    (let [repo-dir (config/get-repo-dir repo)]
+      (util/p-handle
+       (fs/mkdir! (str repo-dir (str "/" config/default-draw-directory)))
+       (fn [_result] nil)
+       (fn [_error] nil)))))
 
 (defn save-excalidraw!
   [file data ok-handler]
   (let [path (str config/default-draw-directory "/" file)
         repo (state/get-current-repo)]
     (when repo
-      (let [repo-dir (util/get-repo-dir repo)]
+      (let [repo-dir (config/get-repo-dir repo)]
         (->
          (p/do!
           (create-draws-directory! repo)
-          (fs/write-file repo repo-dir path data)
+          (fs/write-file! repo repo-dir path data nil)
           (git-handler/git-add repo path)
           (ok-handler file)
-          (let [modified-at (tc/to-long (t/now))]
-            (db/transact! repo
-                          [{:file/path path
-                            :file/last-modified-at modified-at}
-                           {:page/name file
-                            :page/file path
-                            :page/last-modified-at (tc/to-long (t/now))
-                            :page/journal? false}])))
+          (db/transact! repo
+            [{:file/path path}
+             {:page/name file
+              :page/file path
+              :page/journal? false}]))
          (p/catch (fn [error]
                     (prn "Write file failed, path: " path ", data: " data)
                     (js/console.dir error))))))))
@@ -82,7 +79,7 @@
   [ok-handler]
   (when-let [repo (state/get-current-repo)]
     (p/let [_ (create-draws-directory! repo)]
-      (let [dir (str (util/get-repo-dir repo)
+      (let [dir (str (config/get-repo-dir repo)
                      "/"
                      config/default-draw-directory)]
         (util/p-handle
@@ -121,7 +118,7 @@
 (defn create-draw-with-default-content
   [current-file ok-handler]
   (when-let [repo (state/get-current-repo)]
-    (p/let [exists? (fs/file-exists? (util/get-repo-dir repo)
+    (p/let [exists? (fs/file-exists? (config/get-repo-dir repo)
                                      (str config/default-draw-directory current-file))]
       (when-not exists?
         (save-excalidraw! current-file default-content

+ 72 - 74
src/main/frontend/handler/editor.cljs

@@ -48,17 +48,11 @@
             [lambdaisland.glogi :as log]))
 
 ;; FIXME: should support multiple images concurrently uploading
-(defonce *image-pending-file (atom nil))
-(defonce *image-uploading? (atom false))
-(defonce *image-uploading-process (atom 0))
+(defonce *asset-pending-file (atom nil))
+(defonce *asset-uploading? (atom false))
+(defonce *asset-uploading-process (atom 0))
 (defonce *selected-text (atom nil))
 
-(defn modified-time-tx
-  [page file]
-  (let [modified-at (tc/to-long (t/now))]
-    [[:db/add (:db/id page) :page/last-modified-at modified-at]
-     [:db/add (:db/id file) :file/last-modified-at modified-at]]))
-
 (defn- get-selection-and-format
   []
   (when-let [block (state/get-edit-block)]
@@ -502,7 +496,7 @@
                              (util/page-name-sanity))) "."
                        (if (= format "markdown") "md" format))
                  file-path (str "/" path)
-                 dir (util/get-repo-dir repo)]
+                 dir (config/get-repo-dir repo)]
              (p/let [exists? (fs/file-exists? dir file-path)]
                (if exists?
                  (notification/show!
@@ -556,9 +550,6 @@
                  [after-blocks block-children-content new-end-pos] (rebuild-after-blocks-indent-outdent repo file block (:end-pos (:block/meta block)) end-pos indent-left?)
                  retract-refs (compute-retract-refs (:db/id e) (first blocks) ref-pages ref-blocks)
                  page-id (:db/id page)
-                 modified-time (let [modified-at (tc/to-long (t/now))]
-                                 [[:db/add page-id :page/last-modified-at modified-at]
-                                  [:db/add (:db/id file) :file/last-modified-at modified-at]])
                  page-properties (when pre-block?
                                    (if (seq new-properties)
                                      [[:db/retract page-id :page/properties]
@@ -597,8 +588,7 @@
                 page-properties
                 page-tags
                 page-alias
-                after-blocks
-                modified-time)
+                after-blocks)
                {:key :block/change
                 :data (map (fn [block] (assoc block :block/page page)) blocks)}
                (let [new-content (new-file-content-indent-outdent block file-content value block-children-content new-end-pos indent-left?)]
@@ -781,7 +771,7 @@
                   "."
                   (if (= format "markdown") "md" format))
             file-path (str "/" path)
-            dir (util/get-repo-dir repo)]
+            dir (config/get-repo-dir repo)]
         (p/let [exists? (fs/file-exists? dir file-path)]
           (if exists?
             (do (notification/show!
@@ -1528,10 +1518,10 @@
                                      true)]
       (commands/restore-state restore-slash-caret-pos?))))
 
-(defn- get-image-link
-  [format url file-name]
+(defn- get-asset-file-link
+  [format url file-name image?]
   (case (keyword format)
-    :markdown (util/format "![%s](%s)" file-name url)
+    :markdown (util/format (str (when image? "!") "[%s](%s)") file-name url)
     :org (util/format "[[%s][%s]]" url file-name)
     nil))
 
@@ -1541,7 +1531,7 @@
 
 (defn ensure-assets-dir!
   [repo]
-  (let [repo-dir (util/get-repo-dir repo)
+  (let [repo-dir (config/get-repo-dir repo)
         assets-dir "assets"]
     (p/then
      (fs/mkdir-if-not-exists (str repo-dir "/" assets-dir))
@@ -1552,38 +1542,47 @@
    (when-let [block-file (db-model/get-block-file block-id)]
      (p/let [[repo-dir assets-dir] (ensure-assets-dir! repo)]
        (let [prefix (:file/path block-file)
-             prefix (and prefix (string/replace prefix "/" "_"))
+             prefix (and prefix (string/replace
+                                 (if (util/electron?)
+                                   (string/replace prefix (str repo-dir "/") "")
+                                   prefix) "/" "_"))
              prefix (and prefix (subs prefix 0 (string/last-index-of prefix ".")))]
          (save-assets! repo repo-dir assets-dir files
                        (fn [index]
                          (str prefix "_" (.now js/Date) "_" index)))))))
   ([repo dir path files gen-filename]
    (p/all
-    (for [[index file] (map-indexed vector files)]
+    (for [[index ^js file] (map-indexed vector files)]
       (let [ext (.-name file)
             ext (if ext (subs ext (string/last-index-of ext ".")) "")
             filename (str (gen-filename index file) ext)
             filename (str path "/" filename)]
-        ;(js/console.debug "Write asset #" filename file)
-        (p/then (fs/write-file repo dir filename (.stream file))
-                #(p/resolved [filename file])))))))
+        ;(js/console.debug "Write asset #" dir filename file)
+        (if (util/electron?)
+          (let [from (.-path file)]
+            (p/then (js/window.apis.copyFileToAssets dir filename from)
+                    #(p/resolved [filename (if (string? %) (js/File. #js[] %) file)])))
+          (p/then (fs/write-file! repo dir filename (.stream file) nil)
+                  #(p/resolved [filename file]))))))))
 
 (defonce *assets-url-cache (atom {}))
 
 (defn make-asset-url
   [path]                                                    ;; path start with "/assets" or compatible for "../assets"
-  (let [repo-dir (util/get-repo-dir (state/get-current-repo))
-        path (string/replace path "../" "/")
-        handle-path (str "handle" repo-dir path)
-        cached-url (get @*assets-url-cache (keyword handle-path))]
-    (if cached-url
-      (p/resolved cached-url)
-      (p/let [handle (frontend.idb/get-item handle-path)
-              file (and handle (.getFile handle))]
-        (when file
-          (p/let [url (js/URL.createObjectURL file)]
-            (swap! *assets-url-cache assoc (keyword handle-path) url)
-            url))))))
+  (let [repo-dir (config/get-repo-dir (state/get-current-repo))
+        path (string/replace path "../" "/")]
+    (if (util/electron?)
+      (str "assets://" repo-dir path)
+      (let [handle-path (str "handle" repo-dir path)
+            cached-url (get @*assets-url-cache (keyword handle-path))]
+        (if cached-url
+          (p/resolved cached-url)
+          (p/let [handle (frontend.idb/get-item handle-path)
+                  file (and handle (.getFile handle))]
+            (when file
+              (p/let [url (js/URL.createObjectURL file)]
+                (swap! *assets-url-cache assoc (keyword handle-path) url)
+                url))))))))
 
 (defn delete-asset-of-block!
   [{:keys [repo href title full-text block-id local?] :as opts}]
@@ -1595,10 +1594,13 @@
     (save-block! repo block content)
     (when local?
       ;; FIXME: should be relative to current block page path
-      (fs/unlink (str (util/get-repo-dir repo) (string/replace href #"^../" "/")) nil))))
+      (fs/unlink! (config/get-repo-path
+                   repo (-> href
+                            (string/replace #"^../" "/")
+                            (string/replace #"^assets://" ""))) nil))))
 
-(defn upload-image
-  [id files format uploading? drop-or-paste?]
+(defn upload-asset
+  [id ^js files format uploading? drop-or-paste?]
   (let [repo (state/get-current-repo)
         block (state/get-edit-block)]
     (if (config/local-db? repo)
@@ -1606,41 +1608,44 @@
           (p/then
            (fn [res]
              (when-let [[url file] (and (seq res) (first res))]
-               (insert-command!
-                id
-                (get-image-link format (get-asset-link url) (.-name file))
-                format
-                {:last-pattern (if drop-or-paste? "" commands/slash)
-                 :restore?     true}))))
+               (let [image? (util/ext-of-image? url)]
+                 (insert-command!
+                  id
+                  (get-asset-file-link format (get-asset-link url)
+                                       (if file (.-name file) (if image? "image" "asset"))
+                                       image?)
+                  format
+                  {:last-pattern (if drop-or-paste? "" commands/slash)
+                   :restore?     true})))))
           (p/finally
             (fn []
               (reset! uploading? false)
-              (reset! *image-uploading? false)
-              (reset! *image-uploading-process 0))))
+              (reset! *asset-uploading? false)
+              (reset! *asset-uploading-process 0))))
       (image/upload
-       files
-       (fn [file file-name file-type]
+        files
+        (fn [file file-name file-type]
          (image-handler/request-presigned-url
-          file file-name file-type
-          uploading?
-          (fn [signed-url]
+           file file-name file-type
+           uploading?
+           (fn [signed-url]
             (insert-command! id
-                             (get-image-link format signed-url file-name)
+                             (get-asset-file-link format signed-url file-name true)
                              format
                              {:last-pattern (if drop-or-paste? "" commands/slash)
                               :restore?     true})
 
-            (reset! *image-uploading? false)
-            (reset! *image-uploading-process 0))
-          (fn [e]
+            (reset! *asset-uploading? false)
+            (reset! *asset-uploading-process 0))
+           (fn [e]
             (let [process (* (/ (gobj/get e "loaded")
                                 (gobj/get e "total"))
                              100)]
-              (reset! *image-uploading? false)
-              (reset! *image-uploading-process process)))))))))
+              (reset! *asset-uploading? false)
+              (reset! *asset-uploading-process process)))))))))
 
-(defn set-image-pending-file [file]
-  (reset! *image-pending-file file))
+(defn set-asset-pending-file [file]
+  (reset! *asset-pending-file file))
 
 ;; Editor should track some useful information, like editor modes.
 ;; For example:
@@ -1792,7 +1797,7 @@
   [input]
   (or @*show-commands
       @*show-block-commands
-      @*image-uploading?
+      @*asset-uploading?
       (state/get-editor-show-input)
       (state/get-editor-show-page-search?)
       (state/get-editor-show-block-search?)
@@ -1936,15 +1941,12 @@
                                                                 (concat hc2 hc1)])]
                   (when (and start-pos end-pos)
                     (let [new-file-content (utf8/insert! old-file-content start-pos end-pos new-content)
-                          modified-time (modified-time-tx page file)
                           blocks-meta (rebuild-blocks-meta start-pos blocks)]
                       (profile
                        (str "Move block " (if up? "up: " "down: "))
                        (repo-handler/transact-react-and-alter-file!
                         repo
-                        (concat
-                         blocks-meta
-                         modified-time)
+                        blocks-meta
                         {:key :block/change
                          :data (map (fn [block] (assoc block :block/page page)) blocks)}
                         [[file-path new-file-content]])))))))))))))
@@ -2021,16 +2023,14 @@
                 ;;         :last-start-pos @last-start-pos})
                 file-path (:file/path file)
                 file-content (db/get-file file-path)
-                new-content (utf8/insert! file-content start-pos old-end-pos (apply str (map :block/content blocks)))
-                modified-time (modified-time-tx page file)]
+                new-content (utf8/insert! file-content start-pos old-end-pos (apply str (map :block/content blocks)))]
             (profile
              "Indent/outdent: "
              (repo-handler/transact-react-and-alter-file!
               repo
               (concat
                blocks
-               after-blocks
-               modified-time)
+               after-blocks)
               {:key :block/change
                :data (map (fn [block] (assoc block :block/page page)) blocks)}
               [[file-path new-content]])))
@@ -2087,16 +2087,14 @@
               after-blocks (rebuild-after-blocks repo file old-end-pos @last-start-pos)
               file-path (:file/path file)
               file-content (db/get-file file-path)
-              new-content (utf8/insert! file-content start-pos old-end-pos (apply str (map :block/content blocks)))
-              modified-time (modified-time-tx page file)]
+              new-content (utf8/insert! file-content start-pos old-end-pos (apply str (map :block/content blocks)))]
           (profile
            "Indent/outdent: "
            (repo-handler/transact-react-and-alter-file!
             repo
             (concat
              blocks
-             after-blocks
-             modified-time)
+             after-blocks)
             {:key :block/change
              :data (map (fn [block] (assoc block :block/page page)) blocks)}
             [[file-path new-content]])))

+ 2 - 4
src/main/frontend/handler/extract.cljs

@@ -70,9 +70,7 @@
                          :page/journal? journal?
                          :page/journal-day (if journal?
                                              (date/journal-title->int (string/capitalize page))
-                                             0)
-                         :page/created-at journal-date-long
-                         :page/last-modified-at journal-date-long})
+                                             0)})
                         (seq properties)
                         (assoc :page/properties properties)
 
@@ -129,7 +127,7 @@
   [repo-url file content utf8-content]
   (if (string/blank? content)
     []
-    (let [journal? (util/starts-with? file "journals/")
+    (let [journal? (util/journal? file)
           format (format/get-format file)
           ast (mldoc/->edn content
                            (mldoc/default-config format))

+ 46 - 20
src/main/frontend/handler/file.cljs

@@ -2,6 +2,7 @@
   (:refer-clojure :exclude [load-file])
   (:require [frontend.util :as util :refer-macros [profile]]
             [frontend.fs :as fs]
+            [frontend.fs.nfs :as nfs]
             [promesa.core :as p]
             [frontend.state :as state]
             [frontend.db :as db]
@@ -24,12 +25,15 @@
             [cljs-time.core :as t]
             [cljs-time.coerce :as tc]
             [frontend.utf8 :as utf8]
-            ["ignore" :as Ignore]))
+            ["ignore" :as Ignore]
+            ["/frontend/utils" :as utils]))
+
+;; TODO: extract all git ops using a channel
 
 (defn load-file
   [repo-url path]
   (->
-   (p/let [content (fs/read-file (util/get-repo-dir repo-url) path)]
+   (p/let [content (fs/read-file (config/get-repo-dir repo-url) path)]
      content)
    (p/catch
     (fn [e]
@@ -96,7 +100,8 @@
   (state/set-loading-files! true)
   (p/let [files (git/list-files repo-url)
           files (bean/->clj files)
-          config-content (load-file repo-url (str config/app-name "/" config/config-file))
+          config-content (load-file repo-url
+                                    (config/get-config-path repo-url))
           files (if config-content
                   (let [config (restore-config! repo-url config-content true)]
                     (if-let [patterns (seq (:hidden config))]
@@ -125,7 +130,23 @@
 
 (defn reset-file!
   [repo-url file content]
-  (let [new? (nil? (db/entity [:file/path file]))]
+  (let [electron-local-repo? (and (util/electron?)
+                                  (config/local-db? repo-url))
+        ;; FIXME: store relative path in db
+        file (cond
+               (and electron-local-repo?
+                    util/win32?
+                    (utils/win32 file))
+               file
+
+               (and electron-local-repo? (or
+                                          util/win32?
+                                          (not= "/" (first file))))
+               (str (config/get-repo-dir repo-url) "/" file)
+
+               :else
+               file)
+        new? (nil? (db/entity [:file/path file]))]
     (db/set-file-content! repo-url file content)
     (let [format (format/get-format file)
           utf8-content (utf8/encode content)
@@ -137,8 +158,7 @@
                file-content)
           tx (concat tx [(let [t (tc/to-long (t/now))]
                            (cond->
-                            {:file/path file
-                             :file/last-modified-at t}
+                            {:file/path file}
                              new?
                              (assoc :file/created-at t)))])]
       (db/transact! repo-url tx))))
@@ -161,13 +181,13 @@
         (reset-file! repo path content))
       (db/set-file-content! repo path content))
     (util/p-handle
-     (fs/write-file repo (util/get-repo-dir repo) path content {:old-content original-content
-                                                                :last-modified-at (db/get-file-last-modified-at repo path)})
+     (fs/write-file! repo (config/get-repo-dir repo) path content {:old-content original-content
+                                                                   :last-modified-at (db/get-file-last-modified-at repo path)})
      (fn [_]
        (git-handler/git-add repo path update-status?)
-       (when (= path (str config/app-name "/" config/config-file))
+       (when (= path (config/get-config-path repo))
          (restore-config! repo true))
-       (when (= path (str config/app-name "/" config/custom-css-file))
+       (when (= path (config/get-custom-css-path repo))
          (ui-handler/add-style-if-exists!))
        (when re-render-root? (ui-handler/re-render-root!))
        (when add-history?
@@ -207,8 +227,7 @@
           (db/set-file-content! repo path content))))
 
     (when-let [chan (state/get-file-write-chan)]
-      (let [chan-callback
-            (:chan-callback opts)]
+      (let [chan-callback (:chan-callback opts)]
         (async/put! chan [repo files opts file->content])
         (when chan-callback
           (chan-callback))))))
@@ -220,10 +239,10 @@
                     reset? false}} file->content]
   (let [write-file-f (fn [[path content]]
                        (let [original-content (get file->content path)]
-                         (-> (p/let [_ (fs/check-directory-permission! repo)]
-                               (fs/write-file repo (util/get-repo-dir repo) path content
-                                              {:old-content original-content
-                                               :last-modified-at (db/get-file-last-modified-at repo path)}))
+                         (-> (p/let [_ (nfs/check-directory-permission! repo)]
+                               (fs/write-file! repo (config/get-repo-dir repo) path content
+                                               {:old-content original-content
+                                                :last-modified-at (db/get-file-last-modified-at repo path)}))
                              (p/catch (fn [error]
                                         (log/error :write-file/failed {:path path
                                                                        :content content
@@ -263,10 +282,7 @@
   (when-not (string/blank? file)
     (->
      (p/let [_ (git/remove-file repo file)
-             result (fs/unlink (str (util/get-repo-dir repo)
-                                    "/"
-                                    file)
-                               nil)]
+             result (fs/unlink! (config/get-repo-path repo file) nil)]
        (when-let [file (db/entity repo [:file/path file])]
          (common-handler/check-changed-files-status)
          (let [file-id (:db/id file)
@@ -304,3 +320,13 @@
         (<p! (apply alter-files-handler! args)))
       (recur))
     chan))
+
+(defn watch-for-local-dirs!
+  []
+  (when (util/electron?)
+    (let [repos (->> (state/get-repos)
+                     (filter (fn [repo]
+                               (config/local-db? (:url repo)))))
+          directories (map (fn [repo] (config/get-repo-dir (:url repo))) repos)]
+      (doseq [dir directories]
+        (fs/watch-dir! dir)))))

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

@@ -30,7 +30,8 @@
   ([repo-url file]
    (git-add repo-url file true))
   ([repo-url file update-status?]
-   (when-not (config/local-db? repo-url)
+   (when (and (not (config/local-db? repo-url))
+              (not (util/electron?)))
      (-> (p/let [result (git/add repo-url file)]
            (when update-status?
              (common-handler/check-changed-files-status)))
@@ -57,6 +58,6 @@
   [repo-url {:keys [name email]}]
   (when (and name email)
     (git/set-username-email
-     (util/get-repo-dir repo-url)
+     (config/get-repo-dir repo-url)
      name
      email)))

+ 38 - 37
src/main/frontend/handler/image.cljs

@@ -11,43 +11,44 @@
 
 (defn render-local-images!
   []
-  (try
-    (let [images (array-seq (gdom/getElementsByTagName "img"))
-          get-src (fn [image] (.getAttribute image "src"))
-          local-images (filter
-                        (fn [image]
-                          (let [src (get-src image)]
-                            (and src
-                                 (not (or (util/starts-with? src "http://")
-                                          (util/starts-with? src "https://")
-                                          (util/starts-with? src "blob:"))))))
-                        images)]
-      (doseq [img local-images]
-        (gobj/set img
-                  "onerror"
-                  (fn []
-                    (gobj/set (gobj/get img "style")
-                              "display" "none")))
-        (let [path (get-src img)
-              path (string/replace-first path "file:" "")
-              path (if (= (first path) \.)
-                     (subs path 1)
-                     path)]
-          (util/p-handle
-           (fs/read-file (util/get-repo-dir (state/get-current-repo))
-                           path
-                           {})
-           (fn [blob]
-             (let [blob (js/Blob. (array blob) (clj->js {:type "image"}))
-                   img-url (image/create-object-url blob)]
-               (gobj/set img "src" img-url)
-               (gobj/set (gobj/get img "style")
-                         "display" "initial")))
-           (fn [error]
-             (println "Can't read local image file: ")
-             (js/console.dir error))))))
-    (catch js/Error e
-      nil)))
+  (when-not (and (util/electron?)
+                 (config/local-db? (state/get-current-repo)))
+    (try
+      (let [images (array-seq (gdom/getElementsByTagName "img"))
+            get-src (fn [image] (.getAttribute image "src"))
+            local-images (filter
+                          (fn [image]
+                            (let [src (get-src image)]
+                              (and src
+                                   (not (or (util/starts-with? src "http://")
+                                            (util/starts-with? src "https://")
+                                            (util/starts-with? src "blob:"))))))
+                          images)]
+        (doseq [img local-images]
+          (gobj/set img
+                    "onerror"
+                    (fn []
+                      (gobj/set (gobj/get img "style")
+                                "display" "none")))
+          (let [path (get-src img)
+                path (string/replace-first path "file:" "")
+                path (if (= (first path) \.)
+                       (subs path 1)
+                       path)]
+            (util/p-handle
+             (fs/read-file (config/get-repo-dir (state/get-current-repo))
+                           path)
+             (fn [blob]
+               (let [blob (js/Blob. (array blob) (clj->js {:type "image"}))
+                     img-url (image/create-object-url blob)]
+                 (gobj/set img "src" img-url)
+                 (gobj/set (gobj/get img "style")
+                           "display" "initial")))
+             (fn [error]
+               (println "Can't read local image file: ")
+               (js/console.dir error))))))
+      (catch js/Error e
+        nil))))
 
 (defn request-presigned-url
   [file filename mime-type uploading? url-handler on-processing]

+ 20 - 12
src/main/frontend/handler/page.cljs

@@ -46,11 +46,11 @@
            :or {redirect? true}}]
    (let [title (and title (string/trim title))
          repo (state/get-current-repo)
-         dir (util/get-repo-dir repo)
+         dir (config/get-repo-dir repo)
          journal-page? (date/valid-journal-title? title)
          directory (get-directory journal-page?)]
      (when dir
-       (p/let [_ (-> (fs/mkdir (str dir "/" directory))
+       (p/let [_ (-> (fs/mkdir! (str dir "/" directory))
                      (p/catch (fn [_e])))]
          (let [format (name (state/get-preferred-format))
                page (string/lower-case title)
@@ -65,14 +65,20 @@
                 [:p.content
                  (util/format "File %s already exists!" file-path)]
                 :error)
-               ;; create the file
+               ;; Create the file
                (let [content (util/default-content-with-title format title)]
-                 (p/let [_ (fs/create-if-not-exists repo dir file-path content)
+                 ;; Write to the db first, then write to the filesystem,
+                 ;; otherwise, the main electron ipc will notify that there's
+                 ;; a new file created.
+                 ;; Question: what if the fs write failed?
+                 (p/let [_ (file-handler/reset-file! repo path content)
+                         _ (fs/create-if-not-exists repo dir file-path content)
                          _ (git-handler/git-add repo path)]
-                   (file-handler/reset-file! repo path content)
                    (when redirect?
                      (route-handler/redirect! {:to :page
                                                :path-params {:name page}})
+
+                     ;; Edit the first block
                      (let [blocks (db/get-page-blocks page)
                            last-block (last blocks)]
                        (when last-block
@@ -273,10 +279,7 @@
               ;; remove file
               (->
                (p/let [_ (git/remove-file repo file-path)
-                       _ (fs/unlink (str (util/get-repo-dir repo)
-                                         "/"
-                                         file-path)
-                                    nil)]
+                       _ (fs/unlink! (config/get-repo-path repo file-path) nil)]
                  (common-handler/check-changed-files-status)
                  (repo-handler/push-if-auto-enabled! repo))
                (p/catch (fn [err]
@@ -309,10 +312,15 @@
       (when-let [file (d/entity (d/db conn) [:file/path old-path])]
         (d/transact! conn [{:db/id (:db/id file)
                             :file/path new-path}])))
+
     (->
-     (p/let [_ (fs/rename repo
-                          (str (util/get-repo-dir repo) "/" old-path)
-                          (str (util/get-repo-dir repo) "/" new-path))
+     (p/let [_ (fs/rename! repo
+                           (if (util/electron?)
+                             old-path
+                             (str (config/get-repo-dir repo) "/" old-path))
+                           (if (util/electron?)
+                             new-path
+                             (str (config/get-repo-dir repo) "/" new-path)))
              _ (when-not (config/local-db? repo)
                  (git/rename repo old-path new-path))]
        (common-handler/check-changed-files-status)

+ 32 - 30
src/main/frontend/handler/repo.cljs

@@ -2,6 +2,7 @@
   (:refer-clojure :exclude [clone])
   (:require [frontend.util :as util :refer-macros [profile]]
             [frontend.fs :as fs]
+            [frontend.fs.nfs :as nfs]
             [promesa.core :as p]
             [lambdaisland.glogi :as log]
             [frontend.state :as state]
@@ -51,7 +52,7 @@
 (defn create-config-file-if-not-exists
   [repo-url]
   (spec/validate :repos/url repo-url)
-  (let [repo-dir (util/get-repo-dir repo-url)
+  (let [repo-dir (config/get-repo-dir repo-url)
         app-dir config/app-name
         dir (str repo-dir "/" app-dir)]
     (p/let [_ (fs/mkdir-if-not-exists dir)]
@@ -69,7 +70,7 @@
 (defn create-contents-file
   [repo-url]
   (spec/validate :repos/url repo-url)
-  (let [repo-dir (util/get-repo-dir repo-url)
+  (let [repo-dir (config/get-repo-dir repo-url)
         format (state/get-preferred-format)
         path (str (state/get-pages-directory)
                   "/contents."
@@ -85,7 +86,7 @@
 (defn create-custom-theme
   [repo-url]
   (spec/validate :repos/url repo-url)
-  (let [repo-dir (util/get-repo-dir repo-url)
+  (let [repo-dir (config/get-repo-dir repo-url)
         path (str config/app-name "/" config/custom-css-file)
         file-path (str "/" path)
         default-content ""]
@@ -98,7 +99,7 @@
 (defn create-dummy-notes-page
   [repo-url content]
   (spec/validate :repos/url repo-url)
-  (let [repo-dir (util/get-repo-dir repo-url)
+  (let [repo-dir (config/get-repo-dir repo-url)
         path (str (config/get-pages-directory) "/how_to_make_dummy_notes.md")
         file-path (str "/" path)]
     (p/let [_ (fs/mkdir-if-not-exists (str repo-dir "/" (config/get-pages-directory)))
@@ -111,7 +112,7 @@
   ([repo-url content]
    (spec/validate :repos/url repo-url)
    (when (state/enable-journals? repo-url)
-     (let [repo-dir (util/get-repo-dir repo-url)
+     (let [repo-dir (config/get-repo-dir repo-url)
            format (state/get-preferred-format repo-url)
            title (date/today)
            file-name (date/journal-title->default title)
@@ -136,13 +137,14 @@
            empty-blocks? (empty? (db/get-page-blocks-no-cache repo-url (string/lower-case title)))]
        (when (or empty-blocks?
                  (not page-exists?))
-         (p/let [_ (fs/check-directory-permission! repo-url)
+         (p/let [_ (nfs/check-directory-permission! repo-url)
                  _ (fs/mkdir-if-not-exists (str repo-dir "/" config/default-journals-directory))
-                 file-exists? (fs/create-if-not-exists repo-url repo-dir file-path content)]
+                 file-exists? (fs/file-exists? repo-dir file-path)]
            (when-not file-exists?
              (file-handler/reset-file! repo-url path content)
-             (ui-handler/re-render-root!)
-             (git-handler/git-add repo-url path))))))))
+             (p/let [_ (fs/create-if-not-exists repo-url repo-dir file-path content)]
+               (ui-handler/re-render-root!)
+               (git-handler/git-add repo-url path)))))))))
 
 (defn create-today-journal!
   []
@@ -151,7 +153,11 @@
     (when (or (db/cloned? repo)
               (and (config/local-db? repo)
                    ;; config file exists
-                   (db/get-file (str config/app-name "/" config/config-file))))
+                   (let [path (config/get-config-path)
+                         path (if (and (util/electron?) (config/local-db? repo))
+                                (str (config/get-repo-dir repo) "/" path)
+                                path)]
+                     (db/get-file path))))
       (let [today-page (string/lower-case (date/today))]
         (when (empty? (db/get-page-blocks-no-cache repo today-page))
           (create-today-journal-if-not-exists repo))))))
@@ -167,7 +173,7 @@
 (defn- reset-contents-and-blocks!
   [repo-url files blocks-pages delete-files delete-blocks]
   (db/transact-files-db! repo-url files)
-  (let [files (map #(select-keys % [:file/path]) files)
+  (let [files (map #(select-keys % [:file/path :file/last-modified-at]) files)
         all-data (-> (concat delete-files delete-blocks files blocks-pages)
                      (util/remove-nils))]
     (db/transact! repo-url all-data)))
@@ -187,7 +193,7 @@
                        (extract-handler/extract-all-blocks-pages repo-url parsed-files)
                        [])]
     (reset-contents-and-blocks! repo-url files blocks-pages delete-files delete-blocks)
-    (let [config-file (str config/app-name "/" config/config-file)]
+    (let [config-file (config/get-config-path)]
       (if (contains? (set file-paths) config-file)
         (when-let [content (some #(when (= (:file/path %) config-file)
                                     (:file/content %)) files)]
@@ -319,9 +325,12 @@
                                      (util/get-block-idx-inside-container block-element))]
        (when (and idx container)
          (state/set-state! :editor/last-edit-block {:block edit-block
-                                               :idx idx
+                                                    :idx idx
                                                     :container (gobj/get container "id")})))
 
+     (when (seq files)
+       (file-handler/alter-files repo files opts))
+
      (db/transact-react!
       repo
       tx
@@ -329,10 +338,7 @@
      (when (seq pages)
        (let [children-tx (mapcat #(rebuild-page-blocks-children repo %) pages)]
          (when (seq children-tx)
-           (db/transact! repo children-tx))))
-     (when (seq files)
-       (file-handler/alter-files repo files opts))
-     )))
+           (db/transact! repo children-tx)))))))
 
 (declare push)
 
@@ -359,7 +365,7 @@
                 (nil? local-latest-commit)
                 (not descendent?)
                 force-pull?)
-        (p/let [files (js/window.workerThread.getChangedFiles (util/get-repo-dir repo-url))]
+        (p/let [files (js/window.workerThread.getChangedFiles (config/get-repo-dir repo-url))]
           (when (empty? files)
             (let [status (db/get-key-value repo-url :git/status)]
               (when (or
@@ -539,7 +545,7 @@
                       (db/remove-conn! url)
                       (db/remove-db! url)
                       (db/remove-files-db! url)
-                      (fs/rmdir (util/get-repo-dir url))
+                      (fs/rmdir! (config/get-repo-dir url))
                       (state/delete-repo! repo))]
     (if (config/local-db? url)
       (p/let [_ (idb/clear-local-db! url)] ; clear file handles
@@ -620,16 +626,12 @@
     (do
       (doseq [{:keys [id url]} (:repos me)]
         (let [repo url]
-          (p/let [config-exists? (fs/file-exists?
-                                  (util/get-repo-dir url)
-                                  ".git/config")]
-            (if (and config-exists?
-                     (db/cloned? repo))
-              (p/do!
-               (git-handler/git-set-username-email! repo me)
-               (pull repo nil))
-              (p/do!
-               (clone-and-load-db repo))))))
+          (if (db/cloned? repo)
+            (p/do!
+             (git-handler/git-set-username-email! repo me)
+             (pull repo nil))
+            (p/do!
+             (clone-and-load-db repo)))))
 
       (periodically-pull-current-repo)
       (periodically-push-current-repo))
@@ -645,7 +647,7 @@
     (db/clear-query-state!)
     (-> (p/do! (db/remove-db! url)
                (db/remove-files-db! url)
-               (fs/rmdir (util/get-repo-dir url))
+               (fs/rmdir! (config/get-repo-dir url))
                (clone-and-load-db url))
         (p/catch (fn [error]
                    (prn "Delete repo failed, error: " error))))))

+ 149 - 120
src/main/frontend/handler/web/nfs.cljs

@@ -16,6 +16,7 @@
             [clojure.set :as set]
             [frontend.ui :as ui]
             [frontend.fs :as fs]
+            [frontend.fs.nfs :as nfs]
             [frontend.db :as db]
             [frontend.db.model :as db-model]
             [frontend.config :as config]
@@ -24,13 +25,13 @@
 (defn remove-ignore-files
   [files]
   (let [files (remove (fn [f]
-                        (string/starts-with? (:file/path f) ".git/"))
+                        (or (string/starts-with? (:file/path f) ".git/")
+                            (string/includes? (:file/path f) ".git/")))
                       files)]
     (if-let [ignore-file (some #(when (= (:file/name %) ".gitignore")
                                   %) files)]
       (if-let [file (:file/file ignore-file)]
         (p/let [content (.text file)]
-
           (when content
             (let [paths (set (file-handler/ignore-files content (map :file/path files)))]
               (when (seq paths)
@@ -39,20 +40,28 @@
       (p/resolved files))))
 
 (defn- ->db-files
-  [dir-name result]
-  (let [result (flatten (bean/->clj result))]
-    (map (fn [file]
-           (let [handle (gobj/get file "handle")
-                 get-attr #(gobj/get file %)
-                 path (-> (get-attr "webkitRelativePath")
-                          (string/replace-first (str dir-name "/") ""))]
-             {:file/name             (get-attr "name")
-              :file/path             path
-              :file/last-modified-at (get-attr "lastModified")
-              :file/size             (get-attr "size")
-              :file/type             (get-attr "type")
-              :file/file             file
-              :file/handle           handle})) result)))
+  [electron? dir-name result]
+  (if electron?
+    (map (fn [{:keys [path stat content]}]
+           (let [{:keys [mtime size]} stat]
+             {:file/path             path
+              :file/last-modified-at mtime
+              :file/size             size
+              :file/content content}))
+         result)
+    (let [result (flatten (bean/->clj result))]
+      (map (fn [file]
+             (let [handle (gobj/get file "handle")
+                   get-attr #(gobj/get file %)
+                   path (-> (get-attr "webkitRelativePath")
+                            (string/replace-first (str dir-name "/") ""))]
+               {:file/name             (get-attr "name")
+                :file/path             path
+                :file/last-modified-at (get-attr "lastModified")
+                :file/size             (get-attr "size")
+                :file/type             (get-attr "type")
+                :file/file             file
+                :file/handle           handle})) result))))
 
 (defn- filter-markup-and-built-in-files
   [files]
@@ -86,45 +95,55 @@
                          [handle-path handle]))
                      handles)]
     (doseq [[path handle] handles]
-      (fs/add-nfs-file-handle! path handle))
+      (nfs/add-nfs-file-handle! path handle))
     (set-files-aux! handles)))
 
+;; TODO: extract code for `ls-dir-files` and `reload-dir!`
 (defn ls-dir-files
   []
-  (let [path-handles (atom {})]
+  (let [path-handles (atom {})
+        electron? (util/electron?)
+        nfs? (not electron?)]
     ;; TODO: add ext filter to avoid loading .git or other ignored file handlers
     (->
-     (p/let [result (utils/openDirectory #js {:recursive true}
-                                         (fn [path handle]
-                                           (swap! path-handles assoc path handle)))
+     (p/let [result (fs/open-dir (fn [path handle]
+                                   (when nfs?
+                                     (swap! path-handles assoc path handle))))
              _ (state/set-loading-files! true)
-             root-handle (nth result 0)
-             dir-name (gobj/get root-handle "name")
+             root-handle (first result)
+             dir-name (if nfs?
+                        (gobj/get root-handle "name")
+                        root-handle)
              repo (str config/local-db-prefix dir-name)
              root-handle-path (str config/local-handle-prefix dir-name)
-             _ (idb/set-item! root-handle-path root-handle)
-             _ (fs/add-nfs-file-handle! root-handle-path root-handle)
+             _ (when nfs?
+                 (idb/set-item! root-handle-path root-handle)
+                 (nfs/add-nfs-file-handle! root-handle-path root-handle))
              result (nth result 1)
-             files (-> (->db-files dir-name result)
+             files (-> (->db-files electron? dir-name result)
                        remove-ignore-files)
-             _ (let [file-paths (set (map :file/path files))]
-                 (swap! path-handles (fn [handles]
-                                       (->> handles
-                                            (filter (fn [[path _handle]]
-                                                      (or
-                                                       (contains? file-paths
-                                                                  (string/replace-first path (str dir-name "/") ""))
-                                                       (let [last-part (last (string/split path "/"))]
-                                                         (contains? #{config/app-name
-                                                                      config/default-draw-directory
-                                                                      config/default-journals-directory
-                                                                      config/default-pages-directory}
-                                                                    last-part)))))
-                                            (into {})))))
-             _ (set-files! @path-handles)
+             _ (when nfs?
+                 (let [file-paths (set (map :file/path files))]
+                   (swap! path-handles (fn [handles]
+                                         (->> handles
+                                              (filter (fn [[path _handle]]
+                                                        (or
+                                                         (contains? file-paths
+                                                                    (string/replace-first path (str dir-name "/") ""))
+                                                         (let [last-part (last (string/split path "/"))]
+                                                           (contains? #{config/app-name
+                                                                        config/default-draw-directory
+                                                                        config/default-journals-directory
+                                                                        config/default-pages-directory}
+                                                                      last-part)))))
+                                              (into {})))))
+
+                 (set-files! @path-handles))
              markup-files (filter-markup-and-built-in-files files)]
        (-> (p/all (map (fn [file]
-                         (p/let [content (.text (:file/file file))]
+                         (p/let [content (if nfs?
+                                           (.text (:file/file file))
+                                           (:file/content file))]
                            (assoc file :file/content content))) markup-files))
            (p/then (fn [result]
                      _ (state/set-loading-files! false)
@@ -134,20 +153,15 @@
                                                       {:first-clone? true
                                                        :nfs-files    files})
 
-                       (state/add-repo! {:url repo :nfs? true}))))
+                       (state/add-repo! {:url repo :nfs? true})
+                       (when (util/electron?)
+                         (fs/watch-dir! dir-name)))))
            (p/catch (fn [error]
                       (log/error :nfs/load-files-error error)))))
      (p/catch (fn [error]
                 (when (not= "AbortError" (gobj/get error "name"))
                   (log/error :nfs/open-dir-error error)))))))
 
-(defn open-file-picker
-  "Shows a file picker that lets a user select a single existing file, returning a handle for the selected file. "
-  ([]
-   (open-file-picker {}))
-  ([option]
-   (.showOpenFilePicker js/window (bean/->js option))))
-
 (defn get-local-repo
   []
   (when-let [repo (state/get-current-repo)]
@@ -156,16 +170,17 @@
 
 (defn ask-permission
   [repo]
-  (fn [close-fn]
-    [:div
-     [:p.text-gray-700
-      "Grant native filesystem permission for directory: "
-      [:b (config/get-local-dir repo)]]
-     (ui/button
-      "Grant"
-      :on-click (fn []
-                  (fs/check-directory-permission! repo)
-                  (close-fn)))]))
+  (when-not (util/electron?)
+    (fn [close-fn]
+      [:div
+       [:p.text-gray-700
+        "Grant native filesystem permission for directory: "
+        [:b (config/get-local-dir repo)]]
+       (ui/button
+        "Grant"
+        :on-click (fn []
+                    (nfs/check-directory-permission! repo)
+                    (close-fn)))])))
 
 (defn ask-permission-if-local? []
   (when-let [repo (get-local-repo)]
@@ -192,6 +207,58 @@
      :modified modified
      :deleted  deleted}))
 
+(defn- handle-diffs!
+  [repo nfs? old-files new-files handle-path path-handles re-index?]
+  (let [get-last-modified-at (fn [path] (some (fn [file]
+                                                (when (= path (:file/path file))
+                                                  (:file/last-modified-at file)))
+                                              new-files))
+        get-file-f (fn [path files] (some #(when (= (:file/path %) path) %) files))
+        {:keys [added modified deleted] :as diffs} (compute-diffs old-files new-files)
+        ;; Use the same labels as isomorphic-git
+        rename-f (fn [typ col] (mapv (fn [file] {:type typ :path file :last-modified-at (get-last-modified-at file)}) col))
+        _ (when (and nfs? (seq deleted))
+            (let [deleted (doall
+                           (-> (map (fn [path] (if (= "/" (first path))
+                                                 path
+                                                 (str "/" path))) deleted)
+                               (distinct)))]
+              (p/all (map (fn [path]
+                            (let [handle-path (str handle-path path)]
+                              (idb/remove-item! handle-path)
+                              (nfs/remove-nfs-file-handle! handle-path))) deleted))))
+        added-or-modified (set (concat added modified))
+        _ (when (and nfs? (seq added-or-modified))
+            (p/all (map (fn [path]
+                          (when-let [handle (get @path-handles path)]
+                            (idb/set-item! (str handle-path path) handle))) added-or-modified)))]
+    (-> (p/all (map (fn [path]
+                      (when-let [file (get-file-f path new-files)]
+                        (p/let [content (if nfs?
+                                          (.text (:file/file file))
+                                          (:file/content file))]
+                          (assoc file :file/content content)))) added-or-modified))
+        (p/then (fn [result]
+                  (let [files (map #(dissoc % :file/file :file/handle) result)
+                        non-modified? (fn [file]
+                                        (let [content (:file/content file)
+                                              old-content (:file/content (get-file-f (:file/path file) old-files))]
+                                          (= content old-content)))
+                        non-modified-files (->> (filter non-modified? files)
+                                                (map :file/path))
+                        [modified-files modified] (if re-index?
+                                                    [files (set modified)]
+                                                    [(remove non-modified? files) (set/difference (set modified) (set non-modified-files))])
+                        diffs (concat
+                               (rename-f "remove" deleted)
+                               (rename-f "add" added)
+                               (rename-f "modify" modified))]
+                    (when (or (and (seq diffs) (seq modified-files))
+                              (seq diffs))
+                      (repo-handler/load-repo-to-db! repo
+                                                     {:diffs     diffs
+                                                      :nfs-files modified-files}))))))))
+
 (defn- reload-dir!
   ([repo]
    (reload-dir! repo false))
@@ -200,69 +267,31 @@
      (let [old-files (db/get-files-full repo)
            dir-name (config/get-local-dir repo)
            handle-path (str config/local-handle-prefix dir-name)
-           path-handles (atom {})]
+           path-handles (atom {})
+           electron? (util/electron?)
+           nfs? (not electron?)]
        (state/set-graph-syncing? true)
        (->
         (p/let [handle (idb/get-item handle-path)]
-          (when handle
-            (p/let [_ (when handle (common-handler/verify-permission repo handle true))
-                    files-result (utils/getFiles handle true
-                                                 (fn [path handle]
-                                                   (swap! path-handles assoc path handle)))
-                    new-files (-> (->db-files dir-name files-result)
+          (when (or handle electron?)   ; electron doesn't store the file handle
+            (p/let [_ (when handle (nfs/verify-permission repo handle true))
+                    files-result (fs/get-files (if nfs? handle
+                                                   (config/get-local-dir repo))
+                                               (fn [path handle]
+                                                 (when nfs?
+                                                   (swap! path-handles assoc path handle))))
+                    new-files (-> (->db-files electron? dir-name files-result)
                                   remove-ignore-files)
-                    _ (let [file-paths (set (map :file/path new-files))]
-                        (swap! path-handles (fn [handles]
-                                              (->> handles
-                                                   (filter (fn [[path _handle]]
-                                                             (contains? file-paths
-                                                                        (string/replace-first path (str dir-name "/") ""))))
-                                                   (into {})))))
-                    _ (set-files! @path-handles)
-                    get-file-f (fn [path files] (some #(when (= (:file/path %) path) %) files))
-                    {:keys [added modified deleted] :as diffs} (compute-diffs old-files new-files)
-                    ;; Use the same labels as isomorphic-git
-                    rename-f (fn [typ col] (mapv (fn [file] {:type typ :path file}) col))
-                    _ (when (seq deleted)
-                        (let [deleted (doall
-                                       (-> (map (fn [path] (if (= "/" (first path))
-                                                             path
-                                                             (str "/" path))) deleted)
-                                           (distinct)))]
-                          (p/all (map (fn [path]
-                                        (let [handle-path (str handle-path path)]
-                                          (idb/remove-item! handle-path)
-                                          (fs/remove-nfs-file-handle! handle-path))) deleted))))
-                    added-or-modified (set (concat added modified))
-                    _ (when (seq added-or-modified)
-                        (p/all (map (fn [path]
-                                      (when-let [handle (get @path-handles path)]
-                                        (idb/set-item! (str handle-path path) handle))) added-or-modified)))]
-              (-> (p/all (map (fn [path]
-                                (when-let [file (get-file-f path new-files)]
-                                  (p/let [content (.text (:file/file file))]
-                                    (assoc file :file/content content)))) added-or-modified))
-                  (p/then (fn [result]
-                            (let [files (map #(dissoc % :file/file :file/handle) result)
-                                  non-modified? (fn [file]
-                                                  (let [content (:file/content file)
-                                                        old-content (:file/content (get-file-f (:file/path file) old-files))]
-                                                    (= content old-content)))
-                                  non-modified-files (->> (filter non-modified? files)
-                                                          (map :file/path))
-                                  [modified-files modified] (if re-index?
-                                                              [files (set modified)]
-                                                              [(remove non-modified? files) (set/difference (set modified) (set non-modified-files))])
-                                  diffs (concat
-                                         (rename-f "remove" deleted)
-                                         (rename-f "add" added)
-                                         (rename-f "modify" modified))]
-                              (when (or (and (seq diffs) (seq modified-files))
-                                        (seq diffs) ; delete
-)
-                                (repo-handler/load-repo-to-db! repo
-                                                               {:diffs     diffs
-                                                                :nfs-files modified-files})))))))))
+                    _ (when nfs?
+                        (let [file-paths (set (map :file/path new-files))]
+                          (swap! path-handles (fn [handles]
+                                                (->> handles
+                                                     (filter (fn [[path _handle]]
+                                                               (contains? file-paths
+                                                                          (string/replace-first path (str dir-name "/") ""))))
+                                                     (into {})))))
+                        (set-files! @path-handles))]
+              (handle-diffs! repo nfs? old-files new-files handle-path path-handles re-index?))))
         (p/catch (fn [error]
                    (log/error :nfs/load-files-error error)))
         (p/finally (fn [_]
@@ -289,4 +318,4 @@
 
 (defn supported?
   []
-  (utils/nfsSupported))
+  (or (utils/nfsSupported) (util/electron?)))

+ 1 - 0
src/main/frontend/page.cljs

@@ -12,6 +12,7 @@
 (rum/defc current-page < rum/reactive
   {:did-mount    (fn [state]
                    (state/set-root-component! (:rum/react-component state))
+                   (state/setup-electron-updater!)
                    (ui/inject-document-devices-envs!)
                    (ui/inject-dynamic-style-node!)
                    (let [teardown-fn (comp (ui/setup-patch-ios-fixed-bottom-position!))]

+ 1 - 1
src/main/frontend/publishing.cljs

@@ -49,7 +49,7 @@
    (rf/router routes/routes {})
    route/set-route-match!
    ;; set to false to enable HistoryAPI
-   {:use-fragment false}))
+   {:use-fragment true}))
 
 (defn start []
   (when-let [node (.getElementById js/document "root")]

+ 20 - 5
src/main/frontend/state.cljs

@@ -3,6 +3,7 @@
             [rum.core :as rum]
             [frontend.util :as util :refer-macros [profile]]
             [clojure.string :as string]
+            [cljs-bean.core :as bean]
             [medley.core :as medley]
             [goog.object :as gobj]
             [goog.dom :as gdom]
@@ -96,6 +97,10 @@
 
     :preferred-language (storage/get :preferred-language)
 
+    ;; electron
+    :electron/updater-pending? false
+    :electron/updater {}
+
     ;; all notification contents as k-v pairs
     :notification/contents {}
     :graph/syncing? false}))
@@ -194,8 +199,7 @@
   ;;         (get (sub-config) (get-current-repo))))
 
   ;; Disable block timestamps for now, because it doesn't work with undo/redo
-  false
-  )
+  false)
 
 ;; Enable by default
 (defn show-brackets?
@@ -714,6 +718,17 @@
   []
   (get @state :ui/root-component))
 
+(defn setup-electron-updater!
+  []
+  (when (util/electron?)
+    (js/window.apis.setUpdatesCallback
+     (fn [_ args]
+       (let [data (bean/->clj args)
+             pending? (not= (:type data) "completed")]
+         (set-state! :electron/updater-pending? pending?)
+         (when pending? (set-state! :electron/updater data))
+         nil)))))
+
 (defn set-file-component!
   [component]
   (set-state! :ui/file-component component))
@@ -767,7 +782,7 @@
   (or
    (when-let [repo (get-current-repo)]
      (get-in @state [:config repo :date-formatter]))
-   ;; TODO:
+    ;; TODO:
    (get-in @state [:me :settings :date-formatter])
    "MMM do, yyyy"))
 
@@ -1005,7 +1020,7 @@
      (when-let [last-time (get-in @state [:editor/last-input-time repo])]
        (let [now (util/time-ms)]
          (>= (- now last-time) 1000)))
-     ;; not in editing mode
+      ;; not in editing mode
      (not (get-edit-input-id)))))
 
 (defn set-last-persist-transact-id!
@@ -1027,7 +1042,7 @@
                                     (remove (fn [tx] (<= (:tx-id tx) last-persist-tx-id)) result)))
                        latest-txs)
           new-txs (update-in latest-txs [repo files?] (fn [result]
-                                                        (vec (conj result {:tx-id tx-id
+                                                        (vec (conj result {:tx-id   tx-id
                                                                            :tx-data tx-data}))))]
       (storage/set-transit! :db/latest-txs new-txs)
       (set-state! :db/latest-txs new-txs))))

+ 5 - 2
src/main/frontend/ui.cljs

@@ -79,7 +79,7 @@
    child])
 
 (rum/defc dropdown-with-links
-  [content-fn links {:keys [modal-class links-header z-index] :as opts}]
+  [content-fn links {:keys [modal-class links-header links-footer z-index] :as opts}]
   (dropdown
    content-fn
    (fn [{:keys [close-fn] :as state}]
@@ -100,7 +100,8 @@
 ]]
           (rum/with-key
             (menu-link new-options child)
-            title)))])
+            title)))
+      (when links-footer links-footer)])
    opts))
 
 (defn button
@@ -231,6 +232,8 @@
   []
   (let [cl (.-classList js/document.documentElement)]
     (if util/mac? (.add cl "is-mac"))
+    (if util/win32? (.add cl "is-win32"))
+    (if (util/electron?) (.add cl "is-electron"))
     (if (util/ios?) (.add cl "is-ios"))
     (if (util/mobile?) (.add cl "is-mobile"))
     (if (util/safari?) (.add cl "is-safari"))))

+ 21 - 0
src/main/frontend/ui.css

@@ -43,6 +43,27 @@
   }
 }
 
+.ui__button_base {
+  @apply inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4
+  font-medium rounded-md text-white
+  bg-gray-500 hover:bg-gray-700 active:bg-gray-700
+  focus:border-gray-700 focus:shadow-outline-gray
+  focus:outline-none transition
+  ease-in-out duration-150 mt-1;
+
+  &.is-logseq {
+    @apply focus:border-gray-500;
+
+    color: var(--ls-primary-text-color);
+    background: var(--ls-secondary-background-color);
+  }
+
+  &.is-primary {
+    @apply bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-700
+    focus:border-indigo-700 focus:shadow-outline-indigo;
+  }
+}
+
 .dropdown-wrapper {
   background-color: var(--ls-primary-background-color, #fff);
   min-width: 12rem;

+ 19 - 7
src/main/frontend/util.cljc

@@ -50,6 +50,17 @@
       (when-not node-test?
         (re-find #"Mobi" js/navigator.userAgent))))
 
+#?(:cljs
+   (defn electron?
+     []
+     (let [ua (string/lower-case js/navigator.userAgent)]
+       (string/includes? ua " electron"))))
+
+#?(:cljs
+   (defn file-protocol?
+     []
+     (string/starts-with? js/window.location.href "file://")))
+
 (defn format
   [fmt & args]
   #?(:cljs (apply gstring/format fmt args)
@@ -144,6 +155,10 @@
             col)
        (into {})))
 
+(defn ext-of-image? [s]
+  (some #(string/ends-with? s %)
+        [".png" ".jpg" ".jpeg" ".bmp" ".gif" ".webp"]))
+
 ;; ".lg:absolute.lg:inset-y-0.lg:right-0.lg:w-1/2"
 (defn hiccup->class
   [class]
@@ -442,7 +457,7 @@
 
 (defn journal?
   [path]
-  (starts-with? path "journals/"))
+  (string/includes? path "journals/"))
 
 (defn drop-first-line
   [s]
@@ -776,12 +791,6 @@
   []
   #?(:cljs (tc/to-long (cljs-time.core/now))))
 
-(defn get-repo-dir
-  [repo-url]
-  (str "/"
-       (->> (take-last 2 (string/split repo-url #"/"))
-            (string/join "_"))))
-
 (defn d
   [k f]
   (let [result (atom nil)]
@@ -954,6 +963,9 @@
 (defonce mac? #?(:cljs goog.userAgent/MAC
                  :clj nil))
 
+(defonce win32? #?(:cljs goog.userAgent/WINDOWS
+                 :clj nil))
+
 (defn ->system-modifier
   [keyboard-shortcut]
   (if mac?

+ 12 - 0
src/main/frontend/utils.js

@@ -192,3 +192,15 @@ export const reversePatch = patch => {
     length2: patchObj.length1
   }));
 };
+
+// Copied from https://github.com/sindresorhus/path-is-absolute/blob/main/index.js
+export const win32 = path => {
+  // https://github.com/nodejs/node/blob/b3fcc245fb25539909ef1d5eaa01dbf92e168633/lib/path.js#L56
+  var splitDeviceRe = /^([a-zA-Z]:|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)?([\\/])?([\s\S]*?)$/;
+  var result = splitDeviceRe.exec(path);
+  var device = result[1] || '';
+  var isUnc = Boolean(device && device.charAt(1) !== ':');
+
+  // UNC paths are always absolute
+  return Boolean(result[2] || isUnc);
+}

+ 439 - 6
yarn.lock

@@ -183,6 +183,22 @@
     lodash "^4.17.19"
     to-fast-properties "^2.0.0"
 
+"@electron/get@^1.0.1":
+  version "1.12.2"
+  resolved "https://registry.yarnpkg.com/@electron/get/-/get-1.12.2.tgz#6442066afb99be08cefb9a281e4b4692b33764f3"
+  integrity sha512-vAuHUbfvBQpYTJ5wB7uVIDq5c/Ry0fiTBMs7lnEYAo/qXXppIVcWdfBr57u6eRnKdVso7KSiH6p/LbQAG6Izrg==
+  dependencies:
+    debug "^4.1.1"
+    env-paths "^2.2.0"
+    fs-extra "^8.1.0"
+    got "^9.6.0"
+    progress "^2.0.3"
+    sanitize-filename "^1.6.2"
+    sumchecker "^3.0.1"
+  optionalDependencies:
+    global-agent "^2.0.2"
+    global-tunnel-ng "^2.7.1"
+
 "@fullhuman/postcss-purgecss@^3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@fullhuman/postcss-purgecss/-/postcss-purgecss-3.0.0.tgz#e39bf7a7d2a2c664ed151b639785b2efcbca33ff"
@@ -212,6 +228,11 @@
     "@nodelib/fs.scandir" "2.1.3"
     fastq "^1.6.0"
 
+"@sindresorhus/is@^0.14.0":
+  version "0.14.0"
+  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
+  integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
+
 "@stylelint/postcss-css-in-js@^0.37.2":
   version "0.37.2"
   resolved "https://registry.yarnpkg.com/@stylelint/postcss-css-in-js/-/postcss-css-in-js-0.37.2.tgz#7e5a84ad181f4234a2480803422a47b8749af3d2"
@@ -227,6 +248,13 @@
     remark "^13.0.0"
     unist-util-find-all-after "^3.0.2"
 
+"@szmarczak/http-timer@^1.1.2":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
+  integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==
+  dependencies:
+    defer-to-connect "^1.0.1"
+
 "@tailwindcss/custom-forms@^0.2.1":
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/@tailwindcss/custom-forms/-/custom-forms-0.2.1.tgz#40e5ed1fff6d29d8ed1c508a0b2aaf8da96962e0"
@@ -303,6 +331,11 @@
   resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.13.tgz#9e425079799322113ae8477297ae6ef51b8e0cdf"
   integrity sha512-vbxr0VZ8exFMMAjCW8rJwaya0dMCDyYW2ZRdTyjtrCvJoENMpdUHOT/eTzvgyA5ZnqRZ/sI0NwqAxNHKYokLJQ==
 
+"@types/node@^12.0.12":
+  version "12.19.14"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-12.19.14.tgz#59e5029a3c2aea34f68b717955381692fd47cafb"
+  integrity sha512-2U9uLN46+7dv9PiS8VQJcHhuoOjiDPZOLAt0WuA1EanEknIMae+2QbMhayF7cgGqjvRVIfNpt+6jLPczJZFiRw==
+
 "@types/normalize-package-data@^2.4.0":
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
@@ -747,6 +780,11 @@ boolbase@^1.0.0, boolbase@~1.0.0:
   resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
   integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
 
+boolean@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.0.2.tgz#df1baa18b6a2b0e70840475e1d93ec8fe75b2570"
+  integrity sha512-RwywHlpCRc3/Wh81MiCKun4ydaIFyW5Ea6JbL6sRCVx5q5irDw7pMXBUFYF/jArQ6YrG36q0kpovc9P/Kd3I4g==
+
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -855,6 +893,11 @@ browserslist@^4.0.0, browserslist@^4.12.0:
     escalade "^3.1.1"
     node-releases "^1.1.67"
 
+buffer-crc32@~0.2.3:
+  version "0.2.13"
+  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
+
 buffer-equal@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.0.tgz#59616b498304d556abd466966b22eeda3eca5fbe"
@@ -904,6 +947,19 @@ cache-base@^1.0.1:
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+cacheable-request@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912"
+  integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==
+  dependencies:
+    clone-response "^1.0.2"
+    get-stream "^5.1.0"
+    http-cache-semantics "^4.0.0"
+    keyv "^3.0.0"
+    lowercase-keys "^2.0.0"
+    normalize-url "^4.1.0"
+    responselike "^1.0.2"
+
 call-bind@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.0.tgz#24127054bb3f9bdcb4b1fb82418186072f77b8ce"
@@ -1041,6 +1097,21 @@ chokidar@^3.3.0, chokidar@^3.3.1:
   optionalDependencies:
     fsevents "~2.1.2"
 
+chokidar@^3.5.1:
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a"
+  integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==
+  dependencies:
+    anymatch "~3.1.1"
+    braces "~3.0.2"
+    glob-parent "~5.1.0"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.5.0"
+  optionalDependencies:
+    fsevents "~2.3.1"
+
 cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de"
@@ -1110,6 +1181,13 @@ clone-regexp@^2.1.0:
   dependencies:
     is-regexp "^2.0.0"
 
+clone-response@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
+  integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
+  dependencies:
+    mimic-response "^1.0.0"
+
 clone-stats@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
@@ -1230,7 +1308,7 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
-concat-stream@^1.6.0:
+concat-stream@^1.6.0, concat-stream@^1.6.2:
   version "1.6.2"
   resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
   integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
@@ -1247,6 +1325,14 @@ concat-with-sourcemaps@^1.0.0:
   dependencies:
     source-map "^0.6.1"
 
+config-chain@^1.1.11:
+  version "1.1.12"
+  resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa"
+  integrity sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==
+  dependencies:
+    ini "^1.3.4"
+    proto-list "~1.2.1"
+
 console-browserify@^1.1.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336"
@@ -1277,6 +1363,11 @@ copy-props@^2.0.1:
     each-props "^1.3.0"
     is-plain-object "^2.0.1"
 
+core-js@^3.6.5:
+  version "3.8.3"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.8.3.tgz#c21906e1f14f3689f93abcc6e26883550dd92dd0"
+  integrity sha512-KPYXeVZYemC2TkNEkX/01I+7yd+nX3KddKwZ1Ww7SKWdI2wQprSgLmrTddT8nw92AjEklTsPBoSdQBhbI1bQ6Q==
+
 core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -1525,14 +1616,14 @@ d@1, d@^1.0.1:
     es5-ext "^0.10.50"
     type "^1.0.1"
 
-debug@^2.2.0, debug@^2.3.3:
+debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
   version "2.6.9"
   resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@^4.0.0, debug@^4.1.0, debug@^4.2.0:
+debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.2.0:
   version "4.3.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
   integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
@@ -1557,6 +1648,13 @@ decode-uri-component@^0.2.0:
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
   integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
 
+decompress-response@^3.3.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
+  integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
+  dependencies:
+    mimic-response "^1.0.0"
+
 default-compare@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/default-compare/-/default-compare-1.0.0.tgz#cb61131844ad84d84788fb68fd01681ca7781a2f"
@@ -1569,6 +1667,11 @@ default-resolution@^2.0.0:
   resolved "https://registry.yarnpkg.com/default-resolution/-/default-resolution-2.0.0.tgz#bcb82baa72ad79b426a76732f1a81ad6df26d684"
   integrity sha1-vLgrqnKtebQmp2cy8aga1t8m1oQ=
 
+defer-to-connect@^1.0.1:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
+  integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
+
 define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
@@ -1635,6 +1738,11 @@ detect-file@^1.0.0:
   resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
   integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
 
+detect-node@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
+  integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
+
 detective@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/detective/-/detective-5.2.0.tgz#feb2a77e85b904ecdea459ad897cc90a99bd2a7b"
@@ -1728,6 +1836,11 @@ dot-prop@^5.2.0:
   dependencies:
     is-obj "^2.0.0"
 
+duplexer3@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
+
 duplexify@^3.6.0:
   version "3.7.1"
   resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
@@ -1751,6 +1864,15 @@ electron-to-chromium@^1.3.621:
   resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.625.tgz#a7bd18da4dc732c180b2e95e0e296c0bf22f3bd6"
   integrity sha512-CsLk/r0C9dAzVPa9QF74HIXduxaucsaRfqiOYvIv2PRhvyC6EOqc/KbpgToQuDVgPf3sNAFZi3iBu4vpGOwGag==
 
+electron@^11.2.0:
+  version "11.2.0"
+  resolved "https://registry.yarnpkg.com/electron/-/electron-11.2.0.tgz#f8577ea4c9ba94068850256145be26b0b89a5dd7"
+  integrity sha512-weszOPAJPoPu6ozL7vR9enXmaDSqH+KE9iZODfbGdnFgtVfVdfyedjlvEGIUJkLMPXM1y/QWwCl2dINzr0Jq5Q==
+  dependencies:
+    "@electron/get" "^1.0.1"
+    "@types/node" "^12.0.12"
+    extract-zip "^1.0.3"
+
 element-resize-detector@^1.1.14:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/element-resize-detector/-/element-resize-detector-1.2.1.tgz#b0305194447a4863155e58f13323a0aef30851d1"
@@ -1776,6 +1898,11 @@ emoji-regex@^8.0.0:
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
   integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
 
+encodeurl@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
+
 end-of-stream@^1.0.0, end-of-stream@^1.1.0:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
@@ -1793,6 +1920,11 @@ entities@^2.0.0:
   resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
   integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
 
+env-paths@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43"
+  integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA==
+
 error-ex@^1.2.0, error-ex@^1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@@ -1853,6 +1985,11 @@ es5-ext@^0.10.35, es5-ext@^0.10.46, es5-ext@^0.10.50:
     es6-symbol "~3.1.3"
     next-tick "~1.0.0"
 
+es6-error@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d"
+  integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==
+
 es6-iterator@^2.0.1, es6-iterator@^2.0.3, es6-iterator@~2.0.3:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/es6-iterator/-/es6-iterator-2.0.3.tgz#a7de889141a05a94b0854403b2d0a0fbfa98f3b7"
@@ -1890,6 +2027,11 @@ escape-string-regexp@^1.0.5:
   resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
+escape-string-regexp@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+  integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
 esprima@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
@@ -1996,6 +2138,16 @@ extglob@^2.0.4:
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
+extract-zip@^1.0.3:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927"
+  integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==
+  dependencies:
+    concat-stream "^1.6.2"
+    debug "^2.6.9"
+    mkdirp "^0.5.4"
+    yauzl "^2.10.0"
+
 fancy-log@^1.3.2, fancy-log@^1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/fancy-log/-/fancy-log-1.3.3.tgz#dbc19154f558690150a23953a0adbd035be45fc7"
@@ -2052,6 +2204,13 @@ fastq@^1.6.0:
   dependencies:
     reusify "^1.0.4"
 
+fd-slicer@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+  dependencies:
+    pend "~1.2.0"
+
 file-entry-cache@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.0.tgz#7921a89c391c6d93efec2169ac6bf300c527ea0a"
@@ -2180,6 +2339,15 @@ fragment-cache@^0.2.1:
   dependencies:
     map-cache "^0.2.2"
 
+fs-extra@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
+  integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
+  dependencies:
+    graceful-fs "^4.2.0"
+    jsonfile "^4.0.0"
+    universalify "^0.1.0"
+
 fs-extra@^9.0.0, fs-extra@^9.0.1:
   version "9.0.1"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc"
@@ -2203,6 +2371,11 @@ fs.realpath@^1.0.0:
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
+fs@^0.0.1-security:
+  version "0.0.1-security"
+  resolved "https://registry.yarnpkg.com/fs/-/fs-0.0.1-security.tgz#8a7bd37186b6dddf3813f23858b57ecaaf5e41d4"
+  integrity sha1-invTcYa23d84E/I4WLV+yq9eQdQ=
+
 fsevents@^1.2.7:
   version "1.2.13"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
@@ -2216,6 +2389,11 @@ fsevents@~2.1.2:
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
   integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
 
+fsevents@~2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.1.tgz#b209ab14c61012636c8863507edf7fb68cc54e9f"
+  integrity sha512-YR47Eg4hChJGAB1O3yEAOkGO+rlzutoICGqGo9EZ4lKWokzZRSyIW1QmTzqjtw8MJdj9srP869CuWw/hyzSiBw==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -2255,13 +2433,20 @@ get-stdin@^8.0.0:
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53"
   integrity sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==
 
-get-stream@^4.0.0:
+get-stream@^4.0.0, get-stream@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
   integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
   dependencies:
     pump "^3.0.0"
 
+get-stream@^5.1.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3"
+  integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==
+  dependencies:
+    pump "^3.0.0"
+
 get-value@^2.0.3, get-value@^2.0.6:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
@@ -2323,6 +2508,19 @@ glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+global-agent@^2.0.2:
+  version "2.1.12"
+  resolved "https://registry.yarnpkg.com/global-agent/-/global-agent-2.1.12.tgz#e4ae3812b731a9e81cbf825f9377ef450a8e4195"
+  integrity sha512-caAljRMS/qcDo69X9BfkgrihGUgGx44Fb4QQToNQjsiWh+YlQ66uqYVAdA8Olqit+5Ng0nkz09je3ZzANMZcjg==
+  dependencies:
+    boolean "^3.0.1"
+    core-js "^3.6.5"
+    es6-error "^4.1.1"
+    matcher "^3.0.0"
+    roarr "^2.15.3"
+    semver "^7.3.2"
+    serialize-error "^7.0.1"
+
 global-modules@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
@@ -2359,11 +2557,28 @@ global-prefix@^3.0.0:
     kind-of "^6.0.2"
     which "^1.3.1"
 
+global-tunnel-ng@^2.7.1:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/global-tunnel-ng/-/global-tunnel-ng-2.7.1.tgz#d03b5102dfde3a69914f5ee7d86761ca35d57d8f"
+  integrity sha512-4s+DyciWBV0eK148wqXxcmVAbFVPqtc3sEtUE/GTQfuU80rySLcMhUmHKSHI7/LDj8q0gDYI1lIhRRB7ieRAqg==
+  dependencies:
+    encodeurl "^1.0.2"
+    lodash "^4.17.10"
+    npm-conf "^1.1.3"
+    tunnel "^0.0.6"
+
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
+globalthis@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.1.tgz#40116f5d9c071f9e8fb0037654df1ab3a83b7ef9"
+  integrity sha512-mJPRTc/P39NH/iNG4mXa9aIhNymaQikTrnspeCa2ZuJ+mH2QN/rXwtX3XwKrHqWgUQFbNZKtHM105aHzJalElw==
+  dependencies:
+    define-properties "^1.1.3"
+
 globby@^11.0.0, globby@^11.0.1:
   version "11.0.1"
   resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.1.tgz#9a2bf107a068f3ffeabc49ad702c79ede8cfd357"
@@ -2395,6 +2610,23 @@ gonzales-pe@^4.3.0:
   dependencies:
     minimist "^1.2.5"
 
+got@^9.6.0:
+  version "9.6.0"
+  resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
+  integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==
+  dependencies:
+    "@sindresorhus/is" "^0.14.0"
+    "@szmarczak/http-timer" "^1.1.2"
+    cacheable-request "^6.0.0"
+    decompress-response "^3.3.0"
+    duplexer3 "^0.1.4"
+    get-stream "^4.1.0"
+    lowercase-keys "^1.0.1"
+    mimic-response "^1.0.1"
+    p-cancelable "^1.0.0"
+    to-readable-stream "^1.0.0"
+    url-parse-lax "^3.0.0"
+
 graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4:
   version "4.2.4"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
@@ -2632,6 +2864,11 @@ htmlparser2@^3.10.0:
     inherits "^2.0.1"
     readable-stream "^3.1.1"
 
+http-cache-semantics@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
+  integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
+
 https-browserify@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73"
@@ -3131,6 +3368,11 @@ jsesc@^2.5.1:
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
   integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
 
[email protected]:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
+  integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
+
 json-parse-better-errors@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
@@ -3151,6 +3393,11 @@ json-stable-stringify-without-jsonify@^1.0.1:
   resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
   integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
 
+json-stringify-safe@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
+  integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
+
 json5@^2.1.2:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43"
@@ -3158,6 +3405,13 @@ json5@^2.1.2:
   dependencies:
     minimist "^1.2.5"
 
+jsonfile@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+  integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=
+  optionalDependencies:
+    graceful-fs "^4.1.6"
+
 jsonfile@^6.0.1:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae"
@@ -3182,6 +3436,13 @@ just-debounce@^1.0.0:
   resolved "https://registry.yarnpkg.com/just-debounce/-/just-debounce-1.0.0.tgz#87fccfaeffc0b68cd19d55f6722943f929ea35ea"
   integrity sha1-h/zPrv/AtozRnVX2cilD+SnqNeo=
 
+keyv@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
+  integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==
+  dependencies:
+    json-buffer "3.0.0"
+
 kind-of@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-1.1.0.tgz#140a3d2d41a36d2efcfa9377b62c24f8495a5c44"
@@ -3383,6 +3644,16 @@ loose-envify@^1.1.0, loose-envify@^1.4.0:
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
+lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
+  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
+
+lowercase-keys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
+  integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
+
 lru-cache@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@@ -3436,6 +3707,13 @@ matchdep@^2.0.0:
     resolve "^1.4.0"
     stack-trace "0.0.10"
 
+matcher@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca"
+  integrity sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==
+  dependencies:
+    escape-string-regexp "^4.0.0"
+
 mathml-tag-names@^2.1.3:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3"
@@ -3572,6 +3850,11 @@ mimic-fn@^2.0.0:
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
+mimic-response@^1.0.0, mimic-response@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
+  integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
+
 min-indent@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
@@ -3621,7 +3904,7 @@ mixin-deep@^1.2.0:
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
-mkdirp@~0.5.1:
+mkdirp@^0.5.4, mkdirp@~0.5.1:
   version "0.5.5"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
   integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
@@ -3785,6 +4068,11 @@ normalize-url@^3.0.0:
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559"
   integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg==
 
+normalize-url@^4.1.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129"
+  integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==
+
 now-and-later@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/now-and-later/-/now-and-later-2.0.1.tgz#8e579c8685764a7cc02cb680380e94f43ccb1f7c"
@@ -3792,6 +4080,14 @@ now-and-later@^2.0.0:
   dependencies:
     once "^1.3.2"
 
+npm-conf@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/npm-conf/-/npm-conf-1.1.3.tgz#256cc47bd0e218c259c4e9550bf413bc2192aff9"
+  integrity sha512-Yic4bZHJOt9RCFbRP3GgpqhScOY4HH3V2P8yBj6CeYq118Qr+BLXqT2JvpJ00mryLESpgOxf5XlFv4ZjXxLScw==
+  dependencies:
+    config-chain "^1.1.11"
+    pify "^3.0.0"
+
 npm-run-all@^4.1.5:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba"
@@ -3964,6 +4260,11 @@ os-locale@^3.0.0:
     lcid "^2.0.0"
     mem "^4.0.0"
 
+p-cancelable@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
+  integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==
+
 p-defer@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c"
@@ -4176,6 +4477,14 @@ path-type@^4.0.0:
   resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b"
   integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==
 
+path@^0.12.7:
+  version "0.12.7"
+  resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f"
+  integrity sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=
+  dependencies:
+    process "^0.11.1"
+    util "^0.10.3"
+
 pbkdf2@^3.0.3:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/pbkdf2/-/pbkdf2-3.1.1.tgz#cb8724b0fada984596856d1a6ebafd3584654b94"
@@ -4187,6 +4496,11 @@ pbkdf2@^3.0.3:
     safe-buffer "^5.0.1"
     sha.js "^2.4.8"
 
+pend@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
+
 picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
@@ -4679,6 +4993,11 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.21
     source-map "^0.6.1"
     supports-color "^6.1.0"
 
+prepend-http@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
+  integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
+
 pretty-hrtime@^1.0.0, pretty-hrtime@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
@@ -4689,11 +5008,16 @@ process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
   resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
   integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
 
-process@^0.11.10:
+process@^0.11.1, process@^0.11.10:
   version "0.11.10"
   resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
   integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
 
+progress@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
+  integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
+
 prop-types@^15.6.2:
   version "15.7.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
@@ -4703,6 +5027,11 @@ prop-types@^15.6.2:
     object-assign "^4.1.1"
     react-is "^16.8.1"
 
+proto-list@~1.2.1:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
+  integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
+
 public-encrypt@^4.0.0:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/public-encrypt/-/public-encrypt-4.0.3.tgz#4fcc9d77a07e48ba7527e7cbe0de33d0701331e0"
@@ -5114,6 +5443,13 @@ resolve@^1.1.6, resolve@^1.1.7, resolve@^1.10.0, resolve@^1.17.0, resolve@^1.19.
     is-core-module "^2.1.0"
     path-parse "^1.0.6"
 
+responselike@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
+  integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=
+  dependencies:
+    lowercase-keys "^1.0.0"
+
 ret@~0.1.10:
   version "0.1.15"
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
@@ -5149,6 +5485,18 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
     hash-base "^3.0.0"
     inherits "^2.0.1"
 
+roarr@^2.15.3:
+  version "2.15.4"
+  resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd"
+  integrity sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==
+  dependencies:
+    boolean "^3.0.1"
+    detect-node "^2.0.4"
+    globalthis "^1.0.1"
+    json-stringify-safe "^5.0.1"
+    semver-compare "^1.0.0"
+    sprintf-js "^1.1.2"
+
 run-parallel@^1.1.9:
   version "1.1.10"
   resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.10.tgz#60a51b2ae836636c81377df16cb107351bcd13ef"
@@ -5176,6 +5524,13 @@ safer-buffer@^2.1.0:
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
+sanitize-filename@^1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz#755ebd752045931977e30b2025d340d7c9090378"
+  integrity sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==
+  dependencies:
+    truncate-utf8-bytes "^1.0.0"
+
 sax@~1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
@@ -5189,6 +5544,11 @@ scheduler@^0.20.1:
     loose-envify "^1.1.0"
     object-assign "^4.1.1"
 
+semver-compare@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
+  integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w=
+
 semver-greatest-satisfied-range@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/semver-greatest-satisfied-range/-/semver-greatest-satisfied-range-1.1.0.tgz#13e8c2658ab9691cb0cd71093240280d36f77a5b"
@@ -5208,6 +5568,13 @@ semver@^7.3.2:
   dependencies:
     lru-cache "^6.0.0"
 
+serialize-error@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-7.0.1.tgz#f1360b0447f61ffb483ec4157c737fab7d778e18"
+  integrity sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==
+  dependencies:
+    type-fest "^0.13.1"
+
 set-blocking@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
@@ -5424,6 +5791,11 @@ split-string@^3.0.1, split-string@^3.0.2:
   dependencies:
     extend-shallow "^3.0.0"
 
+sprintf-js@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673"
+  integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug==
+
 sprintf-js@~1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
@@ -5683,6 +6055,13 @@ sugarss@^2.0.0:
   dependencies:
     postcss "^7.0.2"
 
+sumchecker@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42"
+  integrity sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==
+  dependencies:
+    debug "^4.1.0"
+
 supports-color@^5.3.0, supports-color@^5.4.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -5845,6 +6224,11 @@ to-object-path@^0.3.0:
   dependencies:
     kind-of "^3.0.2"
 
+to-readable-stream@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771"
+  integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==
+
 to-regex-range@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38"
@@ -5892,6 +6276,13 @@ trough@^1.0.0:
   resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406"
   integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==
 
+truncate-utf8-bytes@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz#405923909592d56f78a5818434b0b78489ca5f2b"
+  integrity sha1-QFkjkJWS1W94pYGENLC3hInKXys=
+  dependencies:
+    utf8-byte-length "^1.0.1"
+
 ts-essentials@^2.0.3:
   version "2.0.12"
   resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-2.0.12.tgz#c9303f3d74f75fa7528c3d49b80e089ab09d8745"
@@ -5902,6 +6293,16 @@ [email protected]:
   resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
   integrity sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=
 
+tunnel@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
+  integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
+
+type-fest@^0.13.1:
+  version "0.13.1"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934"
+  integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==
+
 type-fest@^0.18.0:
   version "0.18.1"
   resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.18.1.tgz#db4bc151a4a2cf4eebf9add5db75508db6cc841f"
@@ -6029,6 +6430,11 @@ unist-util-stringify-position@^2.0.0:
   dependencies:
     "@types/unist" "^2.0.2"
 
+universalify@^0.1.0:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+  integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
+
 universalify@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d"
@@ -6069,6 +6475,13 @@ urix@^0.1.0:
   resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
   integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
 
+url-parse-lax@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c"
+  integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=
+  dependencies:
+    prepend-http "^2.0.0"
+
 url@^0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@@ -6101,6 +6514,11 @@ use@^3.1.0:
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
   integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
 
+utf8-byte-length@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/utf8-byte-length/-/utf8-byte-length-1.0.4.tgz#f45f150c4c66eee968186505ab93fcbb8ad6bf61"
+  integrity sha1-9F8VDExm7uloGGUFq5P8u4rWv2E=
+
 util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
@@ -6123,6 +6541,13 @@ [email protected]:
   dependencies:
     inherits "2.0.1"
 
+util@^0.10.3:
+  version "0.10.4"
+  resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901"
+  integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==
+  dependencies:
+    inherits "2.0.3"
+
 util@^0.11.0:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/util/-/util-0.11.1.tgz#3236733720ec64bb27f6e26f421aaa2e1b588d61"
@@ -6409,6 +6834,14 @@ yargs@^7.1.0:
     y18n "^3.2.1"
     yargs-parser "5.0.0-security.0"
 
+yauzl@^2.10.0:
+  version "2.10.0"
+  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+  integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
+  dependencies:
+    buffer-crc32 "~0.2.3"
+    fd-slicer "~1.1.0"
+
 zwitch@^1.0.0:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"