@bendyline/docblocks-react 1.1.1 → 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 +50 -15
  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 +17 -4
  9. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  10. package/dist/DocBlocksShell/DocBlocksShell.js +1710 -299
  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 +39 -20
  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 +161 -26
  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 +58 -25
  55. package/dist/Export/run-export.js.map +1 -1
  56. package/dist/Export/transform-summaries.d.ts +7 -0
  57. package/dist/Export/transform-summaries.d.ts.map +1 -0
  58. package/dist/Export/transform-summaries.js +6 -0
  59. package/dist/Export/transform-summaries.js.map +1 -0
  60. package/dist/ExportDialog-MEUISWZX.js +9 -0
  61. package/dist/ExportDialog-MEUISWZX.js.map +1 -0
  62. package/dist/ExportToolbarControls-D2YDWOXW.js +373 -0
  63. package/dist/ExportToolbarControls-D2YDWOXW.js.map +1 -0
  64. package/dist/FileExplorer/FileExplorer.d.ts +26 -4
  65. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  66. package/dist/FileExplorer/FileExplorer.js +204 -29
  67. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  68. package/dist/FileExplorer/FileTreeNode.d.ts +24 -3
  69. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
  70. package/dist/FileExplorer/FileTreeNode.js +27 -8
  71. package/dist/FileExplorer/FileTreeNode.js.map +1 -1
  72. package/dist/FileExplorer/index.d.ts +1 -1
  73. package/dist/FileExplorer/index.d.ts.map +1 -1
  74. package/dist/FileExplorer/useFileTree.d.ts +5 -3
  75. package/dist/FileExplorer/useFileTree.d.ts.map +1 -1
  76. package/dist/FileExplorer/useFileTree.js +173 -13
  77. package/dist/FileExplorer/useFileTree.js.map +1 -1
  78. package/dist/Git/GitAuthGuidanceDialog.d.ts +18 -0
  79. package/dist/Git/GitAuthGuidanceDialog.d.ts.map +1 -0
  80. package/dist/Git/GitAuthGuidanceDialog.js +27 -0
  81. package/dist/Git/GitAuthGuidanceDialog.js.map +1 -0
  82. package/dist/Git/GitBranchesDialog.d.ts +13 -0
  83. package/dist/Git/GitBranchesDialog.d.ts.map +1 -0
  84. package/dist/Git/GitBranchesDialog.js +59 -0
  85. package/dist/Git/GitBranchesDialog.js.map +1 -0
  86. package/dist/Git/GitCloneDialog.d.ts +14 -0
  87. package/dist/Git/GitCloneDialog.d.ts.map +1 -0
  88. package/dist/Git/GitCloneDialog.js +79 -0
  89. package/dist/Git/GitCloneDialog.js.map +1 -0
  90. package/dist/Git/GitCommitDialog.d.ts +12 -0
  91. package/dist/Git/GitCommitDialog.d.ts.map +1 -0
  92. package/dist/Git/GitCommitDialog.js +38 -0
  93. package/dist/Git/GitCommitDialog.js.map +1 -0
  94. package/dist/Git/GitConflictsDialog.d.ts +12 -0
  95. package/dist/Git/GitConflictsDialog.d.ts.map +1 -0
  96. package/dist/Git/GitConflictsDialog.js +17 -0
  97. package/dist/Git/GitConflictsDialog.js.map +1 -0
  98. package/dist/Git/GitContext.d.ts +89 -0
  99. package/dist/Git/GitContext.d.ts.map +1 -0
  100. package/dist/Git/GitContext.js +15 -0
  101. package/dist/Git/GitContext.js.map +1 -0
  102. package/dist/Git/GitDiffDialog.d.ts +20 -0
  103. package/dist/Git/GitDiffDialog.d.ts.map +1 -0
  104. package/dist/Git/GitDiffDialog.js +100 -0
  105. package/dist/Git/GitDiffDialog.js.map +1 -0
  106. package/dist/Git/GitDiffView.d.ts +23 -0
  107. package/dist/Git/GitDiffView.d.ts.map +1 -0
  108. package/dist/Git/GitDiffView.js +77 -0
  109. package/dist/Git/GitDiffView.js.map +1 -0
  110. package/dist/Git/GitHistoryDialog.d.ts +15 -0
  111. package/dist/Git/GitHistoryDialog.d.ts.map +1 -0
  112. package/dist/Git/GitHistoryDialog.js +107 -0
  113. package/dist/Git/GitHistoryDialog.js.map +1 -0
  114. package/dist/Git/GitStatusBar.d.ts +8 -0
  115. package/dist/Git/GitStatusBar.d.ts.map +1 -0
  116. package/dist/Git/GitStatusBar.js +49 -0
  117. package/dist/Git/GitStatusBar.js.map +1 -0
  118. package/dist/Git/GitToolbarControl.d.ts +15 -0
  119. package/dist/Git/GitToolbarControl.d.ts.map +1 -0
  120. package/dist/Git/GitToolbarControl.js +51 -0
  121. package/dist/Git/GitToolbarControl.js.map +1 -0
  122. package/dist/Git/GitUI.d.ts +14 -0
  123. package/dist/Git/GitUI.d.ts.map +1 -0
  124. package/dist/Git/GitUI.js +21 -0
  125. package/dist/Git/GitUI.js.map +1 -0
  126. package/dist/Git/commit-selection.d.ts +22 -0
  127. package/dist/Git/commit-selection.d.ts.map +1 -0
  128. package/dist/Git/commit-selection.js +47 -0
  129. package/dist/Git/commit-selection.js.map +1 -0
  130. package/dist/Git/git-clone-utils.d.ts +8 -0
  131. package/dist/Git/git-clone-utils.d.ts.map +1 -0
  132. package/dist/Git/git-clone-utils.js +26 -0
  133. package/dist/Git/git-clone-utils.js.map +1 -0
  134. package/dist/Git/git-status.d.ts +24 -0
  135. package/dist/Git/git-status.d.ts.map +1 -0
  136. package/dist/Git/git-status.js +105 -0
  137. package/dist/Git/git-status.js.map +1 -0
  138. package/dist/Git/useGit.d.ts +15 -0
  139. package/dist/Git/useGit.d.ts.map +1 -0
  140. package/dist/Git/useGit.js +206 -0
  141. package/dist/Git/useGit.js.map +1 -0
  142. package/dist/Git/useGitStatus.d.ts +17 -0
  143. package/dist/Git/useGitStatus.d.ts.map +1 -0
  144. package/dist/Git/useGitStatus.js +63 -0
  145. package/dist/Git/useGitStatus.js.map +1 -0
  146. package/dist/GitToolbarControl-LMIR4R7I.js +117 -0
  147. package/dist/GitToolbarControl-LMIR4R7I.js.map +1 -0
  148. package/dist/GitUI-KGF5MSNV.js +1048 -0
  149. package/dist/GitUI-KGF5MSNV.js.map +1 -0
  150. package/dist/LazyEditorShell-EF2BWRXU.js +6 -0
  151. package/dist/LazyEditorShell-EF2BWRXU.js.map +1 -0
  152. package/dist/Settings/Settings.d.ts +23 -0
  153. package/dist/Settings/Settings.d.ts.map +1 -0
  154. package/dist/Settings/Settings.js +22 -0
  155. package/dist/Settings/Settings.js.map +1 -0
  156. package/dist/Settings/public-api.d.ts +3 -0
  157. package/dist/Settings/public-api.d.ts.map +1 -0
  158. package/dist/Settings/public-api.js +2 -0
  159. package/dist/Settings/public-api.js.map +1 -0
  160. package/dist/WorkspacePicker/WorkspacePicker.d.ts +6 -1
  161. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
  162. package/dist/WorkspacePicker/WorkspacePicker.js +16 -5
  163. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
  164. package/dist/chunk-3SAXDU2U.js +157 -0
  165. package/dist/chunk-3SAXDU2U.js.map +1 -0
  166. package/dist/chunk-4XXSWN3T.js +13 -0
  167. package/dist/chunk-4XXSWN3T.js.map +1 -0
  168. package/dist/chunk-5A4KEWKT.js +97 -0
  169. package/dist/chunk-5A4KEWKT.js.map +1 -0
  170. package/dist/chunk-AOVTHZU7.js +321 -0
  171. package/dist/chunk-AOVTHZU7.js.map +1 -0
  172. package/dist/chunk-DD4VFJQI.js +51 -0
  173. package/dist/chunk-DD4VFJQI.js.map +1 -0
  174. package/dist/chunk-GSNZSCIK.js +55 -0
  175. package/dist/chunk-GSNZSCIK.js.map +1 -0
  176. package/dist/chunk-VHDP4IPL.js +70 -0
  177. package/dist/chunk-VHDP4IPL.js.map +1 -0
  178. package/dist/chunk-ZI56BNB7.js +270 -0
  179. package/dist/chunk-ZI56BNB7.js.map +1 -0
  180. package/dist/components/Dialog.d.ts +22 -0
  181. package/dist/components/Dialog.d.ts.map +1 -0
  182. package/dist/components/Dialog.js +33 -0
  183. package/dist/components/Dialog.js.map +1 -0
  184. package/dist/editor.d.ts +10 -0
  185. package/dist/editor.d.ts.map +1 -0
  186. package/dist/editor.js +70 -0
  187. package/dist/editor.js.map +1 -0
  188. package/dist/export/index.d.ts +87 -0
  189. package/dist/export/index.js +26 -0
  190. package/dist/export/index.js.map +1 -0
  191. package/dist/hooks/index.d.ts +2 -1
  192. package/dist/hooks/index.d.ts.map +1 -1
  193. package/dist/hooks/index.js +1 -1
  194. package/dist/hooks/index.js.map +1 -1
  195. package/dist/hooks/useDocumentSession.d.ts +16 -0
  196. package/dist/hooks/useDocumentSession.d.ts.map +1 -0
  197. package/dist/hooks/useDocumentSession.js +18 -0
  198. package/dist/hooks/useDocumentSession.js.map +1 -0
  199. package/dist/icons.d.ts +11 -6
  200. package/dist/icons.d.ts.map +1 -1
  201. package/dist/icons.js +21 -20
  202. package/dist/icons.js.map +1 -1
  203. package/dist/index.d.ts +8 -5
  204. package/dist/index.d.ts.map +1 -1
  205. package/dist/index.js +4 -2
  206. package/dist/index.js.map +1 -1
  207. package/dist/preferences/theme.d.ts +18 -0
  208. package/dist/preferences/theme.d.ts.map +1 -0
  209. package/dist/preferences/theme.js +63 -0
  210. package/dist/preferences/theme.js.map +1 -0
  211. package/dist/provider-lease.d.ts +10 -0
  212. package/dist/provider-lease.d.ts.map +1 -0
  213. package/dist/provider-lease.js +42 -0
  214. package/dist/provider-lease.js.map +1 -0
  215. package/dist/run-export-ICTZITXC.js +10 -0
  216. package/dist/run-export-ICTZITXC.js.map +1 -0
  217. package/dist/settings/index.d.ts +28 -0
  218. package/dist/settings/index.js +12 -0
  219. package/dist/settings/index.js.map +1 -0
  220. package/package.json +27 -10
  221. package/src/AppMenu/AppMenu.tsx +169 -99
  222. package/src/AppMenu/index.ts +1 -1
  223. package/src/DocBlocksShell/DocBlocksShell.tsx +2183 -516
  224. package/src/DocBlocksShell/LazyEditorShell.ts +2 -0
  225. package/src/DocBlocksShell/UpdateAvailableNotice.tsx +60 -0
  226. package/src/DocBlocksShell/document-title.ts +19 -0
  227. package/src/DocBlocksShell/workspace-authority-barrier.ts +26 -0
  228. package/src/Export/DeferredExportToolbarControls.tsx +23 -0
  229. package/src/Export/ExportDialog.tsx +102 -27
  230. package/src/Export/ExportToolbarControls.tsx +343 -57
  231. package/src/Export/export-destination.ts +30 -0
  232. package/src/Export/index.ts +18 -4
  233. package/src/Export/public-api.ts +17 -0
  234. package/src/Export/run-export.ts +66 -29
  235. package/src/Export/transform-summaries.ts +14 -0
  236. package/src/FileExplorer/FileExplorer.tsx +284 -38
  237. package/src/FileExplorer/FileTreeNode.tsx +100 -8
  238. package/src/FileExplorer/index.ts +1 -1
  239. package/src/FileExplorer/useFileTree.ts +190 -16
  240. package/src/Git/GitAuthGuidanceDialog.tsx +84 -0
  241. package/src/Git/GitBranchesDialog.tsx +144 -0
  242. package/src/Git/GitCloneDialog.tsx +200 -0
  243. package/src/Git/GitCommitDialog.tsx +119 -0
  244. package/src/Git/GitConflictsDialog.tsx +62 -0
  245. package/src/Git/GitContext.tsx +88 -0
  246. package/src/Git/GitDiffDialog.tsx +147 -0
  247. package/src/Git/GitDiffView.tsx +105 -0
  248. package/src/Git/GitHistoryDialog.tsx +195 -0
  249. package/src/Git/GitStatusBar.tsx +157 -0
  250. package/src/Git/GitToolbarControl.tsx +123 -0
  251. package/src/Git/GitUI.tsx +68 -0
  252. package/src/Git/commit-selection.ts +63 -0
  253. package/src/Git/git-clone-utils.ts +23 -0
  254. package/src/Git/git-status.ts +113 -0
  255. package/src/Git/useGit.ts +279 -0
  256. package/src/Git/useGitStatus.ts +78 -0
  257. package/src/Settings/Settings.tsx +118 -0
  258. package/src/Settings/public-api.ts +8 -0
  259. package/src/WorkspacePicker/WorkspacePicker.tsx +62 -8
  260. package/src/components/Dialog.tsx +81 -0
  261. package/src/editor.ts +72 -0
  262. package/src/hooks/index.ts +2 -1
  263. package/src/hooks/useDocumentSession.ts +37 -0
  264. package/src/icons.tsx +33 -55
  265. package/src/index.ts +35 -5
  266. package/src/preferences/theme.ts +67 -0
  267. package/src/provider-lease.ts +52 -0
  268. package/src/styles/docblocks.css +2247 -77
  269. package/src/types/pwa-dom.d.ts +31 -0
  270. package/dist/hooks/useAutoSave.d.ts +0 -15
  271. package/dist/hooks/useAutoSave.d.ts.map +0 -1
  272. package/dist/hooks/useAutoSave.js +0 -83
  273. package/dist/hooks/useAutoSave.js.map +0 -1
  274. package/src/fonts/fonts.css +0 -362
  275. package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +0 -93
  276. package/src/fonts/licenses/LICENSE-crimson-text.txt +0 -93
  277. package/src/fonts/licenses/LICENSE-dm-sans.txt +0 -93
  278. package/src/fonts/licenses/LICENSE-dm-serif-display.txt +0 -93
  279. package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +0 -94
  280. package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +0 -93
  281. package/src/fonts/licenses/LICENSE-inter.txt +0 -93
  282. package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +0 -93
  283. package/src/fonts/licenses/LICENSE-lora.txt +0 -93
  284. package/src/fonts/licenses/LICENSE-merriweather.txt +0 -93
  285. package/src/fonts/licenses/LICENSE-oswald.txt +0 -93
  286. package/src/fonts/licenses/LICENSE-playfair-display.txt +0 -93
  287. package/src/fonts/licenses/LICENSE-pt-serif.txt +0 -93
  288. package/src/fonts/licenses/LICENSE-roboto.txt +0 -93
  289. package/src/fonts/licenses/LICENSE-source-serif-4.txt +0 -93
  290. package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
  291. package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
  292. package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
  293. package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
  294. package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
  295. package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
  296. package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
  297. package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
  298. package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
  299. package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
  300. package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
  301. package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
  302. package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
  303. package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
  304. package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
  305. package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
  306. package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
  307. package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
  308. package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
  309. package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
  310. package/src/fonts/woff2/inter-400.woff2 +0 -0
  311. package/src/fonts/woff2/inter-400i.woff2 +0 -0
  312. package/src/fonts/woff2/inter-500.woff2 +0 -0
  313. package/src/fonts/woff2/inter-600.woff2 +0 -0
  314. package/src/fonts/woff2/inter-700.woff2 +0 -0
  315. package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
  316. package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
  317. package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
  318. package/src/fonts/woff2/lora-400.woff2 +0 -0
  319. package/src/fonts/woff2/lora-400i.woff2 +0 -0
  320. package/src/fonts/woff2/lora-700.woff2 +0 -0
  321. package/src/fonts/woff2/merriweather-400.woff2 +0 -0
  322. package/src/fonts/woff2/merriweather-700.woff2 +0 -0
  323. package/src/fonts/woff2/oswald-400.woff2 +0 -0
  324. package/src/fonts/woff2/oswald-700.woff2 +0 -0
  325. package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
  326. package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
  327. package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
  328. package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
  329. package/src/fonts/woff2/roboto-400.woff2 +0 -0
  330. package/src/fonts/woff2/roboto-400i.woff2 +0 -0
  331. package/src/fonts/woff2/roboto-700.woff2 +0 -0
  332. package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
  333. package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
  334. package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
  335. package/src/hooks/useAutoSave.ts +0 -116
@@ -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(() => {
@@ -73,35 +147,34 @@ function loadLastState() {
73
147
  return null;
74
148
  }
75
149
  }
76
- const THEME_PREF_KEY = 'docblocks:themePreference';
77
- function loadThemePreference() {
150
+ /** One-time first-run callout shown over the welcome doc's Play view.
151
+ * Once the user starts writing, switches views themselves, or dismisses
152
+ * it, it never comes back -- on any workspace. */
153
+ const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
154
+ function isWelcomeGatewayDismissed() {
78
155
  try {
79
- const raw = localStorage.getItem(THEME_PREF_KEY);
80
- if (raw === 'light' || raw === 'dark' || raw === 'auto')
81
- return raw;
156
+ return localStorage.getItem(WELCOME_GATEWAY_KEY) === '1';
82
157
  }
83
158
  catch {
84
- // ignore
159
+ return false;
85
160
  }
86
- return 'auto';
87
161
  }
88
- function saveThemePreference(pref) {
162
+ function markWelcomeGatewayDismissed() {
89
163
  try {
90
- localStorage.setItem(THEME_PREF_KEY, pref);
164
+ localStorage.setItem(WELCOME_GATEWAY_KEY, '1');
91
165
  }
92
166
  catch {
93
167
  // ignore quota errors
94
168
  }
95
169
  }
96
170
  const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
97
- const SIDEBAR_WIDTH_DEFAULT = 260;
98
- const SIDEBAR_WIDTH_MIN = 180;
171
+ const SIDEBAR_WIDTH_DEFAULT = 320;
172
+ const SIDEBAR_WIDTH_MIN = 320;
99
173
  const SIDEBAR_WIDTH_MAX = 600;
100
- /** Drag below this many pixels and the sidebar collapses entirely
101
- * the editor takes the full width and a back-arrow appears in the
102
- * toolbar so the user can pop the sidebar back open. Same UX as
103
- * the existing mobile narrow-viewport flow. */
104
- 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;
105
178
  function loadSidebarWidth() {
106
179
  try {
107
180
  const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
@@ -175,11 +248,115 @@ function stripExtension(name) {
175
248
  return name.replace(/\.[^.]+$/, '');
176
249
  }
177
250
  function normaliseProviderPath(p) {
178
- return '/' + p.replace(/^\/+/, '');
251
+ const canonical = parseWorkspacePath(p);
252
+ return canonical ? `/${canonical}` : '/';
179
253
  }
180
254
  function sameProviderPath(a, b) {
181
255
  return normaliseProviderPath(a) === normaliseProviderPath(b);
182
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
+ }
183
360
  /** Portable relative link from one workspace file to another. Walks up
184
361
  * from the source's directory and back down to the target so the link
185
362
  * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
@@ -211,10 +388,12 @@ async function collectMarkdownFiles(fs, root) {
211
388
  visited.add(dir);
212
389
  let entries;
213
390
  try {
214
- entries = await fs.readDirectory(dir);
391
+ entries = await readProviderDirectory(fs, dir);
215
392
  }
216
- catch {
217
- return;
393
+ catch (error) {
394
+ if (error instanceof FsError && error.code === 'not-found')
395
+ return;
396
+ throw error;
218
397
  }
219
398
  for (const entry of entries) {
220
399
  if (entry.kind === 'directory') {
@@ -236,20 +415,57 @@ async function collectMarkdownFiles(fs, root) {
236
415
  await walk(root);
237
416
  return out;
238
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
+ }
239
459
  async function createElectronProviderFromWorkspace(ws) {
240
460
  if (!ws.rootPath)
241
461
  return null;
242
462
  try {
243
- await getDocBlocksHost().workspaces.register({
244
- id: ws.id,
245
- name: ws.name,
246
- rootPath: ws.rootPath,
247
- });
463
+ await getDocBlocksHost().workspaces.register(ws.id);
248
464
  }
249
465
  catch {
250
466
  return null;
251
467
  }
252
- return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
468
+ return createElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
253
469
  }
254
470
  function useIsMobile(breakpoint = 768) {
255
471
  const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
@@ -267,16 +483,34 @@ function FolderGlyph() {
267
483
  function FileGlyph() {
268
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" })] }));
269
485
  }
270
- 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, }) {
271
487
  const osTheme = useOsTheme();
272
488
  const [themePreference, setThemePreference] = useState(loadThemePreference);
273
- // "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
274
491
  // is kept only for API back-compat and does not override OS detection.
275
492
  const resolvedTheme = themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
276
493
  const handleThemeChange = useCallback((pref) => {
277
494
  setThemePreference(pref);
278
495
  saveThemePreference(pref);
279
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
+ }, []);
280
514
  const [viewPreferences, setViewPreferences] = useState(loadViewPreferences);
281
515
  const handleViewPreferencesChange = useCallback((prefs) => {
282
516
  setViewPreferences(prefs);
@@ -284,21 +518,26 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
284
518
  }, []);
285
519
  const isMobile = useIsMobile();
286
520
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
287
- // Sidebar width persisted across sessions, dragged via the resizer
521
+ // Sidebar width -- persisted across sessions, dragged via the resizer
288
522
  // between sidebar and editor area. We track the "live" width during a
289
523
  // drag in a ref so each mousemove doesn't trigger a state update; only
290
524
  // setState on commit so React doesn't churn through every pixel.
291
525
  const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
292
526
  // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
293
- // 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
294
528
  // the mobile narrow-viewport flow, where only the sidebar OR the
295
529
  // editor is visible at a time and a back-arrow in the toolbar pops
296
- // between them. A "Restore split view" button on the editor toolbar
530
+ // between them. A "Restore split view" button in either pane's header
297
531
  // exits compact mode; on real mobile that button is suppressed
298
532
  // because there's not enough viewport for side-by-side. Not
299
533
  // persisted across reloads. */
300
534
  const [compactLayout, setCompactLayout] = useState(false);
301
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);
302
541
  const sidebarRef = useRef(null);
303
542
  const dragStateRef = useRef(null);
304
543
  const handleResizerPointerDown = useCallback((e) => {
@@ -320,7 +559,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
320
559
  return;
321
560
  lastRaw = drag.startWidth + (ev.clientX - drag.startX);
322
561
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
323
- // Below threshold preview the collapse by snapping to the
562
+ // Below threshold -- preview the collapse by snapping to the
324
563
  // minimum width and fading the sidebar, so the user can see
325
564
  // they've crossed into "release to collapse" territory.
326
565
  sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
@@ -344,8 +583,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
344
583
  sidebarRef.current.style.opacity = '';
345
584
  }
346
585
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
347
- // Released below threshold switch to compact (single-pane)
348
- // 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
349
588
  // sidebarWidth so exiting compact mode restores it.
350
589
  setCompactLayout(true);
351
590
  setMobileShowEditor(true);
@@ -364,8 +603,15 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
364
603
  document.addEventListener('pointerup', onUp);
365
604
  }, [sidebarWidth]);
366
605
  const [provider, setProvider] = useState(null);
606
+ const [workspaceStartupError, setWorkspaceStartupError] = useState(null);
367
607
  const [activeWorkspaceId, setActiveWorkspaceId] = useState(null);
368
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]);
369
615
  // Re-fetch the descriptor whenever the active id (or its versioning
370
616
  // override) changes. `descriptorRefreshKey` is bumped after writes so
371
617
  // the resolver picks up the updated override without remounting.
@@ -384,6 +630,17 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
384
630
  cancelled = true;
385
631
  };
386
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;
387
644
  const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
388
645
  const [versioningPreference, setVersioningPreference] = useState(loadVersioningPreference);
389
646
  const handleVersioningPreferenceChange = useCallback((pref) => {
@@ -404,15 +661,211 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
404
661
  setWorkspaceSettingsOpen(false);
405
662
  }, [activeWorkspaceDescriptor]);
406
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]);
407
676
  const [selectedFolder, setSelectedFolder] = useState(null);
408
677
  const [folderEntries, setFolderEntries] = useState([]);
409
- const [editorContent, setEditorContent] = useState('');
410
- 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]);
411
695
  const [explorerKey, setExplorerKey] = useState(0);
412
696
  const [initialView, setInitialView] = useState('wysiwyg');
413
- /** Suppress popstate handling during programmatic navigation. */
414
- const skipPopState = useRef(false);
415
- const lastLocalSaveRef = useRef(null);
697
+ // First-run gateway over the welcome doc's Play view -- see WELCOME_GATEWAY_KEY.
698
+ const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
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]);
416
869
  /**
417
870
  * Per-file media container: for `notes.md`, images live in
418
871
  * `notes_files/` next to the markdown. Created lazily on first write,
@@ -421,6 +874,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
421
874
  */
422
875
  const mediaContainerRef = useRef(null);
423
876
  const [mediaProvider, setMediaProvider] = useState(null);
877
+ const [mediaEpoch, setMediaEpoch] = useState(0);
424
878
  /**
425
879
  * Per-document container scoped to `<basename>_files/`. This is what
426
880
  * the editor uses for version history (`.versions/` lives here) and
@@ -441,69 +895,112 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
441
895
  const pushHash = useCallback((wsId, filePath) => {
442
896
  const hash = buildHash(wsId, filePath);
443
897
  if (window.location.hash !== hash) {
444
- skipPopState.current = true;
445
898
  window.history.pushState(null, '', hash);
446
899
  }
447
900
  }, []);
448
901
  /** Open a workspace (and optionally a file) from identifiers.
449
902
  * Returns the FileSystemProvider on success, or null on failure. */
450
- const openFromIds = useCallback(async (wsId, filePath, push, view) => {
451
- const workspaces = await listWorkspaces();
452
- const ws = workspaces.find((w) => w.id === wsId);
453
- if (!ws)
903
+ const openFromIds = useCallback(async (wsId, filePath, push, view, navigationRequestId) => {
904
+ const requestId = navigationRequestId ?? ++navigationRequestRef.current;
905
+ if (requestId !== navigationRequestRef.current)
454
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;
455
911
  let fsProvider = null;
456
- if (ws.type === 'electron-native') {
457
- fsProvider = await createElectronProviderFromWorkspace(ws);
458
- if (!fsProvider)
459
- return null;
460
- }
461
- else if (ws.type === 'native') {
462
- const restored = await restoreNativeFolder(ws.id);
463
- if (!restored)
464
- return null;
465
- fsProvider = restored;
912
+ if (transient) {
913
+ ws = transient.descriptor;
914
+ fsProvider = transient.provider;
466
915
  }
467
916
  else {
468
- 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
+ }
469
935
  }
470
- await touchWorkspace(ws.id);
471
- setProvider(fsProvider);
472
- setActiveWorkspaceId(ws.id);
473
- if (filePath) {
474
- const content = await fsProvider.readFile(filePath);
475
- if (content !== null) {
476
- 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);
477
972
  setSelectedFolder(null);
478
973
  setFolderEntries([]);
479
- setEditorContent(content);
480
974
  const effectiveView = view ?? 'wysiwyg';
481
975
  setInitialView(effectiveView);
482
- setEditorKey((k) => k + 1);
483
- 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
+ }
484
981
  }
485
982
  else {
486
983
  setSelectedFile(null);
487
984
  setSelectedFolder(null);
488
985
  setFolderEntries([]);
489
- setEditorContent('');
490
- setEditorKey((k) => k + 1);
491
986
  }
987
+ if (push) {
988
+ pushHash(ws.id, openedFile);
989
+ }
990
+ return fsProvider;
492
991
  }
493
- else {
494
- setSelectedFile(null);
495
- setSelectedFolder(null);
496
- setFolderEntries([]);
497
- setEditorContent('');
498
- setEditorKey((k) => k + 1);
499
- }
500
- if (push) {
501
- pushHash(ws.id, filePath);
992
+ finally {
993
+ if (!transient && !adopted)
994
+ await getFileSystemProviderV2(fsProvider)?.dispose();
502
995
  }
503
- return fsProvider;
504
- }, [pushHash]);
505
- const seedWelcomeFile = useCallback(async (fs) => {
506
- 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;
507
1004
  // If the only file is the welcome doc, auto-select it.
508
1005
  // Match either casing so workspaces seeded before the rename
509
1006
  // (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
@@ -511,15 +1008,18 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
511
1008
  entries[0].kind === 'file' &&
512
1009
  entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md') {
513
1010
  const aboutPath = entries[0].path;
514
- const content = await fs.readFile(aboutPath);
515
- 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;
516
1016
  setSelectedFile(aboutPath);
517
- setEditorContent(content);
518
1017
  setInitialView('preview');
519
- setEditorKey((k) => k + 1);
520
1018
  setExplorerKey((k) => k + 1);
521
1019
  pushHash(fs.id, aboutPath);
522
1020
  saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
1021
+ if (!isWelcomeGatewayDismissed())
1022
+ setShowWelcomeGateway(true);
523
1023
  }
524
1024
  return;
525
1025
  }
@@ -529,46 +1029,127 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
529
1029
  const welcomeContent = [
530
1030
  '# Welcome to DocBlocks',
531
1031
  '',
1032
+ 'Your markdown can do anything.',
1033
+ '',
532
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.',
533
1035
  '',
534
1036
  'Simple to write. Beautiful wherever it goes.',
535
1037
  '',
536
1038
  '## Features',
537
1039
  '',
538
- '- **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.',
539
- '- **Workspaces** Organize your documents into separate workspaces in the browser or on your device.',
540
- '- **Useful Everywhere** Your content is usable across multiple formats Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
541
- '- **Playback & Video** Preview your documents as rich visual presentations and export them as MP4 video',
542
- '- **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',
543
1045
  '',
544
1046
  '## Getting Started',
545
1047
  '',
546
1048
  '1. Create a new file using the **New File** button in the sidebar',
547
- '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',
548
1050
  '3. Your work is saved automatically',
549
1051
  '',
550
- '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).',
551
1053
  ].join('\n');
552
- 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;
553
1071
  setSelectedFile(welcomePath);
554
- setEditorContent(welcomeContent);
555
1072
  setInitialView('preview');
556
- setEditorKey((k) => k + 1);
557
1073
  setExplorerKey((k) => k + 1);
558
1074
  pushHash(fs.id, welcomePath);
559
1075
  saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
560
- }, [pushHash]);
561
- // Initialise workspace on mount — restore from hash or last-used
1076
+ if (!isWelcomeGatewayDismissed())
1077
+ setShowWelcomeGateway(true);
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;
1086
+ /** Hide the welcome gateway and never show it again. Safe to call from
1087
+ * paths where it may not be showing -- only persists when it was. */
1088
+ const closeWelcomeGateway = useCallback(() => {
1089
+ setShowWelcomeGateway((showing) => {
1090
+ if (showing)
1091
+ markWelcomeGatewayDismissed();
1092
+ return false;
1093
+ });
1094
+ }, []);
1095
+ /** Gateway CTA -- flip the welcome doc from Play into the editor. */
1096
+ const handleStartWriting = useCallback(() => {
1097
+ closeWelcomeGateway();
1098
+ setInitialView('wysiwyg');
1099
+ setEditorPresentationEpoch((epoch) => epoch + 1);
1100
+ if (activeWorkspaceId && selectedFile) {
1101
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
1102
+ }
1103
+ }, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
1104
+ // Initialise workspace on mount -- restore from hash or last-used
562
1105
  useEffect(() => {
563
- (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;
564
1138
  // Try restoring from URL hash first
565
1139
  const hashState = parseHash();
566
1140
  if (hashState) {
567
- 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;
568
1149
  if (restoredProvider) {
569
1150
  // If the hash had no file, check whether we should auto-select the welcome doc
570
1151
  if (!hashState.filePath) {
571
- await seedWelcomeFile(restoredProvider);
1152
+ await startupSeedWelcomeFileRef.current(restoredProvider, requestId);
572
1153
  }
573
1154
  return;
574
1155
  }
@@ -576,14 +1157,21 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
576
1157
  // Try restoring last viewed document from localStorage
577
1158
  const lastState = loadLastState();
578
1159
  if (lastState) {
579
- 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;
580
1167
  if (restoredProvider)
581
1168
  return;
582
1169
  }
583
1170
  let fsProvider = null;
584
- const electron = isElectronHost();
585
1171
  const workspaces = await listWorkspaces();
586
- // On desktop, hide web-only workspaces (indexeddb/native) — only
1172
+ if (!isCurrent())
1173
+ return;
1174
+ // On desktop, hide web-only workspaces (indexeddb/native) -- only
587
1175
  // folder-based workspaces are valid.
588
1176
  const candidates = electron
589
1177
  ? workspaces.filter((w) => w.type === 'electron-native')
@@ -595,36 +1183,56 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
595
1183
  const p = await createElectronProviderFromWorkspace(ws);
596
1184
  if (!p)
597
1185
  continue;
1186
+ if (!isCurrent()) {
1187
+ await getFileSystemProviderV2(p)?.dispose();
1188
+ return;
1189
+ }
598
1190
  await touchWorkspace(ws.id);
1191
+ if (!isCurrent()) {
1192
+ await getFileSystemProviderV2(p)?.dispose();
1193
+ return;
1194
+ }
599
1195
  fsProvider = p;
600
- setProvider(p);
601
- setActiveWorkspaceId(ws.id);
602
1196
  break;
603
1197
  }
604
1198
  else if (ws.type === 'native') {
605
- const restored = await restoreNativeFolder(ws.id);
1199
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
606
1200
  if (restored) {
1201
+ if (!isCurrent()) {
1202
+ await getFileSystemProviderV2(restored)?.dispose();
1203
+ return;
1204
+ }
607
1205
  await touchWorkspace(ws.id);
1206
+ if (!isCurrent()) {
1207
+ await getFileSystemProviderV2(restored)?.dispose();
1208
+ return;
1209
+ }
608
1210
  fsProvider = restored;
609
- setProvider(restored);
610
- setActiveWorkspaceId(ws.id);
611
1211
  break;
612
1212
  }
613
1213
  }
614
1214
  else {
615
- 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
+ }
616
1220
  await touchWorkspace(ws.id);
1221
+ if (!isCurrent()) {
1222
+ await getFileSystemProviderV2(p)?.dispose();
1223
+ return;
1224
+ }
617
1225
  fsProvider = p;
618
- setProvider(p);
619
- setActiveWorkspaceId(ws.id);
620
1226
  break;
621
1227
  }
622
1228
  }
623
1229
  if (!fsProvider) {
624
1230
  if (electron) {
625
1231
  // Desktop: ask the host for the default folder workspace
626
- // (creates ~/Documents/DocBlocks on first launch).
1232
+ // (creates DocBlocks in the OS-resolved Documents folder on first launch).
627
1233
  const info = await getDocBlocksHost().workspaces.getDefault();
1234
+ if (!isCurrent())
1235
+ return;
628
1236
  const descriptor = {
629
1237
  id: info.id,
630
1238
  name: info.name,
@@ -633,47 +1241,164 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
633
1241
  lastOpened: new Date().toISOString(),
634
1242
  };
635
1243
  await saveWorkspace(descriptor);
636
- 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
+ }
637
1251
  fsProvider = p;
638
- setProvider(p);
639
- setActiveWorkspaceId(info.id);
640
1252
  }
641
1253
  else {
642
1254
  // Web: create the IndexedDB default workspace
643
1255
  const defaultWs = await ensureDefaultWorkspace();
644
- 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
+ }
645
1263
  fsProvider = p;
646
- setProvider(p);
647
- setActiveWorkspaceId(defaultWs.id);
648
1264
  }
649
1265
  }
650
- // Set initial hash
651
- pushHash(fsProvider.id, null);
652
- // Seed welcome file if workspace is empty
653
- await seedWelcomeFile(fsProvider);
654
- })();
655
- }, [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]);
656
1295
  // Handle browser back/forward
657
1296
  useEffect(() => {
658
1297
  const onPopState = () => {
659
- if (skipPopState.current) {
660
- skipPopState.current = false;
661
- return;
662
- }
663
1298
  const hashState = parseHash();
664
1299
  if (hashState) {
665
- 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
+ });
666
1317
  }
667
1318
  };
668
1319
  window.addEventListener('popstate', onPopState);
669
1320
  return () => window.removeEventListener('popstate', onPopState);
670
- }, [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]);
671
1392
  // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
672
1393
  useEffect(() => {
673
1394
  const handler = (e) => {
674
1395
  const target = e.target.closest?.('[data-view]');
675
1396
  if (target) {
676
1397
  const view = target.getAttribute('data-view');
1398
+ if (view) {
1399
+ // The user found the view tabs on their own -- the gateway's job is done.
1400
+ closeWelcomeGateway();
1401
+ }
677
1402
  if (view && activeWorkspaceId && selectedFile) {
678
1403
  saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
679
1404
  }
@@ -681,22 +1406,10 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
681
1406
  };
682
1407
  window.addEventListener('click', handler, true);
683
1408
  return () => window.removeEventListener('click', handler, true);
684
- }, [activeWorkspaceId, selectedFile]);
685
- const handleAutoSaved = useCallback((filePath, savedContent) => {
686
- lastLocalSaveRef.current = {
687
- filePath: normaliseProviderPath(filePath),
688
- content: savedContent,
689
- savedAt: Date.now(),
690
- };
691
- }, []);
692
- // Auto-save current file. The returned `flush` is called from the
693
- // Ctrl/Cmd+S handler below so the user gets immediate confirmation.
694
- const { flush: flushAutoSave } = useAutoSave(provider, selectedFile, editorContent, 500, handleAutoSaved);
695
- // Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
696
- // small "auto-save confirmed" toast. Files are already saved on every
697
- // keystroke (debounced) — this is purely UX reassurance for users who
698
- // muscle-memory hit Save.
699
- const [saveToastVisible, setSaveToastVisible] = useState(false);
1409
+ }, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
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);
700
1413
  const saveToastTimerRef = useRef(null);
701
1414
  useEffect(() => {
702
1415
  const onKey = (e) => {
@@ -706,21 +1419,89 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
706
1419
  return;
707
1420
  e.preventDefault();
708
1421
  e.stopPropagation();
709
- void flushAutoSave().catch(() => undefined);
710
- setSaveToastVisible(true);
711
- if (saveToastTimerRef.current)
712
- clearTimeout(saveToastTimerRef.current);
713
- 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
+ })();
714
1441
  };
715
1442
  window.addEventListener('keydown', onKey, true);
716
1443
  return () => window.removeEventListener('keydown', onKey, true);
717
- }, [flushAutoSave]);
1444
+ }, [documentSession]);
718
1445
  useEffect(() => {
719
1446
  return () => {
720
1447
  if (saveToastTimerRef.current)
721
1448
  clearTimeout(saveToastTimerRef.current);
722
1449
  };
723
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]);
724
1505
  // Per-file media: for `notes.md` images live in `notes_files/` beside it.
725
1506
  // Rebuilds whenever the provider or selected file changes.
726
1507
  useEffect(() => {
@@ -745,9 +1526,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
745
1526
  return () => {
746
1527
  mp.dispose();
747
1528
  };
748
- }, [provider, selectedFile]);
1529
+ }, [provider, selectedFile, mediaEpoch]);
749
1530
  // Invalidate the document-link candidate cache when the backing
750
- // workspace changes otherwise the link dialog would surface
1531
+ // workspace changes -- otherwise the link dialog would surface
751
1532
  // neighbours from a previously-open workspace.
752
1533
  useEffect(() => {
753
1534
  mdFileCacheRef.current = null;
@@ -807,66 +1588,244 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
807
1588
  }, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
808
1589
  // React to external file changes watched by the Electron host (chokidar).
809
1590
  useEffect(() => {
810
- if (!isElectronHost())
1591
+ if (!provider)
811
1592
  return;
812
- if (!provider || !(provider instanceof ElectronFileSystemProvider))
1593
+ const providerV2 = getFileSystemProviderV2(provider);
1594
+ if (!providerV2?.capabilities.watch)
813
1595
  return;
814
- const unwatch = provider.watch((changedPath) => {
815
- setExplorerKey((k) => k + 1);
816
- 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;
817
1607
  return;
818
- // If the open file's contents changed on disk, reload it (best-effort).
819
- (async () => {
820
- const content = await provider.readFile(selectedFile);
821
- if (content === null || content === editorContent)
822
- return;
823
- const localSave = lastLocalSaveRef.current;
824
- if (localSave &&
825
- sameProviderPath(localSave.filePath, selectedFile) &&
826
- localSave.content === content &&
827
- Date.now() - localSave.savedAt < 5000) {
828
- return;
829
- }
830
- setEditorContent(content);
831
- setEditorKey((k) => k + 1);
832
- })();
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
+ },
833
1654
  });
834
- return unwatch;
835
- }, [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]);
836
1783
  const handleWorkspaceSelect = useCallback(async (ws) => {
1784
+ const requestId = ++navigationRequestRef.current;
837
1785
  await touchWorkspace(ws.id);
1786
+ if (requestId !== navigationRequestRef.current)
1787
+ return;
838
1788
  let nextProvider = null;
839
- 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') {
840
1794
  nextProvider = await createElectronProviderFromWorkspace(ws);
841
1795
  if (!nextProvider)
842
1796
  return;
843
1797
  }
844
1798
  else if (ws.type === 'native') {
845
- const restored = await restoreNativeFolder(ws.id);
1799
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
846
1800
  if (!restored) {
847
- // Permission denied or handle lost fall through without changing provider
1801
+ // Permission denied or handle lost -- fall through without changing provider
848
1802
  return;
849
1803
  }
850
1804
  nextProvider = restored;
851
1805
  }
852
1806
  else {
853
- nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
1807
+ nextProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
854
1808
  }
1809
+ if (requestId !== navigationRequestRef.current)
1810
+ return;
1811
+ if (!(await transitionAwayFromDocument(requestId)))
1812
+ return;
855
1813
  setProvider(nextProvider);
856
1814
  setActiveWorkspaceId(ws.id);
857
1815
  setSelectedFile(null);
858
1816
  setSelectedFolder(null);
859
1817
  setFolderEntries([]);
860
- setEditorContent('');
861
- setEditorKey((k) => k + 1);
862
1818
  pushHash(ws.id, null);
863
- }, [pushHash]);
1819
+ }, [pushHash, transitionAwayFromDocument]);
864
1820
  const handleOpenFolder = useCallback(async () => {
1821
+ const requestId = ++navigationRequestRef.current;
865
1822
  try {
866
1823
  if (isElectronHost()) {
867
1824
  const info = await getDocBlocksHost().workspaces.pickFolder();
868
1825
  if (!info)
869
1826
  return; // user cancelled
1827
+ if (requestId !== navigationRequestRef.current)
1828
+ return;
870
1829
  const descriptor = {
871
1830
  id: info.id,
872
1831
  name: info.name,
@@ -875,18 +1834,22 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
875
1834
  lastOpened: new Date().toISOString(),
876
1835
  };
877
1836
  await saveWorkspace(descriptor);
878
- 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;
879
1842
  setProvider(provider);
880
1843
  setActiveWorkspaceId(descriptor.id);
881
1844
  setSelectedFile(null);
882
1845
  setSelectedFolder(null);
883
1846
  setFolderEntries([]);
884
- setEditorContent('');
885
- setEditorKey((k) => k + 1);
886
1847
  pushHash(descriptor.id, null);
887
1848
  return;
888
1849
  }
889
- const nativeProvider = await openNativeFolder();
1850
+ const nativeProvider = await (await loadNativeFileSystem()).openNativeFolder();
1851
+ if (requestId !== navigationRequestRef.current)
1852
+ return;
890
1853
  const descriptor = {
891
1854
  id: nativeProvider.id,
892
1855
  name: nativeProvider.label,
@@ -894,19 +1857,47 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
894
1857
  lastOpened: new Date().toISOString(),
895
1858
  };
896
1859
  await saveWorkspace(descriptor);
1860
+ if (requestId !== navigationRequestRef.current)
1861
+ return;
1862
+ if (!(await transitionAwayFromDocument(requestId)))
1863
+ return;
897
1864
  setProvider(nativeProvider);
898
1865
  setActiveWorkspaceId(descriptor.id);
899
1866
  setSelectedFile(null);
900
1867
  setSelectedFolder(null);
901
1868
  setFolderEntries([]);
902
- setEditorContent('');
903
- setEditorKey((k) => k + 1);
904
1869
  pushHash(descriptor.id, null);
905
1870
  }
906
1871
  catch {
907
1872
  // User cancelled or API not supported
908
1873
  }
909
- }, [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]);
910
1901
  const handleNewFile = useCallback(async () => {
911
1902
  if (!provider)
912
1903
  return;
@@ -914,22 +1905,90 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
914
1905
  if (!name)
915
1906
  return;
916
1907
  const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
917
- await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
918
- setSelectedFile('/' + filename);
919
- 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);
920
1954
  setInitialView('wysiwyg');
921
- setEditorKey((k) => k + 1);
922
1955
  setExplorerKey((k) => k + 1);
1956
+ closeWelcomeGateway();
923
1957
  if (activeWorkspaceId) {
924
1958
  pushHash(activeWorkspaceId, '/' + filename);
925
1959
  }
926
- }, [provider, activeWorkspaceId, pushHash]);
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]);
927
1986
  const handleRevealWorkspace = useCallback(async () => {
928
1987
  if (!isElectronHost() || !activeWorkspaceId)
929
1988
  return;
930
1989
  const ws = await getWorkspace(activeWorkspaceId);
931
1990
  if (ws?.type === 'electron-native' && ws.rootPath) {
932
- await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
1991
+ await getDocBlocksHost().shell.revealInFolder(ws.id);
933
1992
  }
934
1993
  }, [activeWorkspaceId]);
935
1994
  // Subscribe to native menu commands (Electron host).
@@ -948,6 +2007,46 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
948
2007
  case 'file:revealWorkspace':
949
2008
  handleRevealWorkspace();
950
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;
951
2050
  case 'help:viewOnGitHub':
952
2051
  host.shell.openExternal('https://github.com/bendyline/docblocks');
953
2052
  break;
@@ -967,81 +2066,126 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
967
2066
  }
968
2067
  });
969
2068
  }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
970
- // Subscribe to open-file / deep-link requests from the OS.
971
- useEffect(() => {
972
- if (!isElectronHost())
973
- return;
974
- const host = getDocBlocksHost();
975
- return host.onOpenRequest(async (req) => {
976
- if (req.filePath) {
977
- const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
978
- const match = workspaces.find((w) => req.filePath.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath);
979
- if (match && match.rootPath) {
980
- const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
981
- await openFromIds(match.id, rel, true);
982
- }
983
- }
984
- else if (req.url) {
985
- try {
986
- const u = new URL(req.url);
987
- const path = u.searchParams.get('path');
988
- if (path) {
989
- const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
990
- const match = workspaces.find((w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath);
991
- if (match && match.rootPath) {
992
- const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
993
- await openFromIds(match.id, rel, true);
994
- }
995
- }
996
- }
997
- catch {
998
- // bad URL, ignore
999
- }
1000
- }
1001
- });
1002
- }, [openFromIds]);
2069
+ // OS open-file / deep-link handling lives after the container helpers it
2070
+ // depends on -- see the `openTransient` + onOpenRequest effect below.
1003
2071
  const handleSelect = useCallback(async (path, kind) => {
1004
2072
  if (!provider || !activeWorkspaceId)
1005
2073
  return;
2074
+ const requestId = ++navigationRequestRef.current;
1006
2075
  if (kind === 'directory') {
2076
+ if (!(await transitionAwayFromDocument(requestId)))
2077
+ return;
2078
+ if (requestId !== navigationRequestRef.current)
2079
+ return;
1007
2080
  setSelectedFile(null);
1008
2081
  setSelectedFolder(path);
1009
- const entries = await provider.readDirectory(path);
2082
+ const entries = await readProviderDirectory(provider, path);
2083
+ if (requestId !== navigationRequestRef.current)
2084
+ return;
1010
2085
  setFolderEntries(entries);
1011
- setEditorContent('');
1012
- setEditorKey((k) => k + 1);
1013
2086
  pushHash(activeWorkspaceId, null);
1014
2087
  }
1015
2088
  else {
1016
- 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;
1017
2111
  setSelectedFile(path);
1018
2112
  setSelectedFolder(null);
1019
2113
  setFolderEntries([]);
1020
- setEditorContent(content ?? '');
1021
2114
  setInitialView('wysiwyg');
1022
- setEditorKey((k) => k + 1);
2115
+ closeWelcomeGateway();
1023
2116
  pushHash(activeWorkspaceId, path);
1024
2117
  saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
1025
2118
  if (effectiveCompact)
1026
2119
  setMobileShowEditor(true);
1027
2120
  }
1028
- }, [provider, activeWorkspaceId, pushHash, effectiveCompact]);
1029
- 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) => {
1030
2150
  if (!provider)
1031
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
+ }
1032
2178
  // If the open file was deleted, clear the editor
1033
2179
  if (selectedFile) {
1034
- const exists = await provider.exists(selectedFile);
2180
+ const exists = await providerEntryExists(provider, selectedFile);
1035
2181
  if (!exists) {
1036
2182
  setSelectedFile(null);
1037
- setEditorContent('');
1038
- setEditorKey((k) => k + 1);
1039
2183
  if (activeWorkspaceId)
1040
2184
  pushHash(activeWorkspaceId, null);
1041
2185
  }
1042
2186
  }
1043
2187
  if (selectedFolder) {
1044
- const entries = await provider.readDirectory(selectedFolder);
2188
+ const entries = await readProviderDirectory(provider, selectedFolder);
1045
2189
  setFolderEntries(entries);
1046
2190
  }
1047
2191
  }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
@@ -1055,6 +2199,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1055
2199
  const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
1056
2200
  const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
1057
2201
  const entries = await source.listFiles();
2202
+ const targetV2 = getFileSystemProviderV2(target);
1058
2203
  for (const entry of entries) {
1059
2204
  if (entry.path.endsWith('.md'))
1060
2205
  continue;
@@ -1062,7 +2207,16 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1062
2207
  if (!data)
1063
2208
  continue;
1064
2209
  const cleanPath = entry.path.replace(/^\/+/, '');
1065
- 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
+ }
1066
2220
  }
1067
2221
  }, []);
1068
2222
  const handleImportFiles = useCallback(async (files) => {
@@ -1098,7 +2252,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1098
2252
  else {
1099
2253
  continue;
1100
2254
  }
1101
- await provider.writeFile(destPath, markdown);
2255
+ await writeProviderText(provider, destPath, markdown);
1102
2256
  }
1103
2257
  catch (err) {
1104
2258
  console.error(`Failed to import ${file.name}:`, err);
@@ -1108,8 +2262,16 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1108
2262
  setExplorerKey((k) => k + 1);
1109
2263
  }, [provider, persistImportedMedia]);
1110
2264
  const handleEditorChange = useCallback((source) => {
1111
- setEditorContent(source);
1112
- }, []);
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]);
1113
2275
  const handleRenameWorkspace = useCallback(async () => {
1114
2276
  if (!activeWorkspaceId)
1115
2277
  return;
@@ -1120,45 +2282,210 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1120
2282
  if (!newName || newName === ws.name)
1121
2283
  return;
1122
2284
  await saveWorkspace({ ...ws, name: newName });
1123
- // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
1124
- setEditorKey((k) => k + 1);
2285
+ setDescriptorRefreshKey((key) => key + 1);
1125
2286
  }, [activeWorkspaceId]);
1126
2287
  /**
1127
- * Walk a FileSystemProvider and copy every file into `container` under
1128
- * `pathPrefix` (no leading slash; empty string for the root). Used by
1129
- * 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.
1130
2291
  */
1131
- const copyProviderToContainer = useCallback(async (src, container, pathPrefix) => {
1132
- const encoder = new TextEncoder();
1133
- const stack = ['/'];
1134
- while (stack.length > 0) {
1135
- const dir = stack.pop();
1136
- const entries = await src.readDirectory(dir);
1137
- for (const entry of entries) {
1138
- if (entry.kind === 'directory') {
1139
- stack.push(entry.path);
1140
- 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.');
1141
2323
  }
1142
- const rel = entry.path.replace(/^\/+/, '');
1143
- const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
1144
- // Files may be stored as text (writeFile) or binary (writeBinary);
1145
- // try binary first, fall back to text and encode as UTF-8.
1146
- const binary = await src.readBinary(entry.path);
1147
- if (binary) {
1148
- await container.writeFile(zipPath, binary);
1149
- 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.');
1150
2334
  }
1151
- const text = await src.readFile(entry.path);
1152
- if (text !== null) {
1153
- 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;
1154
2373
  }
2374
+ else if (!registered) {
2375
+ await getFileSystemProviderV2(mem)?.dispose();
2376
+ }
2377
+ }
2378
+ finally {
2379
+ if (!opened)
2380
+ await revokeAbandonedResource();
1155
2381
  }
1156
2382
  }
1157
- }, []);
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;
1158
2484
  const handleDownloadWorkspace = useCallback(async () => {
1159
2485
  if (!provider)
1160
2486
  return;
1161
2487
  try {
2488
+ await documentSession.flush('backup');
1162
2489
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1163
2490
  import('@bendyline/squisq/storage'),
1164
2491
  import('@bendyline/squisq-formats/container'),
@@ -1178,23 +2505,26 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1178
2505
  console.error('Failed to download workspace', err);
1179
2506
  alert('Failed to download workspace. See console for details.');
1180
2507
  }
1181
- }, [provider, copyProviderToContainer]);
2508
+ }, [provider, documentSession]);
1182
2509
  /**
1183
2510
  * Bundle every workspace the host can open without further prompting
1184
2511
  * into a single zip, with each workspace nested under its own folder.
1185
2512
  * Native (browser-picked) workspaces whose handle hasn't been re-granted
1186
- * for this session are skipped restoring them would require a user
2513
+ * for this session are skipped -- restoring them would require a user
1187
2514
  * gesture per workspace.
1188
2515
  */
1189
2516
  const handleDownloadAllWorkspaces = useCallback(async () => {
1190
2517
  try {
2518
+ await documentSession.flush('backup');
1191
2519
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1192
2520
  import('@bendyline/squisq/storage'),
1193
2521
  import('@bendyline/squisq-formats/container'),
1194
2522
  ]);
1195
2523
  const electron = isElectronHost();
1196
2524
  const all = await listWorkspaces();
1197
- 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');
1198
2528
  if (candidates.length === 0) {
1199
2529
  alert('No workspaces to download.');
1200
2530
  return;
@@ -1204,17 +2534,25 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1204
2534
  const skipped = [];
1205
2535
  for (const ws of candidates) {
1206
2536
  let p = null;
2537
+ let ownsProvider = false;
1207
2538
  try {
1208
- 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') {
1209
2544
  p = await createElectronProviderFromWorkspace(ws);
2545
+ ownsProvider = p !== null;
1210
2546
  }
1211
2547
  else if (ws.type === 'native') {
1212
- // Restore without prompting only succeeds when the browser
2548
+ // Restore without prompting -- only succeeds when the browser
1213
2549
  // still remembers the granted handle for this origin/session.
1214
- p = await restoreNativeFolder(ws.id);
2550
+ p = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
2551
+ ownsProvider = p !== null;
1215
2552
  }
1216
2553
  else {
1217
- p = new IndexedDBFileSystemProvider(ws.id, ws.name);
2554
+ p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
2555
+ ownsProvider = true;
1218
2556
  }
1219
2557
  }
1220
2558
  catch (err) {
@@ -1232,7 +2570,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1232
2570
  folder = `${base} (${suffix++})`;
1233
2571
  }
1234
2572
  usedFolders.add(folder);
1235
- 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
+ }
1236
2580
  }
1237
2581
  if (usedFolders.size === 0) {
1238
2582
  alert('No workspaces could be opened for download.');
@@ -1254,7 +2598,52 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1254
2598
  console.error('Failed to download all workspaces', err);
1255
2599
  alert('Failed to download all workspaces. See console for details.');
1256
2600
  }
1257
- }, [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
+ }, []);
1258
2647
  const handleRemoveWorkspace = useCallback(async () => {
1259
2648
  if (!activeWorkspaceId)
1260
2649
  return;
@@ -1263,19 +2652,35 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1263
2652
  : 'Remove this workspace? This cannot be undone.';
1264
2653
  if (!confirm(confirmMsg))
1265
2654
  return;
2655
+ const requestId = ++navigationRequestRef.current;
2656
+ if (!(await transitionAwayFromDocument(requestId)))
2657
+ return;
1266
2658
  const ws = await getWorkspace(activeWorkspaceId);
1267
2659
  if (ws?.type === 'electron-native') {
1268
2660
  try {
1269
2661
  await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
1270
2662
  }
1271
2663
  catch {
1272
- // ignore host cleanup is best-effort
2664
+ // ignore -- host cleanup is best-effort
1273
2665
  }
1274
2666
  }
1275
- else {
1276
- 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);
1277
2680
  }
1278
2681
  await removeWorkspace(activeWorkspaceId);
2682
+ if (requestId !== navigationRequestRef.current)
2683
+ return;
1279
2684
  const electron = isElectronHost();
1280
2685
  // Switch to most recent remaining workspace or create default
1281
2686
  const remaining = (await listWorkspaces()).filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
@@ -1293,27 +2698,33 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
1293
2698
  lastOpened: new Date().toISOString(),
1294
2699
  };
1295
2700
  await saveWorkspace(descriptor);
1296
- const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
2701
+ const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1297
2702
  setProvider(p);
1298
2703
  setActiveWorkspaceId(info.id);
1299
2704
  setSelectedFile(null);
1300
2705
  setSelectedFolder(null);
1301
2706
  setFolderEntries([]);
1302
- setEditorContent('');
1303
- setEditorKey((k) => k + 1);
1304
2707
  }
1305
2708
  else {
1306
2709
  const defaultWs = await ensureDefaultWorkspace();
1307
- const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
2710
+ const fsProvider = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
1308
2711
  setProvider(fsProvider);
1309
2712
  setActiveWorkspaceId(defaultWs.id);
1310
2713
  setSelectedFile(null);
1311
2714
  setSelectedFolder(null);
1312
2715
  setFolderEntries([]);
1313
- setEditorContent('');
1314
- setEditorKey((k) => k + 1);
1315
2716
  }
1316
- }, [activeWorkspaceId, handleWorkspaceSelect]);
1317
- 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: { flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }, children: selectedFile && mediaProvider ? (_jsx(MediaContext.Provider, { value: mediaProvider, children: _jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, fileName: selectedFile, onChange: handleEditorChange, theme: 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}`) })) : 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." })] })) }))] })] }));
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 })] }))] })] }) }));
1318
2729
  }
1319
2730
  //# sourceMappingURL=DocBlocksShell.js.map