@bendyline/docblocks-react 2.0.0 → 2.1.0

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