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