@bendyline/docblocks-react 2.0.0 → 2.1.1

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 (310) hide show
  1. package/README.md +18 -3
  2. package/dist/ExportDialog-VYATD5UC.js +9 -0
  3. package/dist/{ExportToolbarControls-D2YDWOXW.js → ExportToolbarControls-5HUE3LSX.js} +206 -42
  4. package/dist/{GitToolbarControl-LMIR4R7I.js → GitToolbarControl-FW3BZFXV.js} +62 -44
  5. package/dist/{GitUI-KGF5MSNV.js → GitUI-GMH3PAYL.js} +225 -141
  6. package/dist/{LazyEditorShell-EF2BWRXU.js → LazyEditorShell-RF54B5UD.js} +0 -1
  7. package/dist/ShareDialog-PAMOQQFB.js +212 -0
  8. package/dist/chunk-3CZ27FOA.js +359 -0
  9. package/dist/chunk-3QSZY74C.js +255 -0
  10. package/{src/Export/export-destination.ts → dist/chunk-BI7NVU6T.js} +16 -15
  11. package/dist/{chunk-VHDP4IPL.js → chunk-BPGEBGAN.js} +0 -1
  12. package/dist/{chunk-DD4VFJQI.js → chunk-EBWYGTN7.js} +2 -1
  13. package/dist/{chunk-ZI56BNB7.js → chunk-GHQ4X7KM.js} +27 -7
  14. package/dist/{chunk-5A4KEWKT.js → chunk-HFYPTTCH.js} +0 -1
  15. package/dist/chunk-LG6HAWCK.js +109 -0
  16. package/dist/chunk-TFMO7EVO.js +88 -0
  17. package/dist/{chunk-4XXSWN3T.js → chunk-YBEYTVU2.js} +0 -1
  18. package/dist/editor.d.ts +5 -4
  19. package/dist/editor.js +8 -70
  20. package/dist/export/index.d.ts +24 -8
  21. package/dist/export/index.js +10 -6
  22. package/dist/index.d.ts +355 -21
  23. package/dist/index.js +6419 -21
  24. package/dist/run-export-Z5BVFLIR.js +9 -0
  25. package/dist/settings/index.d.ts +9 -1
  26. package/dist/settings/index.js +6 -5
  27. package/package.json +18 -13
  28. package/src/styles/docblocks.css +985 -35
  29. package/dist/AppMenu/AppMenu.d.ts +0 -53
  30. package/dist/AppMenu/AppMenu.d.ts.map +0 -1
  31. package/dist/AppMenu/AppMenu.js +0 -67
  32. package/dist/AppMenu/AppMenu.js.map +0 -1
  33. package/dist/AppMenu/index.d.ts +0 -3
  34. package/dist/AppMenu/index.d.ts.map +0 -1
  35. package/dist/AppMenu/index.js +0 -2
  36. package/dist/AppMenu/index.js.map +0 -1
  37. package/dist/DocBlocksShell/DocBlocksShell.d.ts +0 -49
  38. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +0 -1
  39. package/dist/DocBlocksShell/DocBlocksShell.js +0 -2730
  40. package/dist/DocBlocksShell/DocBlocksShell.js.map +0 -1
  41. package/dist/DocBlocksShell/LazyEditorShell.d.ts +0 -3
  42. package/dist/DocBlocksShell/LazyEditorShell.d.ts.map +0 -1
  43. package/dist/DocBlocksShell/LazyEditorShell.js +0 -3
  44. package/dist/DocBlocksShell/LazyEditorShell.js.map +0 -1
  45. package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts +0 -13
  46. package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts.map +0 -1
  47. package/dist/DocBlocksShell/UpdateAvailableNotice.js +0 -16
  48. package/dist/DocBlocksShell/UpdateAvailableNotice.js.map +0 -1
  49. package/dist/DocBlocksShell/document-title.d.ts +0 -3
  50. package/dist/DocBlocksShell/document-title.d.ts.map +0 -1
  51. package/dist/DocBlocksShell/document-title.js +0 -17
  52. package/dist/DocBlocksShell/document-title.js.map +0 -1
  53. package/dist/DocBlocksShell/index.d.ts +0 -3
  54. package/dist/DocBlocksShell/index.d.ts.map +0 -1
  55. package/dist/DocBlocksShell/index.js +0 -2
  56. package/dist/DocBlocksShell/index.js.map +0 -1
  57. package/dist/DocBlocksShell/workspace-authority-barrier.d.ts +0 -14
  58. package/dist/DocBlocksShell/workspace-authority-barrier.d.ts.map +0 -1
  59. package/dist/DocBlocksShell/workspace-authority-barrier.js +0 -23
  60. package/dist/DocBlocksShell/workspace-authority-barrier.js.map +0 -1
  61. package/dist/Export/DeferredExportToolbarControls.d.ts +0 -5
  62. package/dist/Export/DeferredExportToolbarControls.d.ts.map +0 -1
  63. package/dist/Export/DeferredExportToolbarControls.js +0 -10
  64. package/dist/Export/DeferredExportToolbarControls.js.map +0 -1
  65. package/dist/Export/ExportDialog.d.ts +0 -25
  66. package/dist/Export/ExportDialog.d.ts.map +0 -1
  67. package/dist/Export/ExportDialog.js +0 -160
  68. package/dist/Export/ExportDialog.js.map +0 -1
  69. package/dist/Export/ExportToolbarControls.d.ts +0 -37
  70. package/dist/Export/ExportToolbarControls.d.ts.map +0 -1
  71. package/dist/Export/ExportToolbarControls.js +0 -285
  72. package/dist/Export/ExportToolbarControls.js.map +0 -1
  73. package/dist/Export/export-destination.d.ts +0 -3
  74. package/dist/Export/export-destination.d.ts.map +0 -1
  75. package/dist/Export/export-destination.js +0 -32
  76. package/dist/Export/export-destination.js.map +0 -1
  77. package/dist/Export/export-options.d.ts +0 -53
  78. package/dist/Export/export-options.d.ts.map +0 -1
  79. package/dist/Export/export-options.js +0 -49
  80. package/dist/Export/export-options.js.map +0 -1
  81. package/dist/Export/index.d.ts +0 -10
  82. package/dist/Export/index.d.ts.map +0 -1
  83. package/dist/Export/index.js +0 -6
  84. package/dist/Export/index.js.map +0 -1
  85. package/dist/Export/public-api.d.ts +0 -12
  86. package/dist/Export/public-api.d.ts.map +0 -1
  87. package/dist/Export/public-api.js +0 -9
  88. package/dist/Export/public-api.js.map +0 -1
  89. package/dist/Export/run-export.d.ts +0 -10
  90. package/dist/Export/run-export.d.ts.map +0 -1
  91. package/dist/Export/run-export.js +0 -313
  92. package/dist/Export/run-export.js.map +0 -1
  93. package/dist/Export/transform-summaries.d.ts +0 -7
  94. package/dist/Export/transform-summaries.d.ts.map +0 -1
  95. package/dist/Export/transform-summaries.js +0 -6
  96. package/dist/Export/transform-summaries.js.map +0 -1
  97. package/dist/ExportDialog-MEUISWZX.js +0 -9
  98. package/dist/ExportDialog-MEUISWZX.js.map +0 -1
  99. package/dist/ExportToolbarControls-D2YDWOXW.js.map +0 -1
  100. package/dist/FileExplorer/FileExplorer.d.ts +0 -43
  101. package/dist/FileExplorer/FileExplorer.d.ts.map +0 -1
  102. package/dist/FileExplorer/FileExplorer.js +0 -315
  103. package/dist/FileExplorer/FileExplorer.js.map +0 -1
  104. package/dist/FileExplorer/FileTreeNode.d.ts +0 -39
  105. package/dist/FileExplorer/FileTreeNode.d.ts.map +0 -1
  106. package/dist/FileExplorer/FileTreeNode.js +0 -131
  107. package/dist/FileExplorer/FileTreeNode.js.map +0 -1
  108. package/dist/FileExplorer/index.d.ts +0 -6
  109. package/dist/FileExplorer/index.d.ts.map +0 -1
  110. package/dist/FileExplorer/index.js +0 -4
  111. package/dist/FileExplorer/index.js.map +0 -1
  112. package/dist/FileExplorer/useFileTree.d.ts +0 -37
  113. package/dist/FileExplorer/useFileTree.d.ts.map +0 -1
  114. package/dist/FileExplorer/useFileTree.js +0 -284
  115. package/dist/FileExplorer/useFileTree.js.map +0 -1
  116. package/dist/Git/GitAuthGuidanceDialog.d.ts +0 -18
  117. package/dist/Git/GitAuthGuidanceDialog.d.ts.map +0 -1
  118. package/dist/Git/GitAuthGuidanceDialog.js +0 -27
  119. package/dist/Git/GitAuthGuidanceDialog.js.map +0 -1
  120. package/dist/Git/GitBranchesDialog.d.ts +0 -13
  121. package/dist/Git/GitBranchesDialog.d.ts.map +0 -1
  122. package/dist/Git/GitBranchesDialog.js +0 -59
  123. package/dist/Git/GitBranchesDialog.js.map +0 -1
  124. package/dist/Git/GitCloneDialog.d.ts +0 -14
  125. package/dist/Git/GitCloneDialog.d.ts.map +0 -1
  126. package/dist/Git/GitCloneDialog.js +0 -79
  127. package/dist/Git/GitCloneDialog.js.map +0 -1
  128. package/dist/Git/GitCommitDialog.d.ts +0 -12
  129. package/dist/Git/GitCommitDialog.d.ts.map +0 -1
  130. package/dist/Git/GitCommitDialog.js +0 -38
  131. package/dist/Git/GitCommitDialog.js.map +0 -1
  132. package/dist/Git/GitConflictsDialog.d.ts +0 -12
  133. package/dist/Git/GitConflictsDialog.d.ts.map +0 -1
  134. package/dist/Git/GitConflictsDialog.js +0 -17
  135. package/dist/Git/GitConflictsDialog.js.map +0 -1
  136. package/dist/Git/GitContext.d.ts +0 -89
  137. package/dist/Git/GitContext.d.ts.map +0 -1
  138. package/dist/Git/GitContext.js +0 -15
  139. package/dist/Git/GitContext.js.map +0 -1
  140. package/dist/Git/GitDiffDialog.d.ts +0 -20
  141. package/dist/Git/GitDiffDialog.d.ts.map +0 -1
  142. package/dist/Git/GitDiffDialog.js +0 -100
  143. package/dist/Git/GitDiffDialog.js.map +0 -1
  144. package/dist/Git/GitDiffView.d.ts +0 -23
  145. package/dist/Git/GitDiffView.d.ts.map +0 -1
  146. package/dist/Git/GitDiffView.js +0 -77
  147. package/dist/Git/GitDiffView.js.map +0 -1
  148. package/dist/Git/GitHistoryDialog.d.ts +0 -15
  149. package/dist/Git/GitHistoryDialog.d.ts.map +0 -1
  150. package/dist/Git/GitHistoryDialog.js +0 -107
  151. package/dist/Git/GitHistoryDialog.js.map +0 -1
  152. package/dist/Git/GitStatusBar.d.ts +0 -8
  153. package/dist/Git/GitStatusBar.d.ts.map +0 -1
  154. package/dist/Git/GitStatusBar.js +0 -49
  155. package/dist/Git/GitStatusBar.js.map +0 -1
  156. package/dist/Git/GitToolbarControl.d.ts +0 -15
  157. package/dist/Git/GitToolbarControl.d.ts.map +0 -1
  158. package/dist/Git/GitToolbarControl.js +0 -51
  159. package/dist/Git/GitToolbarControl.js.map +0 -1
  160. package/dist/Git/GitUI.d.ts +0 -14
  161. package/dist/Git/GitUI.d.ts.map +0 -1
  162. package/dist/Git/GitUI.js +0 -21
  163. package/dist/Git/GitUI.js.map +0 -1
  164. package/dist/Git/commit-selection.d.ts +0 -22
  165. package/dist/Git/commit-selection.d.ts.map +0 -1
  166. package/dist/Git/commit-selection.js +0 -47
  167. package/dist/Git/commit-selection.js.map +0 -1
  168. package/dist/Git/git-clone-utils.d.ts +0 -8
  169. package/dist/Git/git-clone-utils.d.ts.map +0 -1
  170. package/dist/Git/git-clone-utils.js +0 -26
  171. package/dist/Git/git-clone-utils.js.map +0 -1
  172. package/dist/Git/git-status.d.ts +0 -24
  173. package/dist/Git/git-status.d.ts.map +0 -1
  174. package/dist/Git/git-status.js +0 -105
  175. package/dist/Git/git-status.js.map +0 -1
  176. package/dist/Git/useGit.d.ts +0 -15
  177. package/dist/Git/useGit.d.ts.map +0 -1
  178. package/dist/Git/useGit.js +0 -206
  179. package/dist/Git/useGit.js.map +0 -1
  180. package/dist/Git/useGitStatus.d.ts +0 -17
  181. package/dist/Git/useGitStatus.d.ts.map +0 -1
  182. package/dist/Git/useGitStatus.js +0 -63
  183. package/dist/Git/useGitStatus.js.map +0 -1
  184. package/dist/GitToolbarControl-LMIR4R7I.js.map +0 -1
  185. package/dist/GitUI-KGF5MSNV.js.map +0 -1
  186. package/dist/LazyEditorShell-EF2BWRXU.js.map +0 -1
  187. package/dist/Settings/Settings.d.ts +0 -23
  188. package/dist/Settings/Settings.d.ts.map +0 -1
  189. package/dist/Settings/Settings.js +0 -22
  190. package/dist/Settings/Settings.js.map +0 -1
  191. package/dist/Settings/public-api.d.ts +0 -3
  192. package/dist/Settings/public-api.d.ts.map +0 -1
  193. package/dist/Settings/public-api.js +0 -2
  194. package/dist/Settings/public-api.js.map +0 -1
  195. package/dist/WorkspacePicker/WorkspacePicker.d.ts +0 -22
  196. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +0 -1
  197. package/dist/WorkspacePicker/WorkspacePicker.js +0 -74
  198. package/dist/WorkspacePicker/WorkspacePicker.js.map +0 -1
  199. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +0 -11
  200. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +0 -1
  201. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +0 -27
  202. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +0 -1
  203. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +0 -20
  204. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +0 -1
  205. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +0 -36
  206. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +0 -1
  207. package/dist/WorkspacePicker/index.d.ts +0 -3
  208. package/dist/WorkspacePicker/index.d.ts.map +0 -1
  209. package/dist/WorkspacePicker/index.js +0 -2
  210. package/dist/WorkspacePicker/index.js.map +0 -1
  211. package/dist/chunk-3SAXDU2U.js +0 -157
  212. package/dist/chunk-3SAXDU2U.js.map +0 -1
  213. package/dist/chunk-4XXSWN3T.js.map +0 -1
  214. package/dist/chunk-5A4KEWKT.js.map +0 -1
  215. package/dist/chunk-AOVTHZU7.js +0 -321
  216. package/dist/chunk-AOVTHZU7.js.map +0 -1
  217. package/dist/chunk-DD4VFJQI.js.map +0 -1
  218. package/dist/chunk-GSNZSCIK.js +0 -55
  219. package/dist/chunk-GSNZSCIK.js.map +0 -1
  220. package/dist/chunk-VHDP4IPL.js.map +0 -1
  221. package/dist/chunk-ZI56BNB7.js.map +0 -1
  222. package/dist/components/Dialog.d.ts +0 -22
  223. package/dist/components/Dialog.d.ts.map +0 -1
  224. package/dist/components/Dialog.js +0 -33
  225. package/dist/components/Dialog.js.map +0 -1
  226. package/dist/editor.d.ts.map +0 -1
  227. package/dist/editor.js.map +0 -1
  228. package/dist/export/index.js.map +0 -1
  229. package/dist/hooks/index.d.ts +0 -3
  230. package/dist/hooks/index.d.ts.map +0 -1
  231. package/dist/hooks/index.js +0 -2
  232. package/dist/hooks/index.js.map +0 -1
  233. package/dist/hooks/useDocumentSession.d.ts +0 -16
  234. package/dist/hooks/useDocumentSession.d.ts.map +0 -1
  235. package/dist/hooks/useDocumentSession.js +0 -18
  236. package/dist/hooks/useDocumentSession.js.map +0 -1
  237. package/dist/icons.d.ts +0 -12
  238. package/dist/icons.d.ts.map +0 -1
  239. package/dist/icons.js +0 -28
  240. package/dist/icons.js.map +0 -1
  241. package/dist/index.d.ts.map +0 -1
  242. package/dist/index.js.map +0 -1
  243. package/dist/preferences/theme.d.ts +0 -18
  244. package/dist/preferences/theme.d.ts.map +0 -1
  245. package/dist/preferences/theme.js +0 -63
  246. package/dist/preferences/theme.js.map +0 -1
  247. package/dist/preferences/versioning.d.ts +0 -27
  248. package/dist/preferences/versioning.d.ts.map +0 -1
  249. package/dist/preferences/versioning.js +0 -62
  250. package/dist/preferences/versioning.js.map +0 -1
  251. package/dist/provider-lease.d.ts +0 -10
  252. package/dist/provider-lease.d.ts.map +0 -1
  253. package/dist/provider-lease.js +0 -42
  254. package/dist/provider-lease.js.map +0 -1
  255. package/dist/run-export-ICTZITXC.js +0 -10
  256. package/dist/run-export-ICTZITXC.js.map +0 -1
  257. package/dist/settings/index.js.map +0 -1
  258. package/src/AppMenu/AppMenu.tsx +0 -334
  259. package/src/AppMenu/index.ts +0 -2
  260. package/src/DocBlocksShell/DocBlocksShell.tsx +0 -3398
  261. package/src/DocBlocksShell/LazyEditorShell.ts +0 -2
  262. package/src/DocBlocksShell/UpdateAvailableNotice.tsx +0 -60
  263. package/src/DocBlocksShell/document-title.ts +0 -19
  264. package/src/DocBlocksShell/index.ts +0 -2
  265. package/src/DocBlocksShell/workspace-authority-barrier.ts +0 -26
  266. package/src/Export/DeferredExportToolbarControls.tsx +0 -23
  267. package/src/Export/ExportDialog.tsx +0 -437
  268. package/src/Export/ExportToolbarControls.tsx +0 -511
  269. package/src/Export/export-options.ts +0 -99
  270. package/src/Export/index.ts +0 -19
  271. package/src/Export/public-api.ts +0 -17
  272. package/src/Export/run-export.ts +0 -374
  273. package/src/Export/transform-summaries.ts +0 -14
  274. package/src/FileExplorer/FileExplorer.tsx +0 -528
  275. package/src/FileExplorer/FileTreeNode.tsx +0 -306
  276. package/src/FileExplorer/index.ts +0 -5
  277. package/src/FileExplorer/useFileTree.ts +0 -352
  278. package/src/Git/GitAuthGuidanceDialog.tsx +0 -84
  279. package/src/Git/GitBranchesDialog.tsx +0 -144
  280. package/src/Git/GitCloneDialog.tsx +0 -200
  281. package/src/Git/GitCommitDialog.tsx +0 -119
  282. package/src/Git/GitConflictsDialog.tsx +0 -62
  283. package/src/Git/GitContext.tsx +0 -88
  284. package/src/Git/GitDiffDialog.tsx +0 -147
  285. package/src/Git/GitDiffView.tsx +0 -105
  286. package/src/Git/GitHistoryDialog.tsx +0 -195
  287. package/src/Git/GitStatusBar.tsx +0 -157
  288. package/src/Git/GitToolbarControl.tsx +0 -123
  289. package/src/Git/GitUI.tsx +0 -68
  290. package/src/Git/commit-selection.ts +0 -63
  291. package/src/Git/git-clone-utils.ts +0 -23
  292. package/src/Git/git-status.ts +0 -113
  293. package/src/Git/useGit.ts +0 -279
  294. package/src/Git/useGitStatus.ts +0 -78
  295. package/src/Settings/Settings.tsx +0 -118
  296. package/src/Settings/public-api.ts +0 -8
  297. package/src/WorkspacePicker/WorkspacePicker.tsx +0 -191
  298. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +0 -88
  299. package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +0 -121
  300. package/src/WorkspacePicker/index.ts +0 -2
  301. package/src/components/Dialog.tsx +0 -81
  302. package/src/editor.ts +0 -72
  303. package/src/hooks/index.ts +0 -2
  304. package/src/hooks/useDocumentSession.ts +0 -37
  305. package/src/icons.tsx +0 -48
  306. package/src/index.ts +0 -61
  307. package/src/preferences/theme.ts +0 -67
  308. package/src/preferences/versioning.ts +0 -69
  309. package/src/provider-lease.ts +0 -52
  310. package/src/types/pwa-dom.d.ts +0 -31
@@ -1,3398 +0,0 @@
1
- /**
2
- * DocBlocksShell -- top-level layout component.
3
- *
4
- * Composes the left sidebar (WorkspacePicker + FileExplorer) with
5
- * the center editor area (squisq EditorShell).
6
- */
7
-
8
- import { useState, useCallback, useEffect, useRef, useMemo, lazy, Suspense } from 'react';
9
- import type {
10
- EditorColorScheme,
11
- EditorView,
12
- ViewPreferences,
13
- DocumentLinkProvider,
14
- DocumentLinkCandidate,
15
- } from '@bendyline/squisq-editor-react';
16
- import '@bendyline/squisq-editor-react/styles';
17
- import { MediaContext } from '@bendyline/squisq-react';
18
- import type { MediaProvider } from '@bendyline/squisq/schemas';
19
- import {
20
- DocumentVersionManager,
21
- type PrunePolicy,
22
- type SaveVersionResult,
23
- } from '@bendyline/squisq/versions';
24
- import type {
25
- DbkWorkspaceSnapshot,
26
- ElectronFileSystemProvider,
27
- FileSystemProvider,
28
- FileSystemEntry,
29
- MemoryFileSystemProvider,
30
- } from '@bendyline/docblocks/filesystem';
31
- import {
32
- createDbkWorkspaceSnapshot,
33
- FileSystemContentContainer,
34
- createFileMediaProvider,
35
- FsError,
36
- getFileSystemProviderV2,
37
- isQuotaExceededError,
38
- parseWorkspacePath,
39
- workspacePathContains,
40
- } from '@bendyline/docblocks/filesystem';
41
- import {
42
- createFileSystemDocumentTarget,
43
- DocumentCommitConflictError,
44
- DocumentSessionConflictError,
45
- type DocumentCommitTarget,
46
- type DocumentSessionEditScope,
47
- } from '@bendyline/docblocks/document';
48
- import type { ContentContainer } from '@bendyline/squisq/storage';
49
- import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
50
- import type { ElectronWorkspaceInfo, OpenRequest } from '@bendyline/docblocks/host';
51
- import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
52
- import {
53
- ensureDefaultWorkspace,
54
- getWorkspace,
55
- listWorkspaces,
56
- reconcileElectronWorkspaceDescriptors,
57
- removeWorkspace,
58
- saveWorkspace,
59
- touchWorkspace,
60
- registerTransientWorkspace,
61
- getTransientWorkspace,
62
- unregisterTransientWorkspace,
63
- } from '@bendyline/docblocks/workspace';
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';
71
- import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
72
- import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
73
- import { SplitViewIcon } from '../icons.js';
74
- import {
75
- WorkspaceSettingsDialog,
76
- type WorkspaceVersioningOverride,
77
- } from '../WorkspacePicker/WorkspaceSettingsDialog.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
- );
102
- import {
103
- loadVersioningPreference,
104
- resolveVersioningEnabled,
105
- saveVersioningPreference,
106
- type VersioningPreference,
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
- }
207
-
208
- export interface DocBlocksShellProps {
209
- /** Optional theme override. Omit or pass 'auto' to follow OS preference. */
210
- theme?: EditorColorScheme | 'auto';
211
- /** Optional logo image URL for the app menu. */
212
- logoUrl?: string;
213
- /**
214
- * Enable document version history. Snapshots are written under
215
- * `<basename>_files/.versions/` next to each markdown file (i.e., the
216
- * per-document container). Defaults to `true`.
217
- */
218
- allowVersioning?: boolean;
219
- /**
220
- * Override the document basename used in version filenames. Defaults
221
- * to the basename of the currently selected file.
222
- */
223
- versionBasename?: string;
224
- /** Prune policy applied after each successful save. Default: keep last 50. */
225
- versioningPrunePolicy?: PrunePolicy;
226
- /** Idle delay (ms) before the editor auto-saves a version. `0` disables. Default 5000. */
227
- versioningAutoSaveIdleMs?: number;
228
- /** Notified after each `saveVersion` attempt (saved=true and saved=false). */
229
- onSaveVersion?: (result: SaveVersionResult) => void;
230
- /** Optional escape hatch for hosts that want imperative access to the version manager. */
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;
245
- }
246
-
247
- function useOsTheme(): 'light' | 'dark' {
248
- const [dark, setDark] = useState(
249
- () =>
250
- typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches,
251
- );
252
- useEffect(() => {
253
- const mq = window.matchMedia('(prefers-color-scheme: dark)');
254
- const handler = (e: MediaQueryListEvent) => setDark(e.matches);
255
- mq.addEventListener('change', handler);
256
- return () => mq.removeEventListener('change', handler);
257
- }, []);
258
- return dark ? 'dark' : 'light';
259
- }
260
-
261
- /** Encode workspace + optional file path into a URL hash. */
262
- function buildHash(workspaceId: string, filePath?: string | null): string {
263
- const ws = encodeURIComponent(workspaceId);
264
- if (!filePath) return `#${ws}`;
265
- const fp = filePath.replace(/^\//, '');
266
- return `#${ws}/${encodeURIComponent(fp)}`;
267
- }
268
-
269
- /** Decode the current URL hash into workspace id + file path. */
270
- function parseHash(): { workspaceId: string; filePath: string | null } | null {
271
- const raw = window.location.hash.slice(1);
272
- if (!raw) return null;
273
- const slashIdx = raw.indexOf('/');
274
- if (slashIdx === -1) {
275
- return { workspaceId: decodeURIComponent(raw), filePath: null };
276
- }
277
- return {
278
- workspaceId: decodeURIComponent(raw.slice(0, slashIdx)),
279
- filePath: '/' + decodeURIComponent(raw.slice(slashIdx + 1)),
280
- };
281
- }
282
-
283
- const LAST_STATE_KEY = 'docblocks:lastState';
284
-
285
- interface LastState {
286
- workspaceId: string;
287
- filePath: string;
288
- view: EditorView;
289
- }
290
-
291
- function saveLastState(state: LastState): void {
292
- try {
293
- localStorage.setItem(LAST_STATE_KEY, JSON.stringify(state));
294
- } catch {
295
- // ignore quota errors
296
- }
297
- }
298
-
299
- function loadLastState(): LastState | null {
300
- try {
301
- const raw = localStorage.getItem(LAST_STATE_KEY);
302
- if (!raw) return null;
303
- return JSON.parse(raw);
304
- } catch {
305
- return null;
306
- }
307
- }
308
-
309
- /** One-time first-run callout shown over the welcome doc's Play view.
310
- * Once the user starts writing, switches views themselves, or dismisses
311
- * it, it never comes back -- on any workspace. */
312
- const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
313
-
314
- function isWelcomeGatewayDismissed(): boolean {
315
- try {
316
- return localStorage.getItem(WELCOME_GATEWAY_KEY) === '1';
317
- } catch {
318
- return false;
319
- }
320
- }
321
-
322
- function markWelcomeGatewayDismissed(): void {
323
- try {
324
- localStorage.setItem(WELCOME_GATEWAY_KEY, '1');
325
- } catch {
326
- // ignore quota errors
327
- }
328
- }
329
-
330
- const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
331
- const SIDEBAR_WIDTH_DEFAULT = 320;
332
- const SIDEBAR_WIDTH_MIN = 320;
333
- const SIDEBAR_WIDTH_MAX = 600;
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;
338
-
339
- function loadSidebarWidth(): number {
340
- try {
341
- const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
342
- if (raw === null) return SIDEBAR_WIDTH_DEFAULT;
343
- const n = Number(raw);
344
- if (!Number.isFinite(n)) return SIDEBAR_WIDTH_DEFAULT;
345
- return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n));
346
- } catch {
347
- return SIDEBAR_WIDTH_DEFAULT;
348
- }
349
- }
350
-
351
- function saveSidebarWidth(px: number): void {
352
- try {
353
- localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px)));
354
- } catch {
355
- // ignore quota errors
356
- }
357
- }
358
-
359
- const VIEW_PREFS_KEY = 'docblocks:viewPreferences';
360
-
361
- const DEFAULT_VIEW_PREFS: ViewPreferences = {
362
- outline: false,
363
- inlinePreview: true,
364
- showStatusBar: true,
365
- };
366
-
367
- function loadViewPreferences(): ViewPreferences {
368
- try {
369
- const raw = localStorage.getItem(VIEW_PREFS_KEY);
370
- if (!raw) return DEFAULT_VIEW_PREFS;
371
- const parsed = JSON.parse(raw) as Partial<ViewPreferences>;
372
- return {
373
- outline: typeof parsed.outline === 'boolean' ? parsed.outline : DEFAULT_VIEW_PREFS.outline,
374
- inlinePreview:
375
- typeof parsed.inlinePreview === 'boolean'
376
- ? parsed.inlinePreview
377
- : DEFAULT_VIEW_PREFS.inlinePreview,
378
- showStatusBar:
379
- typeof parsed.showStatusBar === 'boolean'
380
- ? parsed.showStatusBar
381
- : DEFAULT_VIEW_PREFS.showStatusBar,
382
- };
383
- } catch {
384
- return DEFAULT_VIEW_PREFS;
385
- }
386
- }
387
-
388
- function saveViewPreferences(prefs: ViewPreferences): void {
389
- try {
390
- localStorage.setItem(VIEW_PREFS_KEY, JSON.stringify(prefs));
391
- } catch {
392
- // ignore quota errors
393
- }
394
- }
395
-
396
- function dirnameOf(p: string): string {
397
- const clean = p.replace(/^\/+/, '');
398
- const idx = clean.lastIndexOf('/');
399
- return idx === -1 ? '' : clean.slice(0, idx);
400
- }
401
-
402
- function basenameOf(p: string): string {
403
- const clean = p.replace(/^\/+/, '');
404
- const idx = clean.lastIndexOf('/');
405
- return idx === -1 ? clean : clean.slice(idx + 1);
406
- }
407
-
408
- /** Strip the extension from a filename (`notes.md` -> `notes`). Matches
409
- * squisq's `getDocBasename` convention so version snapshot filenames
410
- * stay readable (`<basename>.<timestamp>.md`). */
411
- function stripExtension(name: string): string {
412
- return name.replace(/\.[^.]+$/, '');
413
- }
414
-
415
- function normaliseProviderPath(p: string): string {
416
- const canonical = parseWorkspacePath(p);
417
- return canonical ? `/${canonical}` : '/';
418
- }
419
-
420
- function sameProviderPath(a: string, b: string): boolean {
421
- return normaliseProviderPath(a) === normaliseProviderPath(b);
422
- }
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
-
548
- /** Portable relative link from one workspace file to another. Walks up
549
- * from the source's directory and back down to the target so the link
550
- * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
551
- * `resume.md` for siblings at the workspace root). */
552
- function relativeMarkdownLink(fromFile: string, toFile: string): string {
553
- const fromParts = fromFile.replace(/^\/+/, '').split('/').filter(Boolean);
554
- const toParts = toFile.replace(/^\/+/, '').split('/').filter(Boolean);
555
- const fromDir = fromParts.slice(0, -1);
556
- const toDir = toParts.slice(0, -1);
557
- const toBase = toParts[toParts.length - 1] ?? '';
558
- let common = 0;
559
- while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
560
- common++;
561
- }
562
- const ups = fromDir.length - common;
563
- const downs = [...toDir.slice(common), toBase];
564
- const parts = [...Array(ups).fill('..'), ...downs];
565
- return parts.length === 0 ? toBase : parts.join('/');
566
- }
567
-
568
- /** Recursively collect all `.md` files reachable from `root`. Skips
569
- * Word-style `*_files/` asset companions, dotfiles, and `node_modules`
570
- * so the candidate list stays workspace-meaningful. */
571
- async function collectMarkdownFiles(
572
- fs: FileSystemProvider,
573
- root: string,
574
- ): Promise<FileSystemEntry[]> {
575
- const out: FileSystemEntry[] = [];
576
- const visited = new Set<string>();
577
-
578
- async function walk(dir: string): Promise<void> {
579
- if (visited.has(dir)) return;
580
- visited.add(dir);
581
- let entries: FileSystemEntry[];
582
- try {
583
- entries = await readProviderDirectory(fs, dir);
584
- } catch (error: unknown) {
585
- if (error instanceof FsError && error.code === 'not-found') return;
586
- throw error;
587
- }
588
- for (const entry of entries) {
589
- if (entry.kind === 'directory') {
590
- const lower = entry.name.toLowerCase();
591
- if (lower.startsWith('.')) continue;
592
- if (lower === 'node_modules') continue;
593
- if (lower.endsWith('_files')) continue;
594
- await walk(entry.path);
595
- } else if (entry.kind === 'file') {
596
- if (entry.name.toLowerCase().endsWith('.md')) out.push(entry);
597
- }
598
- }
599
- }
600
-
601
- await walk(root);
602
- return out;
603
- }
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
-
654
- async function createElectronProviderFromWorkspace(
655
- ws: WorkspaceDescriptor,
656
- ): Promise<ElectronFileSystemProvider | null> {
657
- if (!ws.rootPath) return null;
658
- try {
659
- await getDocBlocksHost().workspaces.register(ws.id);
660
- } catch {
661
- return null;
662
- }
663
- return createElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
664
- }
665
-
666
- function useIsMobile(breakpoint = 768): boolean {
667
- const [isMobile, setIsMobile] = useState(
668
- () =>
669
- typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches,
670
- );
671
- useEffect(() => {
672
- const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
673
- const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
674
- mq.addEventListener('change', handler);
675
- return () => mq.removeEventListener('change', handler);
676
- }, [breakpoint]);
677
- return isMobile;
678
- }
679
-
680
- function FolderGlyph() {
681
- return (
682
- <svg
683
- viewBox="0 0 24 24"
684
- fill="none"
685
- stroke="currentColor"
686
- strokeWidth="1.5"
687
- strokeLinejoin="round"
688
- aria-hidden="true"
689
- >
690
- <path d="M3 7a1 1 0 0 1 1-1h5l2 2h9a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7z" />
691
- </svg>
692
- );
693
- }
694
-
695
- function FileGlyph() {
696
- return (
697
- <svg
698
- viewBox="0 0 24 24"
699
- fill="none"
700
- stroke="currentColor"
701
- strokeWidth="1.5"
702
- strokeLinejoin="round"
703
- aria-hidden="true"
704
- >
705
- <path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
706
- <path d="M14 3v5h5" />
707
- </svg>
708
- );
709
- }
710
-
711
- export function DocBlocksShell({
712
- theme: _themeProp = 'auto',
713
- logoUrl,
714
- allowVersioning = true,
715
- versionBasename,
716
- versioningPrunePolicy,
717
- versioningAutoSaveIdleMs,
718
- onSaveVersion,
719
- versioningRef,
720
- updateAvailable = false,
721
- onApplyUpdate,
722
- offlineReady = false,
723
- }: DocBlocksShellProps) {
724
- const osTheme = useOsTheme();
725
- const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
726
- const [accentColor, setAccentColor] = useState<AccentColor>(loadAccentColor);
727
- // "System default" (auto) always follows the OS -- the host's theme prop
728
- // is kept only for API back-compat and does not override OS detection.
729
- const resolvedTheme: 'light' | 'dark' =
730
- themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
731
-
732
- const handleThemeChange = useCallback((pref: ThemePreference) => {
733
- setThemePreference(pref);
734
- saveThemePreference(pref);
735
- }, []);
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
-
755
- const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
756
- const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
757
- setViewPreferences(prefs);
758
- saveViewPreferences(prefs);
759
- }, []);
760
- const isMobile = useIsMobile();
761
- const [mobileShowEditor, setMobileShowEditor] = useState(false);
762
- // Sidebar width -- persisted across sessions, dragged via the resizer
763
- // between sidebar and editor area. We track the "live" width during a
764
- // drag in a ref so each mousemove doesn't trigger a state update; only
765
- // setState on commit so React doesn't churn through every pixel.
766
- const [sidebarWidth, setSidebarWidth] = useState<number>(loadSidebarWidth);
767
- // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
768
- // we switch the layout into single-pane "compact" mode -- same UX as
769
- // the mobile narrow-viewport flow, where only the sidebar OR the
770
- // editor is visible at a time and a back-arrow in the toolbar pops
771
- // between them. A "Restore split view" button in either pane's header
772
- // exits compact mode; on real mobile that button is suppressed
773
- // because there's not enough viewport for side-by-side. Not
774
- // persisted across reloads. */
775
- const [compactLayout, setCompactLayout] = useState(false);
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
- );
784
- const sidebarRef = useRef<HTMLDivElement>(null);
785
- const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
786
- const handleResizerPointerDown = useCallback(
787
- (e: React.PointerEvent<HTMLDivElement>) => {
788
- if (e.button !== 0) return;
789
- dragStateRef.current = { startX: e.clientX, startWidth: sidebarWidth };
790
- e.preventDefault();
791
- // Disable text selection + flip the body cursor for the duration
792
- // of the drag so the col-resize cursor stays visible even when the
793
- // pointer slips off the 7px hit area. The custom cursor lives in
794
- // the CSS class so Windows' white-cursor preference doesn't make
795
- // the dragging cursor invisible against the light chrome.
796
- document.body.style.userSelect = 'none';
797
- document.body.classList.add('db-resizing-sidebar');
798
- let lastRaw = sidebarWidth;
799
- const onMove = (ev: PointerEvent) => {
800
- const drag = dragStateRef.current;
801
- if (!drag) return;
802
- lastRaw = drag.startWidth + (ev.clientX - drag.startX);
803
- if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
804
- // Below threshold -- preview the collapse by snapping to the
805
- // minimum width and fading the sidebar, so the user can see
806
- // they've crossed into "release to collapse" territory.
807
- sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
808
- sidebarRef.current.style.opacity = '0.45';
809
- return;
810
- }
811
- const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, lastRaw));
812
- if (sidebarRef.current) {
813
- // Update the DOM directly during the drag for jank-free
814
- // dragging; React state syncs on release.
815
- sidebarRef.current.style.width = `${clamped}px`;
816
- sidebarRef.current.style.opacity = '';
817
- }
818
- };
819
- const onUp = () => {
820
- document.removeEventListener('pointermove', onMove);
821
- document.removeEventListener('pointerup', onUp);
822
- document.body.style.userSelect = '';
823
- document.body.classList.remove('db-resizing-sidebar');
824
- if (sidebarRef.current) {
825
- sidebarRef.current.style.opacity = '';
826
- }
827
- if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
828
- // Released below threshold -- switch to compact (single-pane)
829
- // layout focused on the document pane. Keep the persisted
830
- // sidebarWidth so exiting compact mode restores it.
831
- setCompactLayout(true);
832
- setMobileShowEditor(true);
833
- } else {
834
- const finalWidth = sidebarRef.current?.getBoundingClientRect().width;
835
- if (finalWidth) {
836
- const clamped = Math.min(
837
- SIDEBAR_WIDTH_MAX,
838
- Math.max(SIDEBAR_WIDTH_MIN, Math.round(finalWidth)),
839
- );
840
- setSidebarWidth(clamped);
841
- saveSidebarWidth(clamped);
842
- }
843
- }
844
- dragStateRef.current = null;
845
- };
846
- document.addEventListener('pointermove', onMove);
847
- document.addEventListener('pointerup', onUp);
848
- },
849
- [sidebarWidth],
850
- );
851
- const [provider, setProvider] = useState<FileSystemProvider | null>(null);
852
- const [workspaceStartupError, setWorkspaceStartupError] = useState<string | null>(null);
853
- const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
854
- const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] =
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]);
861
- // Re-fetch the descriptor whenever the active id (or its versioning
862
- // override) changes. `descriptorRefreshKey` is bumped after writes so
863
- // the resolver picks up the updated override without remounting.
864
- const [descriptorRefreshKey, setDescriptorRefreshKey] = useState(0);
865
- useEffect(() => {
866
- let cancelled = false;
867
- if (!activeWorkspaceId) {
868
- setActiveWorkspaceDescriptor(null);
869
- return;
870
- }
871
- void getWorkspace(activeWorkspaceId).then((ws) => {
872
- if (!cancelled) setActiveWorkspaceDescriptor(ws);
873
- });
874
- return () => {
875
- cancelled = true;
876
- };
877
- }, [activeWorkspaceId, descriptorRefreshKey]);
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
-
892
- const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
893
- const [versioningPreference, setVersioningPreference] =
894
- useState<VersioningPreference>(loadVersioningPreference);
895
- const handleVersioningPreferenceChange = useCallback((pref: VersioningPreference) => {
896
- setVersioningPreference(pref);
897
- saveVersioningPreference(pref);
898
- }, []);
899
- const effectiveVersioning =
900
- allowVersioning && resolveVersioningEnabled(activeWorkspaceDescriptor, versioningPreference);
901
-
902
- const handleOpenWorkspaceSettings = useCallback(() => {
903
- if (!activeWorkspaceDescriptor) return;
904
- setWorkspaceSettingsOpen(true);
905
- }, [activeWorkspaceDescriptor]);
906
-
907
- const handleWorkspaceVersioningOverrideChange = useCallback(
908
- async (override: WorkspaceVersioningOverride) => {
909
- if (!activeWorkspaceDescriptor) return;
910
- await saveWorkspace({ ...activeWorkspaceDescriptor, versioningOverride: override });
911
- setDescriptorRefreshKey((k) => k + 1);
912
- setWorkspaceSettingsOpen(false);
913
- },
914
- [activeWorkspaceDescriptor],
915
- );
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]);
930
- const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
931
- const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
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]);
953
- const [explorerKey, setExplorerKey] = useState(0);
954
- const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
955
- // First-run gateway over the welcome doc's Play view -- see WELCOME_GATEWAY_KEY.
956
- const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
957
- const navigationRequestRef = useRef(0);
958
- const workspaceAuthorityBarrier = useMemo(() => new WorkspaceAuthorityBarrier(), []);
959
- const preparedCloseRequestRef = useRef<string | null>(null);
960
- const pendingDbkConflictsRef = useRef(new Map<string, PendingDbkConflict>());
961
-
962
- const createDocumentTarget = useCallback(
963
- (
964
- fsProvider: FileSystemProvider,
965
- workspaceId: string,
966
- filePath: string,
967
- ): DocumentCommitTarget => {
968
- const transient = getTransientWorkspace(workspaceId);
969
- const baseTarget = createFileSystemDocumentTarget(fsProvider, filePath);
970
-
971
- const originKind = transient?.descriptor.origin?.kind;
972
- const needsElectronHost = originKind === 'loose-file' || originKind === 'dbk';
973
- if (!transient?.descriptor.origin || (needsElectronHost && !isElectronHost())) {
974
- return {
975
- key: baseTarget.key,
976
- async commit(request) {
977
- const result = await baseTarget.commit(request);
978
- gitScheduleRefresh();
979
- return result;
980
- },
981
- };
982
- }
983
-
984
- const origin = transient.descriptor.origin;
985
-
986
- if (origin.kind === 'web-file' || origin.kind === 'web-dbk') {
987
- // Launched-file origins (installed web app, File Handling API):
988
- // read and write through the FileSystemFileHandle instead of the
989
- // Electron host bridge, with the same optimistic external-change
990
- // detection as the host-backed paths below.
991
- const webOrigin = origin;
992
- const transientProvider = transient.provider;
993
- return {
994
- key: baseTarget.key,
995
- async commit(request) {
996
- if (!(await ensureHandleWritePermission(webOrigin.handle))) {
997
- throw new Error(
998
- `DocBlocks needs permission to write to "${webOrigin.name}". Save again to grant it.`,
999
- );
1000
- }
1001
- if (webOrigin.kind === 'web-file') {
1002
- const current = await (await webOrigin.handle.getFile()).text();
1003
- if (
1004
- request.persistedContent !== null &&
1005
- current !== request.persistedContent &&
1006
- current !== request.content
1007
- ) {
1008
- pendingDbkConflictsRef.current.delete(baseTarget.key);
1009
- await writeProviderText(fsProvider, filePath, current);
1010
- throw new DocumentCommitConflictError(
1011
- 'The original file changed outside DocBlocks.',
1012
- current,
1013
- null,
1014
- );
1015
- }
1016
- const writable = await webOrigin.handle.createWritable();
1017
- await writable.write(request.content);
1018
- await writable.close();
1019
- await baseTarget.commit(request);
1020
- pendingDbkConflictsRef.current.delete(baseTarget.key);
1021
- gitScheduleRefresh();
1022
- return {};
1023
- }
1024
- // web-dbk: package the in-memory workspace into a zip and write
1025
- // it back through the handle, guarded by a SHA-256
1026
- // compare-and-swap that mirrors the Electron dbk path.
1027
- const [{ MemoryContentContainer }, { containerToZip, zipToContainer }] =
1028
- await Promise.all([
1029
- import('@bendyline/squisq/storage'),
1030
- import('@bendyline/squisq-formats/container'),
1031
- ]);
1032
- const currentBytes = await (await webOrigin.handle.getFile()).arrayBuffer();
1033
- const currentVersion = await sha256Hex(currentBytes);
1034
- if (webOrigin.version !== null && currentVersion !== webOrigin.version) {
1035
- if (!isMemoryWorkspaceProvider(transientProvider)) {
1036
- throw new Error('A transient DBK workspace must use in-memory storage.');
1037
- }
1038
- const externalContainer = await zipToContainer(currentBytes);
1039
- const snapshot = await createDbkWorkspaceSnapshot(externalContainer, {
1040
- targetDocumentPath: filePath,
1041
- });
1042
- // Keep both branches intact until the user chooses -- same
1043
- // semantics as the Electron dbk conflict staging below.
1044
- pendingDbkConflictsRef.current.set(baseTarget.key, {
1045
- provider: transientProvider,
1046
- snapshot,
1047
- });
1048
- webOrigin.version = currentVersion;
1049
- throw new DocumentCommitConflictError(
1050
- 'The original DocBlocks bundle changed outside DocBlocks.',
1051
- snapshot.documentContent,
1052
- currentVersion,
1053
- );
1054
- }
1055
- const container = new MemoryContentContainer();
1056
- await copyProviderToContainer(transientProvider, container, '');
1057
- await container.writeFile(
1058
- filePath.replace(/^\/+/, ''),
1059
- new TextEncoder().encode(request.content),
1060
- );
1061
- const blob = await containerToZip(container);
1062
- const bytes = await blob.arrayBuffer();
1063
- const writable = await webOrigin.handle.createWritable();
1064
- await writable.write(bytes);
1065
- await writable.close();
1066
- webOrigin.version = await sha256Hex(bytes);
1067
- // The transient provider intentionally retained the complete
1068
- // local branch while external bundle B was staged for conflict
1069
- // resolution. Once bundle C is committed to the origin, advance
1070
- // that internal markdown baseline unconditionally; comparing its
1071
- // old A content against request.persistedContent B would report a
1072
- // false second conflict after the durable write already succeeded.
1073
- await writeProviderText(fsProvider, filePath, request.content);
1074
- pendingDbkConflictsRef.current.delete(baseTarget.key);
1075
- gitScheduleRefresh();
1076
- return { version: webOrigin.version };
1077
- },
1078
- };
1079
- }
1080
-
1081
- return {
1082
- key: baseTarget.key,
1083
- async commit(request) {
1084
- const host = getDocBlocksHost();
1085
- if (origin.kind === 'loose-file') {
1086
- const result = await host.external.commitText(
1087
- origin.resourceId,
1088
- request.content,
1089
- request.persistedContent,
1090
- );
1091
- if (result.status === 'conflict') {
1092
- pendingDbkConflictsRef.current.delete(baseTarget.key);
1093
- if (result.content === null) await removeProviderEntry(fsProvider, filePath);
1094
- else await writeProviderText(fsProvider, filePath, result.content);
1095
- throw new DocumentCommitConflictError(
1096
- 'The original file changed outside DocBlocks.',
1097
- result.content,
1098
- result.version,
1099
- );
1100
- }
1101
- await baseTarget.commit(request);
1102
- pendingDbkConflictsRef.current.delete(baseTarget.key);
1103
- gitScheduleRefresh();
1104
- return { version: result.version };
1105
- } else {
1106
- const [{ MemoryContentContainer }, { containerToZip, zipToContainer }] =
1107
- await Promise.all([
1108
- import('@bendyline/squisq/storage'),
1109
- import('@bendyline/squisq-formats/container'),
1110
- ]);
1111
- const container = new MemoryContentContainer();
1112
- await copyProviderToContainer(transient.provider, container, '');
1113
- await container.writeFile(
1114
- filePath.replace(/^\/+/, ''),
1115
- new TextEncoder().encode(request.content),
1116
- );
1117
- const blob = await containerToZip(container);
1118
- const result = await host.external.commitBinary(
1119
- origin.resourceId,
1120
- await blob.arrayBuffer(),
1121
- origin.version,
1122
- );
1123
- if (result.status === 'conflict') {
1124
- let externalContent: string | null = null;
1125
- if (result.data) {
1126
- const externalContainer = await zipToContainer(result.data);
1127
- if (!isMemoryWorkspaceProvider(transient.provider)) {
1128
- throw new Error('A transient DBK workspace must use in-memory storage.');
1129
- }
1130
- const snapshot = await createDbkWorkspaceSnapshot(externalContainer, {
1131
- targetDocumentPath: filePath,
1132
- });
1133
- externalContent = snapshot.documentContent;
1134
- // Keep both branches intact until the user chooses. Reload
1135
- // replaces the whole tree; Keep mine packages this untouched
1136
- // local provider against the newly observed bundle version.
1137
- pendingDbkConflictsRef.current.set(baseTarget.key, {
1138
- provider: transient.provider,
1139
- snapshot,
1140
- });
1141
- } else {
1142
- pendingDbkConflictsRef.current.delete(baseTarget.key);
1143
- }
1144
- // Advance the optimistic origin only after the external bundle
1145
- // has been completely staged. A parse/read failure must retry
1146
- // against the old version rather than silently authorize an
1147
- // overwrite of data we could not reconcile.
1148
- origin.version = result.version;
1149
- throw new DocumentCommitConflictError(
1150
- 'The original DocBlocks bundle changed outside DocBlocks.',
1151
- externalContent,
1152
- result.version,
1153
- );
1154
- }
1155
- origin.version = result.version;
1156
- // As with web-dbk above, the in-memory provider still represents
1157
- // the preserved local branch rather than the external conflict
1158
- // baseline. The origin commit is the conditional authority check;
1159
- // now make the internal document agree with that acknowledged C
1160
- // bundle without re-comparing A against B.
1161
- await writeProviderText(fsProvider, filePath, request.content);
1162
- pendingDbkConflictsRef.current.delete(baseTarget.key);
1163
- gitScheduleRefresh();
1164
- return { version: result.version };
1165
- }
1166
- },
1167
- };
1168
- },
1169
- [gitScheduleRefresh],
1170
- );
1171
-
1172
- /**
1173
- * Per-file media container: for `notes.md`, images live in
1174
- * `notes_files/` next to the markdown. Created lazily on first write,
1175
- * picked up automatically if it already exists. Set up by the effect
1176
- * below whenever the selected file (or backing provider) changes.
1177
- */
1178
- const mediaContainerRef = useRef<ContentContainer | null>(null);
1179
- const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
1180
- const [mediaEpoch, setMediaEpoch] = useState(0);
1181
- /**
1182
- * Per-document container scoped to `<basename>_files/`. This is what
1183
- * the editor uses for version history (`.versions/` lives here) and
1184
- * for audio mapping (MP3 / timing.json discovery). Distinct from
1185
- * `mediaContainerRef` which is scoped to the parent directory so the
1186
- * media provider can write `notes_files/image.png` paths that stay
1187
- * portable in the markdown.
1188
- */
1189
- const versionsContainerRef = useRef<ContentContainer | null>(null);
1190
- const [versionsContainer, setVersionsContainer] = useState<ContentContainer | null>(null);
1191
-
1192
- /** Cache of .md files in the active workspace, used to power the
1193
- * squisq link-dialog's document picker. Lazily populated on first
1194
- * provider call; cleared whenever the backing filesystem changes so
1195
- * workspace switches don't surface stale neighbours. A pending Promise
1196
- * during in-flight walks lets concurrent calls share the same scan. */
1197
- const mdFileCacheRef = useRef<Promise<FileSystemEntry[]> | null>(null);
1198
-
1199
- /** Push a new history entry with the given hash. */
1200
- const pushHash = useCallback((wsId: string, filePath?: string | null) => {
1201
- const hash = buildHash(wsId, filePath);
1202
- if (window.location.hash !== hash) {
1203
- window.history.pushState(null, '', hash);
1204
- }
1205
- }, []);
1206
-
1207
- /** Open a workspace (and optionally a file) from identifiers.
1208
- * Returns the FileSystemProvider on success, or null on failure. */
1209
- const openFromIds = useCallback(
1210
- async (
1211
- wsId: string,
1212
- filePath: string | null,
1213
- push: boolean,
1214
- view?: EditorView,
1215
- navigationRequestId?: number,
1216
- ): Promise<FileSystemProvider | null> => {
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;
1223
- let fsProvider: FileSystemProvider | null = null;
1224
- if (transient) {
1225
- ws = transient.descriptor;
1226
- fsProvider = transient.provider;
1227
- } else {
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
- }
1241
- }
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
- }
1260
-
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);
1276
- setSelectedFolder(null);
1277
- setFolderEntries([]);
1278
- const effectiveView = view ?? 'wysiwyg';
1279
- setInitialView(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
- }
1285
- } else {
1286
- setSelectedFile(null);
1287
- setSelectedFolder(null);
1288
- setFolderEntries([]);
1289
- }
1290
-
1291
- if (push) {
1292
- pushHash(ws.id, openedFile);
1293
- }
1294
- return fsProvider;
1295
- } finally {
1296
- if (!transient && !adopted) await getFileSystemProviderV2(fsProvider)?.dispose();
1297
- }
1298
- },
1299
- [createDocumentTarget, documentSession, pushHash],
1300
- );
1301
-
1302
- const seedWelcomeFile = useCallback(
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;
1309
-
1310
- // If the only file is the welcome doc, auto-select it.
1311
- // Match either casing so workspaces seeded before the rename
1312
- // (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
1313
- if (
1314
- entries.length === 1 &&
1315
- entries[0].kind === 'file' &&
1316
- entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
1317
- ) {
1318
- const aboutPath = entries[0].path;
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;
1323
- setSelectedFile(aboutPath);
1324
- setInitialView('preview');
1325
- setExplorerKey((k) => k + 1);
1326
- pushHash(fs.id, aboutPath);
1327
- saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
1328
- if (!isWelcomeGatewayDismissed()) setShowWelcomeGateway(true);
1329
- }
1330
- return;
1331
- }
1332
-
1333
- if (entries.length > 0) return;
1334
-
1335
- const welcomePath = '/aboutDocBlocks.md';
1336
- const welcomeContent = [
1337
- '# Welcome to DocBlocks',
1338
- '',
1339
- 'Your markdown can do anything.',
1340
- '',
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.',
1342
- '',
1343
- 'Simple to write. Beautiful wherever it goes.',
1344
- '',
1345
- '## Features',
1346
- '',
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',
1352
- '',
1353
- '## Getting Started',
1354
- '',
1355
- '1. Create a new file using the **New File** button in the sidebar',
1356
- '2. Start writing in markdown -- the editor supports headings, lists, links, images, and more',
1357
- '3. Your work is saved automatically',
1358
- '',
1359
- 'Built on [Squiggly Square markdown extensions](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
1360
- ].join('\n');
1361
-
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;
1377
- setSelectedFile(welcomePath);
1378
- setInitialView('preview');
1379
- setExplorerKey((k) => k + 1);
1380
- pushHash(fs.id, welcomePath);
1381
- saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
1382
- if (!isWelcomeGatewayDismissed()) setShowWelcomeGateway(true);
1383
- },
1384
- [createDocumentTarget, documentSession, pushHash],
1385
- );
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
-
1395
- /** Hide the welcome gateway and never show it again. Safe to call from
1396
- * paths where it may not be showing -- only persists when it was. */
1397
- const closeWelcomeGateway = useCallback(() => {
1398
- setShowWelcomeGateway((showing) => {
1399
- if (showing) markWelcomeGatewayDismissed();
1400
- return false;
1401
- });
1402
- }, []);
1403
-
1404
- /** Gateway CTA -- flip the welcome doc from Play into the editor. */
1405
- const handleStartWriting = useCallback(() => {
1406
- closeWelcomeGateway();
1407
- setInitialView('wysiwyg');
1408
- setEditorPresentationEpoch((epoch) => epoch + 1);
1409
- if (activeWorkspaceId && selectedFile) {
1410
- saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
1411
- }
1412
- }, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
1413
-
1414
- // Initialise workspace on mount -- restore from hash or last-used
1415
- useEffect(() => {
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
-
1453
- // Try restoring from URL hash first
1454
- const hashState = parseHash();
1455
- if (hashState) {
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,
1463
- hashState.filePath,
1464
- false,
1465
- undefined,
1466
- requestId,
1467
- );
1468
- if (!isCurrent()) return;
1469
- if (restoredProvider) {
1470
- // If the hash had no file, check whether we should auto-select the welcome doc
1471
- if (!hashState.filePath) {
1472
- await startupSeedWelcomeFileRef.current(restoredProvider, requestId);
1473
- }
1474
- return;
1475
- }
1476
- }
1477
-
1478
- // Try restoring last viewed document from localStorage
1479
- const lastState = loadLastState();
1480
- if (lastState) {
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,
1488
- lastState.filePath,
1489
- true,
1490
- lastState.view,
1491
- requestId,
1492
- );
1493
- if (!isCurrent()) return;
1494
- if (restoredProvider) return;
1495
- }
1496
-
1497
- let fsProvider: FileSystemProvider | null = null;
1498
-
1499
- const workspaces = await listWorkspaces();
1500
- if (!isCurrent()) return;
1501
- // On desktop, hide web-only workspaces (indexeddb/native) -- only
1502
- // folder-based workspaces are valid.
1503
- const candidates = electron
1504
- ? workspaces.filter((w) => w.type === 'electron-native')
1505
- : workspaces.filter((w) => w.type !== 'electron-native');
1506
- // Pick the most recently opened workspace
1507
- const sorted = [...candidates].sort((a, b) =>
1508
- (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''),
1509
- );
1510
- for (const ws of sorted) {
1511
- if (ws.type === 'electron-native') {
1512
- const p = await createElectronProviderFromWorkspace(ws);
1513
- if (!p) continue;
1514
- if (!isCurrent()) {
1515
- await getFileSystemProviderV2(p)?.dispose();
1516
- return;
1517
- }
1518
- await touchWorkspace(ws.id);
1519
- if (!isCurrent()) {
1520
- await getFileSystemProviderV2(p)?.dispose();
1521
- return;
1522
- }
1523
- fsProvider = p;
1524
- break;
1525
- } else if (ws.type === 'native') {
1526
- const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
1527
- if (restored) {
1528
- if (!isCurrent()) {
1529
- await getFileSystemProviderV2(restored)?.dispose();
1530
- return;
1531
- }
1532
- await touchWorkspace(ws.id);
1533
- if (!isCurrent()) {
1534
- await getFileSystemProviderV2(restored)?.dispose();
1535
- return;
1536
- }
1537
- fsProvider = restored;
1538
- break;
1539
- }
1540
- } else {
1541
- const p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
1542
- if (!isCurrent()) {
1543
- await getFileSystemProviderV2(p)?.dispose();
1544
- return;
1545
- }
1546
- await touchWorkspace(ws.id);
1547
- if (!isCurrent()) {
1548
- await getFileSystemProviderV2(p)?.dispose();
1549
- return;
1550
- }
1551
- fsProvider = p;
1552
- break;
1553
- }
1554
- }
1555
-
1556
- if (!fsProvider) {
1557
- if (electron) {
1558
- // Desktop: ask the host for the default folder workspace
1559
- // (creates DocBlocks in the OS-resolved Documents folder on first launch).
1560
- const info = await getDocBlocksHost().workspaces.getDefault();
1561
- if (!isCurrent()) return;
1562
- const descriptor: WorkspaceDescriptor = {
1563
- id: info.id,
1564
- name: info.name,
1565
- type: 'electron-native',
1566
- rootPath: info.rootPath,
1567
- lastOpened: new Date().toISOString(),
1568
- };
1569
- await saveWorkspace(descriptor);
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
- }
1576
- fsProvider = p;
1577
- } else {
1578
- // Web: create the IndexedDB default workspace
1579
- const defaultWs = await ensureDefaultWorkspace();
1580
- if (!isCurrent()) return;
1581
- const p = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
1582
- if (!isCurrent()) {
1583
- await getFileSystemProviderV2(p)?.dispose();
1584
- return;
1585
- }
1586
- fsProvider = p;
1587
- }
1588
- }
1589
-
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
- });
1611
-
1612
- return () => {
1613
- cancelled = true;
1614
- if (navigationRequestRef.current === requestId) navigationRequestRef.current += 1;
1615
- };
1616
- }, [pushHash, workspaceAuthorityBarrier]);
1617
-
1618
- // Handle browser back/forward
1619
- useEffect(() => {
1620
- const onPopState = () => {
1621
- const hashState = parseHash();
1622
- if (hashState) {
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
- });
1640
- }
1641
- };
1642
- window.addEventListener('popstate', onPopState);
1643
- return () => window.removeEventListener('popstate', onPopState);
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]);
1713
-
1714
- // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
1715
- useEffect(() => {
1716
- const handler = (e: MouseEvent) => {
1717
- const target = (e.target as HTMLElement).closest?.('[data-view]');
1718
- if (target) {
1719
- const view = target.getAttribute('data-view') as EditorView;
1720
- if (view) {
1721
- // The user found the view tabs on their own -- the gateway's job is done.
1722
- closeWelcomeGateway();
1723
- }
1724
- if (view && activeWorkspaceId && selectedFile) {
1725
- saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
1726
- }
1727
- }
1728
- };
1729
- window.addEventListener('click', handler, true);
1730
- return () => window.removeEventListener('click', handler, true);
1731
- }, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
1732
-
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);
1739
- const saveToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
1740
- useEffect(() => {
1741
- const onKey = (e: KeyboardEvent) => {
1742
- const sKey = e.key === 's' || e.key === 'S';
1743
- const accel = e.ctrlKey || e.metaKey;
1744
- if (!sKey || !accel || e.altKey) return;
1745
- e.preventDefault();
1746
- e.stopPropagation();
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
- })();
1764
- };
1765
- window.addEventListener('keydown', onKey, true);
1766
- return () => window.removeEventListener('keydown', onKey, true);
1767
- }, [documentSession]);
1768
- useEffect(() => {
1769
- return () => {
1770
- if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
1771
- };
1772
- }, []);
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
-
1824
- // Per-file media: for `notes.md` images live in `notes_files/` beside it.
1825
- // Rebuilds whenever the provider or selected file changes.
1826
- useEffect(() => {
1827
- if (!provider || !selectedFile) {
1828
- mediaContainerRef.current = null;
1829
- versionsContainerRef.current = null;
1830
- setMediaProvider(null);
1831
- setVersionsContainer(null);
1832
- return;
1833
- }
1834
- const parentDir = dirnameOf(selectedFile);
1835
- const base = basenameOf(selectedFile);
1836
- const baseNoExt = base.replace(/\.[^.]+$/, '');
1837
- const container = new FileSystemContentContainer(provider, parentDir);
1838
- const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
1839
- const vContainer = new FileSystemContentContainer(provider, vPrefix);
1840
- const mp = createFileMediaProvider(container, base);
1841
- mediaContainerRef.current = container;
1842
- versionsContainerRef.current = vContainer;
1843
- setMediaProvider(mp);
1844
- setVersionsContainer(vContainer);
1845
- return () => {
1846
- mp.dispose();
1847
- };
1848
- }, [provider, selectedFile, mediaEpoch]);
1849
-
1850
- // Invalidate the document-link candidate cache when the backing
1851
- // workspace changes -- otherwise the link dialog would surface
1852
- // neighbours from a previously-open workspace.
1853
- useEffect(() => {
1854
- mdFileCacheRef.current = null;
1855
- }, [provider]);
1856
-
1857
- /** Powers the squisq link dialog's "Browse documents" picker. Returns
1858
- * workspace `.md` neighbours filtered by `query`, with paths expressed
1859
- * relative to the currently-open document so the link survives folder
1860
- * moves. The first call seeds an in-memory cache; subsequent calls
1861
- * filter against it. */
1862
- const documentLinkProvider = useCallback<DocumentLinkProvider>(
1863
- async (query: string): Promise<DocumentLinkCandidate[]> => {
1864
- if (!provider || !selectedFile) return [];
1865
- if (!mdFileCacheRef.current) {
1866
- mdFileCacheRef.current = collectMarkdownFiles(provider, '').catch(() => []);
1867
- }
1868
- const entries = await mdFileCacheRef.current;
1869
- const q = query.trim().toLowerCase();
1870
- const candidates: DocumentLinkCandidate[] = [];
1871
- for (const entry of entries) {
1872
- if (entry.kind !== 'file') continue;
1873
- if (sameProviderPath(entry.path, selectedFile)) continue;
1874
- const label = entry.name.replace(/\.md$/i, '');
1875
- const path = relativeMarkdownLink(selectedFile, entry.path);
1876
- if (q && !label.toLowerCase().includes(q) && !path.toLowerCase().includes(q)) continue;
1877
- const dir = dirnameOf(entry.path);
1878
- candidates.push(dir ? { path, label, description: dir } : { path, label });
1879
- }
1880
- // Stable alphabetical order keeps the picker predictable across
1881
- // re-opens; the dialog can re-sort or rank on its own if needed.
1882
- candidates.sort((a, b) => a.label.localeCompare(b.label));
1883
- return candidates;
1884
- },
1885
- [provider, selectedFile],
1886
- );
1887
-
1888
- // Expose a DocumentVersionManager via versioningRef when requested.
1889
- useEffect(() => {
1890
- const ref = versioningRef;
1891
- if (!ref) return;
1892
- const assign = (mgr: DocumentVersionManager | null) => {
1893
- if (typeof ref === 'function') ref(mgr);
1894
- else (ref as React.MutableRefObject<DocumentVersionManager | null>).current = mgr;
1895
- };
1896
- if (!effectiveVersioning || !versionsContainer || !selectedFile) {
1897
- assign(null);
1898
- return;
1899
- }
1900
- const base = stripExtension(basenameOf(selectedFile));
1901
- const mgr = new DocumentVersionManager(versionsContainer, {
1902
- basename: versionBasename ?? base,
1903
- });
1904
- assign(mgr);
1905
- return () => assign(null);
1906
- }, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
1907
-
1908
- // React to external file changes watched by the Electron host (chokidar).
1909
- useEffect(() => {
1910
- if (!provider) return;
1911
- const providerV2 = getFileSystemProviderV2(provider);
1912
- if (!providerV2?.capabilities.watch) return;
1913
- if (!selectedFile || !documentSnapshot.targetKey) return;
1914
- const watchedFile = selectedFile;
1915
- const targetKey = documentSnapshot.targetKey;
1916
- let disposed = false;
1917
- let reading = false;
1918
- let rerun = false;
1919
- let observationSequence = 0;
1920
-
1921
- const drainWatcherReads = async () => {
1922
- if (reading) {
1923
- rerun = true;
1924
- return;
1925
- }
1926
- reading = true;
1927
- try {
1928
- do {
1929
- rerun = false;
1930
- const sequence = ++observationSequence;
1931
- const external = await readStableFileSnapshot(provider, watchedFile);
1932
- if (disposed) return;
1933
- const result = documentSession.observeExternal({
1934
- targetKey,
1935
- sequence,
1936
- content: external.content,
1937
- version: external.version,
1938
- });
1939
- if (result === 'applied') {
1940
- // observeExternal increments the session generation. The editor
1941
- // remount key and content both derive from that snapshot.
1942
- }
1943
- } while (rerun && !disposed);
1944
- } catch {
1945
- // The next watcher event will retry a file that was changing too
1946
- // quickly to obtain a stable content/metadata pair.
1947
- } finally {
1948
- reading = false;
1949
- if (rerun && !disposed) void drainWatcherReads();
1950
- }
1951
- };
1952
-
1953
- const subscription = providerV2.watch(
1954
- (event) => {
1955
- if (event.type === 'overflow') {
1956
- void drainWatcherReads();
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
2117
- return;
2118
- }
2119
- nextProvider = restored;
2120
- } else {
2121
- nextProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
2122
- }
2123
- if (requestId !== navigationRequestRef.current) return;
2124
- if (!(await transitionAwayFromDocument(requestId))) return;
2125
- setProvider(nextProvider);
2126
- setActiveWorkspaceId(ws.id);
2127
- setSelectedFile(null);
2128
- setSelectedFolder(null);
2129
- setFolderEntries([]);
2130
- pushHash(ws.id, null);
2131
- },
2132
- [pushHash, transitionAwayFromDocument],
2133
- );
2134
-
2135
- const handleOpenFolder = useCallback(async () => {
2136
- const requestId = ++navigationRequestRef.current;
2137
- try {
2138
- if (isElectronHost()) {
2139
- const info = await getDocBlocksHost().workspaces.pickFolder();
2140
- if (!info) return; // user cancelled
2141
- if (requestId !== navigationRequestRef.current) return;
2142
- const descriptor: WorkspaceDescriptor = {
2143
- id: info.id,
2144
- name: info.name,
2145
- type: 'electron-native',
2146
- rootPath: info.rootPath,
2147
- lastOpened: new Date().toISOString(),
2148
- };
2149
- await saveWorkspace(descriptor);
2150
- if (requestId !== navigationRequestRef.current) return;
2151
- const provider = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
2152
- if (!(await transitionAwayFromDocument(requestId))) return;
2153
- setProvider(provider);
2154
- setActiveWorkspaceId(descriptor.id);
2155
- setSelectedFile(null);
2156
- setSelectedFolder(null);
2157
- setFolderEntries([]);
2158
- pushHash(descriptor.id, null);
2159
- return;
2160
- }
2161
-
2162
- const nativeProvider = await (await loadNativeFileSystem()).openNativeFolder();
2163
- if (requestId !== navigationRequestRef.current) return;
2164
- const descriptor: WorkspaceDescriptor = {
2165
- id: nativeProvider.id,
2166
- name: nativeProvider.label,
2167
- type: 'native',
2168
- lastOpened: new Date().toISOString(),
2169
- };
2170
- await saveWorkspace(descriptor);
2171
- if (requestId !== navigationRequestRef.current) return;
2172
- if (!(await transitionAwayFromDocument(requestId))) return;
2173
- setProvider(nativeProvider);
2174
- setActiveWorkspaceId(descriptor.id);
2175
- setSelectedFile(null);
2176
- setSelectedFolder(null);
2177
- setFolderEntries([]);
2178
- pushHash(descriptor.id, null);
2179
- } catch {
2180
- // User cancelled or API not supported
2181
- }
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
- );
2215
-
2216
- const handleNewFile = useCallback(async () => {
2217
- if (!provider) return;
2218
- const name = prompt('New document name:', 'Untitled.md');
2219
- if (!name) return;
2220
- const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
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);
2260
- setInitialView('wysiwyg');
2261
- setExplorerKey((k) => k + 1);
2262
- closeWelcomeGateway();
2263
- if (activeWorkspaceId) {
2264
- pushHash(activeWorkspaceId, '/' + filename);
2265
- }
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]);
2294
-
2295
- const handleRevealWorkspace = useCallback(async () => {
2296
- if (!isElectronHost() || !activeWorkspaceId) return;
2297
- const ws = await getWorkspace(activeWorkspaceId);
2298
- if (ws?.type === 'electron-native' && ws.rootPath) {
2299
- await getDocBlocksHost().shell.revealInFolder(ws.id);
2300
- }
2301
- }, [activeWorkspaceId]);
2302
-
2303
- // Subscribe to native menu commands (Electron host).
2304
- useEffect(() => {
2305
- if (!isElectronHost()) return;
2306
- const host = getDocBlocksHost();
2307
- return host.onMenuCommand((cmd) => {
2308
- switch (cmd) {
2309
- case 'file:new':
2310
- handleNewFile();
2311
- break;
2312
- case 'file:openFolder':
2313
- handleOpenFolder();
2314
- break;
2315
- case 'file:revealWorkspace':
2316
- handleRevealWorkspace();
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;
2350
- case 'help:viewOnGitHub':
2351
- host.shell.openExternal('https://github.com/bendyline/docblocks');
2352
- break;
2353
- case 'help:checkForUpdates':
2354
- host.updater.checkForUpdates().catch(() => {
2355
- // errors surface via updater.onStatus
2356
- });
2357
- break;
2358
- case 'help:about':
2359
- (async () => {
2360
- const version = await host.updater.getVersion();
2361
- alert(`DocBlocks ${version}`);
2362
- })();
2363
- break;
2364
- default:
2365
- break;
2366
- }
2367
- });
2368
- }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
2369
-
2370
- // OS open-file / deep-link handling lives after the container helpers it
2371
- // depends on -- see the `openTransient` + onOpenRequest effect below.
2372
-
2373
- const handleSelect = useCallback(
2374
- async (path: string, kind: 'file' | 'directory') => {
2375
- if (!provider || !activeWorkspaceId) return;
2376
- const requestId = ++navigationRequestRef.current;
2377
-
2378
- if (kind === 'directory') {
2379
- if (!(await transitionAwayFromDocument(requestId))) return;
2380
- if (requestId !== navigationRequestRef.current) return;
2381
- setSelectedFile(null);
2382
- setSelectedFolder(path);
2383
- const entries = await readProviderDirectory(provider, path);
2384
- if (requestId !== navigationRequestRef.current) return;
2385
- setFolderEntries(entries);
2386
- pushHash(activeWorkspaceId, null);
2387
- } else {
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;
2405
- setSelectedFile(path);
2406
- setSelectedFolder(null);
2407
- setFolderEntries([]);
2408
- setInitialView('wysiwyg');
2409
- closeWelcomeGateway();
2410
- pushHash(activeWorkspaceId, path);
2411
- saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
2412
- if (effectiveCompact) setMobileShowEditor(true);
2413
- }
2414
- },
2415
- [
2416
- provider,
2417
- activeWorkspaceId,
2418
- pushHash,
2419
- effectiveCompact,
2420
- closeWelcomeGateway,
2421
- createDocumentTarget,
2422
- documentSession,
2423
- transitionAwayFromDocument,
2424
- ],
2425
- );
2426
-
2427
- const handleTreeMutation = useCallback<FileTreeMutationHandler>(
2428
- async (change, mutate) => {
2429
- if (!provider || !activeWorkspaceId || !selectedFile) {
2430
- await mutate();
2431
- return;
2432
- }
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
- );
2499
-
2500
- /**
2501
- * Copy non-markdown files from an imported container into the target
2502
- * markdown file's `_files/` sibling folder, preserving the source's
2503
- * relative structure.
2504
- */
2505
- const persistImportedMedia = useCallback(
2506
- async (
2507
- source: {
2508
- listFiles(): Promise<Array<{ path: string; mimeType: string }>>;
2509
- readFile(path: string): Promise<ArrayBuffer | null>;
2510
- },
2511
- target: FileSystemProvider,
2512
- importedMarkdownPath: string,
2513
- ) => {
2514
- const parentDir = dirnameOf(importedMarkdownPath);
2515
- const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
2516
- const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
2517
- const entries = await source.listFiles();
2518
- const targetV2 = getFileSystemProviderV2(target);
2519
- for (const entry of entries) {
2520
- if (entry.path.endsWith('.md')) continue;
2521
- const data = await source.readFile(entry.path);
2522
- if (!data) continue;
2523
- const cleanPath = entry.path.replace(/^\/+/, '');
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
- }
2533
- }
2534
- },
2535
- [],
2536
- );
2537
-
2538
- const handleImportFiles = useCallback(
2539
- async (files: File[]) => {
2540
- if (!provider) return;
2541
- for (const file of files) {
2542
- const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
2543
- const baseName = file.name.replace(/\.[^.]+$/, '');
2544
- const destPath = `${baseName}.md`;
2545
-
2546
- try {
2547
- let markdown: string;
2548
- if (ext === '.md' || ext === '.txt') {
2549
- markdown = await file.text();
2550
- } else if (ext === '.docx') {
2551
- const { docxToContainer } = await import('@bendyline/squisq-formats/docx');
2552
- const container = await docxToContainer(await file.arrayBuffer());
2553
- markdown = (await container.readDocument()) ?? '';
2554
- await persistImportedMedia(container, provider, destPath);
2555
- } else if (ext === '.pdf') {
2556
- const { pdfToContainer } = await import('@bendyline/squisq-formats/pdf');
2557
- const container = await pdfToContainer(await file.arrayBuffer());
2558
- markdown = (await container.readDocument()) ?? '';
2559
- await persistImportedMedia(container, provider, destPath);
2560
- } else if (ext === '.dbk' || ext === '.zip') {
2561
- const { zipToContainer } = await import('@bendyline/squisq-formats/container');
2562
- const container = await zipToContainer(await file.arrayBuffer());
2563
- markdown = (await container.readDocument()) ?? '';
2564
- await persistImportedMedia(container, provider, destPath);
2565
- } else {
2566
- continue;
2567
- }
2568
-
2569
- await writeProviderText(provider, destPath, markdown);
2570
- } catch (err) {
2571
- console.error(`Failed to import ${file.name}:`, err);
2572
- }
2573
- }
2574
- // Refresh file tree so the new markdown files + _files folders show up.
2575
- setExplorerKey((k) => k + 1);
2576
- },
2577
- [provider, persistImportedMedia],
2578
- );
2579
-
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
- );
2592
-
2593
- const handleRenameWorkspace = useCallback(async () => {
2594
- if (!activeWorkspaceId) return;
2595
- const ws = await getWorkspace(activeWorkspaceId);
2596
- if (!ws) return;
2597
- const newName = prompt('Rename workspace:', ws.name);
2598
- if (!newName || newName === ws.name) return;
2599
- await saveWorkspace({ ...ws, name: newName });
2600
- setDescriptorRefreshKey((key) => key + 1);
2601
- }, [activeWorkspaceId]);
2602
-
2603
- /**
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.
2607
- */
2608
- const openTransient = useCallback(
2609
- async (
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.');
2644
- }
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.');
2653
- }
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();
2689
- }
2690
- } finally {
2691
- if (!opened) await revokeAbandonedResource();
2692
- }
2693
- }
2694
- },
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],
2784
- );
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
-
2809
- const handleDownloadWorkspace = useCallback(async () => {
2810
- if (!provider) return;
2811
- try {
2812
- await documentSession.flush('backup');
2813
- const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
2814
- import('@bendyline/squisq/storage'),
2815
- import('@bendyline/squisq-formats/container'),
2816
- ]);
2817
-
2818
- const container = new MemoryContentContainer();
2819
- await copyProviderToContainer(provider, container, '');
2820
-
2821
- const blob = await containerToZip(container);
2822
- const url = URL.createObjectURL(blob);
2823
- const a = document.createElement('a');
2824
- a.href = url;
2825
- const safeName =
2826
- (provider.label || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
2827
- a.download = `${safeName}.zip`;
2828
- a.click();
2829
- URL.revokeObjectURL(url);
2830
- } catch (err) {
2831
- console.error('Failed to download workspace', err);
2832
- alert('Failed to download workspace. See console for details.');
2833
- }
2834
- }, [provider, documentSession]);
2835
-
2836
- /**
2837
- * Bundle every workspace the host can open without further prompting
2838
- * into a single zip, with each workspace nested under its own folder.
2839
- * Native (browser-picked) workspaces whose handle hasn't been re-granted
2840
- * for this session are skipped -- restoring them would require a user
2841
- * gesture per workspace.
2842
- */
2843
- const handleDownloadAllWorkspaces = useCallback(async () => {
2844
- try {
2845
- await documentSession.flush('backup');
2846
- const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
2847
- import('@bendyline/squisq/storage'),
2848
- import('@bendyline/squisq-formats/container'),
2849
- ]);
2850
-
2851
- const electron = isElectronHost();
2852
- const all = await listWorkspaces();
2853
- const candidates = all.filter((w) =>
2854
- electron
2855
- ? w.type === 'electron-native' || w.type === 'transient'
2856
- : w.type !== 'electron-native',
2857
- );
2858
- if (candidates.length === 0) {
2859
- alert('No workspaces to download.');
2860
- return;
2861
- }
2862
-
2863
- const container = new MemoryContentContainer();
2864
- const usedFolders = new Set<string>();
2865
- const skipped: string[] = [];
2866
-
2867
- for (const ws of candidates) {
2868
- let p: FileSystemProvider | null = null;
2869
- let ownsProvider = false;
2870
- try {
2871
- const transient = getTransientWorkspace(ws.id);
2872
- if (transient) {
2873
- p = transient.provider;
2874
- } else if (ws.type === 'electron-native') {
2875
- p = await createElectronProviderFromWorkspace(ws);
2876
- ownsProvider = p !== null;
2877
- } else if (ws.type === 'native') {
2878
- // Restore without prompting -- only succeeds when the browser
2879
- // still remembers the granted handle for this origin/session.
2880
- p = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
2881
- ownsProvider = p !== null;
2882
- } else {
2883
- p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
2884
- ownsProvider = true;
2885
- }
2886
- } catch (err) {
2887
- console.warn(`Skipping workspace ${ws.name}:`, err);
2888
- }
2889
-
2890
- if (!p) {
2891
- skipped.push(ws.name);
2892
- continue;
2893
- }
2894
-
2895
- // Pick a unique, filesystem-safe folder name per workspace.
2896
- const base = (ws.name || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
2897
- let folder = base;
2898
- let suffix = 2;
2899
- while (usedFolders.has(folder)) {
2900
- folder = `${base} (${suffix++})`;
2901
- }
2902
- usedFolders.add(folder);
2903
-
2904
- try {
2905
- await copyProviderToContainer(p, container, folder);
2906
- } finally {
2907
- if (ownsProvider) await getFileSystemProviderV2(p)?.dispose();
2908
- }
2909
- }
2910
-
2911
- if (usedFolders.size === 0) {
2912
- alert('No workspaces could be opened for download.');
2913
- return;
2914
- }
2915
-
2916
- const blob = await containerToZip(container);
2917
- const url = URL.createObjectURL(blob);
2918
- const a = document.createElement('a');
2919
- a.href = url;
2920
- const stamp = new Date().toISOString().slice(0, 10);
2921
- a.download = `docblocks-workspaces-${stamp}.zip`;
2922
- a.click();
2923
- URL.revokeObjectURL(url);
2924
-
2925
- if (skipped.length > 0) {
2926
- alert(
2927
- `Downloaded ${usedFolders.size} workspace(s). Skipped ${skipped.length} that require re-granting access: ${skipped.join(', ')}.`,
2928
- );
2929
- }
2930
- } catch (err) {
2931
- console.error('Failed to download all workspaces', err);
2932
- alert('Failed to download all workspaces. See console for details.');
2933
- }
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
- }, []);
2987
-
2988
- const handleRemoveWorkspace = useCallback(async () => {
2989
- if (!activeWorkspaceId) return;
2990
- const confirmMsg = isElectronHost()
2991
- ? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
2992
- : 'Remove this workspace? This cannot be undone.';
2993
- if (!confirm(confirmMsg)) return;
2994
- const requestId = ++navigationRequestRef.current;
2995
- if (!(await transitionAwayFromDocument(requestId))) return;
2996
-
2997
- const ws = await getWorkspace(activeWorkspaceId);
2998
- if (ws?.type === 'electron-native') {
2999
- try {
3000
- await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
3001
- } catch {
3002
- // ignore -- host cleanup is best-effort
3003
- }
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);
3015
- }
3016
- await removeWorkspace(activeWorkspaceId);
3017
- if (requestId !== navigationRequestRef.current) return;
3018
-
3019
- const electron = isElectronHost();
3020
- // Switch to most recent remaining workspace or create default
3021
- const remaining = (await listWorkspaces()).filter((w) =>
3022
- electron ? w.type === 'electron-native' : w.type !== 'electron-native',
3023
- );
3024
- if (remaining.length > 0) {
3025
- const next = remaining[0];
3026
- await handleWorkspaceSelect(next);
3027
- } else if (electron) {
3028
- const info = await getDocBlocksHost().workspaces.getDefault();
3029
- const descriptor: WorkspaceDescriptor = {
3030
- id: info.id,
3031
- name: info.name,
3032
- type: 'electron-native',
3033
- rootPath: info.rootPath,
3034
- lastOpened: new Date().toISOString(),
3035
- };
3036
- await saveWorkspace(descriptor);
3037
- const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
3038
- setProvider(p);
3039
- setActiveWorkspaceId(info.id);
3040
- setSelectedFile(null);
3041
- setSelectedFolder(null);
3042
- setFolderEntries([]);
3043
- } else {
3044
- const defaultWs = await ensureDefaultWorkspace();
3045
- const fsProvider = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
3046
- setProvider(fsProvider);
3047
- setActiveWorkspaceId(defaultWs.id);
3048
- setSelectedFile(null);
3049
- setSelectedFolder(null);
3050
- setFolderEntries([]);
3051
- }
3052
- }, [activeWorkspaceId, handleWorkspaceSelect, transitionAwayFromDocument]);
3053
-
3054
- return (
3055
- <div
3056
- className={`db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`}
3057
- data-theme={resolvedTheme}
3058
- data-accent={accentColor}
3059
- data-document-status={documentSnapshot.status}
3060
- >
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 && (
3068
- <div
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'}
3072
- >
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>
3093
- </div>
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>
3109
- </div>
3110
- </div>
3111
- )}
3112
- {workspaceSettingsOpen && activeWorkspaceDescriptor && (
3113
- <WorkspaceSettingsDialog
3114
- workspace={activeWorkspaceDescriptor}
3115
- globalVersioningPreference={versioningPreference}
3116
- onChange={handleWorkspaceVersioningOverrideChange}
3117
- onClose={() => setWorkspaceSettingsOpen(false)}
3118
- />
3119
- )}
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
3145
- }
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
3160
- }
3161
- />
3162
- <WorkspaceSettingsButton
3163
- onSettings={handleOpenWorkspaceSettings}
3164
- onRename={handleRenameWorkspace}
3165
- onDownload={handleDownloadWorkspace}
3166
- onRemove={handleRemoveWorkspace}
3167
- />
3168
- {compactLayout && !isMobile && (
3169
- <button
3170
- className="db-restore-split"
3171
- onClick={() => setCompactLayout(false)}
3172
- aria-label="Restore split view"
3173
- title="Restore split view"
3174
- >
3175
- <SplitViewIcon />
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;
3208
- </span>
3209
- <button
3210
- type="button"
3211
- className="db-shell-sidebar-footer-action"
3212
- onClick={() => void handleDownloadAllWorkspaces()}
3213
- title="Browser docs can get auto-removed. Download all workspaces."
3214
- >
3215
- Backup browser docs frequently
3216
- </button>
3217
- </>
3218
- )}
3219
- </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>
3396
- </div>
3397
- );
3398
- }