@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,14 +1,13 @@
1
1
  /**
2
- * DocBlocksShell top-level layout component.
2
+ * DocBlocksShell -- top-level layout component.
3
3
  *
4
4
  * Composes the left sidebar (WorkspacePicker + FileExplorer) with
5
5
  * the center editor area (squisq EditorShell).
6
6
  */
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 type {
11
- EditorTheme,
10
+ EditorColorScheme,
12
11
  EditorView,
13
12
  ViewPreferences,
14
13
  DocumentLinkProvider,
@@ -22,47 +21,193 @@ import {
22
21
  type PrunePolicy,
23
22
  type SaveVersionResult,
24
23
  } from '@bendyline/squisq/versions';
25
- import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
26
- import {
27
- IndexedDBFileSystemProvider,
24
+ import type {
25
+ DbkWorkspaceSnapshot,
28
26
  ElectronFileSystemProvider,
27
+ FileSystemProvider,
28
+ FileSystemEntry,
29
+ MemoryFileSystemProvider,
30
+ } from '@bendyline/docblocks/filesystem';
31
+ import {
32
+ createDbkWorkspaceSnapshot,
29
33
  FileSystemContentContainer,
30
34
  createFileMediaProvider,
31
- openNativeFolder,
32
- restoreNativeFolder,
33
- removeDirectoryHandle,
35
+ FsError,
36
+ getFileSystemProviderV2,
37
+ isQuotaExceededError,
38
+ parseWorkspacePath,
39
+ workspacePathContains,
34
40
  } from '@bendyline/docblocks/filesystem';
41
+ import {
42
+ createFileSystemDocumentTarget,
43
+ DocumentCommitConflictError,
44
+ DocumentSessionConflictError,
45
+ type DocumentCommitTarget,
46
+ type DocumentSessionEditScope,
47
+ } from '@bendyline/docblocks/document';
35
48
  import type { ContentContainer } from '@bendyline/squisq/storage';
36
49
  import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
50
+ import type { ElectronWorkspaceInfo, OpenRequest } from '@bendyline/docblocks/host';
37
51
  import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
38
52
  import {
39
53
  ensureDefaultWorkspace,
40
54
  getWorkspace,
41
55
  listWorkspaces,
56
+ reconcileElectronWorkspaceDescriptors,
42
57
  removeWorkspace,
43
58
  saveWorkspace,
44
59
  touchWorkspace,
60
+ registerTransientWorkspace,
61
+ getTransientWorkspace,
62
+ unregisterTransientWorkspace,
45
63
  } from '@bendyline/docblocks/workspace';
46
- import { AppMenu, type ThemePreference } from '../AppMenu/AppMenu.js';
47
- import { FileExplorer } from '../FileExplorer/FileExplorer.js';
64
+ import { AppMenu } from '../AppMenu/AppMenu.js';
65
+ import { pickEmptyDocumentPrompt } from '../editor.js';
66
+ import {
67
+ FileExplorer,
68
+ type FileTreeChange,
69
+ type FileTreeMutationHandler,
70
+ } from '../FileExplorer/FileExplorer.js';
48
71
  import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
49
72
  import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
73
+ import { SplitViewIcon } from '../icons.js';
50
74
  import {
51
75
  WorkspaceSettingsDialog,
52
76
  type WorkspaceVersioningOverride,
53
77
  } from '../WorkspacePicker/WorkspaceSettingsDialog.js';
54
- import { useAutoSave } from '../hooks/useAutoSave.js';
55
- import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
78
+ import { useDocumentSession } from '../hooks/useDocumentSession.js';
79
+ import {
80
+ ExportToolbarControls,
81
+ type ExportDestinationAdapter,
82
+ } from '../Export/DeferredExportToolbarControls.js';
83
+ import { GitContext } from '../Git/GitContext.js';
84
+ import { useGit } from '../Git/useGit.js';
85
+ // The editor is only needed after a document and its media container are
86
+ // ready. Workspace chrome stays interactive while this large feature loads.
87
+ const EditorShell = lazy(async () => {
88
+ const workersReady = (globalThis as { docBlocksMonacoWorkersReady?: Promise<unknown> })
89
+ .docBlocksMonacoWorkersReady;
90
+ // Worker setup is an enhancement; a host that cannot install language
91
+ // workers must not make the document editor unavailable.
92
+ await workersReady?.catch(() => undefined);
93
+ return import('./LazyEditorShell.js');
94
+ });
95
+ // The git dialogs/status bar only ever render under the Electron host, so
96
+ // they load as a split chunk -- the site never pays for them (the entry
97
+ // bundle budget is enforced by scripts/check-bundle-size.ts).
98
+ const GitUI = lazy(() => import('../Git/GitUI.js').then((m) => ({ default: m.GitUI })));
99
+ const GitToolbarControl = lazy(() =>
100
+ import('../Git/GitToolbarControl.js').then((m) => ({ default: m.GitToolbarControl })),
101
+ );
56
102
  import {
57
103
  loadVersioningPreference,
58
104
  resolveVersioningEnabled,
59
105
  saveVersioningPreference,
60
106
  type VersioningPreference,
61
107
  } from '../preferences/versioning.js';
108
+ import {
109
+ DB_CHROME_COLORS,
110
+ loadAccentColor,
111
+ loadThemePreference,
112
+ saveAccentColor,
113
+ saveThemePreference,
114
+ type AccentColor,
115
+ type ThemePreference,
116
+ } from '../preferences/theme.js';
117
+ import { retainFileSystemProvider } from '../provider-lease.js';
118
+ import { useDocumentTitle } from './document-title.js';
119
+ import { UpdateAvailableNotice } from './UpdateAvailableNotice.js';
120
+ import { WorkspaceAuthorityBarrier } from './workspace-authority-barrier.js';
121
+
122
+ let indexedDbFileSystemModule: Promise<
123
+ typeof import('@bendyline/docblocks/filesystem/indexeddb')
124
+ > | null = null;
125
+ let memoryFileSystemModule: Promise<
126
+ typeof import('@bendyline/docblocks/filesystem/memory')
127
+ > | null = null;
128
+ let nativeFileSystemModule: Promise<
129
+ typeof import('@bendyline/docblocks/filesystem/native')
130
+ > | null = null;
131
+ let electronFileSystemModule: Promise<
132
+ typeof import('@bendyline/docblocks/filesystem/electron')
133
+ > | null = null;
134
+
135
+ function loadIndexedDbFileSystem() {
136
+ indexedDbFileSystemModule ??= import('@bendyline/docblocks/filesystem/indexeddb').catch(
137
+ (error: unknown) => {
138
+ indexedDbFileSystemModule = null;
139
+ throw error;
140
+ },
141
+ );
142
+ return indexedDbFileSystemModule;
143
+ }
144
+
145
+ function loadMemoryFileSystem() {
146
+ memoryFileSystemModule ??= import('@bendyline/docblocks/filesystem/memory').catch(
147
+ (error: unknown) => {
148
+ memoryFileSystemModule = null;
149
+ throw error;
150
+ },
151
+ );
152
+ return memoryFileSystemModule;
153
+ }
154
+
155
+ function loadNativeFileSystem() {
156
+ nativeFileSystemModule ??= import('@bendyline/docblocks/filesystem/native').catch(
157
+ (error: unknown) => {
158
+ nativeFileSystemModule = null;
159
+ throw error;
160
+ },
161
+ );
162
+ return nativeFileSystemModule;
163
+ }
164
+
165
+ function loadElectronFileSystem() {
166
+ electronFileSystemModule ??= import('@bendyline/docblocks/filesystem/electron').catch(
167
+ (error: unknown) => {
168
+ electronFileSystemModule = null;
169
+ throw error;
170
+ },
171
+ );
172
+ return electronFileSystemModule;
173
+ }
174
+
175
+ async function createIndexedDbFileSystemProvider(id: string, label: string) {
176
+ const { IndexedDBFileSystemProvider } = await loadIndexedDbFileSystem();
177
+ return new IndexedDBFileSystemProvider(id, label);
178
+ }
179
+
180
+ async function createElectronFileSystemProvider(
181
+ id: string,
182
+ label: string,
183
+ rootPath: string,
184
+ ): Promise<ElectronFileSystemProvider> {
185
+ const { ElectronFileSystemProvider } = await loadElectronFileSystem();
186
+ return new ElectronFileSystemProvider(id, label, rootPath);
187
+ }
188
+
189
+ async function createMemoryFileSystemProvider(
190
+ id: string,
191
+ label: string,
192
+ ): Promise<MemoryFileSystemProvider> {
193
+ const { MemoryFileSystemProvider } = await loadMemoryFileSystem();
194
+ return new MemoryFileSystemProvider(id, label);
195
+ }
196
+
197
+ function isMemoryWorkspaceProvider(
198
+ provider: FileSystemProvider,
199
+ ): provider is MemoryFileSystemProvider {
200
+ const candidate = provider as Partial<MemoryFileSystemProvider>;
201
+ return (
202
+ typeof candidate.captureContents === 'function' &&
203
+ typeof candidate.replaceContents === 'function' &&
204
+ typeof candidate.treeVersion === 'number'
205
+ );
206
+ }
62
207
 
63
208
  export interface DocBlocksShellProps {
64
209
  /** Optional theme override. Omit or pass 'auto' to follow OS preference. */
65
- theme?: EditorTheme | 'auto';
210
+ theme?: EditorColorScheme | 'auto';
66
211
  /** Optional logo image URL for the app menu. */
67
212
  logoUrl?: string;
68
213
  /**
@@ -84,6 +229,19 @@ export interface DocBlocksShellProps {
84
229
  onSaveVersion?: (result: SaveVersionResult) => void;
85
230
  /** Optional escape hatch for hosts that want imperative access to the version manager. */
86
231
  versioningRef?: React.Ref<DocumentVersionManager | null>;
232
+ /**
233
+ * True when a new deploy is waiting (browser PWA hosts: a fresh service
234
+ * worker finished installing). The shell shows an "Update available"
235
+ * status-bar notice that opens a Reload/Later prompt.
236
+ */
237
+ updateAvailable?: boolean;
238
+ /** Activates the waiting update and reloads onto the new version. */
239
+ onApplyUpdate?: () => void;
240
+ /**
241
+ * True once the app has been fully cached for offline use (browser PWA
242
+ * hosts). Shown to the user once as a passive notice.
243
+ */
244
+ offlineReady?: boolean;
87
245
  }
88
246
 
89
247
  function useOsTheme(): 'light' | 'dark' {
@@ -148,35 +306,35 @@ function loadLastState(): LastState | null {
148
306
  }
149
307
  }
150
308
 
151
- const THEME_PREF_KEY = 'docblocks:themePreference';
309
+ /** One-time first-run callout shown over the welcome doc's Play view.
310
+ * Once the user starts writing, switches views themselves, or dismisses
311
+ * it, it never comes back -- on any workspace. */
312
+ const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
152
313
 
153
- function loadThemePreference(): ThemePreference {
314
+ function isWelcomeGatewayDismissed(): boolean {
154
315
  try {
155
- const raw = localStorage.getItem(THEME_PREF_KEY);
156
- if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw;
316
+ return localStorage.getItem(WELCOME_GATEWAY_KEY) === '1';
157
317
  } catch {
158
- // ignore
318
+ return false;
159
319
  }
160
- return 'auto';
161
320
  }
162
321
 
163
- function saveThemePreference(pref: ThemePreference): void {
322
+ function markWelcomeGatewayDismissed(): void {
164
323
  try {
165
- localStorage.setItem(THEME_PREF_KEY, pref);
324
+ localStorage.setItem(WELCOME_GATEWAY_KEY, '1');
166
325
  } catch {
167
326
  // ignore quota errors
168
327
  }
169
328
  }
170
329
 
171
330
  const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
172
- const SIDEBAR_WIDTH_DEFAULT = 260;
173
- const SIDEBAR_WIDTH_MIN = 180;
331
+ const SIDEBAR_WIDTH_DEFAULT = 320;
332
+ const SIDEBAR_WIDTH_MIN = 320;
174
333
  const SIDEBAR_WIDTH_MAX = 600;
175
- /** Drag below this many pixels and the sidebar collapses entirely
176
- * the editor takes the full width and a back-arrow appears in the
177
- * toolbar so the user can pop the sidebar back open. Same UX as
178
- * the existing mobile narrow-viewport flow. */
179
- const SIDEBAR_COLLAPSE_THRESHOLD = 120;
334
+ /** Drag below this many pixels and the sidebar collapses entirely --
335
+ * the workspace pane takes the full width and offers a control to
336
+ * restore the side-by-side layout. */
337
+ const SIDEBAR_COLLAPSE_THRESHOLD = SIDEBAR_WIDTH_MIN;
180
338
 
181
339
  function loadSidebarWidth(): number {
182
340
  try {
@@ -255,13 +413,138 @@ function stripExtension(name: string): string {
255
413
  }
256
414
 
257
415
  function normaliseProviderPath(p: string): string {
258
- return '/' + p.replace(/^\/+/, '');
416
+ const canonical = parseWorkspacePath(p);
417
+ return canonical ? `/${canonical}` : '/';
259
418
  }
260
419
 
261
420
  function sameProviderPath(a: string, b: string): boolean {
262
421
  return normaliseProviderPath(a) === normaliseProviderPath(b);
263
422
  }
264
423
 
424
+ function relocateProviderPath(path: string, oldPath: string, newPath: string): string {
425
+ const current = normaliseProviderPath(path);
426
+ const oldNormalised = normaliseProviderPath(oldPath);
427
+ if (current !== oldNormalised && !current.startsWith(`${oldNormalised}/`)) return path;
428
+ return `${newPath.replace(/\/+$/, '')}${current.slice(oldNormalised.length)}`;
429
+ }
430
+
431
+ function pathContains(parentPath: string, candidatePath: string): boolean {
432
+ return workspacePathContains(parseWorkspacePath(parentPath), parseWorkspacePath(candidatePath));
433
+ }
434
+
435
+ async function readProviderDirectory(
436
+ provider: FileSystemProvider,
437
+ path: string,
438
+ ): Promise<FileSystemEntry[]> {
439
+ const providerV2 = getFileSystemProviderV2(provider);
440
+ if (!providerV2) return provider.readDirectory(path);
441
+ const entries = await providerV2.readDirectory(parseWorkspacePath(path));
442
+ return entries.map((entry) => ({ kind: entry.kind, name: entry.name, path: entry.path }));
443
+ }
444
+
445
+ async function readProviderText(
446
+ provider: FileSystemProvider,
447
+ path: string,
448
+ ): Promise<string | null> {
449
+ const providerV2 = getFileSystemProviderV2(provider);
450
+ if (!providerV2) return provider.readFile(path);
451
+ const file = await providerV2.readFile(parseWorkspacePath(path));
452
+ return file ? new TextDecoder().decode(file.data) : null;
453
+ }
454
+
455
+ async function providerEntryExists(provider: FileSystemProvider, path: string): Promise<boolean> {
456
+ const providerV2 = getFileSystemProviderV2(provider);
457
+ return providerV2
458
+ ? (await providerV2.stat(parseWorkspacePath(path))) !== null
459
+ : provider.exists(path);
460
+ }
461
+
462
+ async function writeProviderText(
463
+ provider: FileSystemProvider,
464
+ path: string,
465
+ content: string,
466
+ mode: 'upsert' | 'create' = 'upsert',
467
+ ): Promise<void> {
468
+ const providerV2 = getFileSystemProviderV2(provider);
469
+ if (providerV2) {
470
+ await providerV2.writeFile(parseWorkspacePath(path), new TextEncoder().encode(content), {
471
+ mode,
472
+ createParents: true,
473
+ expectedVersion: mode === 'create' ? null : undefined,
474
+ });
475
+ return;
476
+ }
477
+ if (mode === 'create' && (await provider.exists(path))) {
478
+ throw new FsError('already-exists', 'File already exists.', { operation: 'write', path });
479
+ }
480
+ await provider.writeFile(path, content);
481
+ }
482
+
483
+ async function removeProviderEntry(provider: FileSystemProvider, path: string): Promise<void> {
484
+ const providerV2 = getFileSystemProviderV2(provider);
485
+ if (providerV2) {
486
+ await providerV2.remove(parseWorkspacePath(path), { recursive: true, missing: 'ignore' });
487
+ return;
488
+ }
489
+ await provider.delete(path);
490
+ }
491
+
492
+ async function readStableFileSnapshot(
493
+ provider: FileSystemProvider,
494
+ path: string,
495
+ ): Promise<{ content: string | null; version: string | null }> {
496
+ const providerV2 = getFileSystemProviderV2(provider);
497
+ if (providerV2) {
498
+ const read = await providerV2.readFile(parseWorkspacePath(path));
499
+ return {
500
+ content: read ? new TextDecoder().decode(read.data) : null,
501
+ version: read?.entry.version ?? null,
502
+ };
503
+ }
504
+ for (let attempt = 0; attempt < 3; attempt += 1) {
505
+ const before = await provider.stat(path);
506
+ const content = await provider.readFile(path);
507
+ const after = await provider.stat(path);
508
+ const unchanged = before?.lastModified === after?.lastModified && before?.size === after?.size;
509
+ const existenceMatches = (content !== null) === (after !== null);
510
+ if (unchanged && existenceMatches) {
511
+ return {
512
+ content,
513
+ version: after ? `${after.lastModified}:${after.size}` : null,
514
+ };
515
+ }
516
+ }
517
+ throw new Error('The file kept changing while DocBlocks was reading it.');
518
+ }
519
+
520
+ async function sha256Hex(data: ArrayBuffer): Promise<string> {
521
+ const digest = await crypto.subtle.digest('SHA-256', data);
522
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('');
523
+ }
524
+
525
+ /**
526
+ * Ask for write access to a file handle delivered by the web File Handling
527
+ * API. Chromium grants read on launch; readwrite needs one prompt, ideally
528
+ * requested while the OS-launch user activation is still fresh. The
529
+ * permission methods are Chromium-only extensions (same inline typing
530
+ * approach as core's native-provider), so absent methods are treated as
531
+ * writable and the write itself surfaces any failure.
532
+ */
533
+ async function ensureHandleWritePermission(handle: FileSystemFileHandle): Promise<boolean> {
534
+ const h = handle as FileSystemFileHandle & {
535
+ queryPermission?(desc: { mode: 'read' | 'readwrite' }): Promise<PermissionState>;
536
+ requestPermission?(desc: { mode: 'read' | 'readwrite' }): Promise<PermissionState>;
537
+ };
538
+ if (!h.queryPermission || !h.requestPermission) return true;
539
+ try {
540
+ const opts = { mode: 'readwrite' as const };
541
+ if ((await h.queryPermission(opts)) === 'granted') return true;
542
+ return (await h.requestPermission(opts)) === 'granted';
543
+ } catch {
544
+ return false;
545
+ }
546
+ }
547
+
265
548
  /** Portable relative link from one workspace file to another. Walks up
266
549
  * from the source's directory and back down to the target so the link
267
550
  * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
@@ -297,9 +580,10 @@ async function collectMarkdownFiles(
297
580
  visited.add(dir);
298
581
  let entries: FileSystemEntry[];
299
582
  try {
300
- entries = await fs.readDirectory(dir);
301
- } catch {
302
- return;
583
+ entries = await readProviderDirectory(fs, dir);
584
+ } catch (error: unknown) {
585
+ if (error instanceof FsError && error.code === 'not-found') return;
586
+ throw error;
303
587
  }
304
588
  for (const entry of entries) {
305
589
  if (entry.kind === 'directory') {
@@ -318,20 +602,65 @@ async function collectMarkdownFiles(
318
602
  return out;
319
603
  }
320
604
 
605
+ /**
606
+ * Walk a FileSystemProvider and copy every file into a content container.
607
+ * Kept outside the component so document commit targets and backup actions
608
+ * share one implementation.
609
+ */
610
+ async function copyProviderToContainer(
611
+ src: FileSystemProvider,
612
+ container: { writeFile: (path: string, data: ArrayBuffer | Uint8Array) => Promise<void> },
613
+ pathPrefix: string,
614
+ ): Promise<void> {
615
+ const providerV2 = getFileSystemProviderV2(src);
616
+ if (providerV2) {
617
+ const snapshot = await providerV2.snapshot();
618
+ for (const entry of snapshot.entries) {
619
+ if (entry.kind !== 'file') continue;
620
+ const rel = entry.path;
621
+ const destination = pathPrefix ? `${pathPrefix}/${rel}` : rel;
622
+ await container.writeFile(destination, entry.data);
623
+ }
624
+ return;
625
+ }
626
+
627
+ const stack: string[] = ['/'];
628
+ while (stack.length > 0) {
629
+ const dir = stack.pop()!;
630
+ const entries = await readProviderDirectory(src, dir);
631
+ for (const entry of entries) {
632
+ if (entry.kind === 'directory') {
633
+ stack.push(entry.path);
634
+ continue;
635
+ }
636
+ const rel = entry.path.replace(/^\/+/, '');
637
+ const zipPath = pathPrefix ? pathPrefix + '/' + rel : rel;
638
+ const binary = await src.readBinary(entry.path);
639
+ if (binary) await container.writeFile(zipPath, binary);
640
+ else {
641
+ const text = await src.readFile(entry.path);
642
+ if (text === null) throw new Error(`The workspace changed while reading ${entry.path}.`);
643
+ await container.writeFile(zipPath, new TextEncoder().encode(text));
644
+ }
645
+ }
646
+ }
647
+ }
648
+
649
+ interface PendingDbkConflict {
650
+ provider: MemoryFileSystemProvider;
651
+ snapshot: DbkWorkspaceSnapshot;
652
+ }
653
+
321
654
  async function createElectronProviderFromWorkspace(
322
655
  ws: WorkspaceDescriptor,
323
656
  ): Promise<ElectronFileSystemProvider | null> {
324
657
  if (!ws.rootPath) return null;
325
658
  try {
326
- await getDocBlocksHost().workspaces.register({
327
- id: ws.id,
328
- name: ws.name,
329
- rootPath: ws.rootPath,
330
- });
659
+ await getDocBlocksHost().workspaces.register(ws.id);
331
660
  } catch {
332
661
  return null;
333
662
  }
334
- return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
663
+ return createElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
335
664
  }
336
665
 
337
666
  function useIsMobile(breakpoint = 768): boolean {
@@ -388,10 +717,14 @@ export function DocBlocksShell({
388
717
  versioningAutoSaveIdleMs,
389
718
  onSaveVersion,
390
719
  versioningRef,
720
+ updateAvailable = false,
721
+ onApplyUpdate,
722
+ offlineReady = false,
391
723
  }: DocBlocksShellProps) {
392
724
  const osTheme = useOsTheme();
393
725
  const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
394
- // "System default" (auto) always follows the OS — the host's theme prop
726
+ const [accentColor, setAccentColor] = useState<AccentColor>(loadAccentColor);
727
+ // "System default" (auto) always follows the OS -- the host's theme prop
395
728
  // is kept only for API back-compat and does not override OS detection.
396
729
  const resolvedTheme: 'light' | 'dark' =
397
730
  themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
@@ -401,6 +734,24 @@ export function DocBlocksShell({
401
734
  saveThemePreference(pref);
402
735
  }, []);
403
736
 
737
+ // Keep the browser's theme-color metas in sync with the resolved theme so
738
+ // the installed web app's titlebar (Window Controls Overlay caption area,
739
+ // Android status bar) matches the shell chrome. Both media-attributed
740
+ // metas are overwritten on purpose: an explicit user theme choice must
741
+ // beat the OS media query. Electron paints its own titlebar.
742
+ useEffect(() => {
743
+ if (isElectronHost() || typeof document === 'undefined') return;
744
+ const metas = document.querySelectorAll<HTMLMetaElement>('meta[name="theme-color"]');
745
+ metas.forEach((meta) => {
746
+ meta.content = DB_CHROME_COLORS[resolvedTheme];
747
+ });
748
+ }, [resolvedTheme]);
749
+
750
+ const handleAccentColorChange = useCallback((color: AccentColor) => {
751
+ setAccentColor(color);
752
+ saveAccentColor(color);
753
+ }, []);
754
+
404
755
  const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
405
756
  const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
406
757
  setViewPreferences(prefs);
@@ -408,21 +759,28 @@ export function DocBlocksShell({
408
759
  }, []);
409
760
  const isMobile = useIsMobile();
410
761
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
411
- // Sidebar width persisted across sessions, dragged via the resizer
762
+ // Sidebar width -- persisted across sessions, dragged via the resizer
412
763
  // between sidebar and editor area. We track the "live" width during a
413
764
  // drag in a ref so each mousemove doesn't trigger a state update; only
414
765
  // setState on commit so React doesn't churn through every pixel.
415
766
  const [sidebarWidth, setSidebarWidth] = useState<number>(loadSidebarWidth);
416
767
  // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
417
- // we switch the layout into single-pane "compact" mode same UX as
768
+ // we switch the layout into single-pane "compact" mode -- same UX as
418
769
  // the mobile narrow-viewport flow, where only the sidebar OR the
419
770
  // editor is visible at a time and a back-arrow in the toolbar pops
420
- // between them. A "Restore split view" button on the editor toolbar
771
+ // between them. A "Restore split view" button in either pane's header
421
772
  // exits compact mode; on real mobile that button is suppressed
422
773
  // because there's not enough viewport for side-by-side. Not
423
774
  // persisted across reloads. */
424
775
  const [compactLayout, setCompactLayout] = useState(false);
425
776
  const effectiveCompact = isMobile || compactLayout;
777
+ const showBrowserStorageWarning = !isElectronHost();
778
+ const [browserStoragePersistent, setBrowserStoragePersistent] = useState(false);
779
+ // Browser PWA install prompt (Chromium only). The event is stashed when
780
+ // the browser deems the app installable and spent on first use.
781
+ const [installPromptEvent, setInstallPromptEvent] = useState<BeforeInstallPromptEvent | null>(
782
+ null,
783
+ );
426
784
  const sidebarRef = useRef<HTMLDivElement>(null);
427
785
  const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
428
786
  const handleResizerPointerDown = useCallback(
@@ -443,7 +801,7 @@ export function DocBlocksShell({
443
801
  if (!drag) return;
444
802
  lastRaw = drag.startWidth + (ev.clientX - drag.startX);
445
803
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
446
- // Below threshold preview the collapse by snapping to the
804
+ // Below threshold -- preview the collapse by snapping to the
447
805
  // minimum width and fading the sidebar, so the user can see
448
806
  // they've crossed into "release to collapse" territory.
449
807
  sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
@@ -467,8 +825,8 @@ export function DocBlocksShell({
467
825
  sidebarRef.current.style.opacity = '';
468
826
  }
469
827
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
470
- // Released below threshold switch to compact (single-pane)
471
- // layout focused on the editor. Keep the persisted
828
+ // Released below threshold -- switch to compact (single-pane)
829
+ // layout focused on the document pane. Keep the persisted
472
830
  // sidebarWidth so exiting compact mode restores it.
473
831
  setCompactLayout(true);
474
832
  setMobileShowEditor(true);
@@ -491,9 +849,15 @@ export function DocBlocksShell({
491
849
  [sidebarWidth],
492
850
  );
493
851
  const [provider, setProvider] = useState<FileSystemProvider | null>(null);
852
+ const [workspaceStartupError, setWorkspaceStartupError] = useState<string | null>(null);
494
853
  const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
495
854
  const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] =
496
855
  useState<WorkspaceDescriptor | null>(null);
856
+ useEffect(() => {
857
+ if (!provider || getTransientWorkspace(provider.id)) return;
858
+ const providerV2 = getFileSystemProviderV2(provider);
859
+ return providerV2 ? retainFileSystemProvider(providerV2) : undefined;
860
+ }, [provider]);
497
861
  // Re-fetch the descriptor whenever the active id (or its versioning
498
862
  // override) changes. `descriptorRefreshKey` is bumped after writes so
499
863
  // the resolver picks up the updated override without remounting.
@@ -512,6 +876,19 @@ export function DocBlocksShell({
512
876
  };
513
877
  }, [activeWorkspaceId, descriptorRefreshKey]);
514
878
 
879
+ // All git UI state/actions -- null-renders on surfaces without git.
880
+ const gitWorkspaceId =
881
+ provider &&
882
+ activeWorkspaceDescriptor?.id === activeWorkspaceId &&
883
+ provider.id === activeWorkspaceId &&
884
+ activeWorkspaceDescriptor.type === 'electron-native'
885
+ ? activeWorkspaceDescriptor.id
886
+ : null;
887
+ const git = useGit(provider, gitWorkspaceId, resolvedTheme);
888
+ const gitRef = useRef(git);
889
+ gitRef.current = git;
890
+ const { scheduleRefresh: gitScheduleRefresh } = git;
891
+
515
892
  const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
516
893
  const [versioningPreference, setVersioningPreference] =
517
894
  useState<VersioningPreference>(loadVersioningPreference);
@@ -537,19 +914,260 @@ export function DocBlocksShell({
537
914
  [activeWorkspaceDescriptor],
538
915
  );
539
916
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
917
+ useDocumentTitle(selectedFile);
918
+ const exportDestinationAdapter = useMemo<ExportDestinationAdapter | undefined>(() => {
919
+ if (!isElectronHost() || !activeWorkspaceId || !selectedFile) return undefined;
920
+ const documentId = JSON.stringify([activeWorkspaceId, selectedFile]);
921
+ const host = getDocBlocksHost().exports;
922
+ return {
923
+ resolveTarget: (filename) => host.resolveTarget(documentId, filename),
924
+ pickTarget: (filename, currentTarget) =>
925
+ host.pickTarget(documentId, filename, currentTarget?.grantId ?? null),
926
+ saveBlob: async (blob, filename, target) =>
927
+ host.save(documentId, filename, target?.grantId ?? null, await blob.arrayBuffer()),
928
+ };
929
+ }, [activeWorkspaceId, selectedFile]);
540
930
  const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
541
931
  const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
542
- const [editorContent, setEditorContent] = useState('');
543
- const [editorKey, setEditorKey] = useState(0);
932
+ const { session: documentSession, snapshot: documentSnapshot } = useDocumentSession(500);
933
+ // The session snapshot is the sole owner of document content and edit
934
+ // identity. React retains only presentation state (path/folder/view).
935
+ const editorContent = documentSnapshot.content;
936
+ const editorSessionScope = useMemo<DocumentSessionEditScope | null>(
937
+ () =>
938
+ documentSnapshot.targetKey && !documentSnapshot.frozen
939
+ ? {
940
+ targetKey: documentSnapshot.targetKey,
941
+ generation: documentSnapshot.generation,
942
+ }
943
+ : null,
944
+ [documentSnapshot.frozen, documentSnapshot.generation, documentSnapshot.targetKey],
945
+ );
946
+ const [editorPresentationEpoch, setEditorPresentationEpoch] = useState(0);
947
+ const editorKey = `${documentSnapshot.generation}:${editorPresentationEpoch}`;
948
+ const editorPlaceholder = useMemo(() => {
949
+ // Keep the selection scoped to the same generation that remounts Squisq.
950
+ void editorKey;
951
+ return pickEmptyDocumentPrompt();
952
+ }, [editorKey]);
544
953
  const [explorerKey, setExplorerKey] = useState(0);
545
954
  const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
546
- /** Suppress popstate handling during programmatic navigation. */
547
- const skipPopState = useRef(false);
548
- const lastLocalSaveRef = useRef<{
549
- filePath: string;
550
- content: string;
551
- savedAt: number;
552
- } | null>(null);
955
+ // First-run gateway over the welcome doc's Play view -- see WELCOME_GATEWAY_KEY.
956
+ const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
957
+ const navigationRequestRef = useRef(0);
958
+ const workspaceAuthorityBarrier = useMemo(() => new WorkspaceAuthorityBarrier(), []);
959
+ const preparedCloseRequestRef = useRef<string | null>(null);
960
+ const pendingDbkConflictsRef = useRef(new Map<string, PendingDbkConflict>());
961
+
962
+ const createDocumentTarget = useCallback(
963
+ (
964
+ fsProvider: FileSystemProvider,
965
+ workspaceId: string,
966
+ filePath: string,
967
+ ): DocumentCommitTarget => {
968
+ const transient = getTransientWorkspace(workspaceId);
969
+ const baseTarget = createFileSystemDocumentTarget(fsProvider, filePath);
970
+
971
+ const originKind = transient?.descriptor.origin?.kind;
972
+ const needsElectronHost = originKind === 'loose-file' || originKind === 'dbk';
973
+ if (!transient?.descriptor.origin || (needsElectronHost && !isElectronHost())) {
974
+ return {
975
+ key: baseTarget.key,
976
+ async commit(request) {
977
+ const result = await baseTarget.commit(request);
978
+ gitScheduleRefresh();
979
+ return result;
980
+ },
981
+ };
982
+ }
983
+
984
+ const origin = transient.descriptor.origin;
985
+
986
+ if (origin.kind === 'web-file' || origin.kind === 'web-dbk') {
987
+ // Launched-file origins (installed web app, File Handling API):
988
+ // read and write through the FileSystemFileHandle instead of the
989
+ // Electron host bridge, with the same optimistic external-change
990
+ // detection as the host-backed paths below.
991
+ const webOrigin = origin;
992
+ const transientProvider = transient.provider;
993
+ return {
994
+ key: baseTarget.key,
995
+ async commit(request) {
996
+ if (!(await ensureHandleWritePermission(webOrigin.handle))) {
997
+ throw new Error(
998
+ `DocBlocks needs permission to write to "${webOrigin.name}". Save again to grant it.`,
999
+ );
1000
+ }
1001
+ if (webOrigin.kind === 'web-file') {
1002
+ const current = await (await webOrigin.handle.getFile()).text();
1003
+ if (
1004
+ request.persistedContent !== null &&
1005
+ current !== request.persistedContent &&
1006
+ current !== request.content
1007
+ ) {
1008
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
1009
+ await writeProviderText(fsProvider, filePath, current);
1010
+ throw new DocumentCommitConflictError(
1011
+ 'The original file changed outside DocBlocks.',
1012
+ current,
1013
+ null,
1014
+ );
1015
+ }
1016
+ const writable = await webOrigin.handle.createWritable();
1017
+ await writable.write(request.content);
1018
+ await writable.close();
1019
+ await baseTarget.commit(request);
1020
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
1021
+ gitScheduleRefresh();
1022
+ return {};
1023
+ }
1024
+ // web-dbk: package the in-memory workspace into a zip and write
1025
+ // it back through the handle, guarded by a SHA-256
1026
+ // compare-and-swap that mirrors the Electron dbk path.
1027
+ const [{ MemoryContentContainer }, { containerToZip, zipToContainer }] =
1028
+ await Promise.all([
1029
+ import('@bendyline/squisq/storage'),
1030
+ import('@bendyline/squisq-formats/container'),
1031
+ ]);
1032
+ const currentBytes = await (await webOrigin.handle.getFile()).arrayBuffer();
1033
+ const currentVersion = await sha256Hex(currentBytes);
1034
+ if (webOrigin.version !== null && currentVersion !== webOrigin.version) {
1035
+ if (!isMemoryWorkspaceProvider(transientProvider)) {
1036
+ throw new Error('A transient DBK workspace must use in-memory storage.');
1037
+ }
1038
+ const externalContainer = await zipToContainer(currentBytes);
1039
+ const snapshot = await createDbkWorkspaceSnapshot(externalContainer, {
1040
+ targetDocumentPath: filePath,
1041
+ });
1042
+ // Keep both branches intact until the user chooses -- same
1043
+ // semantics as the Electron dbk conflict staging below.
1044
+ pendingDbkConflictsRef.current.set(baseTarget.key, {
1045
+ provider: transientProvider,
1046
+ snapshot,
1047
+ });
1048
+ webOrigin.version = currentVersion;
1049
+ throw new DocumentCommitConflictError(
1050
+ 'The original DocBlocks bundle changed outside DocBlocks.',
1051
+ snapshot.documentContent,
1052
+ currentVersion,
1053
+ );
1054
+ }
1055
+ const container = new MemoryContentContainer();
1056
+ await copyProviderToContainer(transientProvider, container, '');
1057
+ await container.writeFile(
1058
+ filePath.replace(/^\/+/, ''),
1059
+ new TextEncoder().encode(request.content),
1060
+ );
1061
+ const blob = await containerToZip(container);
1062
+ const bytes = await blob.arrayBuffer();
1063
+ const writable = await webOrigin.handle.createWritable();
1064
+ await writable.write(bytes);
1065
+ await writable.close();
1066
+ webOrigin.version = await sha256Hex(bytes);
1067
+ // The transient provider intentionally retained the complete
1068
+ // local branch while external bundle B was staged for conflict
1069
+ // resolution. Once bundle C is committed to the origin, advance
1070
+ // that internal markdown baseline unconditionally; comparing its
1071
+ // old A content against request.persistedContent B would report a
1072
+ // false second conflict after the durable write already succeeded.
1073
+ await writeProviderText(fsProvider, filePath, request.content);
1074
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
1075
+ gitScheduleRefresh();
1076
+ return { version: webOrigin.version };
1077
+ },
1078
+ };
1079
+ }
1080
+
1081
+ return {
1082
+ key: baseTarget.key,
1083
+ async commit(request) {
1084
+ const host = getDocBlocksHost();
1085
+ if (origin.kind === 'loose-file') {
1086
+ const result = await host.external.commitText(
1087
+ origin.resourceId,
1088
+ request.content,
1089
+ request.persistedContent,
1090
+ );
1091
+ if (result.status === 'conflict') {
1092
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
1093
+ if (result.content === null) await removeProviderEntry(fsProvider, filePath);
1094
+ else await writeProviderText(fsProvider, filePath, result.content);
1095
+ throw new DocumentCommitConflictError(
1096
+ 'The original file changed outside DocBlocks.',
1097
+ result.content,
1098
+ result.version,
1099
+ );
1100
+ }
1101
+ await baseTarget.commit(request);
1102
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
1103
+ gitScheduleRefresh();
1104
+ return { version: result.version };
1105
+ } else {
1106
+ const [{ MemoryContentContainer }, { containerToZip, zipToContainer }] =
1107
+ await Promise.all([
1108
+ import('@bendyline/squisq/storage'),
1109
+ import('@bendyline/squisq-formats/container'),
1110
+ ]);
1111
+ const container = new MemoryContentContainer();
1112
+ await copyProviderToContainer(transient.provider, container, '');
1113
+ await container.writeFile(
1114
+ filePath.replace(/^\/+/, ''),
1115
+ new TextEncoder().encode(request.content),
1116
+ );
1117
+ const blob = await containerToZip(container);
1118
+ const result = await host.external.commitBinary(
1119
+ origin.resourceId,
1120
+ await blob.arrayBuffer(),
1121
+ origin.version,
1122
+ );
1123
+ if (result.status === 'conflict') {
1124
+ let externalContent: string | null = null;
1125
+ if (result.data) {
1126
+ const externalContainer = await zipToContainer(result.data);
1127
+ if (!isMemoryWorkspaceProvider(transient.provider)) {
1128
+ throw new Error('A transient DBK workspace must use in-memory storage.');
1129
+ }
1130
+ const snapshot = await createDbkWorkspaceSnapshot(externalContainer, {
1131
+ targetDocumentPath: filePath,
1132
+ });
1133
+ externalContent = snapshot.documentContent;
1134
+ // Keep both branches intact until the user chooses. Reload
1135
+ // replaces the whole tree; Keep mine packages this untouched
1136
+ // local provider against the newly observed bundle version.
1137
+ pendingDbkConflictsRef.current.set(baseTarget.key, {
1138
+ provider: transient.provider,
1139
+ snapshot,
1140
+ });
1141
+ } else {
1142
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
1143
+ }
1144
+ // Advance the optimistic origin only after the external bundle
1145
+ // has been completely staged. A parse/read failure must retry
1146
+ // against the old version rather than silently authorize an
1147
+ // overwrite of data we could not reconcile.
1148
+ origin.version = result.version;
1149
+ throw new DocumentCommitConflictError(
1150
+ 'The original DocBlocks bundle changed outside DocBlocks.',
1151
+ externalContent,
1152
+ result.version,
1153
+ );
1154
+ }
1155
+ origin.version = result.version;
1156
+ // As with web-dbk above, the in-memory provider still represents
1157
+ // the preserved local branch rather than the external conflict
1158
+ // baseline. The origin commit is the conditional authority check;
1159
+ // now make the internal document agree with that acknowledged C
1160
+ // bundle without re-comparing A against B.
1161
+ await writeProviderText(fsProvider, filePath, request.content);
1162
+ pendingDbkConflictsRef.current.delete(baseTarget.key);
1163
+ gitScheduleRefresh();
1164
+ return { version: result.version };
1165
+ }
1166
+ },
1167
+ };
1168
+ },
1169
+ [gitScheduleRefresh],
1170
+ );
553
1171
 
554
1172
  /**
555
1173
  * Per-file media container: for `notes.md`, images live in
@@ -559,6 +1177,7 @@ export function DocBlocksShell({
559
1177
  */
560
1178
  const mediaContainerRef = useRef<ContentContainer | null>(null);
561
1179
  const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
1180
+ const [mediaEpoch, setMediaEpoch] = useState(0);
562
1181
  /**
563
1182
  * Per-document container scoped to `<basename>_files/`. This is what
564
1183
  * the editor uses for version history (`.versions/` lives here) and
@@ -581,7 +1200,6 @@ export function DocBlocksShell({
581
1200
  const pushHash = useCallback((wsId: string, filePath?: string | null) => {
582
1201
  const hash = buildHash(wsId, filePath);
583
1202
  if (window.location.hash !== hash) {
584
- skipPopState.current = true;
585
1203
  window.history.pushState(null, '', hash);
586
1204
  }
587
1205
  }, []);
@@ -594,63 +1212,100 @@ export function DocBlocksShell({
594
1212
  filePath: string | null,
595
1213
  push: boolean,
596
1214
  view?: EditorView,
1215
+ navigationRequestId?: number,
597
1216
  ): Promise<FileSystemProvider | null> => {
598
- const workspaces = await listWorkspaces();
599
- const ws = workspaces.find((w) => w.id === wsId);
600
- if (!ws) return null;
601
-
1217
+ const requestId = navigationRequestId ?? ++navigationRequestRef.current;
1218
+ if (requestId !== navigationRequestRef.current) return null;
1219
+ // Transient (session-only) workspaces -- a loose file or `.dbk` opened
1220
+ // from the OS -- carry a pre-built in-memory provider in the registry.
1221
+ const transient = getTransientWorkspace(wsId);
1222
+ let ws: WorkspaceDescriptor | undefined;
602
1223
  let fsProvider: FileSystemProvider | null = null;
603
- if (ws.type === 'electron-native') {
604
- fsProvider = await createElectronProviderFromWorkspace(ws);
605
- if (!fsProvider) return null;
606
- } else if (ws.type === 'native') {
607
- const restored = await restoreNativeFolder(ws.id);
608
- if (!restored) return null;
609
- fsProvider = restored;
1224
+ if (transient) {
1225
+ ws = transient.descriptor;
1226
+ fsProvider = transient.provider;
610
1227
  } else {
611
- fsProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
1228
+ const workspaces = await listWorkspaces();
1229
+ ws = workspaces.find((w) => w.id === wsId);
1230
+ if (!ws) return null;
1231
+ if (ws.type === 'electron-native') {
1232
+ fsProvider = await createElectronProviderFromWorkspace(ws);
1233
+ if (!fsProvider) return null;
1234
+ } else if (ws.type === 'native') {
1235
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
1236
+ if (!restored) return null;
1237
+ fsProvider = restored;
1238
+ } else {
1239
+ fsProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
1240
+ }
612
1241
  }
613
- await touchWorkspace(ws.id);
614
- setProvider(fsProvider);
615
- setActiveWorkspaceId(ws.id);
1242
+ if (!ws || !fsProvider) return null;
1243
+ let adopted = false;
1244
+ try {
1245
+ await touchWorkspace(ws.id);
1246
+ if (requestId !== navigationRequestRef.current) return null;
1247
+
1248
+ let openedFile: string | null = null;
1249
+ let openedContent = '';
1250
+ const transitioned = await documentSession.transitionWithLoad(async () => {
1251
+ if (requestId !== navigationRequestRef.current) return null;
1252
+ if (filePath) {
1253
+ const content = await readProviderText(fsProvider, filePath);
1254
+ if (requestId !== navigationRequestRef.current) return null;
1255
+ if (content !== null) {
1256
+ openedFile = filePath;
1257
+ openedContent = content;
1258
+ }
1259
+ }
616
1260
 
617
- if (filePath) {
618
- const content = await fsProvider.readFile(filePath);
619
- if (content !== null) {
620
- setSelectedFile(filePath);
1261
+ return {
1262
+ target: openedFile ? createDocumentTarget(fsProvider, ws.id, openedFile) : null,
1263
+ content: openedContent,
1264
+ };
1265
+ });
1266
+ if (!transitioned) return null;
1267
+
1268
+ // Once the session has accepted this target, ownership must follow it
1269
+ // even if a newer navigation request arrived in the final microtask.
1270
+ // The newer request will perform the next serialized transition.
1271
+ setProvider(fsProvider);
1272
+ adopted = true;
1273
+ setActiveWorkspaceId(ws.id);
1274
+ if (openedFile) {
1275
+ setSelectedFile(openedFile);
621
1276
  setSelectedFolder(null);
622
1277
  setFolderEntries([]);
623
- setEditorContent(content);
624
1278
  const effectiveView = view ?? 'wysiwyg';
625
1279
  setInitialView(effectiveView);
626
- setEditorKey((k) => k + 1);
627
- saveLastState({ workspaceId: wsId, filePath, view: effectiveView });
1280
+ // Transient workspaces are session-only; don't persist them as the
1281
+ // "last opened" state (the id won't exist after a reload).
1282
+ if (!transient) {
1283
+ saveLastState({ workspaceId: wsId, filePath: openedFile, view: effectiveView });
1284
+ }
628
1285
  } else {
629
1286
  setSelectedFile(null);
630
1287
  setSelectedFolder(null);
631
1288
  setFolderEntries([]);
632
- setEditorContent('');
633
- setEditorKey((k) => k + 1);
634
1289
  }
635
- } else {
636
- setSelectedFile(null);
637
- setSelectedFolder(null);
638
- setFolderEntries([]);
639
- setEditorContent('');
640
- setEditorKey((k) => k + 1);
641
- }
642
1290
 
643
- if (push) {
644
- pushHash(ws.id, filePath);
1291
+ if (push) {
1292
+ pushHash(ws.id, openedFile);
1293
+ }
1294
+ return fsProvider;
1295
+ } finally {
1296
+ if (!transient && !adopted) await getFileSystemProviderV2(fsProvider)?.dispose();
645
1297
  }
646
- return fsProvider;
647
1298
  },
648
- [pushHash],
1299
+ [createDocumentTarget, documentSession, pushHash],
649
1300
  );
650
1301
 
651
1302
  const seedWelcomeFile = useCallback(
652
- async (fs: FileSystemProvider) => {
653
- const entries = await fs.readDirectory('/');
1303
+ async (fs: FileSystemProvider, navigationRequestId?: number) => {
1304
+ const isCurrent = () =>
1305
+ navigationRequestId === undefined || navigationRequestId === navigationRequestRef.current;
1306
+ if (!isCurrent()) return;
1307
+ const entries = await readProviderDirectory(fs, '/');
1308
+ if (!isCurrent()) return;
654
1309
 
655
1310
  // If the only file is the welcome doc, auto-select it.
656
1311
  // Match either casing so workspaces seeded before the rename
@@ -661,15 +1316,16 @@ export function DocBlocksShell({
661
1316
  entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
662
1317
  ) {
663
1318
  const aboutPath = entries[0].path;
664
- const content = await fs.readFile(aboutPath);
665
- if (content !== null) {
1319
+ const content = await readProviderText(fs, aboutPath);
1320
+ if (content !== null && isCurrent()) {
1321
+ await documentSession.transitionTo(createDocumentTarget(fs, fs.id, aboutPath), content);
1322
+ if (!isCurrent()) return;
666
1323
  setSelectedFile(aboutPath);
667
- setEditorContent(content);
668
1324
  setInitialView('preview');
669
- setEditorKey((k) => k + 1);
670
1325
  setExplorerKey((k) => k + 1);
671
1326
  pushHash(fs.id, aboutPath);
672
1327
  saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
1328
+ if (!isWelcomeGatewayDismissed()) setShowWelcomeGateway(true);
673
1329
  }
674
1330
  return;
675
1331
  }
@@ -680,54 +1336,140 @@ export function DocBlocksShell({
680
1336
  const welcomeContent = [
681
1337
  '# Welcome to DocBlocks',
682
1338
  '',
1339
+ 'Your markdown can do anything.',
1340
+ '',
683
1341
  '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.',
684
1342
  '',
685
1343
  'Simple to write. Beautiful wherever it goes.',
686
1344
  '',
687
1345
  '## Features',
688
1346
  '',
689
- '- **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.',
690
- '- **Workspaces** Organize your documents into separate workspaces in the browser or on your device.',
691
- '- **Useful Everywhere** Your content is usable across multiple formats Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
692
- '- **Playback & Video** Preview your documents as rich visual presentations and export them as MP4 video',
693
- '- **No BS** Free, no ads, no accounts, no tracking - everything runs locally in your browser',
1347
+ '- **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.',
1348
+ '- **Workspaces** -- Organize your documents into separate workspaces in the browser or on your device.',
1349
+ '- **Useful Everywhere** -- Your content is usable across multiple formats -- Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
1350
+ '- **Playback & Video** -- Preview your documents as rich visual presentations and export them as MP4 video',
1351
+ '- **No BS** -- Free, no ads, no accounts, no tracking - everything runs locally in your browser',
694
1352
  '',
695
1353
  '## Getting Started',
696
1354
  '',
697
1355
  '1. Create a new file using the **New File** button in the sidebar',
698
- '2. Start writing in markdown the editor supports headings, lists, links, images, and more',
1356
+ '2. Start writing in markdown -- the editor supports headings, lists, links, images, and more',
699
1357
  '3. Your work is saved automatically',
700
1358
  '',
701
- 'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
1359
+ 'Built on [Squiggly Square markdown extensions](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
702
1360
  ].join('\n');
703
1361
 
704
- await fs.writeFile(welcomePath, welcomeContent);
1362
+ let seededContent = welcomeContent;
1363
+ try {
1364
+ await writeProviderText(fs, welcomePath, welcomeContent, 'create');
1365
+ } catch (error: unknown) {
1366
+ if (!(error instanceof FsError && error.code === 'already-exists')) throw error;
1367
+ const existing = await readProviderText(fs, welcomePath);
1368
+ if (existing === null) throw error;
1369
+ seededContent = existing;
1370
+ }
1371
+ if (!isCurrent()) return;
1372
+ await documentSession.transitionTo(
1373
+ createDocumentTarget(fs, fs.id, welcomePath),
1374
+ seededContent,
1375
+ );
1376
+ if (!isCurrent()) return;
705
1377
  setSelectedFile(welcomePath);
706
- setEditorContent(welcomeContent);
707
1378
  setInitialView('preview');
708
- setEditorKey((k) => k + 1);
709
1379
  setExplorerKey((k) => k + 1);
710
1380
  pushHash(fs.id, welcomePath);
711
1381
  saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
1382
+ if (!isWelcomeGatewayDismissed()) setShowWelcomeGateway(true);
712
1383
  },
713
- [pushHash],
1384
+ [createDocumentTarget, documentSession, pushHash],
714
1385
  );
715
1386
 
716
- // Initialise workspace on mount restore from hash or last-used
1387
+ // Startup is a mount lifecycle, not a reaction to callback identity. Keep
1388
+ // the latest implementations available without letting git/provider state
1389
+ // changes restart initialization and supersede an OS-open navigation.
1390
+ const startupOpenFromIdsRef = useRef(openFromIds);
1391
+ startupOpenFromIdsRef.current = openFromIds;
1392
+ const startupSeedWelcomeFileRef = useRef(seedWelcomeFile);
1393
+ startupSeedWelcomeFileRef.current = seedWelcomeFile;
1394
+
1395
+ /** Hide the welcome gateway and never show it again. Safe to call from
1396
+ * paths where it may not be showing -- only persists when it was. */
1397
+ const closeWelcomeGateway = useCallback(() => {
1398
+ setShowWelcomeGateway((showing) => {
1399
+ if (showing) markWelcomeGatewayDismissed();
1400
+ return false;
1401
+ });
1402
+ }, []);
1403
+
1404
+ /** Gateway CTA -- flip the welcome doc from Play into the editor. */
1405
+ const handleStartWriting = useCallback(() => {
1406
+ closeWelcomeGateway();
1407
+ setInitialView('wysiwyg');
1408
+ setEditorPresentationEpoch((epoch) => epoch + 1);
1409
+ if (activeWorkspaceId && selectedFile) {
1410
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
1411
+ }
1412
+ }, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
1413
+
1414
+ // Initialise workspace on mount -- restore from hash or last-used
717
1415
  useEffect(() => {
718
- (async () => {
1416
+ const requestId = ++navigationRequestRef.current;
1417
+ let cancelled = false;
1418
+ const isCurrent = () => !cancelled && requestId === navigationRequestRef.current;
1419
+
1420
+ void (async () => {
1421
+ const electron = isElectronHost();
1422
+ let workspaceIdRemap: Readonly<Record<string, string>> = {};
1423
+ if (electron) {
1424
+ // Main-process persisted roots are authoritative after an upgrade or
1425
+ // collision repair. Reconcile renderer metadata before hash or
1426
+ // last-state restoration so an obsolete id cannot reopen a different
1427
+ // folder that once shared its legacy id.
1428
+ try {
1429
+ const localWorkspaces = await listWorkspaces();
1430
+ const hostWorkspaces = await getDocBlocksHost().workspaces.list();
1431
+ const reconciliation = reconcileElectronWorkspaceDescriptors(
1432
+ localWorkspaces,
1433
+ hostWorkspaces,
1434
+ getDocBlocksHost().env.platform,
1435
+ new Date().toISOString(),
1436
+ );
1437
+ for (const id of reconciliation.removeIds) await removeWorkspace(id);
1438
+ for (const workspace of reconciliation.upsert) await saveWorkspace(workspace);
1439
+ workspaceIdRemap = reconciliation.idRemap;
1440
+ } catch {
1441
+ workspaceAuthorityBarrier.markReady();
1442
+ if (isCurrent()) {
1443
+ setWorkspaceStartupError(
1444
+ 'DocBlocks could not verify access to your saved workspaces. Restart the app or open the folder again.',
1445
+ );
1446
+ }
1447
+ return;
1448
+ }
1449
+ }
1450
+ workspaceAuthorityBarrier.markReady();
1451
+ if (!isCurrent()) return;
1452
+
719
1453
  // Try restoring from URL hash first
720
1454
  const hashState = parseHash();
721
1455
  if (hashState) {
722
- const restoredProvider = await openFromIds(
723
- hashState.workspaceId,
1456
+ const migratedWorkspaceId = workspaceIdRemap[hashState.workspaceId];
1457
+ const restoredWorkspaceId = migratedWorkspaceId ?? hashState.workspaceId;
1458
+ if (migratedWorkspaceId) {
1459
+ window.history.replaceState(null, '', buildHash(restoredWorkspaceId, hashState.filePath));
1460
+ }
1461
+ const restoredProvider = await startupOpenFromIdsRef.current(
1462
+ restoredWorkspaceId,
724
1463
  hashState.filePath,
725
1464
  false,
1465
+ undefined,
1466
+ requestId,
726
1467
  );
1468
+ if (!isCurrent()) return;
727
1469
  if (restoredProvider) {
728
1470
  // If the hash had no file, check whether we should auto-select the welcome doc
729
1471
  if (!hashState.filePath) {
730
- await seedWelcomeFile(restoredProvider);
1472
+ await startupSeedWelcomeFileRef.current(restoredProvider, requestId);
731
1473
  }
732
1474
  return;
733
1475
  }
@@ -736,20 +1478,27 @@ export function DocBlocksShell({
736
1478
  // Try restoring last viewed document from localStorage
737
1479
  const lastState = loadLastState();
738
1480
  if (lastState) {
739
- const restoredProvider = await openFromIds(
740
- lastState.workspaceId,
1481
+ const restoredWorkspaceId =
1482
+ workspaceIdRemap[lastState.workspaceId] ?? lastState.workspaceId;
1483
+ if (restoredWorkspaceId !== lastState.workspaceId) {
1484
+ saveLastState({ ...lastState, workspaceId: restoredWorkspaceId });
1485
+ }
1486
+ const restoredProvider = await startupOpenFromIdsRef.current(
1487
+ restoredWorkspaceId,
741
1488
  lastState.filePath,
742
1489
  true,
743
1490
  lastState.view,
1491
+ requestId,
744
1492
  );
1493
+ if (!isCurrent()) return;
745
1494
  if (restoredProvider) return;
746
1495
  }
747
1496
 
748
1497
  let fsProvider: FileSystemProvider | null = null;
749
1498
 
750
- const electron = isElectronHost();
751
1499
  const workspaces = await listWorkspaces();
752
- // On desktop, hide web-only workspaces (indexeddb/native) — only
1500
+ if (!isCurrent()) return;
1501
+ // On desktop, hide web-only workspaces (indexeddb/native) -- only
753
1502
  // folder-based workspaces are valid.
754
1503
  const candidates = electron
755
1504
  ? workspaces.filter((w) => w.type === 'electron-native')
@@ -762,26 +1511,44 @@ export function DocBlocksShell({
762
1511
  if (ws.type === 'electron-native') {
763
1512
  const p = await createElectronProviderFromWorkspace(ws);
764
1513
  if (!p) continue;
1514
+ if (!isCurrent()) {
1515
+ await getFileSystemProviderV2(p)?.dispose();
1516
+ return;
1517
+ }
765
1518
  await touchWorkspace(ws.id);
1519
+ if (!isCurrent()) {
1520
+ await getFileSystemProviderV2(p)?.dispose();
1521
+ return;
1522
+ }
766
1523
  fsProvider = p;
767
- setProvider(p);
768
- setActiveWorkspaceId(ws.id);
769
1524
  break;
770
1525
  } else if (ws.type === 'native') {
771
- const restored = await restoreNativeFolder(ws.id);
1526
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
772
1527
  if (restored) {
1528
+ if (!isCurrent()) {
1529
+ await getFileSystemProviderV2(restored)?.dispose();
1530
+ return;
1531
+ }
773
1532
  await touchWorkspace(ws.id);
1533
+ if (!isCurrent()) {
1534
+ await getFileSystemProviderV2(restored)?.dispose();
1535
+ return;
1536
+ }
774
1537
  fsProvider = restored;
775
- setProvider(restored);
776
- setActiveWorkspaceId(ws.id);
777
1538
  break;
778
1539
  }
779
1540
  } else {
780
- const p = new IndexedDBFileSystemProvider(ws.id, ws.name);
1541
+ const p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
1542
+ if (!isCurrent()) {
1543
+ await getFileSystemProviderV2(p)?.dispose();
1544
+ return;
1545
+ }
781
1546
  await touchWorkspace(ws.id);
1547
+ if (!isCurrent()) {
1548
+ await getFileSystemProviderV2(p)?.dispose();
1549
+ return;
1550
+ }
782
1551
  fsProvider = p;
783
- setProvider(p);
784
- setActiveWorkspaceId(ws.id);
785
1552
  break;
786
1553
  }
787
1554
  }
@@ -789,8 +1556,9 @@ export function DocBlocksShell({
789
1556
  if (!fsProvider) {
790
1557
  if (electron) {
791
1558
  // Desktop: ask the host for the default folder workspace
792
- // (creates ~/Documents/DocBlocks on first launch).
1559
+ // (creates DocBlocks in the OS-resolved Documents folder on first launch).
793
1560
  const info = await getDocBlocksHost().workspaces.getDefault();
1561
+ if (!isCurrent()) return;
794
1562
  const descriptor: WorkspaceDescriptor = {
795
1563
  id: info.id,
796
1564
  name: info.name,
@@ -799,43 +1567,149 @@ export function DocBlocksShell({
799
1567
  lastOpened: new Date().toISOString(),
800
1568
  };
801
1569
  await saveWorkspace(descriptor);
802
- const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
1570
+ if (!isCurrent()) return;
1571
+ const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1572
+ if (!isCurrent()) {
1573
+ await getFileSystemProviderV2(p)?.dispose();
1574
+ return;
1575
+ }
803
1576
  fsProvider = p;
804
- setProvider(p);
805
- setActiveWorkspaceId(info.id);
806
1577
  } else {
807
1578
  // Web: create the IndexedDB default workspace
808
1579
  const defaultWs = await ensureDefaultWorkspace();
809
- const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
1580
+ if (!isCurrent()) return;
1581
+ const p = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
1582
+ if (!isCurrent()) {
1583
+ await getFileSystemProviderV2(p)?.dispose();
1584
+ return;
1585
+ }
810
1586
  fsProvider = p;
811
- setProvider(p);
812
- setActiveWorkspaceId(defaultWs.id);
813
1587
  }
814
1588
  }
815
1589
 
816
- // Set initial hash
817
- pushHash(fsProvider.id, null);
1590
+ let adopted = false;
1591
+ try {
1592
+ if (!isCurrent()) return;
1593
+ // Set the root hash before seeding; seedWelcomeFile replaces it with
1594
+ // the welcome path when it opens that document.
1595
+ pushHash(fsProvider.id, null);
1596
+ await startupSeedWelcomeFileRef.current(fsProvider, requestId);
1597
+ if (!isCurrent()) return;
1598
+ setProvider(fsProvider);
1599
+ setActiveWorkspaceId(fsProvider.id);
1600
+ adopted = true;
1601
+ } finally {
1602
+ if (!adopted) await getFileSystemProviderV2(fsProvider)?.dispose();
1603
+ }
1604
+ })().catch(() => {
1605
+ if (isCurrent()) {
1606
+ setWorkspaceStartupError(
1607
+ 'DocBlocks could not initialize your workspace. Restart the app or open the folder again.',
1608
+ );
1609
+ }
1610
+ });
818
1611
 
819
- // Seed welcome file if workspace is empty
820
- await seedWelcomeFile(fsProvider);
821
- })();
822
- }, [openFromIds, pushHash, seedWelcomeFile]);
1612
+ return () => {
1613
+ cancelled = true;
1614
+ if (navigationRequestRef.current === requestId) navigationRequestRef.current += 1;
1615
+ };
1616
+ }, [pushHash, workspaceAuthorityBarrier]);
823
1617
 
824
1618
  // Handle browser back/forward
825
1619
  useEffect(() => {
826
1620
  const onPopState = () => {
827
- if (skipPopState.current) {
828
- skipPopState.current = false;
829
- return;
830
- }
831
1621
  const hashState = parseHash();
832
1622
  if (hashState) {
833
- openFromIds(hashState.workspaceId, hashState.filePath, false);
1623
+ const requestedHash = window.location.hash;
1624
+ const restoreCurrentHash = () => {
1625
+ if (!activeWorkspaceId || window.location.hash !== requestedHash) return;
1626
+ window.history.replaceState(null, '', buildHash(activeWorkspaceId, selectedFile));
1627
+ };
1628
+ void openFromIds(hashState.workspaceId, hashState.filePath, false)
1629
+ .then((opened) => {
1630
+ if (!opened) restoreCurrentHash();
1631
+ })
1632
+ .catch((error: unknown) => {
1633
+ restoreCurrentHash();
1634
+ alert(
1635
+ error instanceof Error
1636
+ ? 'DocBlocks could not switch documents: ' + error.message
1637
+ : 'DocBlocks could not switch documents.',
1638
+ );
1639
+ });
834
1640
  }
835
1641
  };
836
1642
  window.addEventListener('popstate', onPopState);
837
1643
  return () => window.removeEventListener('popstate', onPopState);
838
- }, [openFromIds]);
1644
+ }, [activeWorkspaceId, openFromIds, selectedFile]);
1645
+
1646
+ useEffect(() => {
1647
+ if (!showBrowserStorageWarning || typeof navigator === 'undefined') return;
1648
+ const storage = navigator.storage;
1649
+ if (!storage || typeof storage.persisted !== 'function') return;
1650
+
1651
+ let cancelled = false;
1652
+ void storage
1653
+ .persisted()
1654
+ .then((persistent) => {
1655
+ if (!cancelled) setBrowserStoragePersistent(persistent);
1656
+ })
1657
+ .catch(() => {
1658
+ // Ignore unsupported/denied checks; the menu action can still try persist().
1659
+ });
1660
+
1661
+ return () => {
1662
+ cancelled = true;
1663
+ };
1664
+ }, [showBrowserStorageWarning]);
1665
+
1666
+ // Browser PWA install plumbing. Chromium fires `beforeinstallprompt` when
1667
+ // the app is installable; stashing it (after preventDefault) lets the app
1668
+ // menu offer "Install DocBlocks…" on our terms. `appinstalled` fires for
1669
+ // any install path (menu item or the browser's own omnibox affordance).
1670
+ useEffect(() => {
1671
+ if (isElectronHost() || typeof window === 'undefined') return;
1672
+ const onBeforeInstallPrompt = (e: BeforeInstallPromptEvent) => {
1673
+ e.preventDefault();
1674
+ setInstallPromptEvent(e);
1675
+ };
1676
+ const onAppInstalled = () => {
1677
+ setInstallPromptEvent(null);
1678
+ // Chromium auto-grants persistent storage to installed apps -- re-probe
1679
+ // so the "Keep data in browser for longer" nag retires itself.
1680
+ const storage = navigator.storage;
1681
+ if (storage && typeof storage.persisted === 'function') {
1682
+ storage
1683
+ .persisted()
1684
+ .then((persistent) => {
1685
+ if (persistent) setBrowserStoragePersistent(true);
1686
+ })
1687
+ .catch(() => {
1688
+ // Probe is best-effort; the manual menu action still works.
1689
+ });
1690
+ }
1691
+ };
1692
+ window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
1693
+ window.addEventListener('appinstalled', onAppInstalled);
1694
+ return () => {
1695
+ window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
1696
+ window.removeEventListener('appinstalled', onAppInstalled);
1697
+ };
1698
+ }, []);
1699
+
1700
+ const handleInstallApp = useCallback(async () => {
1701
+ const promptEvent = installPromptEvent;
1702
+ // prompt() is single-use: clear the stash up front and rely on the
1703
+ // browser re-firing beforeinstallprompt if the user dismisses.
1704
+ setInstallPromptEvent(null);
1705
+ if (!promptEvent) return;
1706
+ try {
1707
+ await promptEvent.prompt();
1708
+ await promptEvent.userChoice;
1709
+ } catch {
1710
+ // The prompt was already consumed or is not allowed right now.
1711
+ }
1712
+ }, [installPromptEvent]);
839
1713
 
840
1714
  // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
841
1715
  useEffect(() => {
@@ -843,6 +1717,10 @@ export function DocBlocksShell({
843
1717
  const target = (e.target as HTMLElement).closest?.('[data-view]');
844
1718
  if (target) {
845
1719
  const view = target.getAttribute('data-view') as EditorView;
1720
+ if (view) {
1721
+ // The user found the view tabs on their own -- the gateway's job is done.
1722
+ closeWelcomeGateway();
1723
+ }
846
1724
  if (view && activeWorkspaceId && selectedFile) {
847
1725
  saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
848
1726
  }
@@ -850,31 +1728,14 @@ export function DocBlocksShell({
850
1728
  };
851
1729
  window.addEventListener('click', handler, true);
852
1730
  return () => window.removeEventListener('click', handler, true);
853
- }, [activeWorkspaceId, selectedFile]);
854
-
855
- const handleAutoSaved = useCallback((filePath: string, savedContent: string) => {
856
- lastLocalSaveRef.current = {
857
- filePath: normaliseProviderPath(filePath),
858
- content: savedContent,
859
- savedAt: Date.now(),
860
- };
861
- }, []);
862
-
863
- // Auto-save current file. The returned `flush` is called from the
864
- // Ctrl/Cmd+S handler below so the user gets immediate confirmation.
865
- const { flush: flushAutoSave } = useAutoSave(
866
- provider,
867
- selectedFile,
868
- editorContent,
869
- 500,
870
- handleAutoSaved,
871
- );
1731
+ }, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
872
1732
 
873
- // Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
874
- // small "auto-save confirmed" toast. Files are already saved on every
875
- // keystroke (debounced) this is purely UX reassurance for users who
876
- // muscle-memory hit Save.
877
- const [saveToastVisible, setSaveToastVisible] = useState(false);
1733
+ // Ctrl/Cmd+S is a real acknowledgement of the session revision, not an
1734
+ // optimistic toast. A failed commit remains dirty and is surfaced.
1735
+ const [saveToast, setSaveToast] = useState<{
1736
+ kind: 'success' | 'error';
1737
+ message: string;
1738
+ } | null>(null);
878
1739
  const saveToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
879
1740
  useEffect(() => {
880
1741
  const onKey = (e: KeyboardEvent) => {
@@ -883,20 +1744,83 @@ export function DocBlocksShell({
883
1744
  if (!sKey || !accel || e.altKey) return;
884
1745
  e.preventDefault();
885
1746
  e.stopPropagation();
886
- void flushAutoSave().catch(() => undefined);
887
- setSaveToastVisible(true);
888
- if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
889
- saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
1747
+ void (async () => {
1748
+ try {
1749
+ await documentSession.flush('manual');
1750
+ setSaveToast({ kind: 'success', message: 'Saved. You’re all set.' });
1751
+ } catch (error: unknown) {
1752
+ setSaveToast({
1753
+ kind: 'error',
1754
+ message: isQuotaExceededError(error)
1755
+ ? 'Could not save -- browser storage is full. Free up space or back up your work.'
1756
+ : error instanceof Error
1757
+ ? error.message
1758
+ : 'Could not save this document.',
1759
+ });
1760
+ }
1761
+ if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
1762
+ saveToastTimerRef.current = setTimeout(() => setSaveToast(null), 3000);
1763
+ })();
890
1764
  };
891
1765
  window.addEventListener('keydown', onKey, true);
892
1766
  return () => window.removeEventListener('keydown', onKey, true);
893
- }, [flushAutoSave]);
1767
+ }, [documentSession]);
894
1768
  useEffect(() => {
895
1769
  return () => {
896
1770
  if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
897
1771
  };
898
1772
  }, []);
899
1773
 
1774
+ // One-time passive notice when the host reports the app is fully cached
1775
+ // for offline use. Mirrors the save toast's look and self-dismissal.
1776
+ const [offlineReadyToast, setOfflineReadyToast] = useState(false);
1777
+ const offlineReadyAnnouncedRef = useRef(false);
1778
+ useEffect(() => {
1779
+ if (!offlineReady || offlineReadyAnnouncedRef.current) return;
1780
+ offlineReadyAnnouncedRef.current = true;
1781
+ setOfflineReadyToast(true);
1782
+ const timer = setTimeout(() => setOfflineReadyToast(false), 3000);
1783
+ return () => clearTimeout(timer);
1784
+ }, [offlineReady]);
1785
+
1786
+ // Quota-exhaustion banner. The session clears `error` on every keystroke
1787
+ // and at each retry, so the raw snapshot flickers during a failing
1788
+ // type/retry loop -- latch it, and release only when a commit succeeds.
1789
+ // Known gap: version-snapshot writes (squisq's DocumentVersionManager)
1790
+ // swallow their own failures upstream (`SaveVersionResult` has no error
1791
+ // variant), but they hit the same provider, so the primary autosave
1792
+ // latches this banner within one edit cycle anyway.
1793
+ const [storageFull, setStorageFull] = useState(false);
1794
+ useEffect(() => {
1795
+ if (documentSnapshot.error && isQuotaExceededError(documentSnapshot.error)) {
1796
+ setStorageFull(true);
1797
+ } else if (documentSnapshot.status === 'saved') {
1798
+ setStorageFull(false);
1799
+ }
1800
+ }, [documentSnapshot.error, documentSnapshot.status]);
1801
+
1802
+ // Ask the browser to make origin storage durable the first time real work
1803
+ // is saved (browser surface only, once per session). Chromium decides
1804
+ // silently -- installed apps are auto-granted; Firefox may show one prompt
1805
+ // at this meaningful moment rather than at page load.
1806
+ const autoPersistRequestedRef = useRef(false);
1807
+ useEffect(() => {
1808
+ if (!showBrowserStorageWarning || browserStoragePersistent) return;
1809
+ if (autoPersistRequestedRef.current) return;
1810
+ if (documentSnapshot.status !== 'saved') return;
1811
+ const storage = typeof navigator !== 'undefined' ? navigator.storage : undefined;
1812
+ if (!storage || typeof storage.persist !== 'function') return;
1813
+ autoPersistRequestedRef.current = true;
1814
+ storage
1815
+ .persist()
1816
+ .then((granted) => {
1817
+ if (granted) setBrowserStoragePersistent(true);
1818
+ })
1819
+ .catch(() => {
1820
+ // Best-effort; the manual menu action remains available.
1821
+ });
1822
+ }, [showBrowserStorageWarning, browserStoragePersistent, documentSnapshot.status]);
1823
+
900
1824
  // Per-file media: for `notes.md` images live in `notes_files/` beside it.
901
1825
  // Rebuilds whenever the provider or selected file changes.
902
1826
  useEffect(() => {
@@ -921,10 +1845,10 @@ export function DocBlocksShell({
921
1845
  return () => {
922
1846
  mp.dispose();
923
1847
  };
924
- }, [provider, selectedFile]);
1848
+ }, [provider, selectedFile, mediaEpoch]);
925
1849
 
926
1850
  // Invalidate the document-link candidate cache when the backing
927
- // workspace changes otherwise the link dialog would surface
1851
+ // workspace changes -- otherwise the link dialog would surface
928
1852
  // neighbours from a previously-open workspace.
929
1853
  useEffect(() => {
930
1854
  mdFileCacheRef.current = null;
@@ -983,68 +1907,238 @@ export function DocBlocksShell({
983
1907
 
984
1908
  // React to external file changes watched by the Electron host (chokidar).
985
1909
  useEffect(() => {
986
- if (!isElectronHost()) return;
987
- if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
988
- const unwatch = provider.watch((changedPath) => {
989
- setExplorerKey((k) => k + 1);
990
- if (!selectedFile || !sameProviderPath(changedPath, selectedFile)) return;
991
-
992
- // If the open file's contents changed on disk, reload it (best-effort).
993
- (async () => {
994
- const content = await provider.readFile(selectedFile);
995
- if (content === null || content === editorContent) return;
996
-
997
- const localSave = lastLocalSaveRef.current;
998
- if (
999
- localSave &&
1000
- sameProviderPath(localSave.filePath, selectedFile) &&
1001
- localSave.content === content &&
1002
- Date.now() - localSave.savedAt < 5000
1003
- ) {
1910
+ if (!provider) return;
1911
+ const providerV2 = getFileSystemProviderV2(provider);
1912
+ if (!providerV2?.capabilities.watch) return;
1913
+ if (!selectedFile || !documentSnapshot.targetKey) return;
1914
+ const watchedFile = selectedFile;
1915
+ const targetKey = documentSnapshot.targetKey;
1916
+ let disposed = false;
1917
+ let reading = false;
1918
+ let rerun = false;
1919
+ let observationSequence = 0;
1920
+
1921
+ const drainWatcherReads = async () => {
1922
+ if (reading) {
1923
+ rerun = true;
1924
+ return;
1925
+ }
1926
+ reading = true;
1927
+ try {
1928
+ do {
1929
+ rerun = false;
1930
+ const sequence = ++observationSequence;
1931
+ const external = await readStableFileSnapshot(provider, watchedFile);
1932
+ if (disposed) return;
1933
+ const result = documentSession.observeExternal({
1934
+ targetKey,
1935
+ sequence,
1936
+ content: external.content,
1937
+ version: external.version,
1938
+ });
1939
+ if (result === 'applied') {
1940
+ // observeExternal increments the session generation. The editor
1941
+ // remount key and content both derive from that snapshot.
1942
+ }
1943
+ } while (rerun && !disposed);
1944
+ } catch {
1945
+ // The next watcher event will retry a file that was changing too
1946
+ // quickly to obtain a stable content/metadata pair.
1947
+ } finally {
1948
+ reading = false;
1949
+ if (rerun && !disposed) void drainWatcherReads();
1950
+ }
1951
+ };
1952
+
1953
+ const subscription = providerV2.watch(
1954
+ (event) => {
1955
+ if (event.type === 'overflow') {
1956
+ void drainWatcherReads();
1004
1957
  return;
1005
1958
  }
1959
+ const changedCurrent = sameProviderPath(event.path, watchedFile);
1960
+ const changedDestination =
1961
+ event.destinationPath !== null && sameProviderPath(event.destinationPath, watchedFile);
1962
+ if (changedCurrent || changedDestination) void drainWatcherReads();
1963
+ },
1964
+ {
1965
+ onError: () => {
1966
+ // Re-read the active file on watcher failure. The session decides
1967
+ // whether the result is a clean update or an external conflict.
1968
+ void drainWatcherReads();
1969
+ },
1970
+ },
1971
+ );
1972
+ void subscription.ready.catch(() => undefined);
1973
+ return () => {
1974
+ disposed = true;
1975
+ void subscription.dispose();
1976
+ };
1977
+ }, [provider, selectedFile, documentSession, documentSnapshot.targetKey]);
1978
+
1979
+ const transitionAwayFromDocument = useCallback(
1980
+ async (requestId: number): Promise<boolean> => {
1981
+ if (requestId !== navigationRequestRef.current) return false;
1982
+ try {
1983
+ const transitioned = await documentSession.transitionWithLoad(async () =>
1984
+ requestId === navigationRequestRef.current ? { target: null, content: '' } : null,
1985
+ );
1986
+ if (!transitioned || requestId !== navigationRequestRef.current) return false;
1987
+ return true;
1988
+ } catch (error: unknown) {
1989
+ alert(
1990
+ error instanceof Error
1991
+ ? 'DocBlocks could not save this document: ' + error.message
1992
+ : 'DocBlocks could not save this document.',
1993
+ );
1994
+ return false;
1995
+ }
1996
+ },
1997
+ [documentSession],
1998
+ );
1999
+
2000
+ const handleUseExternalDocument = useCallback(async () => {
2001
+ const conflictKey = documentSession.getSnapshot().conflict?.targetKey;
2002
+ const pendingDbk = conflictKey ? pendingDbkConflictsRef.current.get(conflictKey) : undefined;
2003
+ try {
2004
+ if (pendingDbk) {
2005
+ pendingDbk.provider.replaceContents(pendingDbk.snapshot);
2006
+ pendingDbkConflictsRef.current.delete(conflictKey!);
2007
+ mdFileCacheRef.current = null;
2008
+ setMediaEpoch((epoch) => epoch + 1);
2009
+ setExplorerKey((key) => key + 1);
2010
+ }
2011
+ const snapshot = await documentSession.resolveConflict('use-external');
2012
+ if (!snapshot.targetKey) {
2013
+ setSelectedFile(null);
2014
+ if (activeWorkspaceId) pushHash(activeWorkspaceId, null);
2015
+ }
2016
+ } catch (error: unknown) {
2017
+ setSaveToast({
2018
+ kind: 'error',
2019
+ message: error instanceof Error ? error.message : 'Could not reload the external document.',
2020
+ });
2021
+ }
2022
+ }, [activeWorkspaceId, documentSession, pushHash]);
2023
+
2024
+ const handleKeepLocalDocument = useCallback(async () => {
2025
+ const conflictKey = documentSession.getSnapshot().conflict?.targetKey;
2026
+ try {
2027
+ await documentSession.resolveConflict('use-local');
2028
+ if (conflictKey) pendingDbkConflictsRef.current.delete(conflictKey);
2029
+ setSaveToast({ kind: 'success', message: 'Your version was saved.' });
2030
+ } catch (error: unknown) {
2031
+ setSaveToast({
2032
+ kind: 'error',
2033
+ message: error instanceof Error ? error.message : 'Could not save your version.',
2034
+ });
2035
+ }
2036
+ }, [documentSession]);
2037
+
2038
+ useEffect(() => {
2039
+ const needsWarning = ['dirty', 'saving', 'error', 'conflict'].includes(documentSnapshot.status);
2040
+ if (!documentSnapshot.targetKey) return;
2041
+ const flushBestEffort = () => {
2042
+ void documentSession.flush('close').catch(() => {
2043
+ // A browser cannot await asynchronous storage during teardown. The
2044
+ // beforeunload warning remains active when persistence is unresolved.
2045
+ });
2046
+ };
2047
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
2048
+ flushBestEffort();
2049
+ if (!needsWarning) return;
2050
+ event.preventDefault();
2051
+ event.returnValue = '';
2052
+ };
2053
+ const onPageHide = () => flushBestEffort();
2054
+ const onVisibilityChange = () => {
2055
+ if (document.visibilityState === 'hidden') flushBestEffort();
2056
+ };
2057
+ window.addEventListener('beforeunload', onBeforeUnload);
2058
+ window.addEventListener('pagehide', onPageHide);
2059
+ document.addEventListener('visibilitychange', onVisibilityChange);
2060
+ return () => {
2061
+ window.removeEventListener('beforeunload', onBeforeUnload);
2062
+ window.removeEventListener('pagehide', onPageHide);
2063
+ document.removeEventListener('visibilitychange', onVisibilityChange);
2064
+ };
2065
+ }, [documentSession, documentSnapshot.status, documentSnapshot.targetKey]);
2066
+
2067
+ // Electron can defer window destruction and await this acknowledgement.
2068
+ // Browser unload remains best-effort, which is why continuous autosave is
2069
+ // still the primary durability mechanism.
2070
+ useEffect(() => {
2071
+ if (!isElectronHost()) return;
2072
+ const lifecycle = getDocBlocksHost().lifecycle;
2073
+ const stopPrepare = lifecycle.onPrepareClose(async (request) => {
2074
+ preparedCloseRequestRef.current = request.requestId;
2075
+ try {
2076
+ const snapshot = await documentSession.prepareClose();
2077
+ return { status: 'ready' as const, persistedRevision: snapshot.persistedRevision };
2078
+ } catch (error: unknown) {
2079
+ preparedCloseRequestRef.current = null;
2080
+ return {
2081
+ status: 'blocked' as const,
2082
+ code:
2083
+ error instanceof DocumentSessionConflictError
2084
+ ? ('external-conflict' as const)
2085
+ : ('save-failed' as const),
2086
+ message: error instanceof Error ? error.message : 'Could not save the document.',
2087
+ };
2088
+ }
2089
+ });
2090
+ const stopCancel = lifecycle.onCancelClose((requestId) => {
2091
+ if (preparedCloseRequestRef.current !== requestId) return;
2092
+ preparedCloseRequestRef.current = null;
2093
+ documentSession.cancelClose();
2094
+ });
2095
+ return () => {
2096
+ stopPrepare();
2097
+ stopCancel();
2098
+ };
2099
+ }, [documentSession]);
1006
2100
 
1007
- setEditorContent(content);
1008
- setEditorKey((k) => k + 1);
1009
- })();
1010
- });
1011
- return unwatch;
1012
- }, [provider, selectedFile, editorContent]);
1013
-
1014
2101
  const handleWorkspaceSelect = useCallback(
1015
2102
  async (ws: WorkspaceDescriptor) => {
2103
+ const requestId = ++navigationRequestRef.current;
1016
2104
  await touchWorkspace(ws.id);
2105
+ if (requestId !== navigationRequestRef.current) return;
1017
2106
  let nextProvider: FileSystemProvider | null = null;
1018
- if (ws.type === 'electron-native') {
2107
+ const transient = getTransientWorkspace(ws.id);
2108
+ if (transient) {
2109
+ nextProvider = transient.provider;
2110
+ } else if (ws.type === 'electron-native') {
1019
2111
  nextProvider = await createElectronProviderFromWorkspace(ws);
1020
2112
  if (!nextProvider) return;
1021
2113
  } else if (ws.type === 'native') {
1022
- const restored = await restoreNativeFolder(ws.id);
2114
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
1023
2115
  if (!restored) {
1024
- // Permission denied or handle lost fall through without changing provider
2116
+ // Permission denied or handle lost -- fall through without changing provider
1025
2117
  return;
1026
2118
  }
1027
2119
  nextProvider = restored;
1028
2120
  } else {
1029
- nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
2121
+ nextProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
1030
2122
  }
2123
+ if (requestId !== navigationRequestRef.current) return;
2124
+ if (!(await transitionAwayFromDocument(requestId))) return;
1031
2125
  setProvider(nextProvider);
1032
2126
  setActiveWorkspaceId(ws.id);
1033
2127
  setSelectedFile(null);
1034
2128
  setSelectedFolder(null);
1035
2129
  setFolderEntries([]);
1036
- setEditorContent('');
1037
- setEditorKey((k) => k + 1);
1038
2130
  pushHash(ws.id, null);
1039
2131
  },
1040
- [pushHash],
2132
+ [pushHash, transitionAwayFromDocument],
1041
2133
  );
1042
2134
 
1043
2135
  const handleOpenFolder = useCallback(async () => {
2136
+ const requestId = ++navigationRequestRef.current;
1044
2137
  try {
1045
2138
  if (isElectronHost()) {
1046
2139
  const info = await getDocBlocksHost().workspaces.pickFolder();
1047
2140
  if (!info) return; // user cancelled
2141
+ if (requestId !== navigationRequestRef.current) return;
1048
2142
  const descriptor: WorkspaceDescriptor = {
1049
2143
  id: info.id,
1050
2144
  name: info.name,
@@ -1053,19 +2147,20 @@ export function DocBlocksShell({
1053
2147
  lastOpened: new Date().toISOString(),
1054
2148
  };
1055
2149
  await saveWorkspace(descriptor);
1056
- const provider = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
2150
+ if (requestId !== navigationRequestRef.current) return;
2151
+ const provider = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
2152
+ if (!(await transitionAwayFromDocument(requestId))) return;
1057
2153
  setProvider(provider);
1058
2154
  setActiveWorkspaceId(descriptor.id);
1059
2155
  setSelectedFile(null);
1060
2156
  setSelectedFolder(null);
1061
2157
  setFolderEntries([]);
1062
- setEditorContent('');
1063
- setEditorKey((k) => k + 1);
1064
2158
  pushHash(descriptor.id, null);
1065
2159
  return;
1066
2160
  }
1067
2161
 
1068
- const nativeProvider = await openNativeFolder();
2162
+ const nativeProvider = await (await loadNativeFileSystem()).openNativeFolder();
2163
+ if (requestId !== navigationRequestRef.current) return;
1069
2164
  const descriptor: WorkspaceDescriptor = {
1070
2165
  id: nativeProvider.id,
1071
2166
  name: nativeProvider.label,
@@ -1073,40 +2168,135 @@ export function DocBlocksShell({
1073
2168
  lastOpened: new Date().toISOString(),
1074
2169
  };
1075
2170
  await saveWorkspace(descriptor);
2171
+ if (requestId !== navigationRequestRef.current) return;
2172
+ if (!(await transitionAwayFromDocument(requestId))) return;
1076
2173
  setProvider(nativeProvider);
1077
2174
  setActiveWorkspaceId(descriptor.id);
1078
2175
  setSelectedFile(null);
1079
2176
  setSelectedFolder(null);
1080
2177
  setFolderEntries([]);
1081
- setEditorContent('');
1082
- setEditorKey((k) => k + 1);
1083
2178
  pushHash(descriptor.id, null);
1084
2179
  } catch {
1085
2180
  // User cancelled or API not supported
1086
2181
  }
1087
- }, [pushHash]);
2182
+ }, [pushHash, transitionAwayFromDocument]);
2183
+
2184
+ // A clone finished: the host already registered + persisted the folder,
2185
+ // so opening it mirrors the pickFolder success path exactly.
2186
+ const handleWorkspaceCloned = useCallback(
2187
+ (info: ElectronWorkspaceInfo) => {
2188
+ const requestId = ++navigationRequestRef.current;
2189
+ void (async () => {
2190
+ const descriptor: WorkspaceDescriptor = {
2191
+ id: info.id,
2192
+ name: info.name,
2193
+ type: 'electron-native',
2194
+ rootPath: info.rootPath,
2195
+ lastOpened: new Date().toISOString(),
2196
+ };
2197
+ await saveWorkspace(descriptor);
2198
+ if (requestId !== navigationRequestRef.current) return;
2199
+ const cloneProvider = await createElectronFileSystemProvider(
2200
+ info.id,
2201
+ info.name,
2202
+ info.rootPath,
2203
+ );
2204
+ if (!(await transitionAwayFromDocument(requestId))) return;
2205
+ setProvider(cloneProvider);
2206
+ setActiveWorkspaceId(descriptor.id);
2207
+ setSelectedFile(null);
2208
+ setSelectedFolder(null);
2209
+ setFolderEntries([]);
2210
+ pushHash(descriptor.id, null);
2211
+ })();
2212
+ },
2213
+ [pushHash, transitionAwayFromDocument],
2214
+ );
1088
2215
 
1089
2216
  const handleNewFile = useCallback(async () => {
1090
2217
  if (!provider) return;
1091
2218
  const name = prompt('New document name:', 'Untitled.md');
1092
2219
  if (!name) return;
1093
2220
  const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
1094
- await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
1095
- setSelectedFile('/' + filename);
1096
- setEditorContent('# ' + filename.replace(/\.[^.]+$/, '') + '\n');
2221
+ const path = '/' + filename;
2222
+ const content = '# ' + filename.replace(/\.[^.]+$/, '') + '\n';
2223
+ const requestId = ++navigationRequestRef.current;
2224
+ try {
2225
+ const transitioned = await documentSession.transitionWithLoad(async () => {
2226
+ if (requestId !== navigationRequestRef.current) return null;
2227
+ const providerV2 = getFileSystemProviderV2(provider);
2228
+ if (providerV2) {
2229
+ try {
2230
+ await writeProviderText(provider, path, content, 'create');
2231
+ } catch (error: unknown) {
2232
+ if (error instanceof FsError && error.code === 'already-exists') {
2233
+ throw new Error('A document with that name already exists.');
2234
+ }
2235
+ throw error;
2236
+ }
2237
+ } else if (provider.commitFile) {
2238
+ const result = await provider.commitFile(path, content, null);
2239
+ if (result.status === 'conflict') {
2240
+ throw new Error('A document with that name already exists.');
2241
+ }
2242
+ } else {
2243
+ if (await providerEntryExists(provider, path)) {
2244
+ throw new Error('A document with that name already exists.');
2245
+ }
2246
+ await writeProviderText(provider, path, content);
2247
+ }
2248
+ if (requestId !== navigationRequestRef.current) return null;
2249
+ return {
2250
+ target: createDocumentTarget(provider, activeWorkspaceId ?? provider.id, path),
2251
+ content,
2252
+ };
2253
+ });
2254
+ if (!transitioned) return;
2255
+ } catch (error: unknown) {
2256
+ alert(error instanceof Error ? error.message : 'Could not create the document.');
2257
+ return;
2258
+ }
2259
+ setSelectedFile(path);
1097
2260
  setInitialView('wysiwyg');
1098
- setEditorKey((k) => k + 1);
1099
2261
  setExplorerKey((k) => k + 1);
2262
+ closeWelcomeGateway();
1100
2263
  if (activeWorkspaceId) {
1101
2264
  pushHash(activeWorkspaceId, '/' + filename);
1102
2265
  }
1103
- }, [provider, activeWorkspaceId, pushHash]);
2266
+ }, [
2267
+ provider,
2268
+ activeWorkspaceId,
2269
+ pushHash,
2270
+ closeWelcomeGateway,
2271
+ createDocumentTarget,
2272
+ documentSession,
2273
+ ]);
2274
+
2275
+ // Manifest shortcut "New document" launches `/?action=new` (installed
2276
+ // PWA jump list). Handled once the first workspace/provider is ready,
2277
+ // then stripped from the URL so a reload doesn't re-trigger it.
2278
+ const actionNewHandledRef = useRef(false);
2279
+ useEffect(() => {
2280
+ if (!provider || actionNewHandledRef.current) return;
2281
+ if (typeof window === 'undefined') return;
2282
+ const params = new URLSearchParams(window.location.search);
2283
+ if (params.get('action') !== 'new') return;
2284
+ actionNewHandledRef.current = true;
2285
+ params.delete('action');
2286
+ const query = params.toString();
2287
+ window.history.replaceState(
2288
+ null,
2289
+ '',
2290
+ window.location.pathname + (query ? `?${query}` : '') + window.location.hash,
2291
+ );
2292
+ void handleNewFile();
2293
+ }, [provider, handleNewFile]);
1104
2294
 
1105
2295
  const handleRevealWorkspace = useCallback(async () => {
1106
2296
  if (!isElectronHost() || !activeWorkspaceId) return;
1107
2297
  const ws = await getWorkspace(activeWorkspaceId);
1108
2298
  if (ws?.type === 'electron-native' && ws.rootPath) {
1109
- await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
2299
+ await getDocBlocksHost().shell.revealInFolder(ws.id);
1110
2300
  }
1111
2301
  }, [activeWorkspaceId]);
1112
2302
 
@@ -1125,6 +2315,38 @@ export function DocBlocksShell({
1125
2315
  case 'file:revealWorkspace':
1126
2316
  handleRevealWorkspace();
1127
2317
  break;
2318
+ case 'git:commit':
2319
+ if (gitRef.current.repo) gitRef.current.openDialog({ kind: 'commit' });
2320
+ break;
2321
+ case 'git:push':
2322
+ if (gitRef.current.repo) void gitRef.current.push();
2323
+ break;
2324
+ case 'git:pull':
2325
+ if (gitRef.current.repo) void gitRef.current.pull();
2326
+ break;
2327
+ case 'git:fetch':
2328
+ if (gitRef.current.repo) void gitRef.current.fetchRemote();
2329
+ break;
2330
+ case 'git:newBranch':
2331
+ if (gitRef.current.repo) {
2332
+ gitRef.current.openDialog({ kind: 'branches', createFocus: true });
2333
+ }
2334
+ break;
2335
+ case 'git:switchBranch':
2336
+ if (gitRef.current.repo) gitRef.current.openDialog({ kind: 'branches' });
2337
+ break;
2338
+ case 'git:history':
2339
+ if (gitRef.current.repo) gitRef.current.openDialog({ kind: 'history' });
2340
+ break;
2341
+ case 'git:clone':
2342
+ if (gitRef.current.available) gitRef.current.openDialog({ kind: 'clone' });
2343
+ break;
2344
+ case 'git:openOnRemote':
2345
+ gitRef.current.openOnRemote();
2346
+ break;
2347
+ case 'git:createPullRequest':
2348
+ if (gitRef.current.repo) void gitRef.current.createPullRequest();
2349
+ break;
1128
2350
  case 'help:viewOnGitHub':
1129
2351
  host.shell.openExternal('https://github.com/bendyline/docblocks');
1130
2352
  break;
@@ -1145,90 +2367,135 @@ export function DocBlocksShell({
1145
2367
  });
1146
2368
  }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
1147
2369
 
1148
- // Subscribe to open-file / deep-link requests from the OS.
1149
- useEffect(() => {
1150
- if (!isElectronHost()) return;
1151
- const host = getDocBlocksHost();
1152
- return host.onOpenRequest(async (req) => {
1153
- if (req.filePath) {
1154
- const workspaces = (await listWorkspaces()).filter(
1155
- (w) => w.type === 'electron-native' && w.rootPath,
1156
- );
1157
- const match = workspaces.find(
1158
- (w) => req.filePath!.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath,
1159
- );
1160
- if (match && match.rootPath) {
1161
- const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
1162
- await openFromIds(match.id, rel, true);
1163
- }
1164
- } else if (req.url) {
1165
- try {
1166
- const u = new URL(req.url);
1167
- const path = u.searchParams.get('path');
1168
- if (path) {
1169
- const workspaces = (await listWorkspaces()).filter(
1170
- (w) => w.type === 'electron-native' && w.rootPath,
1171
- );
1172
- const match = workspaces.find(
1173
- (w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath,
1174
- );
1175
- if (match && match.rootPath) {
1176
- const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
1177
- await openFromIds(match.id, rel, true);
1178
- }
1179
- }
1180
- } catch {
1181
- // bad URL, ignore
1182
- }
1183
- }
1184
- });
1185
- }, [openFromIds]);
2370
+ // OS open-file / deep-link handling lives after the container helpers it
2371
+ // depends on -- see the `openTransient` + onOpenRequest effect below.
1186
2372
 
1187
2373
  const handleSelect = useCallback(
1188
2374
  async (path: string, kind: 'file' | 'directory') => {
1189
2375
  if (!provider || !activeWorkspaceId) return;
2376
+ const requestId = ++navigationRequestRef.current;
1190
2377
 
1191
2378
  if (kind === 'directory') {
2379
+ if (!(await transitionAwayFromDocument(requestId))) return;
2380
+ if (requestId !== navigationRequestRef.current) return;
1192
2381
  setSelectedFile(null);
1193
2382
  setSelectedFolder(path);
1194
- const entries = await provider.readDirectory(path);
2383
+ const entries = await readProviderDirectory(provider, path);
2384
+ if (requestId !== navigationRequestRef.current) return;
1195
2385
  setFolderEntries(entries);
1196
- setEditorContent('');
1197
- setEditorKey((k) => k + 1);
1198
2386
  pushHash(activeWorkspaceId, null);
1199
2387
  } else {
1200
- const content = await provider.readFile(path);
2388
+ let content: string | null = null;
2389
+ try {
2390
+ const transitioned = await documentSession.transitionWithLoad(async () => {
2391
+ if (requestId !== navigationRequestRef.current) return null;
2392
+ content = await readProviderText(provider, path);
2393
+ if (requestId !== navigationRequestRef.current || content === null) return null;
2394
+ return {
2395
+ target: createDocumentTarget(provider, activeWorkspaceId, path),
2396
+ content,
2397
+ };
2398
+ });
2399
+ if (!transitioned) return;
2400
+ } catch (error: unknown) {
2401
+ alert(error instanceof Error ? error.message : 'Could not switch documents.');
2402
+ return;
2403
+ }
2404
+ if (requestId !== navigationRequestRef.current) return;
1201
2405
  setSelectedFile(path);
1202
2406
  setSelectedFolder(null);
1203
2407
  setFolderEntries([]);
1204
- setEditorContent(content ?? '');
1205
2408
  setInitialView('wysiwyg');
1206
- setEditorKey((k) => k + 1);
2409
+ closeWelcomeGateway();
1207
2410
  pushHash(activeWorkspaceId, path);
1208
2411
  saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
1209
2412
  if (effectiveCompact) setMobileShowEditor(true);
1210
2413
  }
1211
2414
  },
1212
- [provider, activeWorkspaceId, pushHash, effectiveCompact],
2415
+ [
2416
+ provider,
2417
+ activeWorkspaceId,
2418
+ pushHash,
2419
+ effectiveCompact,
2420
+ closeWelcomeGateway,
2421
+ createDocumentTarget,
2422
+ documentSession,
2423
+ transitionAwayFromDocument,
2424
+ ],
1213
2425
  );
1214
2426
 
1215
- const handleTreeChange = useCallback(async () => {
1216
- if (!provider) return;
1217
- // If the open file was deleted, clear the editor
1218
- if (selectedFile) {
1219
- const exists = await provider.exists(selectedFile);
1220
- if (!exists) {
1221
- setSelectedFile(null);
1222
- setEditorContent('');
1223
- setEditorKey((k) => k + 1);
1224
- if (activeWorkspaceId) pushHash(activeWorkspaceId, null);
2427
+ const handleTreeMutation = useCallback<FileTreeMutationHandler>(
2428
+ async (change, mutate) => {
2429
+ if (!provider || !activeWorkspaceId || !selectedFile) {
2430
+ await mutate();
2431
+ return;
1225
2432
  }
1226
- }
1227
- if (selectedFolder) {
1228
- const entries = await provider.readDirectory(selectedFolder);
1229
- setFolderEntries(entries);
1230
- }
1231
- }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
2433
+
2434
+ if (change.type === 'move') {
2435
+ const nextFile = relocateProviderPath(selectedFile, change.oldPath, change.newPath);
2436
+ if (nextFile !== selectedFile) {
2437
+ await documentSession.retarget(
2438
+ createDocumentTarget(provider, activeWorkspaceId, nextFile),
2439
+ mutate,
2440
+ );
2441
+ return;
2442
+ }
2443
+ }
2444
+
2445
+ if (change.type === 'delete' && pathContains(change.path, selectedFile)) {
2446
+ await documentSession.delete(mutate);
2447
+ return;
2448
+ }
2449
+
2450
+ await mutate();
2451
+ },
2452
+ [provider, activeWorkspaceId, selectedFile, documentSession, createDocumentTarget],
2453
+ );
2454
+
2455
+ const handleTreeChange = useCallback(
2456
+ async (change?: FileTreeChange) => {
2457
+ if (!provider) return;
2458
+ if (change?.type === 'move' && change.oldPath && change.newPath) {
2459
+ const nextFile = selectedFile
2460
+ ? relocateProviderPath(selectedFile, change.oldPath, change.newPath)
2461
+ : null;
2462
+ const nextFolder = selectedFolder
2463
+ ? relocateProviderPath(selectedFolder, change.oldPath, change.newPath)
2464
+ : null;
2465
+
2466
+ if (nextFile !== selectedFile) {
2467
+ setSelectedFile(nextFile);
2468
+ if (activeWorkspaceId) {
2469
+ pushHash(activeWorkspaceId, nextFile);
2470
+ if (nextFile) {
2471
+ saveLastState({
2472
+ workspaceId: activeWorkspaceId,
2473
+ filePath: nextFile,
2474
+ view: 'wysiwyg',
2475
+ });
2476
+ }
2477
+ }
2478
+ }
2479
+ if (nextFolder !== selectedFolder) setSelectedFolder(nextFolder);
2480
+ if (nextFolder) setFolderEntries(await readProviderDirectory(provider, nextFolder));
2481
+ return;
2482
+ }
2483
+
2484
+ // If the open file was deleted, clear the editor
2485
+ if (selectedFile) {
2486
+ const exists = await providerEntryExists(provider, selectedFile);
2487
+ if (!exists) {
2488
+ setSelectedFile(null);
2489
+ if (activeWorkspaceId) pushHash(activeWorkspaceId, null);
2490
+ }
2491
+ }
2492
+ if (selectedFolder) {
2493
+ const entries = await readProviderDirectory(provider, selectedFolder);
2494
+ setFolderEntries(entries);
2495
+ }
2496
+ },
2497
+ [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash],
2498
+ );
1232
2499
 
1233
2500
  /**
1234
2501
  * Copy non-markdown files from an imported container into the target
@@ -1248,12 +2515,21 @@ export function DocBlocksShell({
1248
2515
  const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
1249
2516
  const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
1250
2517
  const entries = await source.listFiles();
2518
+ const targetV2 = getFileSystemProviderV2(target);
1251
2519
  for (const entry of entries) {
1252
2520
  if (entry.path.endsWith('.md')) continue;
1253
2521
  const data = await source.readFile(entry.path);
1254
2522
  if (!data) continue;
1255
2523
  const cleanPath = entry.path.replace(/^\/+/, '');
1256
- await target.writeBinary(`${mediaRoot}/${cleanPath}`, new Uint8Array(data));
2524
+ const destination = `${mediaRoot}/${cleanPath}`;
2525
+ if (targetV2) {
2526
+ await targetV2.writeFile(parseWorkspacePath(destination), data, {
2527
+ mode: 'upsert',
2528
+ createParents: true,
2529
+ });
2530
+ } else {
2531
+ await target.writeBinary(destination, data);
2532
+ }
1257
2533
  }
1258
2534
  },
1259
2535
  [],
@@ -1290,7 +2566,7 @@ export function DocBlocksShell({
1290
2566
  continue;
1291
2567
  }
1292
2568
 
1293
- await provider.writeFile(destPath, markdown);
2569
+ await writeProviderText(provider, destPath, markdown);
1294
2570
  } catch (err) {
1295
2571
  console.error(`Failed to import ${file.name}:`, err);
1296
2572
  }
@@ -1301,9 +2577,18 @@ export function DocBlocksShell({
1301
2577
  [provider, persistImportedMedia],
1302
2578
  );
1303
2579
 
1304
- const handleEditorChange = useCallback((source: string) => {
1305
- setEditorContent(source);
1306
- }, []);
2580
+ const handleEditorChange = useCallback(
2581
+ (source: string) => {
2582
+ if (!editorSessionScope) return;
2583
+ try {
2584
+ documentSession.edit(source, editorSessionScope);
2585
+ } catch {
2586
+ // A close/transition has frozen the session. Do not let a late editor
2587
+ // callback create view state that cannot be persisted.
2588
+ }
2589
+ },
2590
+ [documentSession, editorSessionScope],
2591
+ );
1307
2592
 
1308
2593
  const handleRenameWorkspace = useCallback(async () => {
1309
2594
  if (!activeWorkspaceId) return;
@@ -1312,53 +2597,219 @@ export function DocBlocksShell({
1312
2597
  const newName = prompt('Rename workspace:', ws.name);
1313
2598
  if (!newName || newName === ws.name) return;
1314
2599
  await saveWorkspace({ ...ws, name: newName });
1315
- // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
1316
- setEditorKey((k) => k + 1);
2600
+ setDescriptorRefreshKey((key) => key + 1);
1317
2601
  }, [activeWorkspaceId]);
1318
2602
 
1319
2603
  /**
1320
- * Walk a FileSystemProvider and copy every file into `container` under
1321
- * `pathPrefix` (no leading slash; empty string for the root). Used by
1322
- * both single- and all-workspace downloads.
2604
+ * Open a loose file or `.dbk` bundle delivered by the OS into a session-only
2605
+ * *transient* workspace backed by an in-memory provider. Loose files save
2606
+ * straight back to disk; bundles are re-zipped by the session commit target.
1323
2607
  */
1324
- const copyProviderToContainer = useCallback(
2608
+ const openTransient = useCallback(
1325
2609
  async (
1326
- src: FileSystemProvider,
1327
- container: { writeFile: (path: string, data: ArrayBuffer | Uint8Array) => Promise<void> },
1328
- pathPrefix: string,
1329
- ): Promise<void> => {
1330
- const encoder = new TextEncoder();
1331
- const stack: string[] = ['/'];
1332
- while (stack.length > 0) {
1333
- const dir = stack.pop()!;
1334
- const entries = await src.readDirectory(dir);
1335
- for (const entry of entries) {
1336
- if (entry.kind === 'directory') {
1337
- stack.push(entry.path);
1338
- continue;
2610
+ req: Extract<OpenRequest, { kind: 'external-file' | 'external-bundle' }>,
2611
+ navigationRequestId: number,
2612
+ ) => {
2613
+ const isCurrent = () => navigationRequestId === navigationRequestRef.current;
2614
+ const host = getDocBlocksHost();
2615
+ const revokeAbandonedResource = () =>
2616
+ host.external.revoke(req.resourceId).catch(() => undefined);
2617
+ if (!isCurrent()) {
2618
+ await revokeAbandonedResource();
2619
+ return;
2620
+ }
2621
+ const id = `transient-${req.kind}-${req.resourceId}`;
2622
+ let mem: MemoryFileSystemProvider;
2623
+ try {
2624
+ mem = await createMemoryFileSystemProvider(id, req.name);
2625
+ } catch (error: unknown) {
2626
+ await revokeAbandonedResource();
2627
+ throw error;
2628
+ }
2629
+ if (!isCurrent()) {
2630
+ await getFileSystemProviderV2(mem)?.dispose();
2631
+ await revokeAbandonedResource();
2632
+ return;
2633
+ }
2634
+ let primaryFile: string;
2635
+ let origin: WorkspaceDescriptor['origin'];
2636
+ let registered = false;
2637
+ let opened = false;
2638
+
2639
+ try {
2640
+ if (req.kind === 'external-file') {
2641
+ const content = await host.external.readText(req.resourceId);
2642
+ if (content === null) {
2643
+ throw new Error('The external file is no longer available.');
1339
2644
  }
1340
- const rel = entry.path.replace(/^\/+/, '');
1341
- const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
1342
- // Files may be stored as text (writeFile) or binary (writeBinary);
1343
- // try binary first, fall back to text and encode as UTF-8.
1344
- const binary = await src.readBinary(entry.path);
1345
- if (binary) {
1346
- await container.writeFile(zipPath, binary);
1347
- continue;
2645
+ if (!isCurrent()) return;
2646
+ primaryFile = req.name; // e.g. "notes.md"
2647
+ mem.seedText(primaryFile, content);
2648
+ origin = { kind: 'loose-file', resourceId: req.resourceId };
2649
+ } else {
2650
+ const bytes = await host.external.readBinary(req.resourceId);
2651
+ if (!bytes) {
2652
+ throw new Error('The external bundle is no longer available.');
1348
2653
  }
1349
- const text = await src.readFile(entry.path);
1350
- if (text !== null) {
1351
- await container.writeFile(zipPath, encoder.encode(text));
2654
+ if (!isCurrent()) return;
2655
+ const version = await sha256Hex(bytes);
2656
+ if (!isCurrent()) return;
2657
+ const { zipToContainer } = await import('@bendyline/squisq-formats/container');
2658
+ if (!isCurrent()) return;
2659
+ const container = await zipToContainer(bytes);
2660
+ if (!isCurrent()) return;
2661
+ const base = req.name.replace(/\.[^.]+$/, '');
2662
+ primaryFile = `${base}.md`;
2663
+ const snapshot = await createDbkWorkspaceSnapshot(container, {
2664
+ targetDocumentPath: primaryFile,
2665
+ });
2666
+ if (!isCurrent()) return;
2667
+ mem.replaceContents(snapshot);
2668
+ origin = { kind: 'dbk', resourceId: req.resourceId, version };
2669
+ }
2670
+
2671
+ const descriptor: WorkspaceDescriptor = {
2672
+ id,
2673
+ name: req.name,
2674
+ type: 'transient',
2675
+ lastOpened: new Date().toISOString(),
2676
+ origin,
2677
+ };
2678
+ registerTransientWorkspace(descriptor, mem);
2679
+ registered = true;
2680
+ opened =
2681
+ (await openFromIds(id, `/${primaryFile}`, true, undefined, navigationRequestId)) !== null;
2682
+ } finally {
2683
+ try {
2684
+ if (!opened && registered && getTransientWorkspace(id)?.provider === mem) {
2685
+ await unregisterTransientWorkspace(id);
2686
+ registered = false;
2687
+ } else if (!registered) {
2688
+ await getFileSystemProviderV2(mem)?.dispose();
1352
2689
  }
2690
+ } finally {
2691
+ if (!opened) await revokeAbandonedResource();
1353
2692
  }
1354
2693
  }
1355
2694
  },
1356
- [],
2695
+ [openFromIds],
2696
+ );
2697
+
2698
+ // Subscribe to OS open-file / deep-link requests.
2699
+ useEffect(() => {
2700
+ if (!isElectronHost()) return;
2701
+ const host = getDocBlocksHost();
2702
+ return host.onOpenRequest((req) => {
2703
+ // Claim the navigation when the OS request arrives, before any external
2704
+ // file read or bundle decoding. A slower older request can no longer
2705
+ // finish after and replace a newer document.
2706
+ const requestId = ++navigationRequestRef.current;
2707
+ void (
2708
+ req.kind === 'workspace-file'
2709
+ ? workspaceAuthorityBarrier.wait().then(() => {
2710
+ if (requestId !== navigationRequestRef.current) return null;
2711
+ return openFromIds(req.workspaceId, req.path, true, undefined, requestId).then(
2712
+ (opened) => {
2713
+ if (!opened && requestId === navigationRequestRef.current) {
2714
+ throw new Error('The requested workspace file is no longer available.');
2715
+ }
2716
+ return opened;
2717
+ },
2718
+ );
2719
+ })
2720
+ : openTransient(req, requestId)
2721
+ ).catch((error: unknown) => {
2722
+ if (requestId !== navigationRequestRef.current) return;
2723
+ alert(
2724
+ error instanceof Error
2725
+ ? 'DocBlocks could not open the requested file: ' + error.message
2726
+ : 'DocBlocks could not open the requested file.',
2727
+ );
2728
+ });
2729
+ });
2730
+ }, [openFromIds, openTransient, workspaceAuthorityBarrier]);
2731
+
2732
+ /**
2733
+ * Web counterpart of `openTransient`: a `.md` or `.dbk` launched into the
2734
+ * installed PWA (File Handling API) arrives as a FileSystemFileHandle.
2735
+ * Contents are seeded into the same session-only in-memory workspace;
2736
+ * saves write back through the handle (see `createDocumentTarget`).
2737
+ */
2738
+ const openTransientFromHandle = useCallback(
2739
+ async (handle: FileSystemFileHandle) => {
2740
+ const name = handle.name;
2741
+ const isBundle = /\.(dbk|zip)$/i.test(name);
2742
+ // Keyed by file name: relaunching the same file replaces the registry
2743
+ // entry instead of piling up session workspaces. Two different files
2744
+ // that share a name would collide -- acceptable for session-only state.
2745
+ const id = `transient-web-${isBundle ? 'bundle' : 'file'}-${name}`;
2746
+ // Ask for write access while the OS-launch user activation is fresh so
2747
+ // autosave doesn't have to prompt mid-typing. Best-effort: a denial
2748
+ // surfaces on save, not on open.
2749
+ await ensureHandleWritePermission(handle);
2750
+ const mem = await createMemoryFileSystemProvider(id, name);
2751
+ const file = await handle.getFile();
2752
+ let primaryFile: string;
2753
+ let origin: WorkspaceDescriptor['origin'];
2754
+
2755
+ if (!isBundle) {
2756
+ primaryFile = name;
2757
+ mem.seedText(primaryFile, await file.text());
2758
+ origin = { kind: 'web-file', handle, name };
2759
+ } else {
2760
+ const bytes = await file.arrayBuffer();
2761
+ const version = await sha256Hex(bytes);
2762
+ const { zipToContainer } = await import('@bendyline/squisq-formats/container');
2763
+ const container = await zipToContainer(bytes);
2764
+ const base = name.replace(/\.[^.]+$/, '');
2765
+ primaryFile = `${base}.md`;
2766
+ const snapshot = await createDbkWorkspaceSnapshot(container, {
2767
+ targetDocumentPath: primaryFile,
2768
+ });
2769
+ mem.replaceContents(snapshot);
2770
+ origin = { kind: 'web-dbk', handle, name, version };
2771
+ }
2772
+
2773
+ const descriptor: WorkspaceDescriptor = {
2774
+ id,
2775
+ name,
2776
+ type: 'transient',
2777
+ lastOpened: new Date().toISOString(),
2778
+ origin,
2779
+ };
2780
+ registerTransientWorkspace(descriptor, mem);
2781
+ await openFromIds(id, `/${primaryFile}`, true);
2782
+ },
2783
+ [openFromIds],
1357
2784
  );
1358
2785
 
2786
+ // Consume files the OS delivers to the installed PWA (Chromium desktop).
2787
+ // launchQueue buffers launch params until a consumer is set, so a
2788
+ // post-mount effect is early enough to catch the launching file.
2789
+ useEffect(() => {
2790
+ if (isElectronHost() || typeof window === 'undefined') return;
2791
+ const launchQueue = window.launchQueue;
2792
+ if (!launchQueue) return;
2793
+ launchQueue.setConsumer((params) => {
2794
+ if (params.files.length === 0) return;
2795
+ const handle = params.files[0];
2796
+ void openTransientFromHandle(handle).catch((error: unknown) => {
2797
+ alert(
2798
+ error instanceof Error
2799
+ ? 'DocBlocks could not open the launched file: ' + error.message
2800
+ : 'DocBlocks could not open the launched file.',
2801
+ );
2802
+ });
2803
+ });
2804
+ }, [openTransientFromHandle]);
2805
+
2806
+ const updateStatusBarVisible =
2807
+ viewPreferences.showStatusBar === true && selectedFile !== null && mediaProvider !== null;
2808
+
1359
2809
  const handleDownloadWorkspace = useCallback(async () => {
1360
2810
  if (!provider) return;
1361
2811
  try {
2812
+ await documentSession.flush('backup');
1362
2813
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1363
2814
  import('@bendyline/squisq/storage'),
1364
2815
  import('@bendyline/squisq-formats/container'),
@@ -1380,17 +2831,18 @@ export function DocBlocksShell({
1380
2831
  console.error('Failed to download workspace', err);
1381
2832
  alert('Failed to download workspace. See console for details.');
1382
2833
  }
1383
- }, [provider, copyProviderToContainer]);
2834
+ }, [provider, documentSession]);
1384
2835
 
1385
2836
  /**
1386
2837
  * Bundle every workspace the host can open without further prompting
1387
2838
  * into a single zip, with each workspace nested under its own folder.
1388
2839
  * Native (browser-picked) workspaces whose handle hasn't been re-granted
1389
- * for this session are skipped restoring them would require a user
2840
+ * for this session are skipped -- restoring them would require a user
1390
2841
  * gesture per workspace.
1391
2842
  */
1392
2843
  const handleDownloadAllWorkspaces = useCallback(async () => {
1393
2844
  try {
2845
+ await documentSession.flush('backup');
1394
2846
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1395
2847
  import('@bendyline/squisq/storage'),
1396
2848
  import('@bendyline/squisq-formats/container'),
@@ -1399,7 +2851,9 @@ export function DocBlocksShell({
1399
2851
  const electron = isElectronHost();
1400
2852
  const all = await listWorkspaces();
1401
2853
  const candidates = all.filter((w) =>
1402
- electron ? w.type === 'electron-native' : w.type !== 'electron-native',
2854
+ electron
2855
+ ? w.type === 'electron-native' || w.type === 'transient'
2856
+ : w.type !== 'electron-native',
1403
2857
  );
1404
2858
  if (candidates.length === 0) {
1405
2859
  alert('No workspaces to download.');
@@ -1412,15 +2866,22 @@ export function DocBlocksShell({
1412
2866
 
1413
2867
  for (const ws of candidates) {
1414
2868
  let p: FileSystemProvider | null = null;
2869
+ let ownsProvider = false;
1415
2870
  try {
1416
- if (ws.type === 'electron-native') {
2871
+ const transient = getTransientWorkspace(ws.id);
2872
+ if (transient) {
2873
+ p = transient.provider;
2874
+ } else if (ws.type === 'electron-native') {
1417
2875
  p = await createElectronProviderFromWorkspace(ws);
2876
+ ownsProvider = p !== null;
1418
2877
  } else if (ws.type === 'native') {
1419
- // Restore without prompting only succeeds when the browser
2878
+ // Restore without prompting -- only succeeds when the browser
1420
2879
  // still remembers the granted handle for this origin/session.
1421
- p = await restoreNativeFolder(ws.id);
2880
+ p = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
2881
+ ownsProvider = p !== null;
1422
2882
  } else {
1423
- p = new IndexedDBFileSystemProvider(ws.id, ws.name);
2883
+ p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
2884
+ ownsProvider = true;
1424
2885
  }
1425
2886
  } catch (err) {
1426
2887
  console.warn(`Skipping workspace ${ws.name}:`, err);
@@ -1440,7 +2901,11 @@ export function DocBlocksShell({
1440
2901
  }
1441
2902
  usedFolders.add(folder);
1442
2903
 
1443
- await copyProviderToContainer(p, container, folder);
2904
+ try {
2905
+ await copyProviderToContainer(p, container, folder);
2906
+ } finally {
2907
+ if (ownsProvider) await getFileSystemProviderV2(p)?.dispose();
2908
+ }
1444
2909
  }
1445
2910
 
1446
2911
  if (usedFolders.size === 0) {
@@ -1466,7 +2931,59 @@ export function DocBlocksShell({
1466
2931
  console.error('Failed to download all workspaces', err);
1467
2932
  alert('Failed to download all workspaces. See console for details.');
1468
2933
  }
1469
- }, [copyProviderToContainer]);
2934
+ }, [documentSession]);
2935
+
2936
+ const handleKeepBrowserData = useCallback(async () => {
2937
+ if (typeof navigator === 'undefined') return;
2938
+
2939
+ const storage = navigator.storage;
2940
+ if (!storage || typeof storage.persist !== 'function') {
2941
+ alert(
2942
+ 'This browser does not support persistent storage requests. Please back up browser docs frequently.',
2943
+ );
2944
+ return;
2945
+ }
2946
+
2947
+ try {
2948
+ const alreadyPersistent =
2949
+ typeof storage.persisted === 'function' ? await storage.persisted() : false;
2950
+ if (alreadyPersistent) {
2951
+ setBrowserStoragePersistent(true);
2952
+ alert('DocBlocks browser data is already marked as persistent by this browser.');
2953
+ return;
2954
+ }
2955
+
2956
+ const granted = await storage.persist();
2957
+ if (granted) {
2958
+ setBrowserStoragePersistent(true);
2959
+ alert(
2960
+ 'This browser will try to keep DocBlocks data for longer. Please still back up browser docs frequently.',
2961
+ );
2962
+ } else {
2963
+ alert(
2964
+ 'The browser did not grant permanent storage for DocBlocks. Please try again in a day or two. Meanwhile, please back up browser documents frequently.',
2965
+ );
2966
+ }
2967
+ } catch {
2968
+ alert(
2969
+ 'DocBlocks could not request persistent browser storage. Please back up browser docs frequently.',
2970
+ );
2971
+ }
2972
+ }, []);
2973
+
2974
+ // Settings dialog "Storage" row (browser surface): usage/quota estimate.
2975
+ const getBrowserStorageEstimate = useCallback(async () => {
2976
+ if (typeof navigator === 'undefined') return null;
2977
+ const storage = navigator.storage;
2978
+ if (!storage || typeof storage.estimate !== 'function') return null;
2979
+ try {
2980
+ const { usage, quota } = await storage.estimate();
2981
+ if (typeof usage !== 'number' || typeof quota !== 'number') return null;
2982
+ return { usage, quota };
2983
+ } catch {
2984
+ return null;
2985
+ }
2986
+ }, []);
1470
2987
 
1471
2988
  const handleRemoveWorkspace = useCallback(async () => {
1472
2989
  if (!activeWorkspaceId) return;
@@ -1474,18 +2991,30 @@ export function DocBlocksShell({
1474
2991
  ? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
1475
2992
  : 'Remove this workspace? This cannot be undone.';
1476
2993
  if (!confirm(confirmMsg)) return;
2994
+ const requestId = ++navigationRequestRef.current;
2995
+ if (!(await transitionAwayFromDocument(requestId))) return;
1477
2996
 
1478
2997
  const ws = await getWorkspace(activeWorkspaceId);
1479
2998
  if (ws?.type === 'electron-native') {
1480
2999
  try {
1481
3000
  await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
1482
3001
  } catch {
1483
- // ignore host cleanup is best-effort
3002
+ // ignore -- host cleanup is best-effort
1484
3003
  }
1485
- } else {
1486
- await removeDirectoryHandle(activeWorkspaceId);
3004
+ } else if (ws?.type === 'transient') {
3005
+ const origin = ws.origin;
3006
+ if (isElectronHost() && origin && (origin.kind === 'loose-file' || origin.kind === 'dbk')) {
3007
+ try {
3008
+ await getDocBlocksHost().external.revoke(origin.resourceId);
3009
+ } catch {
3010
+ // Navigation/destruction also revokes the capability; removal is best effort.
3011
+ }
3012
+ }
3013
+ } else if (ws?.type === 'native') {
3014
+ await (await loadNativeFileSystem()).removeDirectoryHandle(activeWorkspaceId);
1487
3015
  }
1488
3016
  await removeWorkspace(activeWorkspaceId);
3017
+ if (requestId !== navigationRequestRef.current) return;
1489
3018
 
1490
3019
  const electron = isElectronHost();
1491
3020
  // Switch to most recent remaining workspace or create default
@@ -1505,227 +3034,365 @@ export function DocBlocksShell({
1505
3034
  lastOpened: new Date().toISOString(),
1506
3035
  };
1507
3036
  await saveWorkspace(descriptor);
1508
- const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
3037
+ const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1509
3038
  setProvider(p);
1510
3039
  setActiveWorkspaceId(info.id);
1511
3040
  setSelectedFile(null);
1512
3041
  setSelectedFolder(null);
1513
3042
  setFolderEntries([]);
1514
- setEditorContent('');
1515
- setEditorKey((k) => k + 1);
1516
3043
  } else {
1517
3044
  const defaultWs = await ensureDefaultWorkspace();
1518
- const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
3045
+ const fsProvider = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
1519
3046
  setProvider(fsProvider);
1520
3047
  setActiveWorkspaceId(defaultWs.id);
1521
3048
  setSelectedFile(null);
1522
3049
  setSelectedFolder(null);
1523
3050
  setFolderEntries([]);
1524
- setEditorContent('');
1525
- setEditorKey((k) => k + 1);
1526
3051
  }
1527
- }, [activeWorkspaceId, handleWorkspaceSelect]);
3052
+ }, [activeWorkspaceId, handleWorkspaceSelect, transitionAwayFromDocument]);
1528
3053
 
1529
3054
  return (
1530
3055
  <div
1531
3056
  className={`db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`}
1532
3057
  data-theme={resolvedTheme}
3058
+ data-accent={accentColor}
3059
+ data-document-status={documentSnapshot.status}
1533
3060
  >
1534
- {saveToastVisible && (
1535
- <div className="db-save-toast" role="status" aria-live="polite">
1536
- Autosaved. You're all set.
1537
- </div>
1538
- )}
1539
- {workspaceSettingsOpen && activeWorkspaceDescriptor && (
1540
- <WorkspaceSettingsDialog
1541
- workspace={activeWorkspaceDescriptor}
1542
- globalVersioningPreference={versioningPreference}
1543
- onChange={handleWorkspaceVersioningOverrideChange}
1544
- onClose={() => setWorkspaceSettingsOpen(false)}
1545
- />
1546
- )}
1547
- {/* Main area */}
1548
- <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
1549
- {/* Left sidebar — hidden in compact layout when the editor is
1550
- showing (compact = real mobile narrow viewport OR the user
1551
- dragged the resizer below SIDEBAR_COLLAPSE_THRESHOLD). */}
1552
- {(!effectiveCompact || !mobileShowEditor) && (
3061
+ <GitContext.Provider value={git}>
3062
+ {workspaceStartupError && (
3063
+ <div className="db-save-toast db-save-toast--error" role="alert" aria-live="assertive">
3064
+ {workspaceStartupError}
3065
+ </div>
3066
+ )}
3067
+ {saveToast && (
1553
3068
  <div
1554
- ref={sidebarRef}
1555
- className="db-shell-sidebar"
1556
- style={effectiveCompact ? undefined : { width: `${sidebarWidth}px` }}
3069
+ className={'db-save-toast db-save-toast--' + saveToast.kind}
3070
+ role={saveToast.kind === 'error' ? 'alert' : 'status'}
3071
+ aria-live={saveToast.kind === 'error' ? 'assertive' : 'polite'}
1557
3072
  >
1558
- <div className="db-shell-sidebar-header">
1559
- <AppMenu
1560
- logoUrl={logoUrl}
1561
- themePreference={themePreference}
1562
- onThemeChange={handleThemeChange}
1563
- versioningPreference={versioningPreference}
1564
- onVersioningPreferenceChange={handleVersioningPreferenceChange}
1565
- onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
1566
- />
1567
- <WorkspacePicker
1568
- activeWorkspaceId={activeWorkspaceId}
1569
- onSelect={handleWorkspaceSelect}
1570
- onOpenFolder={handleOpenFolder}
1571
- />
1572
- <WorkspaceSettingsButton
1573
- onSettings={handleOpenWorkspaceSettings}
1574
- onRename={handleRenameWorkspace}
1575
- onDownload={handleDownloadWorkspace}
1576
- onRemove={handleRemoveWorkspace}
1577
- />
3073
+ {saveToast.message}
3074
+ </div>
3075
+ )}
3076
+ {offlineReadyToast && !saveToast && (
3077
+ <div className="db-save-toast db-save-toast--success" role="status" aria-live="polite">
3078
+ DocBlocks is ready to work offline.
3079
+ </div>
3080
+ )}
3081
+ {documentSnapshot.conflict && (
3082
+ <div className="db-document-conflict" role="alert">
3083
+ <span>
3084
+ This document changed outside DocBlocks. Your unsaved version is still intact.
3085
+ </span>
3086
+ <div className="db-document-conflict-actions">
3087
+ <button type="button" onClick={() => void handleKeepLocalDocument()}>
3088
+ Keep mine
3089
+ </button>
3090
+ <button type="button" onClick={() => void handleUseExternalDocument()}>
3091
+ Reload external
3092
+ </button>
1578
3093
  </div>
1579
- <FileExplorer
1580
- key={explorerKey}
1581
- provider={provider}
1582
- onSelect={handleSelect}
1583
- onTreeChange={handleTreeChange}
1584
- onImportFiles={handleImportFiles}
1585
- />
1586
- <div className="db-shell-sidebar-footer">
1587
- <a
1588
- href="https://github.com/bendyline/docblocks/blob/main/LICENSE"
1589
- target="_blank"
1590
- rel="noopener noreferrer"
1591
- >
1592
- Terms of Use
1593
- </a>
3094
+ </div>
3095
+ )}
3096
+ {storageFull && !documentSnapshot.conflict && (
3097
+ <div className="db-storage-full-banner" role="alert">
3098
+ <span>
3099
+ Browser storage is full -- changes can&rsquo;t be saved. Free up space or back up your
3100
+ work now.
3101
+ </span>
3102
+ <div className="db-storage-full-banner-actions">
3103
+ <button type="button" onClick={() => void handleDownloadAllWorkspaces()}>
3104
+ Download all workspaces
3105
+ </button>
3106
+ <button type="button" onClick={() => setStorageFull(false)}>
3107
+ Dismiss
3108
+ </button>
1594
3109
  </div>
1595
3110
  </div>
1596
3111
  )}
1597
-
1598
- {/* Resize handle between sidebar and editor — hidden whenever
1599
- the layout is compact (no sidebar to resize). */}
1600
- {!effectiveCompact && (
1601
- <div
1602
- className="db-shell-sidebar-resizer"
1603
- role="separator"
1604
- aria-orientation="vertical"
1605
- aria-label="Resize sidebar"
1606
- onPointerDown={handleResizerPointerDown}
3112
+ {workspaceSettingsOpen && activeWorkspaceDescriptor && (
3113
+ <WorkspaceSettingsDialog
3114
+ workspace={activeWorkspaceDescriptor}
3115
+ globalVersioningPreference={versioningPreference}
3116
+ onChange={handleWorkspaceVersioningOverrideChange}
3117
+ onClose={() => setWorkspaceSettingsOpen(false)}
1607
3118
  />
1608
3119
  )}
1609
-
1610
- {/* Editor area hidden in compact layout when the sidebar is showing. */}
1611
- {(!effectiveCompact || mobileShowEditor) && (
1612
- <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
1613
- {selectedFile && mediaProvider ? (
1614
- <MediaContext.Provider value={mediaProvider}>
1615
- <EditorShell
1616
- key={`${selectedFile}-${editorKey}`}
1617
- initialMarkdown={editorContent}
1618
- initialView={initialView}
1619
- articleId={selectedFile}
1620
- fileName={selectedFile}
1621
- onChange={handleEditorChange}
1622
- theme={resolvedTheme}
1623
- height="100%"
1624
- outlineWidth={280}
1625
- mediaProvider={mediaProvider}
1626
- documentLinkProvider={documentLinkProvider}
1627
- container={versionsContainer ?? undefined}
1628
- allowVersioning={effectiveVersioning}
1629
- viewPreferences={viewPreferences}
1630
- onViewPreferencesChange={handleViewPreferencesChange}
1631
- versionBasename={versionBasename ?? stripExtension(basenameOf(selectedFile))}
1632
- versioningPrunePolicy={versioningPrunePolicy}
1633
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
1634
- onSaveVersion={onSaveVersion}
1635
- toolbarSlotLeft={
1636
- effectiveCompact ? (
1637
- <button
1638
- className="db-mobile-back"
1639
- onClick={() => setMobileShowEditor(false)}
1640
- aria-label="Show file list"
1641
- >
1642
- <span className="db-mobile-back-arrow">&larr;</span>
1643
- </button>
1644
- ) : undefined
3120
+ {/* Main area */}
3121
+ <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
3122
+ {/* Left sidebar -- hidden in compact layout when the editor is
3123
+ showing (compact = real mobile narrow viewport OR the user
3124
+ dragged the resizer below SIDEBAR_COLLAPSE_THRESHOLD). */}
3125
+ {(!effectiveCompact || !mobileShowEditor) && (
3126
+ <div
3127
+ ref={sidebarRef}
3128
+ className="db-shell-sidebar"
3129
+ style={effectiveCompact ? undefined : { width: `${sidebarWidth}px` }}
3130
+ >
3131
+ <div className="db-shell-sidebar-header">
3132
+ <AppMenu
3133
+ logoUrl={logoUrl}
3134
+ themePreference={themePreference}
3135
+ onThemeChange={handleThemeChange}
3136
+ accentColor={accentColor}
3137
+ onAccentColorChange={handleAccentColorChange}
3138
+ versioningPreference={versioningPreference}
3139
+ onVersioningPreferenceChange={handleVersioningPreferenceChange}
3140
+ onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
3141
+ onKeepBrowserData={
3142
+ showBrowserStorageWarning && !browserStoragePersistent
3143
+ ? handleKeepBrowserData
3144
+ : undefined
1645
3145
  }
1646
- toolbarSlotRight={
1647
- <>
1648
- {/* Restore split view — only relevant when compact
1649
- layout was manually triggered on a wide viewport.
1650
- On real mobile, side-by-side doesn't fit so the
1651
- button is suppressed. */}
1652
- {compactLayout && !isMobile && (
1653
- <button
1654
- className="db-restore-split"
1655
- onClick={() => setCompactLayout(false)}
1656
- aria-label="Restore split view"
1657
- title="Restore split view"
1658
- >
1659
- <svg
1660
- width="16"
1661
- height="16"
1662
- viewBox="0 0 16 16"
1663
- fill="none"
1664
- stroke="currentColor"
1665
- strokeWidth="1.5"
1666
- strokeLinecap="round"
1667
- strokeLinejoin="round"
1668
- >
1669
- <rect x="1.5" y="2.5" width="13" height="11" rx="1" />
1670
- <line x1="6" y1="2.5" x2="6" y2="13.5" />
1671
- </svg>
1672
- </button>
1673
- )}
1674
- <ExportToolbarControls
1675
- selectedFile={selectedFile}
1676
- mediaContainer={mediaContainerRef.current}
1677
- />
1678
- </>
3146
+ onInstallApp={installPromptEvent ? handleInstallApp : undefined}
3147
+ getStorageEstimate={
3148
+ showBrowserStorageWarning ? getBrowserStorageEstimate : undefined
3149
+ }
3150
+ storagePersistent={
3151
+ showBrowserStorageWarning ? browserStoragePersistent : undefined
1679
3152
  }
1680
3153
  />
1681
- </MediaContext.Provider>
1682
- ) : selectedFolder ? (
1683
- <div className="db-folder-view">
1684
- {effectiveCompact && (
1685
- <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
1686
- <span className="db-mobile-back-arrow">&larr;</span>
1687
- Back to files
3154
+ <WorkspacePicker
3155
+ activeWorkspaceId={activeWorkspaceId}
3156
+ onSelect={handleWorkspaceSelect}
3157
+ onOpenFolder={handleOpenFolder}
3158
+ onCloneRepository={
3159
+ git.available ? () => git.openDialog({ kind: 'clone' }) : undefined
3160
+ }
3161
+ />
3162
+ <WorkspaceSettingsButton
3163
+ onSettings={handleOpenWorkspaceSettings}
3164
+ onRename={handleRenameWorkspace}
3165
+ onDownload={handleDownloadWorkspace}
3166
+ onRemove={handleRemoveWorkspace}
3167
+ />
3168
+ {compactLayout && !isMobile && (
3169
+ <button
3170
+ className="db-restore-split"
3171
+ onClick={() => setCompactLayout(false)}
3172
+ aria-label="Restore split view"
3173
+ title="Restore split view"
3174
+ >
3175
+ <SplitViewIcon />
1688
3176
  </button>
1689
3177
  )}
1690
- <div className="db-folder-view-header">
1691
- <span className="db-folder-view-icon">
1692
- <FolderGlyph />
1693
- </span>
1694
- <span className="db-folder-view-path">{selectedFolder}</span>
1695
- </div>
1696
- {folderEntries.length === 0 ? (
1697
- <p className="db-folder-view-empty">This folder is empty.</p>
1698
- ) : (
1699
- <ul className="db-folder-view-list">
1700
- {folderEntries.map((entry) => (
1701
- <li
1702
- key={entry.path}
1703
- className="db-folder-view-item"
1704
- onClick={() => handleSelect(entry.path, entry.kind)}
1705
- >
1706
- <span className="db-folder-view-item-icon">
1707
- {entry.kind === 'directory' ? <FolderGlyph /> : <FileGlyph />}
1708
- </span>
1709
- {entry.name}
1710
- </li>
1711
- ))}
1712
- </ul>
1713
- )}
1714
3178
  </div>
1715
- ) : (
1716
- <div className="db-shell-empty">
1717
- {effectiveCompact && (
1718
- <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
1719
- <span className="db-mobile-back-arrow">&larr;</span>
1720
- Back to files
1721
- </button>
3179
+ {git.available && (
3180
+ <Suspense fallback={null}>
3181
+ <GitUI
3182
+ onOpenFile={(path) => void handleSelect(path, 'file')}
3183
+ onWorkspaceCloned={handleWorkspaceCloned}
3184
+ />
3185
+ </Suspense>
3186
+ )}
3187
+ <FileExplorer
3188
+ key={explorerKey}
3189
+ provider={provider}
3190
+ activeFilePath={selectedFile}
3191
+ onSelect={handleSelect}
3192
+ onTreeMutation={handleTreeMutation}
3193
+ onTreeChange={handleTreeChange}
3194
+ onImportFiles={handleImportFiles}
3195
+ />
3196
+ <div className="db-shell-sidebar-footer">
3197
+ <a
3198
+ href="https://github.com/bendyline/docblocks/blob/main/LICENSE"
3199
+ target="_blank"
3200
+ rel="noopener noreferrer"
3201
+ >
3202
+ Terms of Use
3203
+ </a>
3204
+ {showBrowserStorageWarning && (
3205
+ <>
3206
+ <span className="db-shell-sidebar-footer-separator" aria-hidden="true">
3207
+ &bull;
3208
+ </span>
3209
+ <button
3210
+ type="button"
3211
+ className="db-shell-sidebar-footer-action"
3212
+ onClick={() => void handleDownloadAllWorkspaces()}
3213
+ title="Browser docs can get auto-removed. Download all workspaces."
3214
+ >
3215
+ Backup browser docs frequently
3216
+ </button>
3217
+ </>
1722
3218
  )}
1723
- <p>Select a file to start editing, or create a new one.</p>
1724
3219
  </div>
1725
- )}
1726
- </div>
1727
- )}
1728
- </div>
3220
+ </div>
3221
+ )}
3222
+
3223
+ {/* Resize handle between sidebar and editor -- hidden whenever
3224
+ the layout is compact (no sidebar to resize). */}
3225
+ {!effectiveCompact && (
3226
+ <div
3227
+ className="db-shell-sidebar-resizer"
3228
+ role="separator"
3229
+ aria-orientation="vertical"
3230
+ aria-label="Resize sidebar"
3231
+ onPointerDown={handleResizerPointerDown}
3232
+ />
3233
+ )}
3234
+
3235
+ {/* Editor area -- hidden in compact layout when the sidebar is showing. */}
3236
+ {(!effectiveCompact || mobileShowEditor) && (
3237
+ <div
3238
+ className={
3239
+ updateAvailable && onApplyUpdate && updateStatusBarVisible
3240
+ ? 'db-shell-editor-area db-shell-editor-area--has-update'
3241
+ : 'db-shell-editor-area'
3242
+ }
3243
+ style={{
3244
+ flex: 1,
3245
+ overflow: 'hidden',
3246
+ display: 'flex',
3247
+ flexDirection: 'column',
3248
+ position: 'relative',
3249
+ }}
3250
+ >
3251
+ {selectedFile && mediaProvider ? (
3252
+ <MediaContext.Provider value={mediaProvider}>
3253
+ <Suspense
3254
+ fallback={
3255
+ <div className="db-shell-empty" role="status">
3256
+ Loading editor&hellip;
3257
+ </div>
3258
+ }
3259
+ >
3260
+ <EditorShell
3261
+ key={`${selectedFile}-${editorKey}`}
3262
+ initialMarkdown={editorContent}
3263
+ initialView={initialView}
3264
+ articleId={selectedFile}
3265
+ fileName={selectedFile}
3266
+ onChange={handleEditorChange}
3267
+ colorScheme={resolvedTheme}
3268
+ height="100%"
3269
+ placeholder={editorPlaceholder}
3270
+ outlineWidth={280}
3271
+ mediaProvider={mediaProvider}
3272
+ documentLinkProvider={documentLinkProvider}
3273
+ workspaceContainer={versionsContainer ?? undefined}
3274
+ allowVersioning={effectiveVersioning}
3275
+ viewPreferences={viewPreferences}
3276
+ onViewPreferencesChange={handleViewPreferencesChange}
3277
+ versionBasename={versionBasename ?? stripExtension(basenameOf(selectedFile))}
3278
+ versioningPrunePolicy={versioningPrunePolicy}
3279
+ versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
3280
+ onSaveVersion={onSaveVersion}
3281
+ toolbarSlotLeft={
3282
+ effectiveCompact ? (
3283
+ <button
3284
+ className="db-mobile-back"
3285
+ onClick={() => setMobileShowEditor(false)}
3286
+ aria-label="Show file list"
3287
+ >
3288
+ <span className="db-mobile-back-arrow">&larr;</span>
3289
+ </button>
3290
+ ) : undefined
3291
+ }
3292
+ toolbarSlotRight={
3293
+ <>
3294
+ {/* Restore split view -- only relevant when compact
3295
+ layout was manually triggered on a wide viewport.
3296
+ On real mobile, side-by-side doesn't fit so the
3297
+ button is suppressed. */}
3298
+ {compactLayout && !isMobile && (
3299
+ <button
3300
+ className="db-restore-split"
3301
+ onClick={() => setCompactLayout(false)}
3302
+ aria-label="Restore split view"
3303
+ title="Restore split view"
3304
+ >
3305
+ <SplitViewIcon />
3306
+ </button>
3307
+ )}
3308
+ {git.repo && (
3309
+ <Suspense fallback={null}>
3310
+ <GitToolbarControl selectedFile={selectedFile} />
3311
+ </Suspense>
3312
+ )}
3313
+ <ExportToolbarControls
3314
+ selectedFile={selectedFile}
3315
+ mediaContainer={mediaContainerRef.current}
3316
+ destinationAdapter={exportDestinationAdapter}
3317
+ />
3318
+ </>
3319
+ }
3320
+ />
3321
+ </Suspense>
3322
+ {showWelcomeGateway && (
3323
+ <div className="db-welcome-gateway" role="note" aria-label="Welcome tip">
3324
+ <span className="db-welcome-gateway-text">
3325
+ You&rsquo;re watching this welcome doc in <strong>Slideshow</strong> view --
3326
+ it&rsquo;s a regular markdown file, and so is everything you&rsquo;ll write.
3327
+ </span>
3328
+ <button className="db-welcome-gateway-cta" onClick={handleStartWriting}>
3329
+ Start writing
3330
+ </button>
3331
+ <button
3332
+ className="db-welcome-gateway-dismiss"
3333
+ onClick={closeWelcomeGateway}
3334
+ aria-label="Dismiss welcome tip"
3335
+ title="Dismiss"
3336
+ >
3337
+ &times;
3338
+ </button>
3339
+ </div>
3340
+ )}
3341
+ </MediaContext.Provider>
3342
+ ) : selectedFolder ? (
3343
+ <div className="db-folder-view">
3344
+ {effectiveCompact && (
3345
+ <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
3346
+ <span className="db-mobile-back-arrow">&larr;</span>
3347
+ Back to files
3348
+ </button>
3349
+ )}
3350
+ <div className="db-folder-view-header">
3351
+ <span className="db-folder-view-icon">
3352
+ <FolderGlyph />
3353
+ </span>
3354
+ <span className="db-folder-view-path">{selectedFolder}</span>
3355
+ </div>
3356
+ {folderEntries.length === 0 ? (
3357
+ <p className="db-folder-view-empty">This folder is empty.</p>
3358
+ ) : (
3359
+ <ul className="db-folder-view-list">
3360
+ {folderEntries.map((entry) => (
3361
+ <li
3362
+ key={entry.path}
3363
+ className="db-folder-view-item"
3364
+ onClick={() => handleSelect(entry.path, entry.kind)}
3365
+ >
3366
+ <span className="db-folder-view-item-icon">
3367
+ {entry.kind === 'directory' ? <FolderGlyph /> : <FileGlyph />}
3368
+ </span>
3369
+ {entry.name}
3370
+ </li>
3371
+ ))}
3372
+ </ul>
3373
+ )}
3374
+ </div>
3375
+ ) : (
3376
+ <div className="db-shell-empty">
3377
+ {effectiveCompact && (
3378
+ <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
3379
+ <span className="db-mobile-back-arrow">&larr;</span>
3380
+ Back to files
3381
+ </button>
3382
+ )}
3383
+ <p>Select a file to start editing, or create a new one.</p>
3384
+ </div>
3385
+ )}
3386
+ <UpdateAvailableNotice
3387
+ available={updateAvailable}
3388
+ onApplyUpdate={onApplyUpdate}
3389
+ blocked={documentSnapshot.conflict !== null || storageFull}
3390
+ statusBarVisible={updateStatusBarVisible}
3391
+ />
3392
+ </div>
3393
+ )}
3394
+ </div>
3395
+ </GitContext.Provider>
1729
3396
  </div>
1730
3397
  );
1731
3398
  }