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