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