@bendyline/docblocks-react 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (335) hide show
  1. package/README.md +31 -5
  2. package/dist/AppMenu/AppMenu.d.ts +29 -2
  3. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  4. package/dist/AppMenu/AppMenu.js +41 -2
  5. package/dist/AppMenu/AppMenu.js.map +1 -1
  6. package/dist/AppMenu/index.d.ts +1 -1
  7. package/dist/AppMenu/index.d.ts.map +1 -1
  8. package/dist/DocBlocksShell/DocBlocksShell.d.ts +15 -2
  9. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  10. package/dist/DocBlocksShell/DocBlocksShell.js +1677 -298
  11. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  12. package/dist/DocBlocksShell/LazyEditorShell.d.ts +3 -0
  13. package/dist/DocBlocksShell/LazyEditorShell.d.ts.map +1 -0
  14. package/dist/DocBlocksShell/LazyEditorShell.js +3 -0
  15. package/dist/DocBlocksShell/LazyEditorShell.js.map +1 -0
  16. package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts +13 -0
  17. package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts.map +1 -0
  18. package/dist/DocBlocksShell/UpdateAvailableNotice.js +16 -0
  19. package/dist/DocBlocksShell/UpdateAvailableNotice.js.map +1 -0
  20. package/dist/DocBlocksShell/document-title.d.ts +3 -0
  21. package/dist/DocBlocksShell/document-title.d.ts.map +1 -0
  22. package/dist/DocBlocksShell/document-title.js +17 -0
  23. package/dist/DocBlocksShell/document-title.js.map +1 -0
  24. package/dist/DocBlocksShell/workspace-authority-barrier.d.ts +14 -0
  25. package/dist/DocBlocksShell/workspace-authority-barrier.d.ts.map +1 -0
  26. package/dist/DocBlocksShell/workspace-authority-barrier.js +23 -0
  27. package/dist/DocBlocksShell/workspace-authority-barrier.js.map +1 -0
  28. package/dist/Export/DeferredExportToolbarControls.d.ts +5 -0
  29. package/dist/Export/DeferredExportToolbarControls.d.ts.map +1 -0
  30. package/dist/Export/DeferredExportToolbarControls.js +10 -0
  31. package/dist/Export/DeferredExportToolbarControls.js.map +1 -0
  32. package/dist/Export/ExportDialog.d.ts +10 -1
  33. package/dist/Export/ExportDialog.d.ts.map +1 -1
  34. package/dist/Export/ExportDialog.js +20 -18
  35. package/dist/Export/ExportDialog.js.map +1 -1
  36. package/dist/Export/ExportToolbarControls.d.ts +21 -1
  37. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  38. package/dist/Export/ExportToolbarControls.js +96 -11
  39. package/dist/Export/ExportToolbarControls.js.map +1 -1
  40. package/dist/Export/export-destination.d.ts +3 -0
  41. package/dist/Export/export-destination.d.ts.map +1 -0
  42. package/dist/Export/export-destination.js +32 -0
  43. package/dist/Export/export-destination.js.map +1 -0
  44. package/dist/Export/index.d.ts +8 -4
  45. package/dist/Export/index.d.ts.map +1 -1
  46. package/dist/Export/index.js +4 -1
  47. package/dist/Export/index.js.map +1 -1
  48. package/dist/Export/public-api.d.ts +12 -0
  49. package/dist/Export/public-api.d.ts.map +1 -0
  50. package/dist/Export/public-api.js +9 -0
  51. package/dist/Export/public-api.js.map +1 -0
  52. package/dist/Export/run-export.d.ts +3 -1
  53. package/dist/Export/run-export.d.ts.map +1 -1
  54. package/dist/Export/run-export.js +30 -13
  55. package/dist/Export/run-export.js.map +1 -1
  56. package/dist/ExportDialog-MEUISWZX.js +9 -0
  57. package/dist/ExportDialog-MEUISWZX.js.map +1 -0
  58. package/dist/ExportToolbarControls-D2YDWOXW.js +373 -0
  59. package/dist/ExportToolbarControls-D2YDWOXW.js.map +1 -0
  60. package/dist/FileExplorer/FileExplorer.d.ts +26 -4
  61. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  62. package/dist/FileExplorer/FileExplorer.js +204 -29
  63. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  64. package/dist/FileExplorer/FileTreeNode.d.ts +24 -3
  65. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
  66. package/dist/FileExplorer/FileTreeNode.js +27 -8
  67. package/dist/FileExplorer/FileTreeNode.js.map +1 -1
  68. package/dist/FileExplorer/index.d.ts +1 -1
  69. package/dist/FileExplorer/index.d.ts.map +1 -1
  70. package/dist/FileExplorer/useFileTree.d.ts +5 -3
  71. package/dist/FileExplorer/useFileTree.d.ts.map +1 -1
  72. package/dist/FileExplorer/useFileTree.js +173 -13
  73. package/dist/FileExplorer/useFileTree.js.map +1 -1
  74. package/dist/Git/GitAuthGuidanceDialog.d.ts +18 -0
  75. package/dist/Git/GitAuthGuidanceDialog.d.ts.map +1 -0
  76. package/dist/Git/GitAuthGuidanceDialog.js +27 -0
  77. package/dist/Git/GitAuthGuidanceDialog.js.map +1 -0
  78. package/dist/Git/GitBranchesDialog.d.ts +13 -0
  79. package/dist/Git/GitBranchesDialog.d.ts.map +1 -0
  80. package/dist/Git/GitBranchesDialog.js +59 -0
  81. package/dist/Git/GitBranchesDialog.js.map +1 -0
  82. package/dist/Git/GitCloneDialog.d.ts +14 -0
  83. package/dist/Git/GitCloneDialog.d.ts.map +1 -0
  84. package/dist/Git/GitCloneDialog.js +79 -0
  85. package/dist/Git/GitCloneDialog.js.map +1 -0
  86. package/dist/Git/GitCommitDialog.d.ts +12 -0
  87. package/dist/Git/GitCommitDialog.d.ts.map +1 -0
  88. package/dist/Git/GitCommitDialog.js +38 -0
  89. package/dist/Git/GitCommitDialog.js.map +1 -0
  90. package/dist/Git/GitConflictsDialog.d.ts +12 -0
  91. package/dist/Git/GitConflictsDialog.d.ts.map +1 -0
  92. package/dist/Git/GitConflictsDialog.js +17 -0
  93. package/dist/Git/GitConflictsDialog.js.map +1 -0
  94. package/dist/Git/GitContext.d.ts +89 -0
  95. package/dist/Git/GitContext.d.ts.map +1 -0
  96. package/dist/Git/GitContext.js +15 -0
  97. package/dist/Git/GitContext.js.map +1 -0
  98. package/dist/Git/GitDiffDialog.d.ts +20 -0
  99. package/dist/Git/GitDiffDialog.d.ts.map +1 -0
  100. package/dist/Git/GitDiffDialog.js +100 -0
  101. package/dist/Git/GitDiffDialog.js.map +1 -0
  102. package/dist/Git/GitDiffView.d.ts +23 -0
  103. package/dist/Git/GitDiffView.d.ts.map +1 -0
  104. package/dist/Git/GitDiffView.js +77 -0
  105. package/dist/Git/GitDiffView.js.map +1 -0
  106. package/dist/Git/GitHistoryDialog.d.ts +15 -0
  107. package/dist/Git/GitHistoryDialog.d.ts.map +1 -0
  108. package/dist/Git/GitHistoryDialog.js +107 -0
  109. package/dist/Git/GitHistoryDialog.js.map +1 -0
  110. package/dist/Git/GitStatusBar.d.ts +8 -0
  111. package/dist/Git/GitStatusBar.d.ts.map +1 -0
  112. package/dist/Git/GitStatusBar.js +49 -0
  113. package/dist/Git/GitStatusBar.js.map +1 -0
  114. package/dist/Git/GitToolbarControl.d.ts +15 -0
  115. package/dist/Git/GitToolbarControl.d.ts.map +1 -0
  116. package/dist/Git/GitToolbarControl.js +51 -0
  117. package/dist/Git/GitToolbarControl.js.map +1 -0
  118. package/dist/Git/GitUI.d.ts +14 -0
  119. package/dist/Git/GitUI.d.ts.map +1 -0
  120. package/dist/Git/GitUI.js +21 -0
  121. package/dist/Git/GitUI.js.map +1 -0
  122. package/dist/Git/commit-selection.d.ts +22 -0
  123. package/dist/Git/commit-selection.d.ts.map +1 -0
  124. package/dist/Git/commit-selection.js +47 -0
  125. package/dist/Git/commit-selection.js.map +1 -0
  126. package/dist/Git/git-clone-utils.d.ts +8 -0
  127. package/dist/Git/git-clone-utils.d.ts.map +1 -0
  128. package/dist/Git/git-clone-utils.js +26 -0
  129. package/dist/Git/git-clone-utils.js.map +1 -0
  130. package/dist/Git/git-status.d.ts +24 -0
  131. package/dist/Git/git-status.d.ts.map +1 -0
  132. package/dist/Git/git-status.js +105 -0
  133. package/dist/Git/git-status.js.map +1 -0
  134. package/dist/Git/useGit.d.ts +15 -0
  135. package/dist/Git/useGit.d.ts.map +1 -0
  136. package/dist/Git/useGit.js +206 -0
  137. package/dist/Git/useGit.js.map +1 -0
  138. package/dist/Git/useGitStatus.d.ts +17 -0
  139. package/dist/Git/useGitStatus.d.ts.map +1 -0
  140. package/dist/Git/useGitStatus.js +63 -0
  141. package/dist/Git/useGitStatus.js.map +1 -0
  142. package/dist/GitToolbarControl-LMIR4R7I.js +117 -0
  143. package/dist/GitToolbarControl-LMIR4R7I.js.map +1 -0
  144. package/dist/GitUI-KGF5MSNV.js +1048 -0
  145. package/dist/GitUI-KGF5MSNV.js.map +1 -0
  146. package/dist/LazyEditorShell-EF2BWRXU.js +6 -0
  147. package/dist/LazyEditorShell-EF2BWRXU.js.map +1 -0
  148. package/dist/Settings/Settings.d.ts +23 -0
  149. package/dist/Settings/Settings.d.ts.map +1 -0
  150. package/dist/Settings/Settings.js +22 -0
  151. package/dist/Settings/Settings.js.map +1 -0
  152. package/dist/Settings/public-api.d.ts +3 -0
  153. package/dist/Settings/public-api.d.ts.map +1 -0
  154. package/dist/Settings/public-api.js +2 -0
  155. package/dist/Settings/public-api.js.map +1 -0
  156. package/dist/WorkspacePicker/WorkspacePicker.d.ts +6 -1
  157. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
  158. package/dist/WorkspacePicker/WorkspacePicker.js +16 -5
  159. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
  160. package/dist/chunk-3SAXDU2U.js +157 -0
  161. package/dist/chunk-3SAXDU2U.js.map +1 -0
  162. package/dist/chunk-4XXSWN3T.js +13 -0
  163. package/dist/chunk-4XXSWN3T.js.map +1 -0
  164. package/dist/chunk-5A4KEWKT.js +97 -0
  165. package/dist/chunk-5A4KEWKT.js.map +1 -0
  166. package/dist/chunk-AOVTHZU7.js +321 -0
  167. package/dist/chunk-AOVTHZU7.js.map +1 -0
  168. package/dist/chunk-DD4VFJQI.js +51 -0
  169. package/dist/chunk-DD4VFJQI.js.map +1 -0
  170. package/dist/chunk-GSNZSCIK.js +55 -0
  171. package/dist/chunk-GSNZSCIK.js.map +1 -0
  172. package/dist/chunk-VHDP4IPL.js +70 -0
  173. package/dist/chunk-VHDP4IPL.js.map +1 -0
  174. package/dist/chunk-ZI56BNB7.js +270 -0
  175. package/dist/chunk-ZI56BNB7.js.map +1 -0
  176. package/dist/components/Dialog.d.ts +22 -0
  177. package/dist/components/Dialog.d.ts.map +1 -0
  178. package/dist/components/Dialog.js +33 -0
  179. package/dist/components/Dialog.js.map +1 -0
  180. package/dist/editor.d.ts +10 -0
  181. package/dist/editor.d.ts.map +1 -0
  182. package/dist/editor.js +70 -0
  183. package/dist/editor.js.map +1 -0
  184. package/dist/export/index.d.ts +87 -0
  185. package/dist/export/index.js +26 -0
  186. package/dist/export/index.js.map +1 -0
  187. package/dist/hooks/index.d.ts +2 -1
  188. package/dist/hooks/index.d.ts.map +1 -1
  189. package/dist/hooks/index.js +1 -1
  190. package/dist/hooks/index.js.map +1 -1
  191. package/dist/hooks/useDocumentSession.d.ts +16 -0
  192. package/dist/hooks/useDocumentSession.d.ts.map +1 -0
  193. package/dist/hooks/useDocumentSession.js +18 -0
  194. package/dist/hooks/useDocumentSession.js.map +1 -0
  195. package/dist/icons.d.ts +11 -6
  196. package/dist/icons.d.ts.map +1 -1
  197. package/dist/icons.js +21 -20
  198. package/dist/icons.js.map +1 -1
  199. package/dist/index.d.ts +8 -5
  200. package/dist/index.d.ts.map +1 -1
  201. package/dist/index.js +4 -2
  202. package/dist/index.js.map +1 -1
  203. package/dist/preferences/theme.d.ts +18 -0
  204. package/dist/preferences/theme.d.ts.map +1 -0
  205. package/dist/preferences/theme.js +63 -0
  206. package/dist/preferences/theme.js.map +1 -0
  207. package/dist/provider-lease.d.ts +10 -0
  208. package/dist/provider-lease.d.ts.map +1 -0
  209. package/dist/provider-lease.js +42 -0
  210. package/dist/provider-lease.js.map +1 -0
  211. package/dist/run-export-ICTZITXC.js +10 -0
  212. package/dist/run-export-ICTZITXC.js.map +1 -0
  213. package/dist/settings/index.d.ts +28 -0
  214. package/dist/settings/index.js +12 -0
  215. package/dist/settings/index.js.map +1 -0
  216. package/package.json +24 -9
  217. package/src/AppMenu/AppMenu.tsx +169 -99
  218. package/src/AppMenu/index.ts +1 -1
  219. package/src/DocBlocksShell/DocBlocksShell.tsx +2157 -537
  220. package/src/DocBlocksShell/LazyEditorShell.ts +2 -0
  221. package/src/DocBlocksShell/UpdateAvailableNotice.tsx +60 -0
  222. package/src/DocBlocksShell/document-title.ts +19 -0
  223. package/src/DocBlocksShell/workspace-authority-barrier.ts +26 -0
  224. package/src/Export/DeferredExportToolbarControls.tsx +23 -0
  225. package/src/Export/ExportDialog.tsx +74 -20
  226. package/src/Export/ExportToolbarControls.tsx +238 -40
  227. package/src/Export/export-destination.ts +30 -0
  228. package/src/Export/index.ts +18 -4
  229. package/src/Export/public-api.ts +17 -0
  230. package/src/Export/run-export.ts +36 -11
  231. package/src/FileExplorer/FileExplorer.tsx +284 -38
  232. package/src/FileExplorer/FileTreeNode.tsx +100 -8
  233. package/src/FileExplorer/index.ts +1 -1
  234. package/src/FileExplorer/useFileTree.ts +190 -16
  235. package/src/Git/GitAuthGuidanceDialog.tsx +84 -0
  236. package/src/Git/GitBranchesDialog.tsx +144 -0
  237. package/src/Git/GitCloneDialog.tsx +200 -0
  238. package/src/Git/GitCommitDialog.tsx +119 -0
  239. package/src/Git/GitConflictsDialog.tsx +62 -0
  240. package/src/Git/GitContext.tsx +88 -0
  241. package/src/Git/GitDiffDialog.tsx +147 -0
  242. package/src/Git/GitDiffView.tsx +105 -0
  243. package/src/Git/GitHistoryDialog.tsx +195 -0
  244. package/src/Git/GitStatusBar.tsx +157 -0
  245. package/src/Git/GitToolbarControl.tsx +123 -0
  246. package/src/Git/GitUI.tsx +68 -0
  247. package/src/Git/commit-selection.ts +63 -0
  248. package/src/Git/git-clone-utils.ts +23 -0
  249. package/src/Git/git-status.ts +113 -0
  250. package/src/Git/useGit.ts +279 -0
  251. package/src/Git/useGitStatus.ts +78 -0
  252. package/src/Settings/Settings.tsx +118 -0
  253. package/src/Settings/public-api.ts +8 -0
  254. package/src/WorkspacePicker/WorkspacePicker.tsx +62 -8
  255. package/src/components/Dialog.tsx +81 -0
  256. package/src/editor.ts +72 -0
  257. package/src/hooks/index.ts +2 -1
  258. package/src/hooks/useDocumentSession.ts +37 -0
  259. package/src/icons.tsx +33 -55
  260. package/src/index.ts +35 -5
  261. package/src/preferences/theme.ts +67 -0
  262. package/src/provider-lease.ts +52 -0
  263. package/src/styles/docblocks.css +2174 -79
  264. package/src/types/pwa-dom.d.ts +31 -0
  265. package/dist/hooks/useAutoSave.d.ts +0 -15
  266. package/dist/hooks/useAutoSave.d.ts.map +0 -1
  267. package/dist/hooks/useAutoSave.js +0 -83
  268. package/dist/hooks/useAutoSave.js.map +0 -1
  269. package/dist/monaco-slim.d.ts +0 -19
  270. package/dist/monaco-slim.d.ts.map +0 -1
  271. package/dist/monaco-slim.js +0 -19
  272. package/dist/monaco-slim.js.map +0 -1
  273. package/src/fonts/fonts.css +0 -362
  274. package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +0 -93
  275. package/src/fonts/licenses/LICENSE-crimson-text.txt +0 -93
  276. package/src/fonts/licenses/LICENSE-dm-sans.txt +0 -93
  277. package/src/fonts/licenses/LICENSE-dm-serif-display.txt +0 -93
  278. package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +0 -94
  279. package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +0 -93
  280. package/src/fonts/licenses/LICENSE-inter.txt +0 -93
  281. package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +0 -93
  282. package/src/fonts/licenses/LICENSE-lora.txt +0 -93
  283. package/src/fonts/licenses/LICENSE-merriweather.txt +0 -93
  284. package/src/fonts/licenses/LICENSE-oswald.txt +0 -93
  285. package/src/fonts/licenses/LICENSE-playfair-display.txt +0 -93
  286. package/src/fonts/licenses/LICENSE-pt-serif.txt +0 -93
  287. package/src/fonts/licenses/LICENSE-roboto.txt +0 -93
  288. package/src/fonts/licenses/LICENSE-source-serif-4.txt +0 -93
  289. package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
  290. package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
  291. package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
  292. package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
  293. package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
  294. package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
  295. package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
  296. package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
  297. package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
  298. package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
  299. package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
  300. package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
  301. package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
  302. package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
  303. package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
  304. package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
  305. package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
  306. package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
  307. package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
  308. package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
  309. package/src/fonts/woff2/inter-400.woff2 +0 -0
  310. package/src/fonts/woff2/inter-400i.woff2 +0 -0
  311. package/src/fonts/woff2/inter-500.woff2 +0 -0
  312. package/src/fonts/woff2/inter-600.woff2 +0 -0
  313. package/src/fonts/woff2/inter-700.woff2 +0 -0
  314. package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
  315. package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
  316. package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
  317. package/src/fonts/woff2/lora-400.woff2 +0 -0
  318. package/src/fonts/woff2/lora-400i.woff2 +0 -0
  319. package/src/fonts/woff2/lora-700.woff2 +0 -0
  320. package/src/fonts/woff2/merriweather-400.woff2 +0 -0
  321. package/src/fonts/woff2/merriweather-700.woff2 +0 -0
  322. package/src/fonts/woff2/oswald-400.woff2 +0 -0
  323. package/src/fonts/woff2/oswald-700.woff2 +0 -0
  324. package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
  325. package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
  326. package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
  327. package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
  328. package/src/fonts/woff2/roboto-400.woff2 +0 -0
  329. package/src/fonts/woff2/roboto-400i.woff2 +0 -0
  330. package/src/fonts/woff2/roboto-700.woff2 +0 -0
  331. package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
  332. package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
  333. package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
  334. package/src/hooks/useAutoSave.ts +0 -116
  335. package/src/monaco-slim.ts +0 -20
@@ -1,26 +1,100 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
- * DocBlocksShell top-level layout component.
3
+ * DocBlocksShell -- top-level layout component.
4
4
  *
5
5
  * Composes the left sidebar (WorkspacePicker + FileExplorer) with
6
6
  * the center editor area (squisq EditorShell).
7
7
  */
8
- import { useState, useCallback, useEffect, useRef } from 'react';
9
- import { EditorShell } from '@bendyline/squisq-editor-react';
8
+ import { useState, useCallback, useEffect, useRef, useMemo, lazy, Suspense } from 'react';
10
9
  import '@bendyline/squisq-editor-react/styles';
11
10
  import { MediaContext } from '@bendyline/squisq-react';
12
11
  import { DocumentVersionManager, } from '@bendyline/squisq/versions';
13
- import { IndexedDBFileSystemProvider, ElectronFileSystemProvider, FileSystemContentContainer, createFileMediaProvider, openNativeFolder, restoreNativeFolder, removeDirectoryHandle, } from '@bendyline/docblocks/filesystem';
12
+ import { createDbkWorkspaceSnapshot, FileSystemContentContainer, createFileMediaProvider, FsError, getFileSystemProviderV2, isQuotaExceededError, parseWorkspacePath, workspacePathContains, } from '@bendyline/docblocks/filesystem';
13
+ import { createFileSystemDocumentTarget, DocumentCommitConflictError, DocumentSessionConflictError, } from '@bendyline/docblocks/document';
14
14
  import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
15
- import { ensureDefaultWorkspace, getWorkspace, listWorkspaces, removeWorkspace, saveWorkspace, touchWorkspace, } from '@bendyline/docblocks/workspace';
15
+ import { ensureDefaultWorkspace, getWorkspace, listWorkspaces, reconcileElectronWorkspaceDescriptors, removeWorkspace, saveWorkspace, touchWorkspace, registerTransientWorkspace, getTransientWorkspace, unregisterTransientWorkspace, } from '@bendyline/docblocks/workspace';
16
16
  import { AppMenu } from '../AppMenu/AppMenu.js';
17
- import { FileExplorer } from '../FileExplorer/FileExplorer.js';
17
+ import { pickEmptyDocumentPrompt } from '../editor.js';
18
+ import { FileExplorer, } from '../FileExplorer/FileExplorer.js';
18
19
  import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
19
20
  import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
21
+ import { SplitViewIcon } from '../icons.js';
20
22
  import { WorkspaceSettingsDialog, } from '../WorkspacePicker/WorkspaceSettingsDialog.js';
21
- import { useAutoSave } from '../hooks/useAutoSave.js';
22
- import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
23
+ import { useDocumentSession } from '../hooks/useDocumentSession.js';
24
+ import { ExportToolbarControls, } from '../Export/DeferredExportToolbarControls.js';
25
+ import { GitContext } from '../Git/GitContext.js';
26
+ import { useGit } from '../Git/useGit.js';
27
+ // The editor is only needed after a document and its media container are
28
+ // ready. Workspace chrome stays interactive while this large feature loads.
29
+ const EditorShell = lazy(async () => {
30
+ const workersReady = globalThis
31
+ .docBlocksMonacoWorkersReady;
32
+ // Worker setup is an enhancement; a host that cannot install language
33
+ // workers must not make the document editor unavailable.
34
+ await workersReady?.catch(() => undefined);
35
+ return import('./LazyEditorShell.js');
36
+ });
37
+ // The git dialogs/status bar only ever render under the Electron host, so
38
+ // they load as a split chunk -- the site never pays for them (the entry
39
+ // bundle budget is enforced by scripts/check-bundle-size.ts).
40
+ const GitUI = lazy(() => import('../Git/GitUI.js').then((m) => ({ default: m.GitUI })));
41
+ const GitToolbarControl = lazy(() => import('../Git/GitToolbarControl.js').then((m) => ({ default: m.GitToolbarControl })));
23
42
  import { loadVersioningPreference, resolveVersioningEnabled, saveVersioningPreference, } from '../preferences/versioning.js';
43
+ import { DB_CHROME_COLORS, loadAccentColor, loadThemePreference, saveAccentColor, saveThemePreference, } from '../preferences/theme.js';
44
+ import { retainFileSystemProvider } from '../provider-lease.js';
45
+ import { useDocumentTitle } from './document-title.js';
46
+ import { UpdateAvailableNotice } from './UpdateAvailableNotice.js';
47
+ import { WorkspaceAuthorityBarrier } from './workspace-authority-barrier.js';
48
+ let indexedDbFileSystemModule = null;
49
+ let memoryFileSystemModule = null;
50
+ let nativeFileSystemModule = null;
51
+ let electronFileSystemModule = null;
52
+ function loadIndexedDbFileSystem() {
53
+ indexedDbFileSystemModule ?? (indexedDbFileSystemModule = import('@bendyline/docblocks/filesystem/indexeddb').catch((error) => {
54
+ indexedDbFileSystemModule = null;
55
+ throw error;
56
+ }));
57
+ return indexedDbFileSystemModule;
58
+ }
59
+ function loadMemoryFileSystem() {
60
+ memoryFileSystemModule ?? (memoryFileSystemModule = import('@bendyline/docblocks/filesystem/memory').catch((error) => {
61
+ memoryFileSystemModule = null;
62
+ throw error;
63
+ }));
64
+ return memoryFileSystemModule;
65
+ }
66
+ function loadNativeFileSystem() {
67
+ nativeFileSystemModule ?? (nativeFileSystemModule = import('@bendyline/docblocks/filesystem/native').catch((error) => {
68
+ nativeFileSystemModule = null;
69
+ throw error;
70
+ }));
71
+ return nativeFileSystemModule;
72
+ }
73
+ function loadElectronFileSystem() {
74
+ electronFileSystemModule ?? (electronFileSystemModule = import('@bendyline/docblocks/filesystem/electron').catch((error) => {
75
+ electronFileSystemModule = null;
76
+ throw error;
77
+ }));
78
+ return electronFileSystemModule;
79
+ }
80
+ async function createIndexedDbFileSystemProvider(id, label) {
81
+ const { IndexedDBFileSystemProvider } = await loadIndexedDbFileSystem();
82
+ return new IndexedDBFileSystemProvider(id, label);
83
+ }
84
+ async function createElectronFileSystemProvider(id, label, rootPath) {
85
+ const { ElectronFileSystemProvider } = await loadElectronFileSystem();
86
+ return new ElectronFileSystemProvider(id, label, rootPath);
87
+ }
88
+ async function createMemoryFileSystemProvider(id, label) {
89
+ const { MemoryFileSystemProvider } = await loadMemoryFileSystem();
90
+ return new MemoryFileSystemProvider(id, label);
91
+ }
92
+ function isMemoryWorkspaceProvider(provider) {
93
+ const candidate = provider;
94
+ return (typeof candidate.captureContents === 'function' &&
95
+ typeof candidate.replaceContents === 'function' &&
96
+ typeof candidate.treeVersion === 'number');
97
+ }
24
98
  function useOsTheme() {
25
99
  const [dark, setDark] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
26
100
  useEffect(() => {
@@ -75,7 +149,7 @@ function loadLastState() {
75
149
  }
76
150
  /** One-time first-run callout shown over the welcome doc's Play view.
77
151
  * Once the user starts writing, switches views themselves, or dismisses
78
- * it, it never comes back on any workspace. */
152
+ * it, it never comes back -- on any workspace. */
79
153
  const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
80
154
  function isWelcomeGatewayDismissed() {
81
155
  try {
@@ -93,35 +167,14 @@ function markWelcomeGatewayDismissed() {
93
167
  // ignore quota errors
94
168
  }
95
169
  }
96
- const THEME_PREF_KEY = 'docblocks:themePreference';
97
- function loadThemePreference() {
98
- try {
99
- const raw = localStorage.getItem(THEME_PREF_KEY);
100
- if (raw === 'light' || raw === 'dark' || raw === 'auto')
101
- return raw;
102
- }
103
- catch {
104
- // ignore
105
- }
106
- return 'auto';
107
- }
108
- function saveThemePreference(pref) {
109
- try {
110
- localStorage.setItem(THEME_PREF_KEY, pref);
111
- }
112
- catch {
113
- // ignore quota errors
114
- }
115
- }
116
170
  const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
117
- const SIDEBAR_WIDTH_DEFAULT = 260;
118
- const SIDEBAR_WIDTH_MIN = 180;
171
+ const SIDEBAR_WIDTH_DEFAULT = 320;
172
+ const SIDEBAR_WIDTH_MIN = 320;
119
173
  const SIDEBAR_WIDTH_MAX = 600;
120
- /** Drag below this many pixels and the sidebar collapses entirely
121
- * the editor takes the full width and a back-arrow appears in the
122
- * toolbar so the user can pop the sidebar back open. Same UX as
123
- * the existing mobile narrow-viewport flow. */
124
- const SIDEBAR_COLLAPSE_THRESHOLD = 120;
174
+ /** Drag below this many pixels and the sidebar collapses entirely --
175
+ * the workspace pane takes the full width and offers a control to
176
+ * restore the side-by-side layout. */
177
+ const SIDEBAR_COLLAPSE_THRESHOLD = SIDEBAR_WIDTH_MIN;
125
178
  function loadSidebarWidth() {
126
179
  try {
127
180
  const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
@@ -195,11 +248,115 @@ function stripExtension(name) {
195
248
  return name.replace(/\.[^.]+$/, '');
196
249
  }
197
250
  function normaliseProviderPath(p) {
198
- return '/' + p.replace(/^\/+/, '');
251
+ const canonical = parseWorkspacePath(p);
252
+ return canonical ? `/${canonical}` : '/';
199
253
  }
200
254
  function sameProviderPath(a, b) {
201
255
  return normaliseProviderPath(a) === normaliseProviderPath(b);
202
256
  }
257
+ function relocateProviderPath(path, oldPath, newPath) {
258
+ const current = normaliseProviderPath(path);
259
+ const oldNormalised = normaliseProviderPath(oldPath);
260
+ if (current !== oldNormalised && !current.startsWith(`${oldNormalised}/`))
261
+ return path;
262
+ return `${newPath.replace(/\/+$/, '')}${current.slice(oldNormalised.length)}`;
263
+ }
264
+ function pathContains(parentPath, candidatePath) {
265
+ return workspacePathContains(parseWorkspacePath(parentPath), parseWorkspacePath(candidatePath));
266
+ }
267
+ async function readProviderDirectory(provider, path) {
268
+ const providerV2 = getFileSystemProviderV2(provider);
269
+ if (!providerV2)
270
+ return provider.readDirectory(path);
271
+ const entries = await providerV2.readDirectory(parseWorkspacePath(path));
272
+ return entries.map((entry) => ({ kind: entry.kind, name: entry.name, path: entry.path }));
273
+ }
274
+ async function readProviderText(provider, path) {
275
+ const providerV2 = getFileSystemProviderV2(provider);
276
+ if (!providerV2)
277
+ return provider.readFile(path);
278
+ const file = await providerV2.readFile(parseWorkspacePath(path));
279
+ return file ? new TextDecoder().decode(file.data) : null;
280
+ }
281
+ async function providerEntryExists(provider, path) {
282
+ const providerV2 = getFileSystemProviderV2(provider);
283
+ return providerV2
284
+ ? (await providerV2.stat(parseWorkspacePath(path))) !== null
285
+ : provider.exists(path);
286
+ }
287
+ async function writeProviderText(provider, path, content, mode = 'upsert') {
288
+ const providerV2 = getFileSystemProviderV2(provider);
289
+ if (providerV2) {
290
+ await providerV2.writeFile(parseWorkspacePath(path), new TextEncoder().encode(content), {
291
+ mode,
292
+ createParents: true,
293
+ expectedVersion: mode === 'create' ? null : undefined,
294
+ });
295
+ return;
296
+ }
297
+ if (mode === 'create' && (await provider.exists(path))) {
298
+ throw new FsError('already-exists', 'File already exists.', { operation: 'write', path });
299
+ }
300
+ await provider.writeFile(path, content);
301
+ }
302
+ async function removeProviderEntry(provider, path) {
303
+ const providerV2 = getFileSystemProviderV2(provider);
304
+ if (providerV2) {
305
+ await providerV2.remove(parseWorkspacePath(path), { recursive: true, missing: 'ignore' });
306
+ return;
307
+ }
308
+ await provider.delete(path);
309
+ }
310
+ async function readStableFileSnapshot(provider, path) {
311
+ const providerV2 = getFileSystemProviderV2(provider);
312
+ if (providerV2) {
313
+ const read = await providerV2.readFile(parseWorkspacePath(path));
314
+ return {
315
+ content: read ? new TextDecoder().decode(read.data) : null,
316
+ version: read?.entry.version ?? null,
317
+ };
318
+ }
319
+ for (let attempt = 0; attempt < 3; attempt += 1) {
320
+ const before = await provider.stat(path);
321
+ const content = await provider.readFile(path);
322
+ const after = await provider.stat(path);
323
+ const unchanged = before?.lastModified === after?.lastModified && before?.size === after?.size;
324
+ const existenceMatches = (content !== null) === (after !== null);
325
+ if (unchanged && existenceMatches) {
326
+ return {
327
+ content,
328
+ version: after ? `${after.lastModified}:${after.size}` : null,
329
+ };
330
+ }
331
+ }
332
+ throw new Error('The file kept changing while DocBlocks was reading it.');
333
+ }
334
+ async function sha256Hex(data) {
335
+ const digest = await crypto.subtle.digest('SHA-256', data);
336
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
337
+ }
338
+ /**
339
+ * Ask for write access to a file handle delivered by the web File Handling
340
+ * API. Chromium grants read on launch; readwrite needs one prompt, ideally
341
+ * requested while the OS-launch user activation is still fresh. The
342
+ * permission methods are Chromium-only extensions (same inline typing
343
+ * approach as core's native-provider), so absent methods are treated as
344
+ * writable and the write itself surfaces any failure.
345
+ */
346
+ async function ensureHandleWritePermission(handle) {
347
+ const h = handle;
348
+ if (!h.queryPermission || !h.requestPermission)
349
+ return true;
350
+ try {
351
+ const opts = { mode: 'readwrite' };
352
+ if ((await h.queryPermission(opts)) === 'granted')
353
+ return true;
354
+ return (await h.requestPermission(opts)) === 'granted';
355
+ }
356
+ catch {
357
+ return false;
358
+ }
359
+ }
203
360
  /** Portable relative link from one workspace file to another. Walks up
204
361
  * from the source's directory and back down to the target so the link
205
362
  * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
@@ -231,10 +388,12 @@ async function collectMarkdownFiles(fs, root) {
231
388
  visited.add(dir);
232
389
  let entries;
233
390
  try {
234
- entries = await fs.readDirectory(dir);
391
+ entries = await readProviderDirectory(fs, dir);
235
392
  }
236
- catch {
237
- return;
393
+ catch (error) {
394
+ if (error instanceof FsError && error.code === 'not-found')
395
+ return;
396
+ throw error;
238
397
  }
239
398
  for (const entry of entries) {
240
399
  if (entry.kind === 'directory') {
@@ -256,20 +415,57 @@ async function collectMarkdownFiles(fs, root) {
256
415
  await walk(root);
257
416
  return out;
258
417
  }
418
+ /**
419
+ * Walk a FileSystemProvider and copy every file into a content container.
420
+ * Kept outside the component so document commit targets and backup actions
421
+ * share one implementation.
422
+ */
423
+ async function copyProviderToContainer(src, container, pathPrefix) {
424
+ const providerV2 = getFileSystemProviderV2(src);
425
+ if (providerV2) {
426
+ const snapshot = await providerV2.snapshot();
427
+ for (const entry of snapshot.entries) {
428
+ if (entry.kind !== 'file')
429
+ continue;
430
+ const rel = entry.path;
431
+ const destination = pathPrefix ? `${pathPrefix}/${rel}` : rel;
432
+ await container.writeFile(destination, entry.data);
433
+ }
434
+ return;
435
+ }
436
+ const stack = ['/'];
437
+ while (stack.length > 0) {
438
+ const dir = stack.pop();
439
+ const entries = await readProviderDirectory(src, dir);
440
+ for (const entry of entries) {
441
+ if (entry.kind === 'directory') {
442
+ stack.push(entry.path);
443
+ continue;
444
+ }
445
+ const rel = entry.path.replace(/^\/+/, '');
446
+ const zipPath = pathPrefix ? pathPrefix + '/' + rel : rel;
447
+ const binary = await src.readBinary(entry.path);
448
+ if (binary)
449
+ await container.writeFile(zipPath, binary);
450
+ else {
451
+ const text = await src.readFile(entry.path);
452
+ if (text === null)
453
+ throw new Error(`The workspace changed while reading ${entry.path}.`);
454
+ await container.writeFile(zipPath, new TextEncoder().encode(text));
455
+ }
456
+ }
457
+ }
458
+ }
259
459
  async function createElectronProviderFromWorkspace(ws) {
260
460
  if (!ws.rootPath)
261
461
  return null;
262
462
  try {
263
- await getDocBlocksHost().workspaces.register({
264
- id: ws.id,
265
- name: ws.name,
266
- rootPath: ws.rootPath,
267
- });
463
+ await getDocBlocksHost().workspaces.register(ws.id);
268
464
  }
269
465
  catch {
270
466
  return null;
271
467
  }
272
- return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
468
+ return createElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
273
469
  }
274
470
  function useIsMobile(breakpoint = 768) {
275
471
  const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
@@ -287,16 +483,34 @@ function FolderGlyph() {
287
483
  function FileGlyph() {
288
484
  return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" }), _jsx("path", { d: "M14 3v5h5" })] }));
289
485
  }
290
- export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersioning = true, versionBasename, versioningPrunePolicy, versioningAutoSaveIdleMs, onSaveVersion, versioningRef, }) {
486
+ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersioning = true, versionBasename, versioningPrunePolicy, versioningAutoSaveIdleMs, onSaveVersion, versioningRef, updateAvailable = false, onApplyUpdate, offlineReady = false, }) {
291
487
  const osTheme = useOsTheme();
292
488
  const [themePreference, setThemePreference] = useState(loadThemePreference);
293
- // "System default" (auto) always follows the OS — the host's theme prop
489
+ const [accentColor, setAccentColor] = useState(loadAccentColor);
490
+ // "System default" (auto) always follows the OS -- the host's theme prop
294
491
  // is kept only for API back-compat and does not override OS detection.
295
492
  const resolvedTheme = themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
296
493
  const handleThemeChange = useCallback((pref) => {
297
494
  setThemePreference(pref);
298
495
  saveThemePreference(pref);
299
496
  }, []);
497
+ // Keep the browser's theme-color metas in sync with the resolved theme so
498
+ // the installed web app's titlebar (Window Controls Overlay caption area,
499
+ // Android status bar) matches the shell chrome. Both media-attributed
500
+ // metas are overwritten on purpose: an explicit user theme choice must
501
+ // beat the OS media query. Electron paints its own titlebar.
502
+ useEffect(() => {
503
+ if (isElectronHost() || typeof document === 'undefined')
504
+ return;
505
+ const metas = document.querySelectorAll('meta[name="theme-color"]');
506
+ metas.forEach((meta) => {
507
+ meta.content = DB_CHROME_COLORS[resolvedTheme];
508
+ });
509
+ }, [resolvedTheme]);
510
+ const handleAccentColorChange = useCallback((color) => {
511
+ setAccentColor(color);
512
+ saveAccentColor(color);
513
+ }, []);
300
514
  const [viewPreferences, setViewPreferences] = useState(loadViewPreferences);
301
515
  const handleViewPreferencesChange = useCallback((prefs) => {
302
516
  setViewPreferences(prefs);
@@ -304,21 +518,26 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
304
518
  }, []);
305
519
  const isMobile = useIsMobile();
306
520
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
307
- // Sidebar width persisted across sessions, dragged via the resizer
521
+ // Sidebar width -- persisted across sessions, dragged via the resizer
308
522
  // between sidebar and editor area. We track the "live" width during a
309
523
  // drag in a ref so each mousemove doesn't trigger a state update; only
310
524
  // setState on commit so React doesn't churn through every pixel.
311
525
  const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
312
526
  // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
313
- // we switch the layout into single-pane "compact" mode same UX as
527
+ // we switch the layout into single-pane "compact" mode -- same UX as
314
528
  // the mobile narrow-viewport flow, where only the sidebar OR the
315
529
  // editor is visible at a time and a back-arrow in the toolbar pops
316
- // between them. A "Restore split view" button on the editor toolbar
530
+ // between them. A "Restore split view" button in either pane's header
317
531
  // exits compact mode; on real mobile that button is suppressed
318
532
  // because there's not enough viewport for side-by-side. Not
319
533
  // persisted across reloads. */
320
534
  const [compactLayout, setCompactLayout] = useState(false);
321
535
  const effectiveCompact = isMobile || compactLayout;
536
+ const showBrowserStorageWarning = !isElectronHost();
537
+ const [browserStoragePersistent, setBrowserStoragePersistent] = useState(false);
538
+ // Browser PWA install prompt (Chromium only). The event is stashed when
539
+ // the browser deems the app installable and spent on first use.
540
+ const [installPromptEvent, setInstallPromptEvent] = useState(null);
322
541
  const sidebarRef = useRef(null);
323
542
  const dragStateRef = useRef(null);
324
543
  const handleResizerPointerDown = useCallback((e) => {
@@ -340,7 +559,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
340
559
  return;
341
560
  lastRaw = drag.startWidth + (ev.clientX - drag.startX);
342
561
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
343
- // Below threshold preview the collapse by snapping to the
562
+ // Below threshold -- preview the collapse by snapping to the
344
563
  // minimum width and fading the sidebar, so the user can see
345
564
  // they've crossed into "release to collapse" territory.
346
565
  sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
@@ -364,8 +583,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
364
583
  sidebarRef.current.style.opacity = '';
365
584
  }
366
585
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
367
- // Released below threshold switch to compact (single-pane)
368
- // layout focused on the editor. Keep the persisted
586
+ // Released below threshold -- switch to compact (single-pane)
587
+ // layout focused on the document pane. Keep the persisted
369
588
  // sidebarWidth so exiting compact mode restores it.
370
589
  setCompactLayout(true);
371
590
  setMobileShowEditor(true);
@@ -384,8 +603,15 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
384
603
  document.addEventListener('pointerup', onUp);
385
604
  }, [sidebarWidth]);
386
605
  const [provider, setProvider] = useState(null);
606
+ const [workspaceStartupError, setWorkspaceStartupError] = useState(null);
387
607
  const [activeWorkspaceId, setActiveWorkspaceId] = useState(null);
388
608
  const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] = useState(null);
609
+ useEffect(() => {
610
+ if (!provider || getTransientWorkspace(provider.id))
611
+ return;
612
+ const providerV2 = getFileSystemProviderV2(provider);
613
+ return providerV2 ? retainFileSystemProvider(providerV2) : undefined;
614
+ }, [provider]);
389
615
  // Re-fetch the descriptor whenever the active id (or its versioning
390
616
  // override) changes. `descriptorRefreshKey` is bumped after writes so
391
617
  // the resolver picks up the updated override without remounting.
@@ -404,6 +630,17 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
404
630
  cancelled = true;
405
631
  };
406
632
  }, [activeWorkspaceId, descriptorRefreshKey]);
633
+ // All git UI state/actions -- null-renders on surfaces without git.
634
+ const gitWorkspaceId = provider &&
635
+ activeWorkspaceDescriptor?.id === activeWorkspaceId &&
636
+ provider.id === activeWorkspaceId &&
637
+ activeWorkspaceDescriptor.type === 'electron-native'
638
+ ? activeWorkspaceDescriptor.id
639
+ : null;
640
+ const git = useGit(provider, gitWorkspaceId, resolvedTheme);
641
+ const gitRef = useRef(git);
642
+ gitRef.current = git;
643
+ const { scheduleRefresh: gitScheduleRefresh } = git;
407
644
  const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
408
645
  const [versioningPreference, setVersioningPreference] = useState(loadVersioningPreference);
409
646
  const handleVersioningPreferenceChange = useCallback((pref) => {
@@ -424,17 +661,211 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
424
661
  setWorkspaceSettingsOpen(false);
425
662
  }, [activeWorkspaceDescriptor]);
426
663
  const [selectedFile, setSelectedFile] = useState(null);
664
+ useDocumentTitle(selectedFile);
665
+ const exportDestinationAdapter = useMemo(() => {
666
+ if (!isElectronHost() || !activeWorkspaceId || !selectedFile)
667
+ return undefined;
668
+ const documentId = JSON.stringify([activeWorkspaceId, selectedFile]);
669
+ const host = getDocBlocksHost().exports;
670
+ return {
671
+ resolveTarget: (filename) => host.resolveTarget(documentId, filename),
672
+ pickTarget: (filename, currentTarget) => host.pickTarget(documentId, filename, currentTarget?.grantId ?? null),
673
+ saveBlob: async (blob, filename, target) => host.save(documentId, filename, target?.grantId ?? null, await blob.arrayBuffer()),
674
+ };
675
+ }, [activeWorkspaceId, selectedFile]);
427
676
  const [selectedFolder, setSelectedFolder] = useState(null);
428
677
  const [folderEntries, setFolderEntries] = useState([]);
429
- const [editorContent, setEditorContent] = useState('');
430
- const [editorKey, setEditorKey] = useState(0);
678
+ const { session: documentSession, snapshot: documentSnapshot } = useDocumentSession(500);
679
+ // The session snapshot is the sole owner of document content and edit
680
+ // identity. React retains only presentation state (path/folder/view).
681
+ const editorContent = documentSnapshot.content;
682
+ const editorSessionScope = useMemo(() => documentSnapshot.targetKey && !documentSnapshot.frozen
683
+ ? {
684
+ targetKey: documentSnapshot.targetKey,
685
+ generation: documentSnapshot.generation,
686
+ }
687
+ : null, [documentSnapshot.frozen, documentSnapshot.generation, documentSnapshot.targetKey]);
688
+ const [editorPresentationEpoch, setEditorPresentationEpoch] = useState(0);
689
+ const editorKey = `${documentSnapshot.generation}:${editorPresentationEpoch}`;
690
+ const editorPlaceholder = useMemo(() => {
691
+ // Keep the selection scoped to the same generation that remounts Squisq.
692
+ void editorKey;
693
+ return pickEmptyDocumentPrompt();
694
+ }, [editorKey]);
431
695
  const [explorerKey, setExplorerKey] = useState(0);
432
696
  const [initialView, setInitialView] = useState('wysiwyg');
433
- // First-run gateway over the welcome doc's Play view see WELCOME_GATEWAY_KEY.
697
+ // First-run gateway over the welcome doc's Play view -- see WELCOME_GATEWAY_KEY.
434
698
  const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
435
- /** Suppress popstate handling during programmatic navigation. */
436
- const skipPopState = useRef(false);
437
- const lastLocalSaveRef = useRef(null);
699
+ const navigationRequestRef = useRef(0);
700
+ const workspaceAuthorityBarrier = useMemo(() => new WorkspaceAuthorityBarrier(), []);
701
+ const preparedCloseRequestRef = useRef(null);
702
+ const pendingDbkConflictsRef = useRef(new Map());
703
+ const createDocumentTarget = useCallback((fsProvider, workspaceId, filePath) => {
704
+ const transient = getTransientWorkspace(workspaceId);
705
+ const baseTarget = createFileSystemDocumentTarget(fsProvider, filePath);
706
+ const originKind = transient?.descriptor.origin?.kind;
707
+ const needsElectronHost = originKind === 'loose-file' || originKind === 'dbk';
708
+ if (!transient?.descriptor.origin || (needsElectronHost && !isElectronHost())) {
709
+ return {
710
+ key: baseTarget.key,
711
+ async commit(request) {
712
+ const result = await baseTarget.commit(request);
713
+ gitScheduleRefresh();
714
+ return result;
715
+ },
716
+ };
717
+ }
718
+ const origin = transient.descriptor.origin;
719
+ if (origin.kind === 'web-file' || origin.kind === 'web-dbk') {
720
+ // Launched-file origins (installed web app, File Handling API):
721
+ // read and write through the FileSystemFileHandle instead of the
722
+ // Electron host bridge, with the same optimistic external-change
723
+ // detection as the host-backed paths below.
724
+ const webOrigin = origin;
725
+ const transientProvider = transient.provider;
726
+ return {
727
+ key: baseTarget.key,
728
+ async commit(request) {
729
+ if (!(await ensureHandleWritePermission(webOrigin.handle))) {
730
+ throw new Error(`DocBlocks needs permission to write to "${webOrigin.name}". Save again to grant it.`);
731
+ }
732
+ if (webOrigin.kind === 'web-file') {
733
+ const current = await (await webOrigin.handle.getFile()).text();
734
+ if (request.persistedContent !== null &&
735
+ current !== request.persistedContent &&
736
+ current !== request.content) {
737
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
738
+ await writeProviderText(fsProvider, filePath, current);
739
+ throw new DocumentCommitConflictError('The original file changed outside DocBlocks.', current, null);
740
+ }
741
+ const writable = await webOrigin.handle.createWritable();
742
+ await writable.write(request.content);
743
+ await writable.close();
744
+ await baseTarget.commit(request);
745
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
746
+ gitScheduleRefresh();
747
+ return {};
748
+ }
749
+ // web-dbk: package the in-memory workspace into a zip and write
750
+ // it back through the handle, guarded by a SHA-256
751
+ // compare-and-swap that mirrors the Electron dbk path.
752
+ const [{ MemoryContentContainer }, { containerToZip, zipToContainer }] = await Promise.all([
753
+ import('@bendyline/squisq/storage'),
754
+ import('@bendyline/squisq-formats/container'),
755
+ ]);
756
+ const currentBytes = await (await webOrigin.handle.getFile()).arrayBuffer();
757
+ const currentVersion = await sha256Hex(currentBytes);
758
+ if (webOrigin.version !== null && currentVersion !== webOrigin.version) {
759
+ if (!isMemoryWorkspaceProvider(transientProvider)) {
760
+ throw new Error('A transient DBK workspace must use in-memory storage.');
761
+ }
762
+ const externalContainer = await zipToContainer(currentBytes);
763
+ const snapshot = await createDbkWorkspaceSnapshot(externalContainer, {
764
+ targetDocumentPath: filePath,
765
+ });
766
+ // Keep both branches intact until the user chooses -- same
767
+ // semantics as the Electron dbk conflict staging below.
768
+ pendingDbkConflictsRef.current.set(baseTarget.key, {
769
+ provider: transientProvider,
770
+ snapshot,
771
+ });
772
+ webOrigin.version = currentVersion;
773
+ throw new DocumentCommitConflictError('The original DocBlocks bundle changed outside DocBlocks.', snapshot.documentContent, currentVersion);
774
+ }
775
+ const container = new MemoryContentContainer();
776
+ await copyProviderToContainer(transientProvider, container, '');
777
+ await container.writeFile(filePath.replace(/^\/+/, ''), new TextEncoder().encode(request.content));
778
+ const blob = await containerToZip(container);
779
+ const bytes = await blob.arrayBuffer();
780
+ const writable = await webOrigin.handle.createWritable();
781
+ await writable.write(bytes);
782
+ await writable.close();
783
+ webOrigin.version = await sha256Hex(bytes);
784
+ // The transient provider intentionally retained the complete
785
+ // local branch while external bundle B was staged for conflict
786
+ // resolution. Once bundle C is committed to the origin, advance
787
+ // that internal markdown baseline unconditionally; comparing its
788
+ // old A content against request.persistedContent B would report a
789
+ // false second conflict after the durable write already succeeded.
790
+ await writeProviderText(fsProvider, filePath, request.content);
791
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
792
+ gitScheduleRefresh();
793
+ return { version: webOrigin.version };
794
+ },
795
+ };
796
+ }
797
+ return {
798
+ key: baseTarget.key,
799
+ async commit(request) {
800
+ const host = getDocBlocksHost();
801
+ if (origin.kind === 'loose-file') {
802
+ const result = await host.external.commitText(origin.resourceId, request.content, request.persistedContent);
803
+ if (result.status === 'conflict') {
804
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
805
+ if (result.content === null)
806
+ await removeProviderEntry(fsProvider, filePath);
807
+ else
808
+ await writeProviderText(fsProvider, filePath, result.content);
809
+ throw new DocumentCommitConflictError('The original file changed outside DocBlocks.', result.content, result.version);
810
+ }
811
+ await baseTarget.commit(request);
812
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
813
+ gitScheduleRefresh();
814
+ return { version: result.version };
815
+ }
816
+ else {
817
+ const [{ MemoryContentContainer }, { containerToZip, zipToContainer }] = await Promise.all([
818
+ import('@bendyline/squisq/storage'),
819
+ import('@bendyline/squisq-formats/container'),
820
+ ]);
821
+ const container = new MemoryContentContainer();
822
+ await copyProviderToContainer(transient.provider, container, '');
823
+ await container.writeFile(filePath.replace(/^\/+/, ''), new TextEncoder().encode(request.content));
824
+ const blob = await containerToZip(container);
825
+ const result = await host.external.commitBinary(origin.resourceId, await blob.arrayBuffer(), origin.version);
826
+ if (result.status === 'conflict') {
827
+ let externalContent = null;
828
+ if (result.data) {
829
+ const externalContainer = await zipToContainer(result.data);
830
+ if (!isMemoryWorkspaceProvider(transient.provider)) {
831
+ throw new Error('A transient DBK workspace must use in-memory storage.');
832
+ }
833
+ const snapshot = await createDbkWorkspaceSnapshot(externalContainer, {
834
+ targetDocumentPath: filePath,
835
+ });
836
+ externalContent = snapshot.documentContent;
837
+ // Keep both branches intact until the user chooses. Reload
838
+ // replaces the whole tree; Keep mine packages this untouched
839
+ // local provider against the newly observed bundle version.
840
+ pendingDbkConflictsRef.current.set(baseTarget.key, {
841
+ provider: transient.provider,
842
+ snapshot,
843
+ });
844
+ }
845
+ else {
846
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
847
+ }
848
+ // Advance the optimistic origin only after the external bundle
849
+ // has been completely staged. A parse/read failure must retry
850
+ // against the old version rather than silently authorize an
851
+ // overwrite of data we could not reconcile.
852
+ origin.version = result.version;
853
+ throw new DocumentCommitConflictError('The original DocBlocks bundle changed outside DocBlocks.', externalContent, result.version);
854
+ }
855
+ origin.version = result.version;
856
+ // As with web-dbk above, the in-memory provider still represents
857
+ // the preserved local branch rather than the external conflict
858
+ // baseline. The origin commit is the conditional authority check;
859
+ // now make the internal document agree with that acknowledged C
860
+ // bundle without re-comparing A against B.
861
+ await writeProviderText(fsProvider, filePath, request.content);
862
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
863
+ gitScheduleRefresh();
864
+ return { version: result.version };
865
+ }
866
+ },
867
+ };
868
+ }, [gitScheduleRefresh]);
438
869
  /**
439
870
  * Per-file media container: for `notes.md`, images live in
440
871
  * `notes_files/` next to the markdown. Created lazily on first write,
@@ -443,6 +874,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
443
874
  */
444
875
  const mediaContainerRef = useRef(null);
445
876
  const [mediaProvider, setMediaProvider] = useState(null);
877
+ const [mediaEpoch, setMediaEpoch] = useState(0);
446
878
  /**
447
879
  * Per-document container scoped to `<basename>_files/`. This is what
448
880
  * the editor uses for version history (`.versions/` lives here) and
@@ -463,69 +895,112 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
463
895
  const pushHash = useCallback((wsId, filePath) => {
464
896
  const hash = buildHash(wsId, filePath);
465
897
  if (window.location.hash !== hash) {
466
- skipPopState.current = true;
467
898
  window.history.pushState(null, '', hash);
468
899
  }
469
900
  }, []);
470
901
  /** Open a workspace (and optionally a file) from identifiers.
471
902
  * Returns the FileSystemProvider on success, or null on failure. */
472
- const openFromIds = useCallback(async (wsId, filePath, push, view) => {
473
- const workspaces = await listWorkspaces();
474
- const ws = workspaces.find((w) => w.id === wsId);
475
- if (!ws)
903
+ const openFromIds = useCallback(async (wsId, filePath, push, view, navigationRequestId) => {
904
+ const requestId = navigationRequestId ?? ++navigationRequestRef.current;
905
+ if (requestId !== navigationRequestRef.current)
476
906
  return null;
907
+ // Transient (session-only) workspaces -- a loose file or `.dbk` opened
908
+ // from the OS -- carry a pre-built in-memory provider in the registry.
909
+ const transient = getTransientWorkspace(wsId);
910
+ let ws;
477
911
  let fsProvider = null;
478
- if (ws.type === 'electron-native') {
479
- fsProvider = await createElectronProviderFromWorkspace(ws);
480
- if (!fsProvider)
481
- return null;
482
- }
483
- else if (ws.type === 'native') {
484
- const restored = await restoreNativeFolder(ws.id);
485
- if (!restored)
486
- return null;
487
- fsProvider = restored;
912
+ if (transient) {
913
+ ws = transient.descriptor;
914
+ fsProvider = transient.provider;
488
915
  }
489
916
  else {
490
- fsProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
917
+ const workspaces = await listWorkspaces();
918
+ ws = workspaces.find((w) => w.id === wsId);
919
+ if (!ws)
920
+ return null;
921
+ if (ws.type === 'electron-native') {
922
+ fsProvider = await createElectronProviderFromWorkspace(ws);
923
+ if (!fsProvider)
924
+ return null;
925
+ }
926
+ else if (ws.type === 'native') {
927
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
928
+ if (!restored)
929
+ return null;
930
+ fsProvider = restored;
931
+ }
932
+ else {
933
+ fsProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
934
+ }
491
935
  }
492
- await touchWorkspace(ws.id);
493
- setProvider(fsProvider);
494
- setActiveWorkspaceId(ws.id);
495
- if (filePath) {
496
- const content = await fsProvider.readFile(filePath);
497
- if (content !== null) {
498
- setSelectedFile(filePath);
936
+ if (!ws || !fsProvider)
937
+ return null;
938
+ let adopted = false;
939
+ try {
940
+ await touchWorkspace(ws.id);
941
+ if (requestId !== navigationRequestRef.current)
942
+ return null;
943
+ let openedFile = null;
944
+ let openedContent = '';
945
+ const transitioned = await documentSession.transitionWithLoad(async () => {
946
+ if (requestId !== navigationRequestRef.current)
947
+ return null;
948
+ if (filePath) {
949
+ const content = await readProviderText(fsProvider, filePath);
950
+ if (requestId !== navigationRequestRef.current)
951
+ return null;
952
+ if (content !== null) {
953
+ openedFile = filePath;
954
+ openedContent = content;
955
+ }
956
+ }
957
+ return {
958
+ target: openedFile ? createDocumentTarget(fsProvider, ws.id, openedFile) : null,
959
+ content: openedContent,
960
+ };
961
+ });
962
+ if (!transitioned)
963
+ return null;
964
+ // Once the session has accepted this target, ownership must follow it
965
+ // even if a newer navigation request arrived in the final microtask.
966
+ // The newer request will perform the next serialized transition.
967
+ setProvider(fsProvider);
968
+ adopted = true;
969
+ setActiveWorkspaceId(ws.id);
970
+ if (openedFile) {
971
+ setSelectedFile(openedFile);
499
972
  setSelectedFolder(null);
500
973
  setFolderEntries([]);
501
- setEditorContent(content);
502
974
  const effectiveView = view ?? 'wysiwyg';
503
975
  setInitialView(effectiveView);
504
- setEditorKey((k) => k + 1);
505
- saveLastState({ workspaceId: wsId, filePath, view: effectiveView });
976
+ // Transient workspaces are session-only; don't persist them as the
977
+ // "last opened" state (the id won't exist after a reload).
978
+ if (!transient) {
979
+ saveLastState({ workspaceId: wsId, filePath: openedFile, view: effectiveView });
980
+ }
506
981
  }
507
982
  else {
508
983
  setSelectedFile(null);
509
984
  setSelectedFolder(null);
510
985
  setFolderEntries([]);
511
- setEditorContent('');
512
- setEditorKey((k) => k + 1);
513
986
  }
987
+ if (push) {
988
+ pushHash(ws.id, openedFile);
989
+ }
990
+ return fsProvider;
514
991
  }
515
- else {
516
- setSelectedFile(null);
517
- setSelectedFolder(null);
518
- setFolderEntries([]);
519
- setEditorContent('');
520
- setEditorKey((k) => k + 1);
521
- }
522
- if (push) {
523
- pushHash(ws.id, filePath);
992
+ finally {
993
+ if (!transient && !adopted)
994
+ await getFileSystemProviderV2(fsProvider)?.dispose();
524
995
  }
525
- return fsProvider;
526
- }, [pushHash]);
527
- const seedWelcomeFile = useCallback(async (fs) => {
528
- const entries = await fs.readDirectory('/');
996
+ }, [createDocumentTarget, documentSession, pushHash]);
997
+ const seedWelcomeFile = useCallback(async (fs, navigationRequestId) => {
998
+ const isCurrent = () => navigationRequestId === undefined || navigationRequestId === navigationRequestRef.current;
999
+ if (!isCurrent())
1000
+ return;
1001
+ const entries = await readProviderDirectory(fs, '/');
1002
+ if (!isCurrent())
1003
+ return;
529
1004
  // If the only file is the welcome doc, auto-select it.
530
1005
  // Match either casing so workspaces seeded before the rename
531
1006
  // (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
@@ -533,12 +1008,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
533
1008
  entries[0].kind === 'file' &&
534
1009
  entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md') {
535
1010
  const aboutPath = entries[0].path;
536
- const content = await fs.readFile(aboutPath);
537
- if (content !== null) {
1011
+ const content = await readProviderText(fs, aboutPath);
1012
+ if (content !== null && isCurrent()) {
1013
+ await documentSession.transitionTo(createDocumentTarget(fs, fs.id, aboutPath), content);
1014
+ if (!isCurrent())
1015
+ return;
538
1016
  setSelectedFile(aboutPath);
539
- setEditorContent(content);
540
1017
  setInitialView('preview');
541
- setEditorKey((k) => k + 1);
542
1018
  setExplorerKey((k) => k + 1);
543
1019
  pushHash(fs.id, aboutPath);
544
1020
  saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
@@ -553,39 +1029,62 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
553
1029
  const welcomeContent = [
554
1030
  '# Welcome to DocBlocks',
555
1031
  '',
1032
+ 'Your markdown can do anything.',
1033
+ '',
556
1034
  'DocBlocks is a free browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser. What you write here can become a Word or PDF doc, a slide deck, an e-book, or a video.',
557
1035
  '',
558
1036
  'Simple to write. Beautiful wherever it goes.',
559
1037
  '',
560
1038
  '## Features',
561
1039
  '',
562
- '- **Rich Markdown Editing** Write in a visual editor or switch to raw markdown anytime. Use section annotations to change the visualization for blocks of content.',
563
- '- **Workspaces** Organize your documents into separate workspaces in the browser or on your device.',
564
- '- **Useful Everywhere** Your content is usable across multiple formats Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
565
- '- **Playback & Video** Preview your documents as rich visual presentations and export them as MP4 video',
566
- '- **No BS** Free, no ads, no accounts, no tracking - everything runs locally in your browser',
1040
+ '- **Rich Markdown Editing** -- Write in a visual editor or switch to raw markdown source anytime. Use section annotations to change the visualization for blocks of content.',
1041
+ '- **Workspaces** -- Organize your documents into separate workspaces in the browser or on your device.',
1042
+ '- **Useful Everywhere** -- Your content is usable across multiple formats -- Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
1043
+ '- **Playback & Video** -- Preview your documents as rich visual presentations and export them as MP4 video',
1044
+ '- **No BS** -- Free, no ads, no accounts, no tracking - everything runs locally in your browser',
567
1045
  '',
568
1046
  '## Getting Started',
569
1047
  '',
570
1048
  '1. Create a new file using the **New File** button in the sidebar',
571
- '2. Start writing in markdown the editor supports headings, lists, links, images, and more',
1049
+ '2. Start writing in markdown -- the editor supports headings, lists, links, images, and more',
572
1050
  '3. Your work is saved automatically',
573
1051
  '',
574
- 'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
1052
+ 'Built on [Squiggly Square markdown extensions](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
575
1053
  ].join('\n');
576
- await fs.writeFile(welcomePath, welcomeContent);
1054
+ let seededContent = welcomeContent;
1055
+ try {
1056
+ await writeProviderText(fs, welcomePath, welcomeContent, 'create');
1057
+ }
1058
+ catch (error) {
1059
+ if (!(error instanceof FsError && error.code === 'already-exists'))
1060
+ throw error;
1061
+ const existing = await readProviderText(fs, welcomePath);
1062
+ if (existing === null)
1063
+ throw error;
1064
+ seededContent = existing;
1065
+ }
1066
+ if (!isCurrent())
1067
+ return;
1068
+ await documentSession.transitionTo(createDocumentTarget(fs, fs.id, welcomePath), seededContent);
1069
+ if (!isCurrent())
1070
+ return;
577
1071
  setSelectedFile(welcomePath);
578
- setEditorContent(welcomeContent);
579
1072
  setInitialView('preview');
580
- setEditorKey((k) => k + 1);
581
1073
  setExplorerKey((k) => k + 1);
582
1074
  pushHash(fs.id, welcomePath);
583
1075
  saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
584
1076
  if (!isWelcomeGatewayDismissed())
585
1077
  setShowWelcomeGateway(true);
586
- }, [pushHash]);
1078
+ }, [createDocumentTarget, documentSession, pushHash]);
1079
+ // Startup is a mount lifecycle, not a reaction to callback identity. Keep
1080
+ // the latest implementations available without letting git/provider state
1081
+ // changes restart initialization and supersede an OS-open navigation.
1082
+ const startupOpenFromIdsRef = useRef(openFromIds);
1083
+ startupOpenFromIdsRef.current = openFromIds;
1084
+ const startupSeedWelcomeFileRef = useRef(seedWelcomeFile);
1085
+ startupSeedWelcomeFileRef.current = seedWelcomeFile;
587
1086
  /** Hide the welcome gateway and never show it again. Safe to call from
588
- * paths where it may not be showing only persists when it was. */
1087
+ * paths where it may not be showing -- only persists when it was. */
589
1088
  const closeWelcomeGateway = useCallback(() => {
590
1089
  setShowWelcomeGateway((showing) => {
591
1090
  if (showing)
@@ -593,26 +1092,64 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
593
1092
  return false;
594
1093
  });
595
1094
  }, []);
596
- /** Gateway CTA flip the welcome doc from Play into the editor. */
1095
+ /** Gateway CTA -- flip the welcome doc from Play into the editor. */
597
1096
  const handleStartWriting = useCallback(() => {
598
1097
  closeWelcomeGateway();
599
1098
  setInitialView('wysiwyg');
600
- setEditorKey((k) => k + 1);
1099
+ setEditorPresentationEpoch((epoch) => epoch + 1);
601
1100
  if (activeWorkspaceId && selectedFile) {
602
1101
  saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
603
1102
  }
604
1103
  }, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
605
- // Initialise workspace on mount restore from hash or last-used
1104
+ // Initialise workspace on mount -- restore from hash or last-used
606
1105
  useEffect(() => {
607
- (async () => {
1106
+ const requestId = ++navigationRequestRef.current;
1107
+ let cancelled = false;
1108
+ const isCurrent = () => !cancelled && requestId === navigationRequestRef.current;
1109
+ void (async () => {
1110
+ const electron = isElectronHost();
1111
+ let workspaceIdRemap = {};
1112
+ if (electron) {
1113
+ // Main-process persisted roots are authoritative after an upgrade or
1114
+ // collision repair. Reconcile renderer metadata before hash or
1115
+ // last-state restoration so an obsolete id cannot reopen a different
1116
+ // folder that once shared its legacy id.
1117
+ try {
1118
+ const localWorkspaces = await listWorkspaces();
1119
+ const hostWorkspaces = await getDocBlocksHost().workspaces.list();
1120
+ const reconciliation = reconcileElectronWorkspaceDescriptors(localWorkspaces, hostWorkspaces, getDocBlocksHost().env.platform, new Date().toISOString());
1121
+ for (const id of reconciliation.removeIds)
1122
+ await removeWorkspace(id);
1123
+ for (const workspace of reconciliation.upsert)
1124
+ await saveWorkspace(workspace);
1125
+ workspaceIdRemap = reconciliation.idRemap;
1126
+ }
1127
+ catch {
1128
+ workspaceAuthorityBarrier.markReady();
1129
+ if (isCurrent()) {
1130
+ setWorkspaceStartupError('DocBlocks could not verify access to your saved workspaces. Restart the app or open the folder again.');
1131
+ }
1132
+ return;
1133
+ }
1134
+ }
1135
+ workspaceAuthorityBarrier.markReady();
1136
+ if (!isCurrent())
1137
+ return;
608
1138
  // Try restoring from URL hash first
609
1139
  const hashState = parseHash();
610
1140
  if (hashState) {
611
- const restoredProvider = await openFromIds(hashState.workspaceId, hashState.filePath, false);
1141
+ const migratedWorkspaceId = workspaceIdRemap[hashState.workspaceId];
1142
+ const restoredWorkspaceId = migratedWorkspaceId ?? hashState.workspaceId;
1143
+ if (migratedWorkspaceId) {
1144
+ window.history.replaceState(null, '', buildHash(restoredWorkspaceId, hashState.filePath));
1145
+ }
1146
+ const restoredProvider = await startupOpenFromIdsRef.current(restoredWorkspaceId, hashState.filePath, false, undefined, requestId);
1147
+ if (!isCurrent())
1148
+ return;
612
1149
  if (restoredProvider) {
613
1150
  // If the hash had no file, check whether we should auto-select the welcome doc
614
1151
  if (!hashState.filePath) {
615
- await seedWelcomeFile(restoredProvider);
1152
+ await startupSeedWelcomeFileRef.current(restoredProvider, requestId);
616
1153
  }
617
1154
  return;
618
1155
  }
@@ -620,14 +1157,21 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
620
1157
  // Try restoring last viewed document from localStorage
621
1158
  const lastState = loadLastState();
622
1159
  if (lastState) {
623
- const restoredProvider = await openFromIds(lastState.workspaceId, lastState.filePath, true, lastState.view);
1160
+ const restoredWorkspaceId = workspaceIdRemap[lastState.workspaceId] ?? lastState.workspaceId;
1161
+ if (restoredWorkspaceId !== lastState.workspaceId) {
1162
+ saveLastState({ ...lastState, workspaceId: restoredWorkspaceId });
1163
+ }
1164
+ const restoredProvider = await startupOpenFromIdsRef.current(restoredWorkspaceId, lastState.filePath, true, lastState.view, requestId);
1165
+ if (!isCurrent())
1166
+ return;
624
1167
  if (restoredProvider)
625
1168
  return;
626
1169
  }
627
1170
  let fsProvider = null;
628
- const electron = isElectronHost();
629
1171
  const workspaces = await listWorkspaces();
630
- // On desktop, hide web-only workspaces (indexeddb/native) — only
1172
+ if (!isCurrent())
1173
+ return;
1174
+ // On desktop, hide web-only workspaces (indexeddb/native) -- only
631
1175
  // folder-based workspaces are valid.
632
1176
  const candidates = electron
633
1177
  ? workspaces.filter((w) => w.type === 'electron-native')
@@ -639,36 +1183,56 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
639
1183
  const p = await createElectronProviderFromWorkspace(ws);
640
1184
  if (!p)
641
1185
  continue;
1186
+ if (!isCurrent()) {
1187
+ await getFileSystemProviderV2(p)?.dispose();
1188
+ return;
1189
+ }
642
1190
  await touchWorkspace(ws.id);
1191
+ if (!isCurrent()) {
1192
+ await getFileSystemProviderV2(p)?.dispose();
1193
+ return;
1194
+ }
643
1195
  fsProvider = p;
644
- setProvider(p);
645
- setActiveWorkspaceId(ws.id);
646
1196
  break;
647
1197
  }
648
1198
  else if (ws.type === 'native') {
649
- const restored = await restoreNativeFolder(ws.id);
1199
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
650
1200
  if (restored) {
1201
+ if (!isCurrent()) {
1202
+ await getFileSystemProviderV2(restored)?.dispose();
1203
+ return;
1204
+ }
651
1205
  await touchWorkspace(ws.id);
1206
+ if (!isCurrent()) {
1207
+ await getFileSystemProviderV2(restored)?.dispose();
1208
+ return;
1209
+ }
652
1210
  fsProvider = restored;
653
- setProvider(restored);
654
- setActiveWorkspaceId(ws.id);
655
1211
  break;
656
1212
  }
657
1213
  }
658
1214
  else {
659
- const p = new IndexedDBFileSystemProvider(ws.id, ws.name);
1215
+ const p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
1216
+ if (!isCurrent()) {
1217
+ await getFileSystemProviderV2(p)?.dispose();
1218
+ return;
1219
+ }
660
1220
  await touchWorkspace(ws.id);
1221
+ if (!isCurrent()) {
1222
+ await getFileSystemProviderV2(p)?.dispose();
1223
+ return;
1224
+ }
661
1225
  fsProvider = p;
662
- setProvider(p);
663
- setActiveWorkspaceId(ws.id);
664
1226
  break;
665
1227
  }
666
1228
  }
667
1229
  if (!fsProvider) {
668
1230
  if (electron) {
669
1231
  // Desktop: ask the host for the default folder workspace
670
- // (creates ~/Documents/DocBlocks on first launch).
1232
+ // (creates DocBlocks in the OS-resolved Documents folder on first launch).
671
1233
  const info = await getDocBlocksHost().workspaces.getDefault();
1234
+ if (!isCurrent())
1235
+ return;
672
1236
  const descriptor = {
673
1237
  id: info.id,
674
1238
  name: info.name,
@@ -677,41 +1241,154 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
677
1241
  lastOpened: new Date().toISOString(),
678
1242
  };
679
1243
  await saveWorkspace(descriptor);
680
- const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
1244
+ if (!isCurrent())
1245
+ return;
1246
+ const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1247
+ if (!isCurrent()) {
1248
+ await getFileSystemProviderV2(p)?.dispose();
1249
+ return;
1250
+ }
681
1251
  fsProvider = p;
682
- setProvider(p);
683
- setActiveWorkspaceId(info.id);
684
1252
  }
685
1253
  else {
686
1254
  // Web: create the IndexedDB default workspace
687
1255
  const defaultWs = await ensureDefaultWorkspace();
688
- const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
1256
+ if (!isCurrent())
1257
+ return;
1258
+ const p = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
1259
+ if (!isCurrent()) {
1260
+ await getFileSystemProviderV2(p)?.dispose();
1261
+ return;
1262
+ }
689
1263
  fsProvider = p;
690
- setProvider(p);
691
- setActiveWorkspaceId(defaultWs.id);
692
1264
  }
693
1265
  }
694
- // Set initial hash
695
- pushHash(fsProvider.id, null);
696
- // Seed welcome file if workspace is empty
697
- await seedWelcomeFile(fsProvider);
698
- })();
699
- }, [openFromIds, pushHash, seedWelcomeFile]);
1266
+ let adopted = false;
1267
+ try {
1268
+ if (!isCurrent())
1269
+ return;
1270
+ // Set the root hash before seeding; seedWelcomeFile replaces it with
1271
+ // the welcome path when it opens that document.
1272
+ pushHash(fsProvider.id, null);
1273
+ await startupSeedWelcomeFileRef.current(fsProvider, requestId);
1274
+ if (!isCurrent())
1275
+ return;
1276
+ setProvider(fsProvider);
1277
+ setActiveWorkspaceId(fsProvider.id);
1278
+ adopted = true;
1279
+ }
1280
+ finally {
1281
+ if (!adopted)
1282
+ await getFileSystemProviderV2(fsProvider)?.dispose();
1283
+ }
1284
+ })().catch(() => {
1285
+ if (isCurrent()) {
1286
+ setWorkspaceStartupError('DocBlocks could not initialize your workspace. Restart the app or open the folder again.');
1287
+ }
1288
+ });
1289
+ return () => {
1290
+ cancelled = true;
1291
+ if (navigationRequestRef.current === requestId)
1292
+ navigationRequestRef.current += 1;
1293
+ };
1294
+ }, [pushHash, workspaceAuthorityBarrier]);
700
1295
  // Handle browser back/forward
701
1296
  useEffect(() => {
702
1297
  const onPopState = () => {
703
- if (skipPopState.current) {
704
- skipPopState.current = false;
705
- return;
706
- }
707
1298
  const hashState = parseHash();
708
1299
  if (hashState) {
709
- openFromIds(hashState.workspaceId, hashState.filePath, false);
1300
+ const requestedHash = window.location.hash;
1301
+ const restoreCurrentHash = () => {
1302
+ if (!activeWorkspaceId || window.location.hash !== requestedHash)
1303
+ return;
1304
+ window.history.replaceState(null, '', buildHash(activeWorkspaceId, selectedFile));
1305
+ };
1306
+ void openFromIds(hashState.workspaceId, hashState.filePath, false)
1307
+ .then((opened) => {
1308
+ if (!opened)
1309
+ restoreCurrentHash();
1310
+ })
1311
+ .catch((error) => {
1312
+ restoreCurrentHash();
1313
+ alert(error instanceof Error
1314
+ ? 'DocBlocks could not switch documents: ' + error.message
1315
+ : 'DocBlocks could not switch documents.');
1316
+ });
710
1317
  }
711
1318
  };
712
1319
  window.addEventListener('popstate', onPopState);
713
1320
  return () => window.removeEventListener('popstate', onPopState);
714
- }, [openFromIds]);
1321
+ }, [activeWorkspaceId, openFromIds, selectedFile]);
1322
+ useEffect(() => {
1323
+ if (!showBrowserStorageWarning || typeof navigator === 'undefined')
1324
+ return;
1325
+ const storage = navigator.storage;
1326
+ if (!storage || typeof storage.persisted !== 'function')
1327
+ return;
1328
+ let cancelled = false;
1329
+ void storage
1330
+ .persisted()
1331
+ .then((persistent) => {
1332
+ if (!cancelled)
1333
+ setBrowserStoragePersistent(persistent);
1334
+ })
1335
+ .catch(() => {
1336
+ // Ignore unsupported/denied checks; the menu action can still try persist().
1337
+ });
1338
+ return () => {
1339
+ cancelled = true;
1340
+ };
1341
+ }, [showBrowserStorageWarning]);
1342
+ // Browser PWA install plumbing. Chromium fires `beforeinstallprompt` when
1343
+ // the app is installable; stashing it (after preventDefault) lets the app
1344
+ // menu offer "Install DocBlocks…" on our terms. `appinstalled` fires for
1345
+ // any install path (menu item or the browser's own omnibox affordance).
1346
+ useEffect(() => {
1347
+ if (isElectronHost() || typeof window === 'undefined')
1348
+ return;
1349
+ const onBeforeInstallPrompt = (e) => {
1350
+ e.preventDefault();
1351
+ setInstallPromptEvent(e);
1352
+ };
1353
+ const onAppInstalled = () => {
1354
+ setInstallPromptEvent(null);
1355
+ // Chromium auto-grants persistent storage to installed apps -- re-probe
1356
+ // so the "Keep data in browser for longer" nag retires itself.
1357
+ const storage = navigator.storage;
1358
+ if (storage && typeof storage.persisted === 'function') {
1359
+ storage
1360
+ .persisted()
1361
+ .then((persistent) => {
1362
+ if (persistent)
1363
+ setBrowserStoragePersistent(true);
1364
+ })
1365
+ .catch(() => {
1366
+ // Probe is best-effort; the manual menu action still works.
1367
+ });
1368
+ }
1369
+ };
1370
+ window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
1371
+ window.addEventListener('appinstalled', onAppInstalled);
1372
+ return () => {
1373
+ window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
1374
+ window.removeEventListener('appinstalled', onAppInstalled);
1375
+ };
1376
+ }, []);
1377
+ const handleInstallApp = useCallback(async () => {
1378
+ const promptEvent = installPromptEvent;
1379
+ // prompt() is single-use: clear the stash up front and rely on the
1380
+ // browser re-firing beforeinstallprompt if the user dismisses.
1381
+ setInstallPromptEvent(null);
1382
+ if (!promptEvent)
1383
+ return;
1384
+ try {
1385
+ await promptEvent.prompt();
1386
+ await promptEvent.userChoice;
1387
+ }
1388
+ catch {
1389
+ // The prompt was already consumed or is not allowed right now.
1390
+ }
1391
+ }, [installPromptEvent]);
715
1392
  // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
716
1393
  useEffect(() => {
717
1394
  const handler = (e) => {
@@ -719,7 +1396,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
719
1396
  if (target) {
720
1397
  const view = target.getAttribute('data-view');
721
1398
  if (view) {
722
- // The user found the view tabs on their own the gateway's job is done.
1399
+ // The user found the view tabs on their own -- the gateway's job is done.
723
1400
  closeWelcomeGateway();
724
1401
  }
725
1402
  if (view && activeWorkspaceId && selectedFile) {
@@ -730,21 +1407,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
730
1407
  window.addEventListener('click', handler, true);
731
1408
  return () => window.removeEventListener('click', handler, true);
732
1409
  }, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
733
- const handleAutoSaved = useCallback((filePath, savedContent) => {
734
- lastLocalSaveRef.current = {
735
- filePath: normaliseProviderPath(filePath),
736
- content: savedContent,
737
- savedAt: Date.now(),
738
- };
739
- }, []);
740
- // Auto-save current file. The returned `flush` is called from the
741
- // Ctrl/Cmd+S handler below so the user gets immediate confirmation.
742
- const { flush: flushAutoSave } = useAutoSave(provider, selectedFile, editorContent, 500, handleAutoSaved);
743
- // Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
744
- // small "auto-save confirmed" toast. Files are already saved on every
745
- // keystroke (debounced) — this is purely UX reassurance for users who
746
- // muscle-memory hit Save.
747
- const [saveToastVisible, setSaveToastVisible] = useState(false);
1410
+ // Ctrl/Cmd+S is a real acknowledgement of the session revision, not an
1411
+ // optimistic toast. A failed commit remains dirty and is surfaced.
1412
+ const [saveToast, setSaveToast] = useState(null);
748
1413
  const saveToastTimerRef = useRef(null);
749
1414
  useEffect(() => {
750
1415
  const onKey = (e) => {
@@ -754,21 +1419,89 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
754
1419
  return;
755
1420
  e.preventDefault();
756
1421
  e.stopPropagation();
757
- void flushAutoSave().catch(() => undefined);
758
- setSaveToastVisible(true);
759
- if (saveToastTimerRef.current)
760
- clearTimeout(saveToastTimerRef.current);
761
- saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
1422
+ void (async () => {
1423
+ try {
1424
+ await documentSession.flush('manual');
1425
+ setSaveToast({ kind: 'success', message: 'Saved. You’re all set.' });
1426
+ }
1427
+ catch (error) {
1428
+ setSaveToast({
1429
+ kind: 'error',
1430
+ message: isQuotaExceededError(error)
1431
+ ? 'Could not save -- browser storage is full. Free up space or back up your work.'
1432
+ : error instanceof Error
1433
+ ? error.message
1434
+ : 'Could not save this document.',
1435
+ });
1436
+ }
1437
+ if (saveToastTimerRef.current)
1438
+ clearTimeout(saveToastTimerRef.current);
1439
+ saveToastTimerRef.current = setTimeout(() => setSaveToast(null), 3000);
1440
+ })();
762
1441
  };
763
1442
  window.addEventListener('keydown', onKey, true);
764
1443
  return () => window.removeEventListener('keydown', onKey, true);
765
- }, [flushAutoSave]);
1444
+ }, [documentSession]);
766
1445
  useEffect(() => {
767
1446
  return () => {
768
1447
  if (saveToastTimerRef.current)
769
1448
  clearTimeout(saveToastTimerRef.current);
770
1449
  };
771
1450
  }, []);
1451
+ // One-time passive notice when the host reports the app is fully cached
1452
+ // for offline use. Mirrors the save toast's look and self-dismissal.
1453
+ const [offlineReadyToast, setOfflineReadyToast] = useState(false);
1454
+ const offlineReadyAnnouncedRef = useRef(false);
1455
+ useEffect(() => {
1456
+ if (!offlineReady || offlineReadyAnnouncedRef.current)
1457
+ return;
1458
+ offlineReadyAnnouncedRef.current = true;
1459
+ setOfflineReadyToast(true);
1460
+ const timer = setTimeout(() => setOfflineReadyToast(false), 3000);
1461
+ return () => clearTimeout(timer);
1462
+ }, [offlineReady]);
1463
+ // Quota-exhaustion banner. The session clears `error` on every keystroke
1464
+ // and at each retry, so the raw snapshot flickers during a failing
1465
+ // type/retry loop -- latch it, and release only when a commit succeeds.
1466
+ // Known gap: version-snapshot writes (squisq's DocumentVersionManager)
1467
+ // swallow their own failures upstream (`SaveVersionResult` has no error
1468
+ // variant), but they hit the same provider, so the primary autosave
1469
+ // latches this banner within one edit cycle anyway.
1470
+ const [storageFull, setStorageFull] = useState(false);
1471
+ useEffect(() => {
1472
+ if (documentSnapshot.error && isQuotaExceededError(documentSnapshot.error)) {
1473
+ setStorageFull(true);
1474
+ }
1475
+ else if (documentSnapshot.status === 'saved') {
1476
+ setStorageFull(false);
1477
+ }
1478
+ }, [documentSnapshot.error, documentSnapshot.status]);
1479
+ // Ask the browser to make origin storage durable the first time real work
1480
+ // is saved (browser surface only, once per session). Chromium decides
1481
+ // silently -- installed apps are auto-granted; Firefox may show one prompt
1482
+ // at this meaningful moment rather than at page load.
1483
+ const autoPersistRequestedRef = useRef(false);
1484
+ useEffect(() => {
1485
+ if (!showBrowserStorageWarning || browserStoragePersistent)
1486
+ return;
1487
+ if (autoPersistRequestedRef.current)
1488
+ return;
1489
+ if (documentSnapshot.status !== 'saved')
1490
+ return;
1491
+ const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
1492
+ if (!storage || typeof storage.persist !== 'function')
1493
+ return;
1494
+ autoPersistRequestedRef.current = true;
1495
+ storage
1496
+ .persist()
1497
+ .then((granted) => {
1498
+ if (granted)
1499
+ setBrowserStoragePersistent(true);
1500
+ })
1501
+ .catch(() => {
1502
+ // Best-effort; the manual menu action remains available.
1503
+ });
1504
+ }, [showBrowserStorageWarning, browserStoragePersistent, documentSnapshot.status]);
772
1505
  // Per-file media: for `notes.md` images live in `notes_files/` beside it.
773
1506
  // Rebuilds whenever the provider or selected file changes.
774
1507
  useEffect(() => {
@@ -793,9 +1526,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
793
1526
  return () => {
794
1527
  mp.dispose();
795
1528
  };
796
- }, [provider, selectedFile]);
1529
+ }, [provider, selectedFile, mediaEpoch]);
797
1530
  // Invalidate the document-link candidate cache when the backing
798
- // workspace changes otherwise the link dialog would surface
1531
+ // workspace changes -- otherwise the link dialog would surface
799
1532
  // neighbours from a previously-open workspace.
800
1533
  useEffect(() => {
801
1534
  mdFileCacheRef.current = null;
@@ -855,66 +1588,244 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
855
1588
  }, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
856
1589
  // React to external file changes watched by the Electron host (chokidar).
857
1590
  useEffect(() => {
858
- if (!isElectronHost())
1591
+ if (!provider)
859
1592
  return;
860
- if (!provider || !(provider instanceof ElectronFileSystemProvider))
1593
+ const providerV2 = getFileSystemProviderV2(provider);
1594
+ if (!providerV2?.capabilities.watch)
861
1595
  return;
862
- const unwatch = provider.watch((changedPath) => {
863
- setExplorerKey((k) => k + 1);
864
- if (!selectedFile || !sameProviderPath(changedPath, selectedFile))
1596
+ if (!selectedFile || !documentSnapshot.targetKey)
1597
+ return;
1598
+ const watchedFile = selectedFile;
1599
+ const targetKey = documentSnapshot.targetKey;
1600
+ let disposed = false;
1601
+ let reading = false;
1602
+ let rerun = false;
1603
+ let observationSequence = 0;
1604
+ const drainWatcherReads = async () => {
1605
+ if (reading) {
1606
+ rerun = true;
865
1607
  return;
866
- // If the open file's contents changed on disk, reload it (best-effort).
867
- (async () => {
868
- const content = await provider.readFile(selectedFile);
869
- if (content === null || content === editorContent)
870
- return;
871
- const localSave = lastLocalSaveRef.current;
872
- if (localSave &&
873
- sameProviderPath(localSave.filePath, selectedFile) &&
874
- localSave.content === content &&
875
- Date.now() - localSave.savedAt < 5000) {
876
- return;
877
- }
878
- setEditorContent(content);
879
- setEditorKey((k) => k + 1);
880
- })();
1608
+ }
1609
+ reading = true;
1610
+ try {
1611
+ do {
1612
+ rerun = false;
1613
+ const sequence = ++observationSequence;
1614
+ const external = await readStableFileSnapshot(provider, watchedFile);
1615
+ if (disposed)
1616
+ return;
1617
+ const result = documentSession.observeExternal({
1618
+ targetKey,
1619
+ sequence,
1620
+ content: external.content,
1621
+ version: external.version,
1622
+ });
1623
+ if (result === 'applied') {
1624
+ // observeExternal increments the session generation. The editor
1625
+ // remount key and content both derive from that snapshot.
1626
+ }
1627
+ } while (rerun && !disposed);
1628
+ }
1629
+ catch {
1630
+ // The next watcher event will retry a file that was changing too
1631
+ // quickly to obtain a stable content/metadata pair.
1632
+ }
1633
+ finally {
1634
+ reading = false;
1635
+ if (rerun && !disposed)
1636
+ void drainWatcherReads();
1637
+ }
1638
+ };
1639
+ const subscription = providerV2.watch((event) => {
1640
+ if (event.type === 'overflow') {
1641
+ void drainWatcherReads();
1642
+ return;
1643
+ }
1644
+ const changedCurrent = sameProviderPath(event.path, watchedFile);
1645
+ const changedDestination = event.destinationPath !== null && sameProviderPath(event.destinationPath, watchedFile);
1646
+ if (changedCurrent || changedDestination)
1647
+ void drainWatcherReads();
1648
+ }, {
1649
+ onError: () => {
1650
+ // Re-read the active file on watcher failure. The session decides
1651
+ // whether the result is a clean update or an external conflict.
1652
+ void drainWatcherReads();
1653
+ },
881
1654
  });
882
- return unwatch;
883
- }, [provider, selectedFile, editorContent]);
1655
+ void subscription.ready.catch(() => undefined);
1656
+ return () => {
1657
+ disposed = true;
1658
+ void subscription.dispose();
1659
+ };
1660
+ }, [provider, selectedFile, documentSession, documentSnapshot.targetKey]);
1661
+ const transitionAwayFromDocument = useCallback(async (requestId) => {
1662
+ if (requestId !== navigationRequestRef.current)
1663
+ return false;
1664
+ try {
1665
+ const transitioned = await documentSession.transitionWithLoad(async () => requestId === navigationRequestRef.current ? { target: null, content: '' } : null);
1666
+ if (!transitioned || requestId !== navigationRequestRef.current)
1667
+ return false;
1668
+ return true;
1669
+ }
1670
+ catch (error) {
1671
+ alert(error instanceof Error
1672
+ ? 'DocBlocks could not save this document: ' + error.message
1673
+ : 'DocBlocks could not save this document.');
1674
+ return false;
1675
+ }
1676
+ }, [documentSession]);
1677
+ const handleUseExternalDocument = useCallback(async () => {
1678
+ const conflictKey = documentSession.getSnapshot().conflict?.targetKey;
1679
+ const pendingDbk = conflictKey ? pendingDbkConflictsRef.current.get(conflictKey) : undefined;
1680
+ try {
1681
+ if (pendingDbk) {
1682
+ pendingDbk.provider.replaceContents(pendingDbk.snapshot);
1683
+ pendingDbkConflictsRef.current.delete(conflictKey);
1684
+ mdFileCacheRef.current = null;
1685
+ setMediaEpoch((epoch) => epoch + 1);
1686
+ setExplorerKey((key) => key + 1);
1687
+ }
1688
+ const snapshot = await documentSession.resolveConflict('use-external');
1689
+ if (!snapshot.targetKey) {
1690
+ setSelectedFile(null);
1691
+ if (activeWorkspaceId)
1692
+ pushHash(activeWorkspaceId, null);
1693
+ }
1694
+ }
1695
+ catch (error) {
1696
+ setSaveToast({
1697
+ kind: 'error',
1698
+ message: error instanceof Error ? error.message : 'Could not reload the external document.',
1699
+ });
1700
+ }
1701
+ }, [activeWorkspaceId, documentSession, pushHash]);
1702
+ const handleKeepLocalDocument = useCallback(async () => {
1703
+ const conflictKey = documentSession.getSnapshot().conflict?.targetKey;
1704
+ try {
1705
+ await documentSession.resolveConflict('use-local');
1706
+ if (conflictKey)
1707
+ pendingDbkConflictsRef.current.delete(conflictKey);
1708
+ setSaveToast({ kind: 'success', message: 'Your version was saved.' });
1709
+ }
1710
+ catch (error) {
1711
+ setSaveToast({
1712
+ kind: 'error',
1713
+ message: error instanceof Error ? error.message : 'Could not save your version.',
1714
+ });
1715
+ }
1716
+ }, [documentSession]);
1717
+ useEffect(() => {
1718
+ const needsWarning = ['dirty', 'saving', 'error', 'conflict'].includes(documentSnapshot.status);
1719
+ if (!documentSnapshot.targetKey)
1720
+ return;
1721
+ const flushBestEffort = () => {
1722
+ void documentSession.flush('close').catch(() => {
1723
+ // A browser cannot await asynchronous storage during teardown. The
1724
+ // beforeunload warning remains active when persistence is unresolved.
1725
+ });
1726
+ };
1727
+ const onBeforeUnload = (event) => {
1728
+ flushBestEffort();
1729
+ if (!needsWarning)
1730
+ return;
1731
+ event.preventDefault();
1732
+ event.returnValue = '';
1733
+ };
1734
+ const onPageHide = () => flushBestEffort();
1735
+ const onVisibilityChange = () => {
1736
+ if (document.visibilityState === 'hidden')
1737
+ flushBestEffort();
1738
+ };
1739
+ window.addEventListener('beforeunload', onBeforeUnload);
1740
+ window.addEventListener('pagehide', onPageHide);
1741
+ document.addEventListener('visibilitychange', onVisibilityChange);
1742
+ return () => {
1743
+ window.removeEventListener('beforeunload', onBeforeUnload);
1744
+ window.removeEventListener('pagehide', onPageHide);
1745
+ document.removeEventListener('visibilitychange', onVisibilityChange);
1746
+ };
1747
+ }, [documentSession, documentSnapshot.status, documentSnapshot.targetKey]);
1748
+ // Electron can defer window destruction and await this acknowledgement.
1749
+ // Browser unload remains best-effort, which is why continuous autosave is
1750
+ // still the primary durability mechanism.
1751
+ useEffect(() => {
1752
+ if (!isElectronHost())
1753
+ return;
1754
+ const lifecycle = getDocBlocksHost().lifecycle;
1755
+ const stopPrepare = lifecycle.onPrepareClose(async (request) => {
1756
+ preparedCloseRequestRef.current = request.requestId;
1757
+ try {
1758
+ const snapshot = await documentSession.prepareClose();
1759
+ return { status: 'ready', persistedRevision: snapshot.persistedRevision };
1760
+ }
1761
+ catch (error) {
1762
+ preparedCloseRequestRef.current = null;
1763
+ return {
1764
+ status: 'blocked',
1765
+ code: error instanceof DocumentSessionConflictError
1766
+ ? 'external-conflict'
1767
+ : 'save-failed',
1768
+ message: error instanceof Error ? error.message : 'Could not save the document.',
1769
+ };
1770
+ }
1771
+ });
1772
+ const stopCancel = lifecycle.onCancelClose((requestId) => {
1773
+ if (preparedCloseRequestRef.current !== requestId)
1774
+ return;
1775
+ preparedCloseRequestRef.current = null;
1776
+ documentSession.cancelClose();
1777
+ });
1778
+ return () => {
1779
+ stopPrepare();
1780
+ stopCancel();
1781
+ };
1782
+ }, [documentSession]);
884
1783
  const handleWorkspaceSelect = useCallback(async (ws) => {
1784
+ const requestId = ++navigationRequestRef.current;
885
1785
  await touchWorkspace(ws.id);
1786
+ if (requestId !== navigationRequestRef.current)
1787
+ return;
886
1788
  let nextProvider = null;
887
- if (ws.type === 'electron-native') {
1789
+ const transient = getTransientWorkspace(ws.id);
1790
+ if (transient) {
1791
+ nextProvider = transient.provider;
1792
+ }
1793
+ else if (ws.type === 'electron-native') {
888
1794
  nextProvider = await createElectronProviderFromWorkspace(ws);
889
1795
  if (!nextProvider)
890
1796
  return;
891
1797
  }
892
1798
  else if (ws.type === 'native') {
893
- const restored = await restoreNativeFolder(ws.id);
1799
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
894
1800
  if (!restored) {
895
- // Permission denied or handle lost fall through without changing provider
1801
+ // Permission denied or handle lost -- fall through without changing provider
896
1802
  return;
897
1803
  }
898
1804
  nextProvider = restored;
899
1805
  }
900
1806
  else {
901
- nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
1807
+ nextProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
902
1808
  }
1809
+ if (requestId !== navigationRequestRef.current)
1810
+ return;
1811
+ if (!(await transitionAwayFromDocument(requestId)))
1812
+ return;
903
1813
  setProvider(nextProvider);
904
1814
  setActiveWorkspaceId(ws.id);
905
1815
  setSelectedFile(null);
906
1816
  setSelectedFolder(null);
907
1817
  setFolderEntries([]);
908
- setEditorContent('');
909
- setEditorKey((k) => k + 1);
910
1818
  pushHash(ws.id, null);
911
- }, [pushHash]);
1819
+ }, [pushHash, transitionAwayFromDocument]);
912
1820
  const handleOpenFolder = useCallback(async () => {
1821
+ const requestId = ++navigationRequestRef.current;
913
1822
  try {
914
1823
  if (isElectronHost()) {
915
1824
  const info = await getDocBlocksHost().workspaces.pickFolder();
916
1825
  if (!info)
917
1826
  return; // user cancelled
1827
+ if (requestId !== navigationRequestRef.current)
1828
+ return;
918
1829
  const descriptor = {
919
1830
  id: info.id,
920
1831
  name: info.name,
@@ -923,18 +1834,22 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
923
1834
  lastOpened: new Date().toISOString(),
924
1835
  };
925
1836
  await saveWorkspace(descriptor);
926
- const provider = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
1837
+ if (requestId !== navigationRequestRef.current)
1838
+ return;
1839
+ const provider = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1840
+ if (!(await transitionAwayFromDocument(requestId)))
1841
+ return;
927
1842
  setProvider(provider);
928
1843
  setActiveWorkspaceId(descriptor.id);
929
1844
  setSelectedFile(null);
930
1845
  setSelectedFolder(null);
931
1846
  setFolderEntries([]);
932
- setEditorContent('');
933
- setEditorKey((k) => k + 1);
934
1847
  pushHash(descriptor.id, null);
935
1848
  return;
936
1849
  }
937
- const nativeProvider = await openNativeFolder();
1850
+ const nativeProvider = await (await loadNativeFileSystem()).openNativeFolder();
1851
+ if (requestId !== navigationRequestRef.current)
1852
+ return;
938
1853
  const descriptor = {
939
1854
  id: nativeProvider.id,
940
1855
  name: nativeProvider.label,
@@ -942,19 +1857,47 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
942
1857
  lastOpened: new Date().toISOString(),
943
1858
  };
944
1859
  await saveWorkspace(descriptor);
1860
+ if (requestId !== navigationRequestRef.current)
1861
+ return;
1862
+ if (!(await transitionAwayFromDocument(requestId)))
1863
+ return;
945
1864
  setProvider(nativeProvider);
946
1865
  setActiveWorkspaceId(descriptor.id);
947
1866
  setSelectedFile(null);
948
1867
  setSelectedFolder(null);
949
1868
  setFolderEntries([]);
950
- setEditorContent('');
951
- setEditorKey((k) => k + 1);
952
1869
  pushHash(descriptor.id, null);
953
1870
  }
954
1871
  catch {
955
1872
  // User cancelled or API not supported
956
1873
  }
957
- }, [pushHash]);
1874
+ }, [pushHash, transitionAwayFromDocument]);
1875
+ // A clone finished: the host already registered + persisted the folder,
1876
+ // so opening it mirrors the pickFolder success path exactly.
1877
+ const handleWorkspaceCloned = useCallback((info) => {
1878
+ const requestId = ++navigationRequestRef.current;
1879
+ void (async () => {
1880
+ const descriptor = {
1881
+ id: info.id,
1882
+ name: info.name,
1883
+ type: 'electron-native',
1884
+ rootPath: info.rootPath,
1885
+ lastOpened: new Date().toISOString(),
1886
+ };
1887
+ await saveWorkspace(descriptor);
1888
+ if (requestId !== navigationRequestRef.current)
1889
+ return;
1890
+ const cloneProvider = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1891
+ if (!(await transitionAwayFromDocument(requestId)))
1892
+ return;
1893
+ setProvider(cloneProvider);
1894
+ setActiveWorkspaceId(descriptor.id);
1895
+ setSelectedFile(null);
1896
+ setSelectedFolder(null);
1897
+ setFolderEntries([]);
1898
+ pushHash(descriptor.id, null);
1899
+ })();
1900
+ }, [pushHash, transitionAwayFromDocument]);
958
1901
  const handleNewFile = useCallback(async () => {
959
1902
  if (!provider)
960
1903
  return;
@@ -962,23 +1905,90 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
962
1905
  if (!name)
963
1906
  return;
964
1907
  const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
965
- await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
966
- setSelectedFile('/' + filename);
967
- setEditorContent('# ' + filename.replace(/\.[^.]+$/, '') + '\n');
1908
+ const path = '/' + filename;
1909
+ const content = '# ' + filename.replace(/\.[^.]+$/, '') + '\n';
1910
+ const requestId = ++navigationRequestRef.current;
1911
+ try {
1912
+ const transitioned = await documentSession.transitionWithLoad(async () => {
1913
+ if (requestId !== navigationRequestRef.current)
1914
+ return null;
1915
+ const providerV2 = getFileSystemProviderV2(provider);
1916
+ if (providerV2) {
1917
+ try {
1918
+ await writeProviderText(provider, path, content, 'create');
1919
+ }
1920
+ catch (error) {
1921
+ if (error instanceof FsError && error.code === 'already-exists') {
1922
+ throw new Error('A document with that name already exists.');
1923
+ }
1924
+ throw error;
1925
+ }
1926
+ }
1927
+ else if (provider.commitFile) {
1928
+ const result = await provider.commitFile(path, content, null);
1929
+ if (result.status === 'conflict') {
1930
+ throw new Error('A document with that name already exists.');
1931
+ }
1932
+ }
1933
+ else {
1934
+ if (await providerEntryExists(provider, path)) {
1935
+ throw new Error('A document with that name already exists.');
1936
+ }
1937
+ await writeProviderText(provider, path, content);
1938
+ }
1939
+ if (requestId !== navigationRequestRef.current)
1940
+ return null;
1941
+ return {
1942
+ target: createDocumentTarget(provider, activeWorkspaceId ?? provider.id, path),
1943
+ content,
1944
+ };
1945
+ });
1946
+ if (!transitioned)
1947
+ return;
1948
+ }
1949
+ catch (error) {
1950
+ alert(error instanceof Error ? error.message : 'Could not create the document.');
1951
+ return;
1952
+ }
1953
+ setSelectedFile(path);
968
1954
  setInitialView('wysiwyg');
969
- setEditorKey((k) => k + 1);
970
1955
  setExplorerKey((k) => k + 1);
971
1956
  closeWelcomeGateway();
972
1957
  if (activeWorkspaceId) {
973
1958
  pushHash(activeWorkspaceId, '/' + filename);
974
1959
  }
975
- }, [provider, activeWorkspaceId, pushHash, closeWelcomeGateway]);
1960
+ }, [
1961
+ provider,
1962
+ activeWorkspaceId,
1963
+ pushHash,
1964
+ closeWelcomeGateway,
1965
+ createDocumentTarget,
1966
+ documentSession,
1967
+ ]);
1968
+ // Manifest shortcut "New document" launches `/?action=new` (installed
1969
+ // PWA jump list). Handled once the first workspace/provider is ready,
1970
+ // then stripped from the URL so a reload doesn't re-trigger it.
1971
+ const actionNewHandledRef = useRef(false);
1972
+ useEffect(() => {
1973
+ if (!provider || actionNewHandledRef.current)
1974
+ return;
1975
+ if (typeof window === 'undefined')
1976
+ return;
1977
+ const params = new URLSearchParams(window.location.search);
1978
+ if (params.get('action') !== 'new')
1979
+ return;
1980
+ actionNewHandledRef.current = true;
1981
+ params.delete('action');
1982
+ const query = params.toString();
1983
+ window.history.replaceState(null, '', window.location.pathname + (query ? `?${query}` : '') + window.location.hash);
1984
+ void handleNewFile();
1985
+ }, [provider, handleNewFile]);
976
1986
  const handleRevealWorkspace = useCallback(async () => {
977
1987
  if (!isElectronHost() || !activeWorkspaceId)
978
1988
  return;
979
1989
  const ws = await getWorkspace(activeWorkspaceId);
980
1990
  if (ws?.type === 'electron-native' && ws.rootPath) {
981
- await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
1991
+ await getDocBlocksHost().shell.revealInFolder(ws.id);
982
1992
  }
983
1993
  }, [activeWorkspaceId]);
984
1994
  // Subscribe to native menu commands (Electron host).
@@ -997,6 +2007,46 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
997
2007
  case 'file:revealWorkspace':
998
2008
  handleRevealWorkspace();
999
2009
  break;
2010
+ case 'git:commit':
2011
+ if (gitRef.current.repo)
2012
+ gitRef.current.openDialog({ kind: 'commit' });
2013
+ break;
2014
+ case 'git:push':
2015
+ if (gitRef.current.repo)
2016
+ void gitRef.current.push();
2017
+ break;
2018
+ case 'git:pull':
2019
+ if (gitRef.current.repo)
2020
+ void gitRef.current.pull();
2021
+ break;
2022
+ case 'git:fetch':
2023
+ if (gitRef.current.repo)
2024
+ void gitRef.current.fetchRemote();
2025
+ break;
2026
+ case 'git:newBranch':
2027
+ if (gitRef.current.repo) {
2028
+ gitRef.current.openDialog({ kind: 'branches', createFocus: true });
2029
+ }
2030
+ break;
2031
+ case 'git:switchBranch':
2032
+ if (gitRef.current.repo)
2033
+ gitRef.current.openDialog({ kind: 'branches' });
2034
+ break;
2035
+ case 'git:history':
2036
+ if (gitRef.current.repo)
2037
+ gitRef.current.openDialog({ kind: 'history' });
2038
+ break;
2039
+ case 'git:clone':
2040
+ if (gitRef.current.available)
2041
+ gitRef.current.openDialog({ kind: 'clone' });
2042
+ break;
2043
+ case 'git:openOnRemote':
2044
+ gitRef.current.openOnRemote();
2045
+ break;
2046
+ case 'git:createPullRequest':
2047
+ if (gitRef.current.repo)
2048
+ void gitRef.current.createPullRequest();
2049
+ break;
1000
2050
  case 'help:viewOnGitHub':
1001
2051
  host.shell.openExternal('https://github.com/bendyline/docblocks');
1002
2052
  break;
@@ -1016,58 +2066,126 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1016
2066
  }
1017
2067
  });
1018
2068
  }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
1019
- // Subscribe to open-file / deep-link requests from the OS.
1020
- useEffect(() => {
1021
- if (!isElectronHost())
1022
- return;
1023
- const host = getDocBlocksHost();
1024
- return host.onOpenRequest(async (req) => {
1025
- await openFromIds(req.workspaceId, req.path, true);
1026
- });
1027
- }, [openFromIds]);
2069
+ // OS open-file / deep-link handling lives after the container helpers it
2070
+ // depends on -- see the `openTransient` + onOpenRequest effect below.
1028
2071
  const handleSelect = useCallback(async (path, kind) => {
1029
2072
  if (!provider || !activeWorkspaceId)
1030
2073
  return;
2074
+ const requestId = ++navigationRequestRef.current;
1031
2075
  if (kind === 'directory') {
2076
+ if (!(await transitionAwayFromDocument(requestId)))
2077
+ return;
2078
+ if (requestId !== navigationRequestRef.current)
2079
+ return;
1032
2080
  setSelectedFile(null);
1033
2081
  setSelectedFolder(path);
1034
- const entries = await provider.readDirectory(path);
2082
+ const entries = await readProviderDirectory(provider, path);
2083
+ if (requestId !== navigationRequestRef.current)
2084
+ return;
1035
2085
  setFolderEntries(entries);
1036
- setEditorContent('');
1037
- setEditorKey((k) => k + 1);
1038
2086
  pushHash(activeWorkspaceId, null);
1039
2087
  }
1040
2088
  else {
1041
- const content = await provider.readFile(path);
2089
+ let content = null;
2090
+ try {
2091
+ const transitioned = await documentSession.transitionWithLoad(async () => {
2092
+ if (requestId !== navigationRequestRef.current)
2093
+ return null;
2094
+ content = await readProviderText(provider, path);
2095
+ if (requestId !== navigationRequestRef.current || content === null)
2096
+ return null;
2097
+ return {
2098
+ target: createDocumentTarget(provider, activeWorkspaceId, path),
2099
+ content,
2100
+ };
2101
+ });
2102
+ if (!transitioned)
2103
+ return;
2104
+ }
2105
+ catch (error) {
2106
+ alert(error instanceof Error ? error.message : 'Could not switch documents.');
2107
+ return;
2108
+ }
2109
+ if (requestId !== navigationRequestRef.current)
2110
+ return;
1042
2111
  setSelectedFile(path);
1043
2112
  setSelectedFolder(null);
1044
2113
  setFolderEntries([]);
1045
- setEditorContent(content ?? '');
1046
2114
  setInitialView('wysiwyg');
1047
- setEditorKey((k) => k + 1);
1048
2115
  closeWelcomeGateway();
1049
2116
  pushHash(activeWorkspaceId, path);
1050
2117
  saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
1051
2118
  if (effectiveCompact)
1052
2119
  setMobileShowEditor(true);
1053
2120
  }
1054
- }, [provider, activeWorkspaceId, pushHash, effectiveCompact, closeWelcomeGateway]);
1055
- const handleTreeChange = useCallback(async () => {
2121
+ }, [
2122
+ provider,
2123
+ activeWorkspaceId,
2124
+ pushHash,
2125
+ effectiveCompact,
2126
+ closeWelcomeGateway,
2127
+ createDocumentTarget,
2128
+ documentSession,
2129
+ transitionAwayFromDocument,
2130
+ ]);
2131
+ const handleTreeMutation = useCallback(async (change, mutate) => {
2132
+ if (!provider || !activeWorkspaceId || !selectedFile) {
2133
+ await mutate();
2134
+ return;
2135
+ }
2136
+ if (change.type === 'move') {
2137
+ const nextFile = relocateProviderPath(selectedFile, change.oldPath, change.newPath);
2138
+ if (nextFile !== selectedFile) {
2139
+ await documentSession.retarget(createDocumentTarget(provider, activeWorkspaceId, nextFile), mutate);
2140
+ return;
2141
+ }
2142
+ }
2143
+ if (change.type === 'delete' && pathContains(change.path, selectedFile)) {
2144
+ await documentSession.delete(mutate);
2145
+ return;
2146
+ }
2147
+ await mutate();
2148
+ }, [provider, activeWorkspaceId, selectedFile, documentSession, createDocumentTarget]);
2149
+ const handleTreeChange = useCallback(async (change) => {
1056
2150
  if (!provider)
1057
2151
  return;
2152
+ if (change?.type === 'move' && change.oldPath && change.newPath) {
2153
+ const nextFile = selectedFile
2154
+ ? relocateProviderPath(selectedFile, change.oldPath, change.newPath)
2155
+ : null;
2156
+ const nextFolder = selectedFolder
2157
+ ? relocateProviderPath(selectedFolder, change.oldPath, change.newPath)
2158
+ : null;
2159
+ if (nextFile !== selectedFile) {
2160
+ setSelectedFile(nextFile);
2161
+ if (activeWorkspaceId) {
2162
+ pushHash(activeWorkspaceId, nextFile);
2163
+ if (nextFile) {
2164
+ saveLastState({
2165
+ workspaceId: activeWorkspaceId,
2166
+ filePath: nextFile,
2167
+ view: 'wysiwyg',
2168
+ });
2169
+ }
2170
+ }
2171
+ }
2172
+ if (nextFolder !== selectedFolder)
2173
+ setSelectedFolder(nextFolder);
2174
+ if (nextFolder)
2175
+ setFolderEntries(await readProviderDirectory(provider, nextFolder));
2176
+ return;
2177
+ }
1058
2178
  // If the open file was deleted, clear the editor
1059
2179
  if (selectedFile) {
1060
- const exists = await provider.exists(selectedFile);
2180
+ const exists = await providerEntryExists(provider, selectedFile);
1061
2181
  if (!exists) {
1062
2182
  setSelectedFile(null);
1063
- setEditorContent('');
1064
- setEditorKey((k) => k + 1);
1065
2183
  if (activeWorkspaceId)
1066
2184
  pushHash(activeWorkspaceId, null);
1067
2185
  }
1068
2186
  }
1069
2187
  if (selectedFolder) {
1070
- const entries = await provider.readDirectory(selectedFolder);
2188
+ const entries = await readProviderDirectory(provider, selectedFolder);
1071
2189
  setFolderEntries(entries);
1072
2190
  }
1073
2191
  }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
@@ -1081,6 +2199,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1081
2199
  const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
1082
2200
  const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
1083
2201
  const entries = await source.listFiles();
2202
+ const targetV2 = getFileSystemProviderV2(target);
1084
2203
  for (const entry of entries) {
1085
2204
  if (entry.path.endsWith('.md'))
1086
2205
  continue;
@@ -1088,7 +2207,16 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1088
2207
  if (!data)
1089
2208
  continue;
1090
2209
  const cleanPath = entry.path.replace(/^\/+/, '');
1091
- await target.writeBinary(`${mediaRoot}/${cleanPath}`, new Uint8Array(data));
2210
+ const destination = `${mediaRoot}/${cleanPath}`;
2211
+ if (targetV2) {
2212
+ await targetV2.writeFile(parseWorkspacePath(destination), data, {
2213
+ mode: 'upsert',
2214
+ createParents: true,
2215
+ });
2216
+ }
2217
+ else {
2218
+ await target.writeBinary(destination, data);
2219
+ }
1092
2220
  }
1093
2221
  }, []);
1094
2222
  const handleImportFiles = useCallback(async (files) => {
@@ -1124,7 +2252,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1124
2252
  else {
1125
2253
  continue;
1126
2254
  }
1127
- await provider.writeFile(destPath, markdown);
2255
+ await writeProviderText(provider, destPath, markdown);
1128
2256
  }
1129
2257
  catch (err) {
1130
2258
  console.error(`Failed to import ${file.name}:`, err);
@@ -1134,8 +2262,16 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1134
2262
  setExplorerKey((k) => k + 1);
1135
2263
  }, [provider, persistImportedMedia]);
1136
2264
  const handleEditorChange = useCallback((source) => {
1137
- setEditorContent(source);
1138
- }, []);
2265
+ if (!editorSessionScope)
2266
+ return;
2267
+ try {
2268
+ documentSession.edit(source, editorSessionScope);
2269
+ }
2270
+ catch {
2271
+ // A close/transition has frozen the session. Do not let a late editor
2272
+ // callback create view state that cannot be persisted.
2273
+ }
2274
+ }, [documentSession, editorSessionScope]);
1139
2275
  const handleRenameWorkspace = useCallback(async () => {
1140
2276
  if (!activeWorkspaceId)
1141
2277
  return;
@@ -1146,45 +2282,210 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1146
2282
  if (!newName || newName === ws.name)
1147
2283
  return;
1148
2284
  await saveWorkspace({ ...ws, name: newName });
1149
- // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
1150
- setEditorKey((k) => k + 1);
2285
+ setDescriptorRefreshKey((key) => key + 1);
1151
2286
  }, [activeWorkspaceId]);
1152
2287
  /**
1153
- * Walk a FileSystemProvider and copy every file into `container` under
1154
- * `pathPrefix` (no leading slash; empty string for the root). Used by
1155
- * both single- and all-workspace downloads.
2288
+ * Open a loose file or `.dbk` bundle delivered by the OS into a session-only
2289
+ * *transient* workspace backed by an in-memory provider. Loose files save
2290
+ * straight back to disk; bundles are re-zipped by the session commit target.
1156
2291
  */
1157
- const copyProviderToContainer = useCallback(async (src, container, pathPrefix) => {
1158
- const encoder = new TextEncoder();
1159
- const stack = ['/'];
1160
- while (stack.length > 0) {
1161
- const dir = stack.pop();
1162
- const entries = await src.readDirectory(dir);
1163
- for (const entry of entries) {
1164
- if (entry.kind === 'directory') {
1165
- stack.push(entry.path);
1166
- continue;
2292
+ const openTransient = useCallback(async (req, navigationRequestId) => {
2293
+ const isCurrent = () => navigationRequestId === navigationRequestRef.current;
2294
+ const host = getDocBlocksHost();
2295
+ const revokeAbandonedResource = () => host.external.revoke(req.resourceId).catch(() => undefined);
2296
+ if (!isCurrent()) {
2297
+ await revokeAbandonedResource();
2298
+ return;
2299
+ }
2300
+ const id = `transient-${req.kind}-${req.resourceId}`;
2301
+ let mem;
2302
+ try {
2303
+ mem = await createMemoryFileSystemProvider(id, req.name);
2304
+ }
2305
+ catch (error) {
2306
+ await revokeAbandonedResource();
2307
+ throw error;
2308
+ }
2309
+ if (!isCurrent()) {
2310
+ await getFileSystemProviderV2(mem)?.dispose();
2311
+ await revokeAbandonedResource();
2312
+ return;
2313
+ }
2314
+ let primaryFile;
2315
+ let origin;
2316
+ let registered = false;
2317
+ let opened = false;
2318
+ try {
2319
+ if (req.kind === 'external-file') {
2320
+ const content = await host.external.readText(req.resourceId);
2321
+ if (content === null) {
2322
+ throw new Error('The external file is no longer available.');
1167
2323
  }
1168
- const rel = entry.path.replace(/^\/+/, '');
1169
- const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
1170
- // Files may be stored as text (writeFile) or binary (writeBinary);
1171
- // try binary first, fall back to text and encode as UTF-8.
1172
- const binary = await src.readBinary(entry.path);
1173
- if (binary) {
1174
- await container.writeFile(zipPath, binary);
1175
- continue;
2324
+ if (!isCurrent())
2325
+ return;
2326
+ primaryFile = req.name; // e.g. "notes.md"
2327
+ mem.seedText(primaryFile, content);
2328
+ origin = { kind: 'loose-file', resourceId: req.resourceId };
2329
+ }
2330
+ else {
2331
+ const bytes = await host.external.readBinary(req.resourceId);
2332
+ if (!bytes) {
2333
+ throw new Error('The external bundle is no longer available.');
1176
2334
  }
1177
- const text = await src.readFile(entry.path);
1178
- if (text !== null) {
1179
- await container.writeFile(zipPath, encoder.encode(text));
2335
+ if (!isCurrent())
2336
+ return;
2337
+ const version = await sha256Hex(bytes);
2338
+ if (!isCurrent())
2339
+ return;
2340
+ const { zipToContainer } = await import('@bendyline/squisq-formats/container');
2341
+ if (!isCurrent())
2342
+ return;
2343
+ const container = await zipToContainer(bytes);
2344
+ if (!isCurrent())
2345
+ return;
2346
+ const base = req.name.replace(/\.[^.]+$/, '');
2347
+ primaryFile = `${base}.md`;
2348
+ const snapshot = await createDbkWorkspaceSnapshot(container, {
2349
+ targetDocumentPath: primaryFile,
2350
+ });
2351
+ if (!isCurrent())
2352
+ return;
2353
+ mem.replaceContents(snapshot);
2354
+ origin = { kind: 'dbk', resourceId: req.resourceId, version };
2355
+ }
2356
+ const descriptor = {
2357
+ id,
2358
+ name: req.name,
2359
+ type: 'transient',
2360
+ lastOpened: new Date().toISOString(),
2361
+ origin,
2362
+ };
2363
+ registerTransientWorkspace(descriptor, mem);
2364
+ registered = true;
2365
+ opened =
2366
+ (await openFromIds(id, `/${primaryFile}`, true, undefined, navigationRequestId)) !== null;
2367
+ }
2368
+ finally {
2369
+ try {
2370
+ if (!opened && registered && getTransientWorkspace(id)?.provider === mem) {
2371
+ await unregisterTransientWorkspace(id);
2372
+ registered = false;
2373
+ }
2374
+ else if (!registered) {
2375
+ await getFileSystemProviderV2(mem)?.dispose();
1180
2376
  }
1181
2377
  }
2378
+ finally {
2379
+ if (!opened)
2380
+ await revokeAbandonedResource();
2381
+ }
1182
2382
  }
1183
- }, []);
2383
+ }, [openFromIds]);
2384
+ // Subscribe to OS open-file / deep-link requests.
2385
+ useEffect(() => {
2386
+ if (!isElectronHost())
2387
+ return;
2388
+ const host = getDocBlocksHost();
2389
+ return host.onOpenRequest((req) => {
2390
+ // Claim the navigation when the OS request arrives, before any external
2391
+ // file read or bundle decoding. A slower older request can no longer
2392
+ // finish after and replace a newer document.
2393
+ const requestId = ++navigationRequestRef.current;
2394
+ void (req.kind === 'workspace-file'
2395
+ ? workspaceAuthorityBarrier.wait().then(() => {
2396
+ if (requestId !== navigationRequestRef.current)
2397
+ return null;
2398
+ return openFromIds(req.workspaceId, req.path, true, undefined, requestId).then((opened) => {
2399
+ if (!opened && requestId === navigationRequestRef.current) {
2400
+ throw new Error('The requested workspace file is no longer available.');
2401
+ }
2402
+ return opened;
2403
+ });
2404
+ })
2405
+ : openTransient(req, requestId)).catch((error) => {
2406
+ if (requestId !== navigationRequestRef.current)
2407
+ return;
2408
+ alert(error instanceof Error
2409
+ ? 'DocBlocks could not open the requested file: ' + error.message
2410
+ : 'DocBlocks could not open the requested file.');
2411
+ });
2412
+ });
2413
+ }, [openFromIds, openTransient, workspaceAuthorityBarrier]);
2414
+ /**
2415
+ * Web counterpart of `openTransient`: a `.md` or `.dbk` launched into the
2416
+ * installed PWA (File Handling API) arrives as a FileSystemFileHandle.
2417
+ * Contents are seeded into the same session-only in-memory workspace;
2418
+ * saves write back through the handle (see `createDocumentTarget`).
2419
+ */
2420
+ const openTransientFromHandle = useCallback(async (handle) => {
2421
+ const name = handle.name;
2422
+ const isBundle = /\.(dbk|zip)$/i.test(name);
2423
+ // Keyed by file name: relaunching the same file replaces the registry
2424
+ // entry instead of piling up session workspaces. Two different files
2425
+ // that share a name would collide -- acceptable for session-only state.
2426
+ const id = `transient-web-${isBundle ? 'bundle' : 'file'}-${name}`;
2427
+ // Ask for write access while the OS-launch user activation is fresh so
2428
+ // autosave doesn't have to prompt mid-typing. Best-effort: a denial
2429
+ // surfaces on save, not on open.
2430
+ await ensureHandleWritePermission(handle);
2431
+ const mem = await createMemoryFileSystemProvider(id, name);
2432
+ const file = await handle.getFile();
2433
+ let primaryFile;
2434
+ let origin;
2435
+ if (!isBundle) {
2436
+ primaryFile = name;
2437
+ mem.seedText(primaryFile, await file.text());
2438
+ origin = { kind: 'web-file', handle, name };
2439
+ }
2440
+ else {
2441
+ const bytes = await file.arrayBuffer();
2442
+ const version = await sha256Hex(bytes);
2443
+ const { zipToContainer } = await import('@bendyline/squisq-formats/container');
2444
+ const container = await zipToContainer(bytes);
2445
+ const base = name.replace(/\.[^.]+$/, '');
2446
+ primaryFile = `${base}.md`;
2447
+ const snapshot = await createDbkWorkspaceSnapshot(container, {
2448
+ targetDocumentPath: primaryFile,
2449
+ });
2450
+ mem.replaceContents(snapshot);
2451
+ origin = { kind: 'web-dbk', handle, name, version };
2452
+ }
2453
+ const descriptor = {
2454
+ id,
2455
+ name,
2456
+ type: 'transient',
2457
+ lastOpened: new Date().toISOString(),
2458
+ origin,
2459
+ };
2460
+ registerTransientWorkspace(descriptor, mem);
2461
+ await openFromIds(id, `/${primaryFile}`, true);
2462
+ }, [openFromIds]);
2463
+ // Consume files the OS delivers to the installed PWA (Chromium desktop).
2464
+ // launchQueue buffers launch params until a consumer is set, so a
2465
+ // post-mount effect is early enough to catch the launching file.
2466
+ useEffect(() => {
2467
+ if (isElectronHost() || typeof window === 'undefined')
2468
+ return;
2469
+ const launchQueue = window.launchQueue;
2470
+ if (!launchQueue)
2471
+ return;
2472
+ launchQueue.setConsumer((params) => {
2473
+ if (params.files.length === 0)
2474
+ return;
2475
+ const handle = params.files[0];
2476
+ void openTransientFromHandle(handle).catch((error) => {
2477
+ alert(error instanceof Error
2478
+ ? 'DocBlocks could not open the launched file: ' + error.message
2479
+ : 'DocBlocks could not open the launched file.');
2480
+ });
2481
+ });
2482
+ }, [openTransientFromHandle]);
2483
+ const updateStatusBarVisible = viewPreferences.showStatusBar === true && selectedFile !== null && mediaProvider !== null;
1184
2484
  const handleDownloadWorkspace = useCallback(async () => {
1185
2485
  if (!provider)
1186
2486
  return;
1187
2487
  try {
2488
+ await documentSession.flush('backup');
1188
2489
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1189
2490
  import('@bendyline/squisq/storage'),
1190
2491
  import('@bendyline/squisq-formats/container'),
@@ -1204,23 +2505,26 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1204
2505
  console.error('Failed to download workspace', err);
1205
2506
  alert('Failed to download workspace. See console for details.');
1206
2507
  }
1207
- }, [provider, copyProviderToContainer]);
2508
+ }, [provider, documentSession]);
1208
2509
  /**
1209
2510
  * Bundle every workspace the host can open without further prompting
1210
2511
  * into a single zip, with each workspace nested under its own folder.
1211
2512
  * Native (browser-picked) workspaces whose handle hasn't been re-granted
1212
- * for this session are skipped restoring them would require a user
2513
+ * for this session are skipped -- restoring them would require a user
1213
2514
  * gesture per workspace.
1214
2515
  */
1215
2516
  const handleDownloadAllWorkspaces = useCallback(async () => {
1216
2517
  try {
2518
+ await documentSession.flush('backup');
1217
2519
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1218
2520
  import('@bendyline/squisq/storage'),
1219
2521
  import('@bendyline/squisq-formats/container'),
1220
2522
  ]);
1221
2523
  const electron = isElectronHost();
1222
2524
  const all = await listWorkspaces();
1223
- const candidates = all.filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
2525
+ const candidates = all.filter((w) => electron
2526
+ ? w.type === 'electron-native' || w.type === 'transient'
2527
+ : w.type !== 'electron-native');
1224
2528
  if (candidates.length === 0) {
1225
2529
  alert('No workspaces to download.');
1226
2530
  return;
@@ -1230,17 +2534,25 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1230
2534
  const skipped = [];
1231
2535
  for (const ws of candidates) {
1232
2536
  let p = null;
2537
+ let ownsProvider = false;
1233
2538
  try {
1234
- if (ws.type === 'electron-native') {
2539
+ const transient = getTransientWorkspace(ws.id);
2540
+ if (transient) {
2541
+ p = transient.provider;
2542
+ }
2543
+ else if (ws.type === 'electron-native') {
1235
2544
  p = await createElectronProviderFromWorkspace(ws);
2545
+ ownsProvider = p !== null;
1236
2546
  }
1237
2547
  else if (ws.type === 'native') {
1238
- // Restore without prompting only succeeds when the browser
2548
+ // Restore without prompting -- only succeeds when the browser
1239
2549
  // still remembers the granted handle for this origin/session.
1240
- p = await restoreNativeFolder(ws.id);
2550
+ p = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
2551
+ ownsProvider = p !== null;
1241
2552
  }
1242
2553
  else {
1243
- p = new IndexedDBFileSystemProvider(ws.id, ws.name);
2554
+ p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
2555
+ ownsProvider = true;
1244
2556
  }
1245
2557
  }
1246
2558
  catch (err) {
@@ -1258,7 +2570,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1258
2570
  folder = `${base} (${suffix++})`;
1259
2571
  }
1260
2572
  usedFolders.add(folder);
1261
- await copyProviderToContainer(p, container, folder);
2573
+ try {
2574
+ await copyProviderToContainer(p, container, folder);
2575
+ }
2576
+ finally {
2577
+ if (ownsProvider)
2578
+ await getFileSystemProviderV2(p)?.dispose();
2579
+ }
1262
2580
  }
1263
2581
  if (usedFolders.size === 0) {
1264
2582
  alert('No workspaces could be opened for download.');
@@ -1280,7 +2598,52 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1280
2598
  console.error('Failed to download all workspaces', err);
1281
2599
  alert('Failed to download all workspaces. See console for details.');
1282
2600
  }
1283
- }, [copyProviderToContainer]);
2601
+ }, [documentSession]);
2602
+ const handleKeepBrowserData = useCallback(async () => {
2603
+ if (typeof navigator === 'undefined')
2604
+ return;
2605
+ const storage = navigator.storage;
2606
+ if (!storage || typeof storage.persist !== 'function') {
2607
+ alert('This browser does not support persistent storage requests. Please back up browser docs frequently.');
2608
+ return;
2609
+ }
2610
+ try {
2611
+ const alreadyPersistent = typeof storage.persisted === 'function' ? await storage.persisted() : false;
2612
+ if (alreadyPersistent) {
2613
+ setBrowserStoragePersistent(true);
2614
+ alert('DocBlocks browser data is already marked as persistent by this browser.');
2615
+ return;
2616
+ }
2617
+ const granted = await storage.persist();
2618
+ if (granted) {
2619
+ setBrowserStoragePersistent(true);
2620
+ alert('This browser will try to keep DocBlocks data for longer. Please still back up browser docs frequently.');
2621
+ }
2622
+ else {
2623
+ alert('The browser did not grant permanent storage for DocBlocks. Please try again in a day or two. Meanwhile, please back up browser documents frequently.');
2624
+ }
2625
+ }
2626
+ catch {
2627
+ alert('DocBlocks could not request persistent browser storage. Please back up browser docs frequently.');
2628
+ }
2629
+ }, []);
2630
+ // Settings dialog "Storage" row (browser surface): usage/quota estimate.
2631
+ const getBrowserStorageEstimate = useCallback(async () => {
2632
+ if (typeof navigator === 'undefined')
2633
+ return null;
2634
+ const storage = navigator.storage;
2635
+ if (!storage || typeof storage.estimate !== 'function')
2636
+ return null;
2637
+ try {
2638
+ const { usage, quota } = await storage.estimate();
2639
+ if (typeof usage !== 'number' || typeof quota !== 'number')
2640
+ return null;
2641
+ return { usage, quota };
2642
+ }
2643
+ catch {
2644
+ return null;
2645
+ }
2646
+ }, []);
1284
2647
  const handleRemoveWorkspace = useCallback(async () => {
1285
2648
  if (!activeWorkspaceId)
1286
2649
  return;
@@ -1289,19 +2652,35 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1289
2652
  : 'Remove this workspace? This cannot be undone.';
1290
2653
  if (!confirm(confirmMsg))
1291
2654
  return;
2655
+ const requestId = ++navigationRequestRef.current;
2656
+ if (!(await transitionAwayFromDocument(requestId)))
2657
+ return;
1292
2658
  const ws = await getWorkspace(activeWorkspaceId);
1293
2659
  if (ws?.type === 'electron-native') {
1294
2660
  try {
1295
2661
  await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
1296
2662
  }
1297
2663
  catch {
1298
- // ignore host cleanup is best-effort
2664
+ // ignore -- host cleanup is best-effort
1299
2665
  }
1300
2666
  }
1301
- else {
1302
- await removeDirectoryHandle(activeWorkspaceId);
2667
+ else if (ws?.type === 'transient') {
2668
+ const origin = ws.origin;
2669
+ if (isElectronHost() && origin && (origin.kind === 'loose-file' || origin.kind === 'dbk')) {
2670
+ try {
2671
+ await getDocBlocksHost().external.revoke(origin.resourceId);
2672
+ }
2673
+ catch {
2674
+ // Navigation/destruction also revokes the capability; removal is best effort.
2675
+ }
2676
+ }
2677
+ }
2678
+ else if (ws?.type === 'native') {
2679
+ await (await loadNativeFileSystem()).removeDirectoryHandle(activeWorkspaceId);
1303
2680
  }
1304
2681
  await removeWorkspace(activeWorkspaceId);
2682
+ if (requestId !== navigationRequestRef.current)
2683
+ return;
1305
2684
  const electron = isElectronHost();
1306
2685
  // Switch to most recent remaining workspace or create default
1307
2686
  const remaining = (await listWorkspaces()).filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
@@ -1319,33 +2698,33 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1319
2698
  lastOpened: new Date().toISOString(),
1320
2699
  };
1321
2700
  await saveWorkspace(descriptor);
1322
- const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
2701
+ const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1323
2702
  setProvider(p);
1324
2703
  setActiveWorkspaceId(info.id);
1325
2704
  setSelectedFile(null);
1326
2705
  setSelectedFolder(null);
1327
2706
  setFolderEntries([]);
1328
- setEditorContent('');
1329
- setEditorKey((k) => k + 1);
1330
2707
  }
1331
2708
  else {
1332
2709
  const defaultWs = await ensureDefaultWorkspace();
1333
- const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
2710
+ const fsProvider = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
1334
2711
  setProvider(fsProvider);
1335
2712
  setActiveWorkspaceId(defaultWs.id);
1336
2713
  setSelectedFile(null);
1337
2714
  setSelectedFolder(null);
1338
2715
  setFolderEntries([]);
1339
- setEditorContent('');
1340
- setEditorKey((k) => k + 1);
1341
- }
1342
- }, [activeWorkspaceId, handleWorkspaceSelect]);
1343
- return (_jsxs("div", { className: `db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, children: [saveToastVisible && (_jsx("div", { className: "db-save-toast", role: "status", "aria-live": "polite", children: "Autosaved. You're all set." })), workspaceSettingsOpen && activeWorkspaceDescriptor && (_jsx(WorkspaceSettingsDialog, { workspace: activeWorkspaceDescriptor, globalVersioningPreference: versioningPreference, onChange: handleWorkspaceVersioningOverrideChange, onClose: () => setWorkspaceSettingsOpen(false) })), _jsxs("div", { style: { display: 'flex', flex: 1, overflow: 'hidden' }, children: [(!effectiveCompact || !mobileShowEditor) && (_jsxs("div", { ref: sidebarRef, className: "db-shell-sidebar", style: effectiveCompact ? undefined : { width: `${sidebarWidth}px` }, children: [_jsxs("div", { className: "db-shell-sidebar-header", children: [_jsx(AppMenu, { logoUrl: logoUrl, themePreference: themePreference, onThemeChange: handleThemeChange, versioningPreference: versioningPreference, onVersioningPreferenceChange: handleVersioningPreferenceChange, onDownloadAllWorkspaces: handleDownloadAllWorkspaces }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder }), _jsx(WorkspaceSettingsButton, { onSettings: handleOpenWorkspaceSettings, onRename: handleRenameWorkspace, onDownload: handleDownloadWorkspace, onRemove: handleRemoveWorkspace })] }), _jsx(FileExplorer, { provider: provider, onSelect: handleSelect, onTreeChange: handleTreeChange, onImportFiles: handleImportFiles }, explorerKey), _jsx("div", { className: "db-shell-sidebar-footer", children: _jsx("a", { href: "https://github.com/bendyline/docblocks/blob/main/LICENSE", target: "_blank", rel: "noopener noreferrer", children: "Terms of Use" }) })] })), !effectiveCompact && (_jsx("div", { className: "db-shell-sidebar-resizer", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize sidebar", onPointerDown: handleResizerPointerDown })), (!effectiveCompact || mobileShowEditor) && (_jsx("div", { style: {
1344
- flex: 1,
1345
- overflow: 'hidden',
1346
- display: 'flex',
1347
- flexDirection: 'column',
1348
- position: 'relative',
1349
- }, children: selectedFile && mediaProvider ? (_jsxs(MediaContext.Provider, { value: mediaProvider, children: [_jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, fileName: selectedFile, onChange: handleEditorChange, colorScheme: resolvedTheme, height: "100%", outlineWidth: 280, mediaProvider: mediaProvider, documentLinkProvider: documentLinkProvider, container: versionsContainer ?? undefined, allowVersioning: effectiveVersioning, viewPreferences: viewPreferences, onViewPreferencesChange: handleViewPreferencesChange, versionBasename: versionBasename ?? stripExtension(basenameOf(selectedFile)), versioningPrunePolicy: versioningPrunePolicy, versioningAutoSaveIdleMs: versioningAutoSaveIdleMs, onSaveVersion: onSaveVersion, toolbarSlotLeft: effectiveCompact ? (_jsx("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), "aria-label": "Show file list", children: _jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }) })) : undefined, toolbarSlotRight: _jsxs(_Fragment, { children: [compactLayout && !isMobile && (_jsx("button", { className: "db-restore-split", onClick: () => setCompactLayout(false), "aria-label": "Restore split view", title: "Restore split view", children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "1.5", y: "2.5", width: "13", height: "11", rx: "1" }), _jsx("line", { x1: "6", y1: "2.5", x2: "6", y2: "13.5" })] }) })), _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current })] }) }, `${selectedFile}-${editorKey}`), showWelcomeGateway && (_jsxs("div", { className: "db-welcome-gateway", role: "note", "aria-label": "Welcome tip", children: [_jsxs("span", { className: "db-welcome-gateway-text", children: ["You\u2019re watching this welcome doc in ", _jsx("strong", { children: "Play" }), " view \u2014 it\u2019s a regular markdown file, and so is everything you\u2019ll write."] }), _jsx("button", { className: "db-welcome-gateway-cta", onClick: handleStartWriting, children: "Start writing" }), _jsx("button", { className: "db-welcome-gateway-dismiss", onClick: closeWelcomeGateway, "aria-label": "Dismiss welcome tip", title: "Dismiss", children: "\u00D7" })] }))] })) : selectedFolder ? (_jsxs("div", { className: "db-folder-view", children: [effectiveCompact && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsxs("div", { className: "db-folder-view-header", children: [_jsx("span", { className: "db-folder-view-icon", children: _jsx(FolderGlyph, {}) }), _jsx("span", { className: "db-folder-view-path", children: selectedFolder })] }), folderEntries.length === 0 ? (_jsx("p", { className: "db-folder-view-empty", children: "This folder is empty." })) : (_jsx("ul", { className: "db-folder-view-list", children: folderEntries.map((entry) => (_jsxs("li", { className: "db-folder-view-item", onClick: () => handleSelect(entry.path, entry.kind), children: [_jsx("span", { className: "db-folder-view-item-icon", children: entry.kind === 'directory' ? _jsx(FolderGlyph, {}) : _jsx(FileGlyph, {}) }), entry.name] }, entry.path))) }))] })) : (_jsxs("div", { className: "db-shell-empty", children: [effectiveCompact && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsx("p", { children: "Select a file to start editing, or create a new one." })] })) }))] })] }));
2716
+ }
2717
+ }, [activeWorkspaceId, handleWorkspaceSelect, transitionAwayFromDocument]);
2718
+ return (_jsx("div", { className: `db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, "data-accent": accentColor, "data-document-status": documentSnapshot.status, children: _jsxs(GitContext.Provider, { value: git, children: [workspaceStartupError && (_jsx("div", { className: "db-save-toast db-save-toast--error", role: "alert", "aria-live": "assertive", children: workspaceStartupError })), saveToast && (_jsx("div", { className: 'db-save-toast db-save-toast--' + saveToast.kind, role: saveToast.kind === 'error' ? 'alert' : 'status', "aria-live": saveToast.kind === 'error' ? 'assertive' : 'polite', children: saveToast.message })), offlineReadyToast && !saveToast && (_jsx("div", { className: "db-save-toast db-save-toast--success", role: "status", "aria-live": "polite", children: "DocBlocks is ready to work offline." })), documentSnapshot.conflict && (_jsxs("div", { className: "db-document-conflict", role: "alert", children: [_jsx("span", { children: "This document changed outside DocBlocks. Your unsaved version is still intact." }), _jsxs("div", { className: "db-document-conflict-actions", children: [_jsx("button", { type: "button", onClick: () => void handleKeepLocalDocument(), children: "Keep mine" }), _jsx("button", { type: "button", onClick: () => void handleUseExternalDocument(), children: "Reload external" })] })] })), storageFull && !documentSnapshot.conflict && (_jsxs("div", { className: "db-storage-full-banner", role: "alert", children: [_jsx("span", { children: "Browser storage is full -- changes can\u2019t be saved. Free up space or back up your work now." }), _jsxs("div", { className: "db-storage-full-banner-actions", children: [_jsx("button", { type: "button", onClick: () => void handleDownloadAllWorkspaces(), children: "Download all workspaces" }), _jsx("button", { type: "button", onClick: () => setStorageFull(false), children: "Dismiss" })] })] })), workspaceSettingsOpen && activeWorkspaceDescriptor && (_jsx(WorkspaceSettingsDialog, { workspace: activeWorkspaceDescriptor, globalVersioningPreference: versioningPreference, onChange: handleWorkspaceVersioningOverrideChange, onClose: () => setWorkspaceSettingsOpen(false) })), _jsxs("div", { style: { display: 'flex', flex: 1, overflow: 'hidden' }, children: [(!effectiveCompact || !mobileShowEditor) && (_jsxs("div", { ref: sidebarRef, className: "db-shell-sidebar", style: effectiveCompact ? undefined : { width: `${sidebarWidth}px` }, children: [_jsxs("div", { className: "db-shell-sidebar-header", children: [_jsx(AppMenu, { logoUrl: logoUrl, themePreference: themePreference, onThemeChange: handleThemeChange, accentColor: accentColor, onAccentColorChange: handleAccentColorChange, versioningPreference: versioningPreference, onVersioningPreferenceChange: handleVersioningPreferenceChange, onDownloadAllWorkspaces: handleDownloadAllWorkspaces, onKeepBrowserData: showBrowserStorageWarning && !browserStoragePersistent
2719
+ ? handleKeepBrowserData
2720
+ : undefined, onInstallApp: installPromptEvent ? handleInstallApp : undefined, getStorageEstimate: showBrowserStorageWarning ? getBrowserStorageEstimate : undefined, storagePersistent: showBrowserStorageWarning ? browserStoragePersistent : undefined }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder, onCloneRepository: git.available ? () => git.openDialog({ kind: 'clone' }) : undefined }), _jsx(WorkspaceSettingsButton, { onSettings: handleOpenWorkspaceSettings, onRename: handleRenameWorkspace, onDownload: handleDownloadWorkspace, onRemove: handleRemoveWorkspace }), compactLayout && !isMobile && (_jsx("button", { className: "db-restore-split", onClick: () => setCompactLayout(false), "aria-label": "Restore split view", title: "Restore split view", children: _jsx(SplitViewIcon, {}) }))] }), git.available && (_jsx(Suspense, { fallback: null, children: _jsx(GitUI, { onOpenFile: (path) => void handleSelect(path, 'file'), onWorkspaceCloned: handleWorkspaceCloned }) })), _jsx(FileExplorer, { provider: provider, activeFilePath: selectedFile, onSelect: handleSelect, onTreeMutation: handleTreeMutation, onTreeChange: handleTreeChange, onImportFiles: handleImportFiles }, explorerKey), _jsxs("div", { className: "db-shell-sidebar-footer", children: [_jsx("a", { href: "https://github.com/bendyline/docblocks/blob/main/LICENSE", target: "_blank", rel: "noopener noreferrer", children: "Terms of Use" }), showBrowserStorageWarning && (_jsxs(_Fragment, { children: [_jsx("span", { className: "db-shell-sidebar-footer-separator", "aria-hidden": "true", children: "\u2022" }), _jsx("button", { type: "button", className: "db-shell-sidebar-footer-action", onClick: () => void handleDownloadAllWorkspaces(), title: "Browser docs can get auto-removed. Download all workspaces.", children: "Backup browser docs frequently" })] }))] })] })), !effectiveCompact && (_jsx("div", { className: "db-shell-sidebar-resizer", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize sidebar", onPointerDown: handleResizerPointerDown })), (!effectiveCompact || mobileShowEditor) && (_jsxs("div", { className: updateAvailable && onApplyUpdate && updateStatusBarVisible
2721
+ ? 'db-shell-editor-area db-shell-editor-area--has-update'
2722
+ : 'db-shell-editor-area', style: {
2723
+ flex: 1,
2724
+ overflow: 'hidden',
2725
+ display: 'flex',
2726
+ flexDirection: 'column',
2727
+ position: 'relative',
2728
+ }, children: [selectedFile && mediaProvider ? (_jsxs(MediaContext.Provider, { value: mediaProvider, children: [_jsx(Suspense, { fallback: _jsx("div", { className: "db-shell-empty", role: "status", children: "Loading editor\u2026" }), children: _jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, fileName: selectedFile, onChange: handleEditorChange, colorScheme: resolvedTheme, height: "100%", placeholder: editorPlaceholder, outlineWidth: 280, mediaProvider: mediaProvider, documentLinkProvider: documentLinkProvider, workspaceContainer: versionsContainer ?? undefined, allowVersioning: effectiveVersioning, viewPreferences: viewPreferences, onViewPreferencesChange: handleViewPreferencesChange, versionBasename: versionBasename ?? stripExtension(basenameOf(selectedFile)), versioningPrunePolicy: versioningPrunePolicy, versioningAutoSaveIdleMs: versioningAutoSaveIdleMs, onSaveVersion: onSaveVersion, toolbarSlotLeft: effectiveCompact ? (_jsx("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), "aria-label": "Show file list", children: _jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }) })) : undefined, toolbarSlotRight: _jsxs(_Fragment, { children: [compactLayout && !isMobile && (_jsx("button", { className: "db-restore-split", onClick: () => setCompactLayout(false), "aria-label": "Restore split view", title: "Restore split view", children: _jsx(SplitViewIcon, {}) })), git.repo && (_jsx(Suspense, { fallback: null, children: _jsx(GitToolbarControl, { selectedFile: selectedFile }) })), _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current, destinationAdapter: exportDestinationAdapter })] }) }, `${selectedFile}-${editorKey}`) }), showWelcomeGateway && (_jsxs("div", { className: "db-welcome-gateway", role: "note", "aria-label": "Welcome tip", children: [_jsxs("span", { className: "db-welcome-gateway-text", children: ["You\u2019re watching this welcome doc in ", _jsx("strong", { children: "Slideshow" }), " view -- it\u2019s a regular markdown file, and so is everything you\u2019ll write."] }), _jsx("button", { className: "db-welcome-gateway-cta", onClick: handleStartWriting, children: "Start writing" }), _jsx("button", { className: "db-welcome-gateway-dismiss", onClick: closeWelcomeGateway, "aria-label": "Dismiss welcome tip", title: "Dismiss", children: "\u00D7" })] }))] })) : selectedFolder ? (_jsxs("div", { className: "db-folder-view", children: [effectiveCompact && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsxs("div", { className: "db-folder-view-header", children: [_jsx("span", { className: "db-folder-view-icon", children: _jsx(FolderGlyph, {}) }), _jsx("span", { className: "db-folder-view-path", children: selectedFolder })] }), folderEntries.length === 0 ? (_jsx("p", { className: "db-folder-view-empty", children: "This folder is empty." })) : (_jsx("ul", { className: "db-folder-view-list", children: folderEntries.map((entry) => (_jsxs("li", { className: "db-folder-view-item", onClick: () => handleSelect(entry.path, entry.kind), children: [_jsx("span", { className: "db-folder-view-item-icon", children: entry.kind === 'directory' ? _jsx(FolderGlyph, {}) : _jsx(FileGlyph, {}) }), entry.name] }, entry.path))) }))] })) : (_jsxs("div", { className: "db-shell-empty", children: [effectiveCompact && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsx("p", { children: "Select a file to start editing, or create a new one." })] })), _jsx(UpdateAvailableNotice, { available: updateAvailable, onApplyUpdate: onApplyUpdate, blocked: documentSnapshot.conflict !== null || storageFull, statusBarVisible: updateStatusBarVisible })] }))] })] }) }));
1350
2729
  }
1351
2730
  //# sourceMappingURL=DocBlocksShell.js.map