@bendyline/docblocks-react 1.1.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (335) hide show
  1. package/README.md +31 -5
  2. package/dist/AppMenu/AppMenu.d.ts +29 -2
  3. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  4. package/dist/AppMenu/AppMenu.js +41 -2
  5. package/dist/AppMenu/AppMenu.js.map +1 -1
  6. package/dist/AppMenu/index.d.ts +1 -1
  7. package/dist/AppMenu/index.d.ts.map +1 -1
  8. package/dist/DocBlocksShell/DocBlocksShell.d.ts +15 -2
  9. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  10. package/dist/DocBlocksShell/DocBlocksShell.js +1677 -298
  11. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  12. package/dist/DocBlocksShell/LazyEditorShell.d.ts +3 -0
  13. package/dist/DocBlocksShell/LazyEditorShell.d.ts.map +1 -0
  14. package/dist/DocBlocksShell/LazyEditorShell.js +3 -0
  15. package/dist/DocBlocksShell/LazyEditorShell.js.map +1 -0
  16. package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts +13 -0
  17. package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts.map +1 -0
  18. package/dist/DocBlocksShell/UpdateAvailableNotice.js +16 -0
  19. package/dist/DocBlocksShell/UpdateAvailableNotice.js.map +1 -0
  20. package/dist/DocBlocksShell/document-title.d.ts +3 -0
  21. package/dist/DocBlocksShell/document-title.d.ts.map +1 -0
  22. package/dist/DocBlocksShell/document-title.js +17 -0
  23. package/dist/DocBlocksShell/document-title.js.map +1 -0
  24. package/dist/DocBlocksShell/workspace-authority-barrier.d.ts +14 -0
  25. package/dist/DocBlocksShell/workspace-authority-barrier.d.ts.map +1 -0
  26. package/dist/DocBlocksShell/workspace-authority-barrier.js +23 -0
  27. package/dist/DocBlocksShell/workspace-authority-barrier.js.map +1 -0
  28. package/dist/Export/DeferredExportToolbarControls.d.ts +5 -0
  29. package/dist/Export/DeferredExportToolbarControls.d.ts.map +1 -0
  30. package/dist/Export/DeferredExportToolbarControls.js +10 -0
  31. package/dist/Export/DeferredExportToolbarControls.js.map +1 -0
  32. package/dist/Export/ExportDialog.d.ts +10 -1
  33. package/dist/Export/ExportDialog.d.ts.map +1 -1
  34. package/dist/Export/ExportDialog.js +20 -18
  35. package/dist/Export/ExportDialog.js.map +1 -1
  36. package/dist/Export/ExportToolbarControls.d.ts +21 -1
  37. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  38. package/dist/Export/ExportToolbarControls.js +96 -11
  39. package/dist/Export/ExportToolbarControls.js.map +1 -1
  40. package/dist/Export/export-destination.d.ts +3 -0
  41. package/dist/Export/export-destination.d.ts.map +1 -0
  42. package/dist/Export/export-destination.js +32 -0
  43. package/dist/Export/export-destination.js.map +1 -0
  44. package/dist/Export/index.d.ts +8 -4
  45. package/dist/Export/index.d.ts.map +1 -1
  46. package/dist/Export/index.js +4 -1
  47. package/dist/Export/index.js.map +1 -1
  48. package/dist/Export/public-api.d.ts +12 -0
  49. package/dist/Export/public-api.d.ts.map +1 -0
  50. package/dist/Export/public-api.js +9 -0
  51. package/dist/Export/public-api.js.map +1 -0
  52. package/dist/Export/run-export.d.ts +3 -1
  53. package/dist/Export/run-export.d.ts.map +1 -1
  54. package/dist/Export/run-export.js +30 -13
  55. package/dist/Export/run-export.js.map +1 -1
  56. package/dist/ExportDialog-MEUISWZX.js +9 -0
  57. package/dist/ExportDialog-MEUISWZX.js.map +1 -0
  58. package/dist/ExportToolbarControls-D2YDWOXW.js +373 -0
  59. package/dist/ExportToolbarControls-D2YDWOXW.js.map +1 -0
  60. package/dist/FileExplorer/FileExplorer.d.ts +26 -4
  61. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  62. package/dist/FileExplorer/FileExplorer.js +204 -29
  63. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  64. package/dist/FileExplorer/FileTreeNode.d.ts +24 -3
  65. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
  66. package/dist/FileExplorer/FileTreeNode.js +27 -8
  67. package/dist/FileExplorer/FileTreeNode.js.map +1 -1
  68. package/dist/FileExplorer/index.d.ts +1 -1
  69. package/dist/FileExplorer/index.d.ts.map +1 -1
  70. package/dist/FileExplorer/useFileTree.d.ts +5 -3
  71. package/dist/FileExplorer/useFileTree.d.ts.map +1 -1
  72. package/dist/FileExplorer/useFileTree.js +173 -13
  73. package/dist/FileExplorer/useFileTree.js.map +1 -1
  74. package/dist/Git/GitAuthGuidanceDialog.d.ts +18 -0
  75. package/dist/Git/GitAuthGuidanceDialog.d.ts.map +1 -0
  76. package/dist/Git/GitAuthGuidanceDialog.js +27 -0
  77. package/dist/Git/GitAuthGuidanceDialog.js.map +1 -0
  78. package/dist/Git/GitBranchesDialog.d.ts +13 -0
  79. package/dist/Git/GitBranchesDialog.d.ts.map +1 -0
  80. package/dist/Git/GitBranchesDialog.js +59 -0
  81. package/dist/Git/GitBranchesDialog.js.map +1 -0
  82. package/dist/Git/GitCloneDialog.d.ts +14 -0
  83. package/dist/Git/GitCloneDialog.d.ts.map +1 -0
  84. package/dist/Git/GitCloneDialog.js +79 -0
  85. package/dist/Git/GitCloneDialog.js.map +1 -0
  86. package/dist/Git/GitCommitDialog.d.ts +12 -0
  87. package/dist/Git/GitCommitDialog.d.ts.map +1 -0
  88. package/dist/Git/GitCommitDialog.js +38 -0
  89. package/dist/Git/GitCommitDialog.js.map +1 -0
  90. package/dist/Git/GitConflictsDialog.d.ts +12 -0
  91. package/dist/Git/GitConflictsDialog.d.ts.map +1 -0
  92. package/dist/Git/GitConflictsDialog.js +17 -0
  93. package/dist/Git/GitConflictsDialog.js.map +1 -0
  94. package/dist/Git/GitContext.d.ts +89 -0
  95. package/dist/Git/GitContext.d.ts.map +1 -0
  96. package/dist/Git/GitContext.js +15 -0
  97. package/dist/Git/GitContext.js.map +1 -0
  98. package/dist/Git/GitDiffDialog.d.ts +20 -0
  99. package/dist/Git/GitDiffDialog.d.ts.map +1 -0
  100. package/dist/Git/GitDiffDialog.js +100 -0
  101. package/dist/Git/GitDiffDialog.js.map +1 -0
  102. package/dist/Git/GitDiffView.d.ts +23 -0
  103. package/dist/Git/GitDiffView.d.ts.map +1 -0
  104. package/dist/Git/GitDiffView.js +77 -0
  105. package/dist/Git/GitDiffView.js.map +1 -0
  106. package/dist/Git/GitHistoryDialog.d.ts +15 -0
  107. package/dist/Git/GitHistoryDialog.d.ts.map +1 -0
  108. package/dist/Git/GitHistoryDialog.js +107 -0
  109. package/dist/Git/GitHistoryDialog.js.map +1 -0
  110. package/dist/Git/GitStatusBar.d.ts +8 -0
  111. package/dist/Git/GitStatusBar.d.ts.map +1 -0
  112. package/dist/Git/GitStatusBar.js +49 -0
  113. package/dist/Git/GitStatusBar.js.map +1 -0
  114. package/dist/Git/GitToolbarControl.d.ts +15 -0
  115. package/dist/Git/GitToolbarControl.d.ts.map +1 -0
  116. package/dist/Git/GitToolbarControl.js +51 -0
  117. package/dist/Git/GitToolbarControl.js.map +1 -0
  118. package/dist/Git/GitUI.d.ts +14 -0
  119. package/dist/Git/GitUI.d.ts.map +1 -0
  120. package/dist/Git/GitUI.js +21 -0
  121. package/dist/Git/GitUI.js.map +1 -0
  122. package/dist/Git/commit-selection.d.ts +22 -0
  123. package/dist/Git/commit-selection.d.ts.map +1 -0
  124. package/dist/Git/commit-selection.js +47 -0
  125. package/dist/Git/commit-selection.js.map +1 -0
  126. package/dist/Git/git-clone-utils.d.ts +8 -0
  127. package/dist/Git/git-clone-utils.d.ts.map +1 -0
  128. package/dist/Git/git-clone-utils.js +26 -0
  129. package/dist/Git/git-clone-utils.js.map +1 -0
  130. package/dist/Git/git-status.d.ts +24 -0
  131. package/dist/Git/git-status.d.ts.map +1 -0
  132. package/dist/Git/git-status.js +105 -0
  133. package/dist/Git/git-status.js.map +1 -0
  134. package/dist/Git/useGit.d.ts +15 -0
  135. package/dist/Git/useGit.d.ts.map +1 -0
  136. package/dist/Git/useGit.js +206 -0
  137. package/dist/Git/useGit.js.map +1 -0
  138. package/dist/Git/useGitStatus.d.ts +17 -0
  139. package/dist/Git/useGitStatus.d.ts.map +1 -0
  140. package/dist/Git/useGitStatus.js +63 -0
  141. package/dist/Git/useGitStatus.js.map +1 -0
  142. package/dist/GitToolbarControl-LMIR4R7I.js +117 -0
  143. package/dist/GitToolbarControl-LMIR4R7I.js.map +1 -0
  144. package/dist/GitUI-KGF5MSNV.js +1048 -0
  145. package/dist/GitUI-KGF5MSNV.js.map +1 -0
  146. package/dist/LazyEditorShell-EF2BWRXU.js +6 -0
  147. package/dist/LazyEditorShell-EF2BWRXU.js.map +1 -0
  148. package/dist/Settings/Settings.d.ts +23 -0
  149. package/dist/Settings/Settings.d.ts.map +1 -0
  150. package/dist/Settings/Settings.js +22 -0
  151. package/dist/Settings/Settings.js.map +1 -0
  152. package/dist/Settings/public-api.d.ts +3 -0
  153. package/dist/Settings/public-api.d.ts.map +1 -0
  154. package/dist/Settings/public-api.js +2 -0
  155. package/dist/Settings/public-api.js.map +1 -0
  156. package/dist/WorkspacePicker/WorkspacePicker.d.ts +6 -1
  157. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
  158. package/dist/WorkspacePicker/WorkspacePicker.js +16 -5
  159. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
  160. package/dist/chunk-3SAXDU2U.js +157 -0
  161. package/dist/chunk-3SAXDU2U.js.map +1 -0
  162. package/dist/chunk-4XXSWN3T.js +13 -0
  163. package/dist/chunk-4XXSWN3T.js.map +1 -0
  164. package/dist/chunk-5A4KEWKT.js +97 -0
  165. package/dist/chunk-5A4KEWKT.js.map +1 -0
  166. package/dist/chunk-AOVTHZU7.js +321 -0
  167. package/dist/chunk-AOVTHZU7.js.map +1 -0
  168. package/dist/chunk-DD4VFJQI.js +51 -0
  169. package/dist/chunk-DD4VFJQI.js.map +1 -0
  170. package/dist/chunk-GSNZSCIK.js +55 -0
  171. package/dist/chunk-GSNZSCIK.js.map +1 -0
  172. package/dist/chunk-VHDP4IPL.js +70 -0
  173. package/dist/chunk-VHDP4IPL.js.map +1 -0
  174. package/dist/chunk-ZI56BNB7.js +270 -0
  175. package/dist/chunk-ZI56BNB7.js.map +1 -0
  176. package/dist/components/Dialog.d.ts +22 -0
  177. package/dist/components/Dialog.d.ts.map +1 -0
  178. package/dist/components/Dialog.js +33 -0
  179. package/dist/components/Dialog.js.map +1 -0
  180. package/dist/editor.d.ts +10 -0
  181. package/dist/editor.d.ts.map +1 -0
  182. package/dist/editor.js +70 -0
  183. package/dist/editor.js.map +1 -0
  184. package/dist/export/index.d.ts +87 -0
  185. package/dist/export/index.js +26 -0
  186. package/dist/export/index.js.map +1 -0
  187. package/dist/hooks/index.d.ts +2 -1
  188. package/dist/hooks/index.d.ts.map +1 -1
  189. package/dist/hooks/index.js +1 -1
  190. package/dist/hooks/index.js.map +1 -1
  191. package/dist/hooks/useDocumentSession.d.ts +16 -0
  192. package/dist/hooks/useDocumentSession.d.ts.map +1 -0
  193. package/dist/hooks/useDocumentSession.js +18 -0
  194. package/dist/hooks/useDocumentSession.js.map +1 -0
  195. package/dist/icons.d.ts +11 -6
  196. package/dist/icons.d.ts.map +1 -1
  197. package/dist/icons.js +21 -20
  198. package/dist/icons.js.map +1 -1
  199. package/dist/index.d.ts +8 -5
  200. package/dist/index.d.ts.map +1 -1
  201. package/dist/index.js +4 -2
  202. package/dist/index.js.map +1 -1
  203. package/dist/preferences/theme.d.ts +18 -0
  204. package/dist/preferences/theme.d.ts.map +1 -0
  205. package/dist/preferences/theme.js +63 -0
  206. package/dist/preferences/theme.js.map +1 -0
  207. package/dist/provider-lease.d.ts +10 -0
  208. package/dist/provider-lease.d.ts.map +1 -0
  209. package/dist/provider-lease.js +42 -0
  210. package/dist/provider-lease.js.map +1 -0
  211. package/dist/run-export-ICTZITXC.js +10 -0
  212. package/dist/run-export-ICTZITXC.js.map +1 -0
  213. package/dist/settings/index.d.ts +28 -0
  214. package/dist/settings/index.js +12 -0
  215. package/dist/settings/index.js.map +1 -0
  216. package/package.json +24 -9
  217. package/src/AppMenu/AppMenu.tsx +169 -99
  218. package/src/AppMenu/index.ts +1 -1
  219. package/src/DocBlocksShell/DocBlocksShell.tsx +2157 -537
  220. package/src/DocBlocksShell/LazyEditorShell.ts +2 -0
  221. package/src/DocBlocksShell/UpdateAvailableNotice.tsx +60 -0
  222. package/src/DocBlocksShell/document-title.ts +19 -0
  223. package/src/DocBlocksShell/workspace-authority-barrier.ts +26 -0
  224. package/src/Export/DeferredExportToolbarControls.tsx +23 -0
  225. package/src/Export/ExportDialog.tsx +74 -20
  226. package/src/Export/ExportToolbarControls.tsx +238 -40
  227. package/src/Export/export-destination.ts +30 -0
  228. package/src/Export/index.ts +18 -4
  229. package/src/Export/public-api.ts +17 -0
  230. package/src/Export/run-export.ts +36 -11
  231. package/src/FileExplorer/FileExplorer.tsx +284 -38
  232. package/src/FileExplorer/FileTreeNode.tsx +100 -8
  233. package/src/FileExplorer/index.ts +1 -1
  234. package/src/FileExplorer/useFileTree.ts +190 -16
  235. package/src/Git/GitAuthGuidanceDialog.tsx +84 -0
  236. package/src/Git/GitBranchesDialog.tsx +144 -0
  237. package/src/Git/GitCloneDialog.tsx +200 -0
  238. package/src/Git/GitCommitDialog.tsx +119 -0
  239. package/src/Git/GitConflictsDialog.tsx +62 -0
  240. package/src/Git/GitContext.tsx +88 -0
  241. package/src/Git/GitDiffDialog.tsx +147 -0
  242. package/src/Git/GitDiffView.tsx +105 -0
  243. package/src/Git/GitHistoryDialog.tsx +195 -0
  244. package/src/Git/GitStatusBar.tsx +157 -0
  245. package/src/Git/GitToolbarControl.tsx +123 -0
  246. package/src/Git/GitUI.tsx +68 -0
  247. package/src/Git/commit-selection.ts +63 -0
  248. package/src/Git/git-clone-utils.ts +23 -0
  249. package/src/Git/git-status.ts +113 -0
  250. package/src/Git/useGit.ts +279 -0
  251. package/src/Git/useGitStatus.ts +78 -0
  252. package/src/Settings/Settings.tsx +118 -0
  253. package/src/Settings/public-api.ts +8 -0
  254. package/src/WorkspacePicker/WorkspacePicker.tsx +62 -8
  255. package/src/components/Dialog.tsx +81 -0
  256. package/src/editor.ts +72 -0
  257. package/src/hooks/index.ts +2 -1
  258. package/src/hooks/useDocumentSession.ts +37 -0
  259. package/src/icons.tsx +33 -55
  260. package/src/index.ts +35 -5
  261. package/src/preferences/theme.ts +67 -0
  262. package/src/provider-lease.ts +52 -0
  263. package/src/styles/docblocks.css +2174 -79
  264. package/src/types/pwa-dom.d.ts +31 -0
  265. package/dist/hooks/useAutoSave.d.ts +0 -15
  266. package/dist/hooks/useAutoSave.d.ts.map +0 -1
  267. package/dist/hooks/useAutoSave.js +0 -83
  268. package/dist/hooks/useAutoSave.js.map +0 -1
  269. package/dist/monaco-slim.d.ts +0 -19
  270. package/dist/monaco-slim.d.ts.map +0 -1
  271. package/dist/monaco-slim.js +0 -19
  272. package/dist/monaco-slim.js.map +0 -1
  273. package/src/fonts/fonts.css +0 -362
  274. package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +0 -93
  275. package/src/fonts/licenses/LICENSE-crimson-text.txt +0 -93
  276. package/src/fonts/licenses/LICENSE-dm-sans.txt +0 -93
  277. package/src/fonts/licenses/LICENSE-dm-serif-display.txt +0 -93
  278. package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +0 -94
  279. package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +0 -93
  280. package/src/fonts/licenses/LICENSE-inter.txt +0 -93
  281. package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +0 -93
  282. package/src/fonts/licenses/LICENSE-lora.txt +0 -93
  283. package/src/fonts/licenses/LICENSE-merriweather.txt +0 -93
  284. package/src/fonts/licenses/LICENSE-oswald.txt +0 -93
  285. package/src/fonts/licenses/LICENSE-playfair-display.txt +0 -93
  286. package/src/fonts/licenses/LICENSE-pt-serif.txt +0 -93
  287. package/src/fonts/licenses/LICENSE-roboto.txt +0 -93
  288. package/src/fonts/licenses/LICENSE-source-serif-4.txt +0 -93
  289. package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
  290. package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
  291. package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
  292. package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
  293. package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
  294. package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
  295. package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
  296. package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
  297. package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
  298. package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
  299. package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
  300. package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
  301. package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
  302. package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
  303. package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
  304. package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
  305. package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
  306. package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
  307. package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
  308. package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
  309. package/src/fonts/woff2/inter-400.woff2 +0 -0
  310. package/src/fonts/woff2/inter-400i.woff2 +0 -0
  311. package/src/fonts/woff2/inter-500.woff2 +0 -0
  312. package/src/fonts/woff2/inter-600.woff2 +0 -0
  313. package/src/fonts/woff2/inter-700.woff2 +0 -0
  314. package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
  315. package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
  316. package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
  317. package/src/fonts/woff2/lora-400.woff2 +0 -0
  318. package/src/fonts/woff2/lora-400i.woff2 +0 -0
  319. package/src/fonts/woff2/lora-700.woff2 +0 -0
  320. package/src/fonts/woff2/merriweather-400.woff2 +0 -0
  321. package/src/fonts/woff2/merriweather-700.woff2 +0 -0
  322. package/src/fonts/woff2/oswald-400.woff2 +0 -0
  323. package/src/fonts/woff2/oswald-700.woff2 +0 -0
  324. package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
  325. package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
  326. package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
  327. package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
  328. package/src/fonts/woff2/roboto-400.woff2 +0 -0
  329. package/src/fonts/woff2/roboto-400i.woff2 +0 -0
  330. package/src/fonts/woff2/roboto-700.woff2 +0 -0
  331. package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
  332. package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
  333. package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
  334. package/src/hooks/useAutoSave.ts +0 -116
  335. package/src/monaco-slim.ts +0 -20
@@ -1,12 +1,11 @@
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
10
  EditorColorScheme,
12
11
  EditorView,
@@ -22,43 +21,189 @@ 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. */
@@ -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' {
@@ -150,7 +308,7 @@ function loadLastState(): LastState | null {
150
308
 
151
309
  /** One-time first-run callout shown over the welcome doc's Play view.
152
310
  * Once the user starts writing, switches views themselves, or dismisses
153
- * it, it never comes back on any workspace. */
311
+ * it, it never comes back -- on any workspace. */
154
312
  const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
155
313
 
156
314
  function isWelcomeGatewayDismissed(): boolean {
@@ -169,35 +327,14 @@ function markWelcomeGatewayDismissed(): void {
169
327
  }
170
328
  }
171
329
 
172
- const THEME_PREF_KEY = 'docblocks:themePreference';
173
-
174
- function loadThemePreference(): ThemePreference {
175
- try {
176
- const raw = localStorage.getItem(THEME_PREF_KEY);
177
- if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw;
178
- } catch {
179
- // ignore
180
- }
181
- return 'auto';
182
- }
183
-
184
- function saveThemePreference(pref: ThemePreference): void {
185
- try {
186
- localStorage.setItem(THEME_PREF_KEY, pref);
187
- } catch {
188
- // ignore quota errors
189
- }
190
- }
191
-
192
330
  const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
193
- const SIDEBAR_WIDTH_DEFAULT = 260;
194
- const SIDEBAR_WIDTH_MIN = 180;
331
+ const SIDEBAR_WIDTH_DEFAULT = 320;
332
+ const SIDEBAR_WIDTH_MIN = 320;
195
333
  const SIDEBAR_WIDTH_MAX = 600;
196
- /** Drag below this many pixels and the sidebar collapses entirely
197
- * the editor takes the full width and a back-arrow appears in the
198
- * toolbar so the user can pop the sidebar back open. Same UX as
199
- * the existing mobile narrow-viewport flow. */
200
- 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;
201
338
 
202
339
  function loadSidebarWidth(): number {
203
340
  try {
@@ -276,13 +413,138 @@ function stripExtension(name: string): string {
276
413
  }
277
414
 
278
415
  function normaliseProviderPath(p: string): string {
279
- return '/' + p.replace(/^\/+/, '');
416
+ const canonical = parseWorkspacePath(p);
417
+ return canonical ? `/${canonical}` : '/';
280
418
  }
281
419
 
282
420
  function sameProviderPath(a: string, b: string): boolean {
283
421
  return normaliseProviderPath(a) === normaliseProviderPath(b);
284
422
  }
285
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
+
286
548
  /** Portable relative link from one workspace file to another. Walks up
287
549
  * from the source's directory and back down to the target so the link
288
550
  * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
@@ -318,9 +580,10 @@ async function collectMarkdownFiles(
318
580
  visited.add(dir);
319
581
  let entries: FileSystemEntry[];
320
582
  try {
321
- entries = await fs.readDirectory(dir);
322
- } catch {
323
- return;
583
+ entries = await readProviderDirectory(fs, dir);
584
+ } catch (error: unknown) {
585
+ if (error instanceof FsError && error.code === 'not-found') return;
586
+ throw error;
324
587
  }
325
588
  for (const entry of entries) {
326
589
  if (entry.kind === 'directory') {
@@ -339,20 +602,65 @@ async function collectMarkdownFiles(
339
602
  return out;
340
603
  }
341
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
+
342
654
  async function createElectronProviderFromWorkspace(
343
655
  ws: WorkspaceDescriptor,
344
656
  ): Promise<ElectronFileSystemProvider | null> {
345
657
  if (!ws.rootPath) return null;
346
658
  try {
347
- await getDocBlocksHost().workspaces.register({
348
- id: ws.id,
349
- name: ws.name,
350
- rootPath: ws.rootPath,
351
- });
659
+ await getDocBlocksHost().workspaces.register(ws.id);
352
660
  } catch {
353
661
  return null;
354
662
  }
355
- return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
663
+ return createElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
356
664
  }
357
665
 
358
666
  function useIsMobile(breakpoint = 768): boolean {
@@ -409,10 +717,14 @@ export function DocBlocksShell({
409
717
  versioningAutoSaveIdleMs,
410
718
  onSaveVersion,
411
719
  versioningRef,
720
+ updateAvailable = false,
721
+ onApplyUpdate,
722
+ offlineReady = false,
412
723
  }: DocBlocksShellProps) {
413
724
  const osTheme = useOsTheme();
414
725
  const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
415
- // "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
416
728
  // is kept only for API back-compat and does not override OS detection.
417
729
  const resolvedTheme: 'light' | 'dark' =
418
730
  themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
@@ -422,6 +734,24 @@ export function DocBlocksShell({
422
734
  saveThemePreference(pref);
423
735
  }, []);
424
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
+
425
755
  const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
426
756
  const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
427
757
  setViewPreferences(prefs);
@@ -429,21 +759,28 @@ export function DocBlocksShell({
429
759
  }, []);
430
760
  const isMobile = useIsMobile();
431
761
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
432
- // Sidebar width persisted across sessions, dragged via the resizer
762
+ // Sidebar width -- persisted across sessions, dragged via the resizer
433
763
  // between sidebar and editor area. We track the "live" width during a
434
764
  // drag in a ref so each mousemove doesn't trigger a state update; only
435
765
  // setState on commit so React doesn't churn through every pixel.
436
766
  const [sidebarWidth, setSidebarWidth] = useState<number>(loadSidebarWidth);
437
767
  // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
438
- // 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
439
769
  // the mobile narrow-viewport flow, where only the sidebar OR the
440
770
  // editor is visible at a time and a back-arrow in the toolbar pops
441
- // between them. A "Restore split view" button on the editor toolbar
771
+ // between them. A "Restore split view" button in either pane's header
442
772
  // exits compact mode; on real mobile that button is suppressed
443
773
  // because there's not enough viewport for side-by-side. Not
444
774
  // persisted across reloads. */
445
775
  const [compactLayout, setCompactLayout] = useState(false);
446
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
+ );
447
784
  const sidebarRef = useRef<HTMLDivElement>(null);
448
785
  const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
449
786
  const handleResizerPointerDown = useCallback(
@@ -464,7 +801,7 @@ export function DocBlocksShell({
464
801
  if (!drag) return;
465
802
  lastRaw = drag.startWidth + (ev.clientX - drag.startX);
466
803
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
467
- // Below threshold preview the collapse by snapping to the
804
+ // Below threshold -- preview the collapse by snapping to the
468
805
  // minimum width and fading the sidebar, so the user can see
469
806
  // they've crossed into "release to collapse" territory.
470
807
  sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
@@ -488,8 +825,8 @@ export function DocBlocksShell({
488
825
  sidebarRef.current.style.opacity = '';
489
826
  }
490
827
  if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
491
- // Released below threshold switch to compact (single-pane)
492
- // 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
493
830
  // sidebarWidth so exiting compact mode restores it.
494
831
  setCompactLayout(true);
495
832
  setMobileShowEditor(true);
@@ -512,9 +849,15 @@ export function DocBlocksShell({
512
849
  [sidebarWidth],
513
850
  );
514
851
  const [provider, setProvider] = useState<FileSystemProvider | null>(null);
852
+ const [workspaceStartupError, setWorkspaceStartupError] = useState<string | null>(null);
515
853
  const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
516
854
  const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] =
517
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]);
518
861
  // Re-fetch the descriptor whenever the active id (or its versioning
519
862
  // override) changes. `descriptorRefreshKey` is bumped after writes so
520
863
  // the resolver picks up the updated override without remounting.
@@ -533,6 +876,19 @@ export function DocBlocksShell({
533
876
  };
534
877
  }, [activeWorkspaceId, descriptorRefreshKey]);
535
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
+
536
892
  const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
537
893
  const [versioningPreference, setVersioningPreference] =
538
894
  useState<VersioningPreference>(loadVersioningPreference);
@@ -558,21 +914,260 @@ export function DocBlocksShell({
558
914
  [activeWorkspaceDescriptor],
559
915
  );
560
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]);
561
930
  const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
562
931
  const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
563
- const [editorContent, setEditorContent] = useState('');
564
- 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]);
565
953
  const [explorerKey, setExplorerKey] = useState(0);
566
954
  const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
567
- // First-run gateway over the welcome doc's Play view see WELCOME_GATEWAY_KEY.
955
+ // First-run gateway over the welcome doc's Play view -- see WELCOME_GATEWAY_KEY.
568
956
  const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
569
- /** Suppress popstate handling during programmatic navigation. */
570
- const skipPopState = useRef(false);
571
- const lastLocalSaveRef = useRef<{
572
- filePath: string;
573
- content: string;
574
- savedAt: number;
575
- } | null>(null);
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
+ );
576
1171
 
577
1172
  /**
578
1173
  * Per-file media container: for `notes.md`, images live in
@@ -582,6 +1177,7 @@ export function DocBlocksShell({
582
1177
  */
583
1178
  const mediaContainerRef = useRef<ContentContainer | null>(null);
584
1179
  const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
1180
+ const [mediaEpoch, setMediaEpoch] = useState(0);
585
1181
  /**
586
1182
  * Per-document container scoped to `<basename>_files/`. This is what
587
1183
  * the editor uses for version history (`.versions/` lives here) and
@@ -604,7 +1200,6 @@ export function DocBlocksShell({
604
1200
  const pushHash = useCallback((wsId: string, filePath?: string | null) => {
605
1201
  const hash = buildHash(wsId, filePath);
606
1202
  if (window.location.hash !== hash) {
607
- skipPopState.current = true;
608
1203
  window.history.pushState(null, '', hash);
609
1204
  }
610
1205
  }, []);
@@ -617,63 +1212,100 @@ export function DocBlocksShell({
617
1212
  filePath: string | null,
618
1213
  push: boolean,
619
1214
  view?: EditorView,
1215
+ navigationRequestId?: number,
620
1216
  ): Promise<FileSystemProvider | null> => {
621
- const workspaces = await listWorkspaces();
622
- const ws = workspaces.find((w) => w.id === wsId);
623
- if (!ws) return null;
624
-
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;
625
1223
  let fsProvider: FileSystemProvider | null = null;
626
- if (ws.type === 'electron-native') {
627
- fsProvider = await createElectronProviderFromWorkspace(ws);
628
- if (!fsProvider) return null;
629
- } else if (ws.type === 'native') {
630
- const restored = await restoreNativeFolder(ws.id);
631
- if (!restored) return null;
632
- fsProvider = restored;
1224
+ if (transient) {
1225
+ ws = transient.descriptor;
1226
+ fsProvider = transient.provider;
633
1227
  } else {
634
- 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
+ }
635
1241
  }
636
- await touchWorkspace(ws.id);
637
- setProvider(fsProvider);
638
- 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
+ }
639
1260
 
640
- if (filePath) {
641
- const content = await fsProvider.readFile(filePath);
642
- if (content !== null) {
643
- 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);
644
1276
  setSelectedFolder(null);
645
1277
  setFolderEntries([]);
646
- setEditorContent(content);
647
1278
  const effectiveView = view ?? 'wysiwyg';
648
1279
  setInitialView(effectiveView);
649
- setEditorKey((k) => k + 1);
650
- 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
+ }
651
1285
  } else {
652
1286
  setSelectedFile(null);
653
1287
  setSelectedFolder(null);
654
1288
  setFolderEntries([]);
655
- setEditorContent('');
656
- setEditorKey((k) => k + 1);
657
1289
  }
658
- } else {
659
- setSelectedFile(null);
660
- setSelectedFolder(null);
661
- setFolderEntries([]);
662
- setEditorContent('');
663
- setEditorKey((k) => k + 1);
664
- }
665
1290
 
666
- if (push) {
667
- 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();
668
1297
  }
669
- return fsProvider;
670
1298
  },
671
- [pushHash],
1299
+ [createDocumentTarget, documentSession, pushHash],
672
1300
  );
673
1301
 
674
1302
  const seedWelcomeFile = useCallback(
675
- async (fs: FileSystemProvider) => {
676
- 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;
677
1309
 
678
1310
  // If the only file is the welcome doc, auto-select it.
679
1311
  // Match either casing so workspaces seeded before the rename
@@ -684,12 +1316,12 @@ export function DocBlocksShell({
684
1316
  entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
685
1317
  ) {
686
1318
  const aboutPath = entries[0].path;
687
- const content = await fs.readFile(aboutPath);
688
- 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;
689
1323
  setSelectedFile(aboutPath);
690
- setEditorContent(content);
691
1324
  setInitialView('preview');
692
- setEditorKey((k) => k + 1);
693
1325
  setExplorerKey((k) => k + 1);
694
1326
  pushHash(fs.id, aboutPath);
695
1327
  saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
@@ -704,42 +1336,64 @@ export function DocBlocksShell({
704
1336
  const welcomeContent = [
705
1337
  '# Welcome to DocBlocks',
706
1338
  '',
1339
+ 'Your markdown can do anything.',
1340
+ '',
707
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.',
708
1342
  '',
709
1343
  'Simple to write. Beautiful wherever it goes.',
710
1344
  '',
711
1345
  '## Features',
712
1346
  '',
713
- '- **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.',
714
- '- **Workspaces** Organize your documents into separate workspaces in the browser or on your device.',
715
- '- **Useful Everywhere** Your content is usable across multiple formats Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
716
- '- **Playback & Video** Preview your documents as rich visual presentations and export them as MP4 video',
717
- '- **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',
718
1352
  '',
719
1353
  '## Getting Started',
720
1354
  '',
721
1355
  '1. Create a new file using the **New File** button in the sidebar',
722
- '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',
723
1357
  '3. Your work is saved automatically',
724
1358
  '',
725
- '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).',
726
1360
  ].join('\n');
727
1361
 
728
- 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;
729
1377
  setSelectedFile(welcomePath);
730
- setEditorContent(welcomeContent);
731
1378
  setInitialView('preview');
732
- setEditorKey((k) => k + 1);
733
1379
  setExplorerKey((k) => k + 1);
734
1380
  pushHash(fs.id, welcomePath);
735
1381
  saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
736
1382
  if (!isWelcomeGatewayDismissed()) setShowWelcomeGateway(true);
737
1383
  },
738
- [pushHash],
1384
+ [createDocumentTarget, documentSession, pushHash],
739
1385
  );
740
1386
 
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
+
741
1395
  /** Hide the welcome gateway and never show it again. Safe to call from
742
- * paths where it may not be showing only persists when it was. */
1396
+ * paths where it may not be showing -- only persists when it was. */
743
1397
  const closeWelcomeGateway = useCallback(() => {
744
1398
  setShowWelcomeGateway((showing) => {
745
1399
  if (showing) markWelcomeGatewayDismissed();
@@ -747,31 +1401,75 @@ export function DocBlocksShell({
747
1401
  });
748
1402
  }, []);
749
1403
 
750
- /** Gateway CTA flip the welcome doc from Play into the editor. */
1404
+ /** Gateway CTA -- flip the welcome doc from Play into the editor. */
751
1405
  const handleStartWriting = useCallback(() => {
752
1406
  closeWelcomeGateway();
753
1407
  setInitialView('wysiwyg');
754
- setEditorKey((k) => k + 1);
1408
+ setEditorPresentationEpoch((epoch) => epoch + 1);
755
1409
  if (activeWorkspaceId && selectedFile) {
756
1410
  saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
757
1411
  }
758
1412
  }, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
759
1413
 
760
- // Initialise workspace on mount restore from hash or last-used
1414
+ // Initialise workspace on mount -- restore from hash or last-used
761
1415
  useEffect(() => {
762
- (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
+
763
1453
  // Try restoring from URL hash first
764
1454
  const hashState = parseHash();
765
1455
  if (hashState) {
766
- const restoredProvider = await openFromIds(
767
- 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,
768
1463
  hashState.filePath,
769
1464
  false,
1465
+ undefined,
1466
+ requestId,
770
1467
  );
1468
+ if (!isCurrent()) return;
771
1469
  if (restoredProvider) {
772
1470
  // If the hash had no file, check whether we should auto-select the welcome doc
773
1471
  if (!hashState.filePath) {
774
- await seedWelcomeFile(restoredProvider);
1472
+ await startupSeedWelcomeFileRef.current(restoredProvider, requestId);
775
1473
  }
776
1474
  return;
777
1475
  }
@@ -780,20 +1478,27 @@ export function DocBlocksShell({
780
1478
  // Try restoring last viewed document from localStorage
781
1479
  const lastState = loadLastState();
782
1480
  if (lastState) {
783
- const restoredProvider = await openFromIds(
784
- 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,
785
1488
  lastState.filePath,
786
1489
  true,
787
1490
  lastState.view,
1491
+ requestId,
788
1492
  );
1493
+ if (!isCurrent()) return;
789
1494
  if (restoredProvider) return;
790
1495
  }
791
1496
 
792
1497
  let fsProvider: FileSystemProvider | null = null;
793
1498
 
794
- const electron = isElectronHost();
795
1499
  const workspaces = await listWorkspaces();
796
- // On desktop, hide web-only workspaces (indexeddb/native) — only
1500
+ if (!isCurrent()) return;
1501
+ // On desktop, hide web-only workspaces (indexeddb/native) -- only
797
1502
  // folder-based workspaces are valid.
798
1503
  const candidates = electron
799
1504
  ? workspaces.filter((w) => w.type === 'electron-native')
@@ -806,26 +1511,44 @@ export function DocBlocksShell({
806
1511
  if (ws.type === 'electron-native') {
807
1512
  const p = await createElectronProviderFromWorkspace(ws);
808
1513
  if (!p) continue;
1514
+ if (!isCurrent()) {
1515
+ await getFileSystemProviderV2(p)?.dispose();
1516
+ return;
1517
+ }
809
1518
  await touchWorkspace(ws.id);
1519
+ if (!isCurrent()) {
1520
+ await getFileSystemProviderV2(p)?.dispose();
1521
+ return;
1522
+ }
810
1523
  fsProvider = p;
811
- setProvider(p);
812
- setActiveWorkspaceId(ws.id);
813
1524
  break;
814
1525
  } else if (ws.type === 'native') {
815
- const restored = await restoreNativeFolder(ws.id);
1526
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
816
1527
  if (restored) {
1528
+ if (!isCurrent()) {
1529
+ await getFileSystemProviderV2(restored)?.dispose();
1530
+ return;
1531
+ }
817
1532
  await touchWorkspace(ws.id);
1533
+ if (!isCurrent()) {
1534
+ await getFileSystemProviderV2(restored)?.dispose();
1535
+ return;
1536
+ }
818
1537
  fsProvider = restored;
819
- setProvider(restored);
820
- setActiveWorkspaceId(ws.id);
821
1538
  break;
822
1539
  }
823
1540
  } else {
824
- 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
+ }
825
1546
  await touchWorkspace(ws.id);
1547
+ if (!isCurrent()) {
1548
+ await getFileSystemProviderV2(p)?.dispose();
1549
+ return;
1550
+ }
826
1551
  fsProvider = p;
827
- setProvider(p);
828
- setActiveWorkspaceId(ws.id);
829
1552
  break;
830
1553
  }
831
1554
  }
@@ -833,8 +1556,9 @@ export function DocBlocksShell({
833
1556
  if (!fsProvider) {
834
1557
  if (electron) {
835
1558
  // Desktop: ask the host for the default folder workspace
836
- // (creates ~/Documents/DocBlocks on first launch).
1559
+ // (creates DocBlocks in the OS-resolved Documents folder on first launch).
837
1560
  const info = await getDocBlocksHost().workspaces.getDefault();
1561
+ if (!isCurrent()) return;
838
1562
  const descriptor: WorkspaceDescriptor = {
839
1563
  id: info.id,
840
1564
  name: info.name,
@@ -843,43 +1567,149 @@ export function DocBlocksShell({
843
1567
  lastOpened: new Date().toISOString(),
844
1568
  };
845
1569
  await saveWorkspace(descriptor);
846
- 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
+ }
847
1576
  fsProvider = p;
848
- setProvider(p);
849
- setActiveWorkspaceId(info.id);
850
1577
  } else {
851
1578
  // Web: create the IndexedDB default workspace
852
1579
  const defaultWs = await ensureDefaultWorkspace();
853
- 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
+ }
854
1586
  fsProvider = p;
855
- setProvider(p);
856
- setActiveWorkspaceId(defaultWs.id);
857
1587
  }
858
1588
  }
859
1589
 
860
- // Set initial hash
861
- 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
+ });
862
1611
 
863
- // Seed welcome file if workspace is empty
864
- await seedWelcomeFile(fsProvider);
865
- })();
866
- }, [openFromIds, pushHash, seedWelcomeFile]);
1612
+ return () => {
1613
+ cancelled = true;
1614
+ if (navigationRequestRef.current === requestId) navigationRequestRef.current += 1;
1615
+ };
1616
+ }, [pushHash, workspaceAuthorityBarrier]);
867
1617
 
868
1618
  // Handle browser back/forward
869
1619
  useEffect(() => {
870
1620
  const onPopState = () => {
871
- if (skipPopState.current) {
872
- skipPopState.current = false;
873
- return;
874
- }
875
1621
  const hashState = parseHash();
876
1622
  if (hashState) {
877
- 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
+ });
878
1640
  }
879
1641
  };
880
1642
  window.addEventListener('popstate', onPopState);
881
1643
  return () => window.removeEventListener('popstate', onPopState);
882
- }, [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]);
883
1713
 
884
1714
  // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
885
1715
  useEffect(() => {
@@ -888,7 +1718,7 @@ export function DocBlocksShell({
888
1718
  if (target) {
889
1719
  const view = target.getAttribute('data-view') as EditorView;
890
1720
  if (view) {
891
- // The user found the view tabs on their own the gateway's job is done.
1721
+ // The user found the view tabs on their own -- the gateway's job is done.
892
1722
  closeWelcomeGateway();
893
1723
  }
894
1724
  if (view && activeWorkspaceId && selectedFile) {
@@ -900,29 +1730,12 @@ export function DocBlocksShell({
900
1730
  return () => window.removeEventListener('click', handler, true);
901
1731
  }, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
902
1732
 
903
- const handleAutoSaved = useCallback((filePath: string, savedContent: string) => {
904
- lastLocalSaveRef.current = {
905
- filePath: normaliseProviderPath(filePath),
906
- content: savedContent,
907
- savedAt: Date.now(),
908
- };
909
- }, []);
910
-
911
- // Auto-save current file. The returned `flush` is called from the
912
- // Ctrl/Cmd+S handler below so the user gets immediate confirmation.
913
- const { flush: flushAutoSave } = useAutoSave(
914
- provider,
915
- selectedFile,
916
- editorContent,
917
- 500,
918
- handleAutoSaved,
919
- );
920
-
921
- // Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
922
- // small "auto-save confirmed" toast. Files are already saved on every
923
- // keystroke (debounced) — this is purely UX reassurance for users who
924
- // muscle-memory hit Save.
925
- 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);
926
1739
  const saveToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
927
1740
  useEffect(() => {
928
1741
  const onKey = (e: KeyboardEvent) => {
@@ -931,20 +1744,83 @@ export function DocBlocksShell({
931
1744
  if (!sKey || !accel || e.altKey) return;
932
1745
  e.preventDefault();
933
1746
  e.stopPropagation();
934
- void flushAutoSave().catch(() => undefined);
935
- setSaveToastVisible(true);
936
- if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
937
- 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
+ })();
938
1764
  };
939
1765
  window.addEventListener('keydown', onKey, true);
940
1766
  return () => window.removeEventListener('keydown', onKey, true);
941
- }, [flushAutoSave]);
1767
+ }, [documentSession]);
942
1768
  useEffect(() => {
943
1769
  return () => {
944
1770
  if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
945
1771
  };
946
1772
  }, []);
947
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
+
948
1824
  // Per-file media: for `notes.md` images live in `notes_files/` beside it.
949
1825
  // Rebuilds whenever the provider or selected file changes.
950
1826
  useEffect(() => {
@@ -969,10 +1845,10 @@ export function DocBlocksShell({
969
1845
  return () => {
970
1846
  mp.dispose();
971
1847
  };
972
- }, [provider, selectedFile]);
1848
+ }, [provider, selectedFile, mediaEpoch]);
973
1849
 
974
1850
  // Invalidate the document-link candidate cache when the backing
975
- // workspace changes otherwise the link dialog would surface
1851
+ // workspace changes -- otherwise the link dialog would surface
976
1852
  // neighbours from a previously-open workspace.
977
1853
  useEffect(() => {
978
1854
  mdFileCacheRef.current = null;
@@ -1031,68 +1907,238 @@ export function DocBlocksShell({
1031
1907
 
1032
1908
  // React to external file changes watched by the Electron host (chokidar).
1033
1909
  useEffect(() => {
1034
- if (!isElectronHost()) return;
1035
- if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
1036
- const unwatch = provider.watch((changedPath) => {
1037
- setExplorerKey((k) => k + 1);
1038
- if (!selectedFile || !sameProviderPath(changedPath, selectedFile)) return;
1039
-
1040
- // If the open file's contents changed on disk, reload it (best-effort).
1041
- (async () => {
1042
- const content = await provider.readFile(selectedFile);
1043
- if (content === null || content === editorContent) return;
1044
-
1045
- const localSave = lastLocalSaveRef.current;
1046
- if (
1047
- localSave &&
1048
- sameProviderPath(localSave.filePath, selectedFile) &&
1049
- localSave.content === content &&
1050
- Date.now() - localSave.savedAt < 5000
1051
- ) {
1052
- return;
1053
- }
1054
-
1055
- setEditorContent(content);
1056
- setEditorKey((k) => k + 1);
1057
- })();
1058
- });
1059
- return unwatch;
1060
- }, [provider, selectedFile, editorContent]);
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
+ };
1061
1952
 
1062
- const handleWorkspaceSelect = useCallback(
1063
- async (ws: WorkspaceDescriptor) => {
1064
- await touchWorkspace(ws.id);
1065
- let nextProvider: FileSystemProvider | null = null;
1066
- if (ws.type === 'electron-native') {
1067
- nextProvider = await createElectronProviderFromWorkspace(ws);
1068
- if (!nextProvider) return;
1069
- } else if (ws.type === 'native') {
1070
- const restored = await restoreNativeFolder(ws.id);
1071
- if (!restored) {
1072
- // Permission denied or handle lost — fall through without changing provider
1953
+ const subscription = providerV2.watch(
1954
+ (event) => {
1955
+ if (event.type === 'overflow') {
1956
+ void drainWatcherReads();
1957
+ return;
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]);
2100
+
2101
+ const handleWorkspaceSelect = useCallback(
2102
+ async (ws: WorkspaceDescriptor) => {
2103
+ const requestId = ++navigationRequestRef.current;
2104
+ await touchWorkspace(ws.id);
2105
+ if (requestId !== navigationRequestRef.current) return;
2106
+ let nextProvider: FileSystemProvider | null = null;
2107
+ const transient = getTransientWorkspace(ws.id);
2108
+ if (transient) {
2109
+ nextProvider = transient.provider;
2110
+ } else if (ws.type === 'electron-native') {
2111
+ nextProvider = await createElectronProviderFromWorkspace(ws);
2112
+ if (!nextProvider) return;
2113
+ } else if (ws.type === 'native') {
2114
+ const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
2115
+ if (!restored) {
2116
+ // Permission denied or handle lost -- fall through without changing provider
1073
2117
  return;
1074
2118
  }
1075
2119
  nextProvider = restored;
1076
2120
  } else {
1077
- nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
2121
+ nextProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
1078
2122
  }
2123
+ if (requestId !== navigationRequestRef.current) return;
2124
+ if (!(await transitionAwayFromDocument(requestId))) return;
1079
2125
  setProvider(nextProvider);
1080
2126
  setActiveWorkspaceId(ws.id);
1081
2127
  setSelectedFile(null);
1082
2128
  setSelectedFolder(null);
1083
2129
  setFolderEntries([]);
1084
- setEditorContent('');
1085
- setEditorKey((k) => k + 1);
1086
2130
  pushHash(ws.id, null);
1087
2131
  },
1088
- [pushHash],
2132
+ [pushHash, transitionAwayFromDocument],
1089
2133
  );
1090
2134
 
1091
2135
  const handleOpenFolder = useCallback(async () => {
2136
+ const requestId = ++navigationRequestRef.current;
1092
2137
  try {
1093
2138
  if (isElectronHost()) {
1094
2139
  const info = await getDocBlocksHost().workspaces.pickFolder();
1095
2140
  if (!info) return; // user cancelled
2141
+ if (requestId !== navigationRequestRef.current) return;
1096
2142
  const descriptor: WorkspaceDescriptor = {
1097
2143
  id: info.id,
1098
2144
  name: info.name,
@@ -1101,19 +2147,20 @@ export function DocBlocksShell({
1101
2147
  lastOpened: new Date().toISOString(),
1102
2148
  };
1103
2149
  await saveWorkspace(descriptor);
1104
- 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;
1105
2153
  setProvider(provider);
1106
2154
  setActiveWorkspaceId(descriptor.id);
1107
2155
  setSelectedFile(null);
1108
2156
  setSelectedFolder(null);
1109
2157
  setFolderEntries([]);
1110
- setEditorContent('');
1111
- setEditorKey((k) => k + 1);
1112
2158
  pushHash(descriptor.id, null);
1113
2159
  return;
1114
2160
  }
1115
2161
 
1116
- const nativeProvider = await openNativeFolder();
2162
+ const nativeProvider = await (await loadNativeFileSystem()).openNativeFolder();
2163
+ if (requestId !== navigationRequestRef.current) return;
1117
2164
  const descriptor: WorkspaceDescriptor = {
1118
2165
  id: nativeProvider.id,
1119
2166
  name: nativeProvider.label,
@@ -1121,41 +2168,135 @@ export function DocBlocksShell({
1121
2168
  lastOpened: new Date().toISOString(),
1122
2169
  };
1123
2170
  await saveWorkspace(descriptor);
2171
+ if (requestId !== navigationRequestRef.current) return;
2172
+ if (!(await transitionAwayFromDocument(requestId))) return;
1124
2173
  setProvider(nativeProvider);
1125
2174
  setActiveWorkspaceId(descriptor.id);
1126
2175
  setSelectedFile(null);
1127
2176
  setSelectedFolder(null);
1128
2177
  setFolderEntries([]);
1129
- setEditorContent('');
1130
- setEditorKey((k) => k + 1);
1131
2178
  pushHash(descriptor.id, null);
1132
2179
  } catch {
1133
2180
  // User cancelled or API not supported
1134
2181
  }
1135
- }, [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
+ );
1136
2215
 
1137
2216
  const handleNewFile = useCallback(async () => {
1138
2217
  if (!provider) return;
1139
2218
  const name = prompt('New document name:', 'Untitled.md');
1140
2219
  if (!name) return;
1141
2220
  const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
1142
- await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
1143
- setSelectedFile('/' + filename);
1144
- 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);
1145
2260
  setInitialView('wysiwyg');
1146
- setEditorKey((k) => k + 1);
1147
2261
  setExplorerKey((k) => k + 1);
1148
2262
  closeWelcomeGateway();
1149
2263
  if (activeWorkspaceId) {
1150
2264
  pushHash(activeWorkspaceId, '/' + filename);
1151
2265
  }
1152
- }, [provider, activeWorkspaceId, pushHash, closeWelcomeGateway]);
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]);
1153
2294
 
1154
2295
  const handleRevealWorkspace = useCallback(async () => {
1155
2296
  if (!isElectronHost() || !activeWorkspaceId) return;
1156
2297
  const ws = await getWorkspace(activeWorkspaceId);
1157
2298
  if (ws?.type === 'electron-native' && ws.rootPath) {
1158
- await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
2299
+ await getDocBlocksHost().shell.revealInFolder(ws.id);
1159
2300
  }
1160
2301
  }, [activeWorkspaceId]);
1161
2302
 
@@ -1174,6 +2315,38 @@ export function DocBlocksShell({
1174
2315
  case 'file:revealWorkspace':
1175
2316
  handleRevealWorkspace();
1176
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;
1177
2350
  case 'help:viewOnGitHub':
1178
2351
  host.shell.openExternal('https://github.com/bendyline/docblocks');
1179
2352
  break;
@@ -1194,61 +2367,135 @@ export function DocBlocksShell({
1194
2367
  });
1195
2368
  }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
1196
2369
 
1197
- // Subscribe to open-file / deep-link requests from the OS.
1198
- useEffect(() => {
1199
- if (!isElectronHost()) return;
1200
- const host = getDocBlocksHost();
1201
- return host.onOpenRequest(async (req) => {
1202
- await openFromIds(req.workspaceId, req.path, true);
1203
- });
1204
- }, [openFromIds]);
2370
+ // OS open-file / deep-link handling lives after the container helpers it
2371
+ // depends on -- see the `openTransient` + onOpenRequest effect below.
1205
2372
 
1206
2373
  const handleSelect = useCallback(
1207
2374
  async (path: string, kind: 'file' | 'directory') => {
1208
2375
  if (!provider || !activeWorkspaceId) return;
2376
+ const requestId = ++navigationRequestRef.current;
1209
2377
 
1210
2378
  if (kind === 'directory') {
2379
+ if (!(await transitionAwayFromDocument(requestId))) return;
2380
+ if (requestId !== navigationRequestRef.current) return;
1211
2381
  setSelectedFile(null);
1212
2382
  setSelectedFolder(path);
1213
- const entries = await provider.readDirectory(path);
2383
+ const entries = await readProviderDirectory(provider, path);
2384
+ if (requestId !== navigationRequestRef.current) return;
1214
2385
  setFolderEntries(entries);
1215
- setEditorContent('');
1216
- setEditorKey((k) => k + 1);
1217
2386
  pushHash(activeWorkspaceId, null);
1218
2387
  } else {
1219
- 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;
1220
2405
  setSelectedFile(path);
1221
2406
  setSelectedFolder(null);
1222
2407
  setFolderEntries([]);
1223
- setEditorContent(content ?? '');
1224
2408
  setInitialView('wysiwyg');
1225
- setEditorKey((k) => k + 1);
1226
2409
  closeWelcomeGateway();
1227
2410
  pushHash(activeWorkspaceId, path);
1228
2411
  saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
1229
2412
  if (effectiveCompact) setMobileShowEditor(true);
1230
2413
  }
1231
2414
  },
1232
- [provider, activeWorkspaceId, pushHash, effectiveCompact, closeWelcomeGateway],
2415
+ [
2416
+ provider,
2417
+ activeWorkspaceId,
2418
+ pushHash,
2419
+ effectiveCompact,
2420
+ closeWelcomeGateway,
2421
+ createDocumentTarget,
2422
+ documentSession,
2423
+ transitionAwayFromDocument,
2424
+ ],
1233
2425
  );
1234
2426
 
1235
- const handleTreeChange = useCallback(async () => {
1236
- if (!provider) return;
1237
- // If the open file was deleted, clear the editor
1238
- if (selectedFile) {
1239
- const exists = await provider.exists(selectedFile);
1240
- if (!exists) {
1241
- setSelectedFile(null);
1242
- setEditorContent('');
1243
- setEditorKey((k) => k + 1);
1244
- if (activeWorkspaceId) pushHash(activeWorkspaceId, null);
2427
+ const handleTreeMutation = useCallback<FileTreeMutationHandler>(
2428
+ async (change, mutate) => {
2429
+ if (!provider || !activeWorkspaceId || !selectedFile) {
2430
+ await mutate();
2431
+ return;
1245
2432
  }
1246
- }
1247
- if (selectedFolder) {
1248
- const entries = await provider.readDirectory(selectedFolder);
1249
- setFolderEntries(entries);
1250
- }
1251
- }, [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
+ );
1252
2499
 
1253
2500
  /**
1254
2501
  * Copy non-markdown files from an imported container into the target
@@ -1268,12 +2515,21 @@ export function DocBlocksShell({
1268
2515
  const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
1269
2516
  const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
1270
2517
  const entries = await source.listFiles();
2518
+ const targetV2 = getFileSystemProviderV2(target);
1271
2519
  for (const entry of entries) {
1272
2520
  if (entry.path.endsWith('.md')) continue;
1273
2521
  const data = await source.readFile(entry.path);
1274
2522
  if (!data) continue;
1275
2523
  const cleanPath = entry.path.replace(/^\/+/, '');
1276
- 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
+ }
1277
2533
  }
1278
2534
  },
1279
2535
  [],
@@ -1310,7 +2566,7 @@ export function DocBlocksShell({
1310
2566
  continue;
1311
2567
  }
1312
2568
 
1313
- await provider.writeFile(destPath, markdown);
2569
+ await writeProviderText(provider, destPath, markdown);
1314
2570
  } catch (err) {
1315
2571
  console.error(`Failed to import ${file.name}:`, err);
1316
2572
  }
@@ -1321,9 +2577,18 @@ export function DocBlocksShell({
1321
2577
  [provider, persistImportedMedia],
1322
2578
  );
1323
2579
 
1324
- const handleEditorChange = useCallback((source: string) => {
1325
- setEditorContent(source);
1326
- }, []);
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
+ );
1327
2592
 
1328
2593
  const handleRenameWorkspace = useCallback(async () => {
1329
2594
  if (!activeWorkspaceId) return;
@@ -1332,53 +2597,219 @@ export function DocBlocksShell({
1332
2597
  const newName = prompt('Rename workspace:', ws.name);
1333
2598
  if (!newName || newName === ws.name) return;
1334
2599
  await saveWorkspace({ ...ws, name: newName });
1335
- // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
1336
- setEditorKey((k) => k + 1);
2600
+ setDescriptorRefreshKey((key) => key + 1);
1337
2601
  }, [activeWorkspaceId]);
1338
2602
 
1339
2603
  /**
1340
- * Walk a FileSystemProvider and copy every file into `container` under
1341
- * `pathPrefix` (no leading slash; empty string for the root). Used by
1342
- * 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.
1343
2607
  */
1344
- const copyProviderToContainer = useCallback(
2608
+ const openTransient = useCallback(
1345
2609
  async (
1346
- src: FileSystemProvider,
1347
- container: { writeFile: (path: string, data: ArrayBuffer | Uint8Array) => Promise<void> },
1348
- pathPrefix: string,
1349
- ): Promise<void> => {
1350
- const encoder = new TextEncoder();
1351
- const stack: string[] = ['/'];
1352
- while (stack.length > 0) {
1353
- const dir = stack.pop()!;
1354
- const entries = await src.readDirectory(dir);
1355
- for (const entry of entries) {
1356
- if (entry.kind === 'directory') {
1357
- stack.push(entry.path);
1358
- 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.');
1359
2644
  }
1360
- const rel = entry.path.replace(/^\/+/, '');
1361
- const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
1362
- // Files may be stored as text (writeFile) or binary (writeBinary);
1363
- // try binary first, fall back to text and encode as UTF-8.
1364
- const binary = await src.readBinary(entry.path);
1365
- if (binary) {
1366
- await container.writeFile(zipPath, binary);
1367
- 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.');
1368
2653
  }
1369
- const text = await src.readFile(entry.path);
1370
- if (text !== null) {
1371
- 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();
1372
2689
  }
2690
+ } finally {
2691
+ if (!opened) await revokeAbandonedResource();
1373
2692
  }
1374
2693
  }
1375
2694
  },
1376
- [],
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],
1377
2784
  );
1378
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
+
1379
2809
  const handleDownloadWorkspace = useCallback(async () => {
1380
2810
  if (!provider) return;
1381
2811
  try {
2812
+ await documentSession.flush('backup');
1382
2813
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1383
2814
  import('@bendyline/squisq/storage'),
1384
2815
  import('@bendyline/squisq-formats/container'),
@@ -1400,17 +2831,18 @@ export function DocBlocksShell({
1400
2831
  console.error('Failed to download workspace', err);
1401
2832
  alert('Failed to download workspace. See console for details.');
1402
2833
  }
1403
- }, [provider, copyProviderToContainer]);
2834
+ }, [provider, documentSession]);
1404
2835
 
1405
2836
  /**
1406
2837
  * Bundle every workspace the host can open without further prompting
1407
2838
  * into a single zip, with each workspace nested under its own folder.
1408
2839
  * Native (browser-picked) workspaces whose handle hasn't been re-granted
1409
- * for this session are skipped restoring them would require a user
2840
+ * for this session are skipped -- restoring them would require a user
1410
2841
  * gesture per workspace.
1411
2842
  */
1412
2843
  const handleDownloadAllWorkspaces = useCallback(async () => {
1413
2844
  try {
2845
+ await documentSession.flush('backup');
1414
2846
  const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1415
2847
  import('@bendyline/squisq/storage'),
1416
2848
  import('@bendyline/squisq-formats/container'),
@@ -1419,7 +2851,9 @@ export function DocBlocksShell({
1419
2851
  const electron = isElectronHost();
1420
2852
  const all = await listWorkspaces();
1421
2853
  const candidates = all.filter((w) =>
1422
- electron ? w.type === 'electron-native' : w.type !== 'electron-native',
2854
+ electron
2855
+ ? w.type === 'electron-native' || w.type === 'transient'
2856
+ : w.type !== 'electron-native',
1423
2857
  );
1424
2858
  if (candidates.length === 0) {
1425
2859
  alert('No workspaces to download.');
@@ -1432,15 +2866,22 @@ export function DocBlocksShell({
1432
2866
 
1433
2867
  for (const ws of candidates) {
1434
2868
  let p: FileSystemProvider | null = null;
2869
+ let ownsProvider = false;
1435
2870
  try {
1436
- 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') {
1437
2875
  p = await createElectronProviderFromWorkspace(ws);
2876
+ ownsProvider = p !== null;
1438
2877
  } else if (ws.type === 'native') {
1439
- // Restore without prompting only succeeds when the browser
2878
+ // Restore without prompting -- only succeeds when the browser
1440
2879
  // still remembers the granted handle for this origin/session.
1441
- p = await restoreNativeFolder(ws.id);
2880
+ p = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
2881
+ ownsProvider = p !== null;
1442
2882
  } else {
1443
- p = new IndexedDBFileSystemProvider(ws.id, ws.name);
2883
+ p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
2884
+ ownsProvider = true;
1444
2885
  }
1445
2886
  } catch (err) {
1446
2887
  console.warn(`Skipping workspace ${ws.name}:`, err);
@@ -1460,7 +2901,11 @@ export function DocBlocksShell({
1460
2901
  }
1461
2902
  usedFolders.add(folder);
1462
2903
 
1463
- await copyProviderToContainer(p, container, folder);
2904
+ try {
2905
+ await copyProviderToContainer(p, container, folder);
2906
+ } finally {
2907
+ if (ownsProvider) await getFileSystemProviderV2(p)?.dispose();
2908
+ }
1464
2909
  }
1465
2910
 
1466
2911
  if (usedFolders.size === 0) {
@@ -1486,7 +2931,59 @@ export function DocBlocksShell({
1486
2931
  console.error('Failed to download all workspaces', err);
1487
2932
  alert('Failed to download all workspaces. See console for details.');
1488
2933
  }
1489
- }, [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
+ }, []);
1490
2987
 
1491
2988
  const handleRemoveWorkspace = useCallback(async () => {
1492
2989
  if (!activeWorkspaceId) return;
@@ -1494,18 +2991,30 @@ export function DocBlocksShell({
1494
2991
  ? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
1495
2992
  : 'Remove this workspace? This cannot be undone.';
1496
2993
  if (!confirm(confirmMsg)) return;
2994
+ const requestId = ++navigationRequestRef.current;
2995
+ if (!(await transitionAwayFromDocument(requestId))) return;
1497
2996
 
1498
2997
  const ws = await getWorkspace(activeWorkspaceId);
1499
2998
  if (ws?.type === 'electron-native') {
1500
2999
  try {
1501
3000
  await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
1502
3001
  } catch {
1503
- // ignore host cleanup is best-effort
3002
+ // ignore -- host cleanup is best-effort
1504
3003
  }
1505
- } else {
1506
- 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);
1507
3015
  }
1508
3016
  await removeWorkspace(activeWorkspaceId);
3017
+ if (requestId !== navigationRequestRef.current) return;
1509
3018
 
1510
3019
  const electron = isElectronHost();
1511
3020
  // Switch to most recent remaining workspace or create default
@@ -1525,254 +3034,365 @@ export function DocBlocksShell({
1525
3034
  lastOpened: new Date().toISOString(),
1526
3035
  };
1527
3036
  await saveWorkspace(descriptor);
1528
- const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
3037
+ const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
1529
3038
  setProvider(p);
1530
3039
  setActiveWorkspaceId(info.id);
1531
3040
  setSelectedFile(null);
1532
3041
  setSelectedFolder(null);
1533
3042
  setFolderEntries([]);
1534
- setEditorContent('');
1535
- setEditorKey((k) => k + 1);
1536
3043
  } else {
1537
3044
  const defaultWs = await ensureDefaultWorkspace();
1538
- const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
3045
+ const fsProvider = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
1539
3046
  setProvider(fsProvider);
1540
3047
  setActiveWorkspaceId(defaultWs.id);
1541
3048
  setSelectedFile(null);
1542
3049
  setSelectedFolder(null);
1543
3050
  setFolderEntries([]);
1544
- setEditorContent('');
1545
- setEditorKey((k) => k + 1);
1546
3051
  }
1547
- }, [activeWorkspaceId, handleWorkspaceSelect]);
3052
+ }, [activeWorkspaceId, handleWorkspaceSelect, transitionAwayFromDocument]);
1548
3053
 
1549
3054
  return (
1550
3055
  <div
1551
3056
  className={`db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`}
1552
3057
  data-theme={resolvedTheme}
3058
+ data-accent={accentColor}
3059
+ data-document-status={documentSnapshot.status}
1553
3060
  >
1554
- {saveToastVisible && (
1555
- <div className="db-save-toast" role="status" aria-live="polite">
1556
- Autosaved. You're all set.
1557
- </div>
1558
- )}
1559
- {workspaceSettingsOpen && activeWorkspaceDescriptor && (
1560
- <WorkspaceSettingsDialog
1561
- workspace={activeWorkspaceDescriptor}
1562
- globalVersioningPreference={versioningPreference}
1563
- onChange={handleWorkspaceVersioningOverrideChange}
1564
- onClose={() => setWorkspaceSettingsOpen(false)}
1565
- />
1566
- )}
1567
- {/* Main area */}
1568
- <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
1569
- {/* Left sidebar — hidden in compact layout when the editor is
1570
- showing (compact = real mobile narrow viewport OR the user
1571
- dragged the resizer below SIDEBAR_COLLAPSE_THRESHOLD). */}
1572
- {(!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 && (
1573
3068
  <div
1574
- ref={sidebarRef}
1575
- className="db-shell-sidebar"
1576
- 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'}
1577
3072
  >
1578
- <div className="db-shell-sidebar-header">
1579
- <AppMenu
1580
- logoUrl={logoUrl}
1581
- themePreference={themePreference}
1582
- onThemeChange={handleThemeChange}
1583
- versioningPreference={versioningPreference}
1584
- onVersioningPreferenceChange={handleVersioningPreferenceChange}
1585
- onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
1586
- />
1587
- <WorkspacePicker
1588
- activeWorkspaceId={activeWorkspaceId}
1589
- onSelect={handleWorkspaceSelect}
1590
- onOpenFolder={handleOpenFolder}
1591
- />
1592
- <WorkspaceSettingsButton
1593
- onSettings={handleOpenWorkspaceSettings}
1594
- onRename={handleRenameWorkspace}
1595
- onDownload={handleDownloadWorkspace}
1596
- onRemove={handleRemoveWorkspace}
1597
- />
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>
1598
3093
  </div>
1599
- <FileExplorer
1600
- key={explorerKey}
1601
- provider={provider}
1602
- onSelect={handleSelect}
1603
- onTreeChange={handleTreeChange}
1604
- onImportFiles={handleImportFiles}
1605
- />
1606
- <div className="db-shell-sidebar-footer">
1607
- <a
1608
- href="https://github.com/bendyline/docblocks/blob/main/LICENSE"
1609
- target="_blank"
1610
- rel="noopener noreferrer"
1611
- >
1612
- Terms of Use
1613
- </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>
1614
3109
  </div>
1615
3110
  </div>
1616
3111
  )}
1617
-
1618
- {/* Resize handle between sidebar and editor — hidden whenever
1619
- the layout is compact (no sidebar to resize). */}
1620
- {!effectiveCompact && (
1621
- <div
1622
- className="db-shell-sidebar-resizer"
1623
- role="separator"
1624
- aria-orientation="vertical"
1625
- aria-label="Resize sidebar"
1626
- onPointerDown={handleResizerPointerDown}
3112
+ {workspaceSettingsOpen && activeWorkspaceDescriptor && (
3113
+ <WorkspaceSettingsDialog
3114
+ workspace={activeWorkspaceDescriptor}
3115
+ globalVersioningPreference={versioningPreference}
3116
+ onChange={handleWorkspaceVersioningOverrideChange}
3117
+ onClose={() => setWorkspaceSettingsOpen(false)}
1627
3118
  />
1628
3119
  )}
1629
-
1630
- {/* Editor area hidden in compact layout when the sidebar is showing. */}
1631
- {(!effectiveCompact || mobileShowEditor) && (
1632
- <div
1633
- style={{
1634
- flex: 1,
1635
- overflow: 'hidden',
1636
- display: 'flex',
1637
- flexDirection: 'column',
1638
- position: 'relative',
1639
- }}
1640
- >
1641
- {selectedFile && mediaProvider ? (
1642
- <MediaContext.Provider value={mediaProvider}>
1643
- <EditorShell
1644
- key={`${selectedFile}-${editorKey}`}
1645
- initialMarkdown={editorContent}
1646
- initialView={initialView}
1647
- articleId={selectedFile}
1648
- fileName={selectedFile}
1649
- onChange={handleEditorChange}
1650
- colorScheme={resolvedTheme}
1651
- height="100%"
1652
- outlineWidth={280}
1653
- mediaProvider={mediaProvider}
1654
- documentLinkProvider={documentLinkProvider}
1655
- container={versionsContainer ?? undefined}
1656
- allowVersioning={effectiveVersioning}
1657
- viewPreferences={viewPreferences}
1658
- onViewPreferencesChange={handleViewPreferencesChange}
1659
- versionBasename={versionBasename ?? stripExtension(basenameOf(selectedFile))}
1660
- versioningPrunePolicy={versioningPrunePolicy}
1661
- versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
1662
- onSaveVersion={onSaveVersion}
1663
- toolbarSlotLeft={
1664
- effectiveCompact ? (
1665
- <button
1666
- className="db-mobile-back"
1667
- onClick={() => setMobileShowEditor(false)}
1668
- aria-label="Show file list"
1669
- >
1670
- <span className="db-mobile-back-arrow">&larr;</span>
1671
- </button>
1672
- ) : 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
1673
3145
  }
1674
- toolbarSlotRight={
1675
- <>
1676
- {/* Restore split view — only relevant when compact
1677
- layout was manually triggered on a wide viewport.
1678
- On real mobile, side-by-side doesn't fit so the
1679
- button is suppressed. */}
1680
- {compactLayout && !isMobile && (
1681
- <button
1682
- className="db-restore-split"
1683
- onClick={() => setCompactLayout(false)}
1684
- aria-label="Restore split view"
1685
- title="Restore split view"
1686
- >
1687
- <svg
1688
- width="16"
1689
- height="16"
1690
- viewBox="0 0 16 16"
1691
- fill="none"
1692
- stroke="currentColor"
1693
- strokeWidth="1.5"
1694
- strokeLinecap="round"
1695
- strokeLinejoin="round"
1696
- >
1697
- <rect x="1.5" y="2.5" width="13" height="11" rx="1" />
1698
- <line x1="6" y1="2.5" x2="6" y2="13.5" />
1699
- </svg>
1700
- </button>
1701
- )}
1702
- <ExportToolbarControls
1703
- selectedFile={selectedFile}
1704
- mediaContainer={mediaContainerRef.current}
1705
- />
1706
- </>
3146
+ onInstallApp={installPromptEvent ? handleInstallApp : undefined}
3147
+ getStorageEstimate={
3148
+ showBrowserStorageWarning ? getBrowserStorageEstimate : undefined
3149
+ }
3150
+ storagePersistent={
3151
+ showBrowserStorageWarning ? browserStoragePersistent : undefined
3152
+ }
3153
+ />
3154
+ <WorkspacePicker
3155
+ activeWorkspaceId={activeWorkspaceId}
3156
+ onSelect={handleWorkspaceSelect}
3157
+ onOpenFolder={handleOpenFolder}
3158
+ onCloneRepository={
3159
+ git.available ? () => git.openDialog({ kind: 'clone' }) : undefined
1707
3160
  }
1708
3161
  />
1709
- {showWelcomeGateway && (
1710
- <div className="db-welcome-gateway" role="note" aria-label="Welcome tip">
1711
- <span className="db-welcome-gateway-text">
1712
- You&rsquo;re watching this welcome doc in <strong>Play</strong> view —
1713
- it&rsquo;s a regular markdown file, and so is everything you&rsquo;ll write.
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 />
3176
+ </button>
3177
+ )}
3178
+ </div>
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;
1714
3208
  </span>
1715
- <button className="db-welcome-gateway-cta" onClick={handleStartWriting}>
1716
- Start writing
1717
- </button>
1718
3209
  <button
1719
- className="db-welcome-gateway-dismiss"
1720
- onClick={closeWelcomeGateway}
1721
- aria-label="Dismiss welcome tip"
1722
- title="Dismiss"
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."
1723
3214
  >
1724
- &times;
3215
+ Backup browser docs frequently
1725
3216
  </button>
1726
- </div>
1727
- )}
1728
- </MediaContext.Provider>
1729
- ) : selectedFolder ? (
1730
- <div className="db-folder-view">
1731
- {effectiveCompact && (
1732
- <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
1733
- <span className="db-mobile-back-arrow">&larr;</span>
1734
- Back to files
1735
- </button>
1736
- )}
1737
- <div className="db-folder-view-header">
1738
- <span className="db-folder-view-icon">
1739
- <FolderGlyph />
1740
- </span>
1741
- <span className="db-folder-view-path">{selectedFolder}</span>
1742
- </div>
1743
- {folderEntries.length === 0 ? (
1744
- <p className="db-folder-view-empty">This folder is empty.</p>
1745
- ) : (
1746
- <ul className="db-folder-view-list">
1747
- {folderEntries.map((entry) => (
1748
- <li
1749
- key={entry.path}
1750
- className="db-folder-view-item"
1751
- onClick={() => handleSelect(entry.path, entry.kind)}
1752
- >
1753
- <span className="db-folder-view-item-icon">
1754
- {entry.kind === 'directory' ? <FolderGlyph /> : <FileGlyph />}
1755
- </span>
1756
- {entry.name}
1757
- </li>
1758
- ))}
1759
- </ul>
1760
- )}
1761
- </div>
1762
- ) : (
1763
- <div className="db-shell-empty">
1764
- {effectiveCompact && (
1765
- <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
1766
- <span className="db-mobile-back-arrow">&larr;</span>
1767
- Back to files
1768
- </button>
3217
+ </>
1769
3218
  )}
1770
- <p>Select a file to start editing, or create a new one.</p>
1771
3219
  </div>
1772
- )}
1773
- </div>
1774
- )}
1775
- </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>
1776
3396
  </div>
1777
3397
  );
1778
3398
  }