@bendyline/docblocks-react 1.1.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -5
- package/dist/AppMenu/AppMenu.d.ts +29 -2
- package/dist/AppMenu/AppMenu.d.ts.map +1 -1
- package/dist/AppMenu/AppMenu.js +41 -2
- package/dist/AppMenu/AppMenu.js.map +1 -1
- package/dist/AppMenu/index.d.ts +1 -1
- package/dist/AppMenu/index.d.ts.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.d.ts +15 -2
- package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
- package/dist/DocBlocksShell/DocBlocksShell.js +1677 -298
- package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
- package/dist/DocBlocksShell/LazyEditorShell.d.ts +3 -0
- package/dist/DocBlocksShell/LazyEditorShell.d.ts.map +1 -0
- package/dist/DocBlocksShell/LazyEditorShell.js +3 -0
- package/dist/DocBlocksShell/LazyEditorShell.js.map +1 -0
- package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts +13 -0
- package/dist/DocBlocksShell/UpdateAvailableNotice.d.ts.map +1 -0
- package/dist/DocBlocksShell/UpdateAvailableNotice.js +16 -0
- package/dist/DocBlocksShell/UpdateAvailableNotice.js.map +1 -0
- package/dist/DocBlocksShell/document-title.d.ts +3 -0
- package/dist/DocBlocksShell/document-title.d.ts.map +1 -0
- package/dist/DocBlocksShell/document-title.js +17 -0
- package/dist/DocBlocksShell/document-title.js.map +1 -0
- package/dist/DocBlocksShell/workspace-authority-barrier.d.ts +14 -0
- package/dist/DocBlocksShell/workspace-authority-barrier.d.ts.map +1 -0
- package/dist/DocBlocksShell/workspace-authority-barrier.js +23 -0
- package/dist/DocBlocksShell/workspace-authority-barrier.js.map +1 -0
- package/dist/Export/DeferredExportToolbarControls.d.ts +5 -0
- package/dist/Export/DeferredExportToolbarControls.d.ts.map +1 -0
- package/dist/Export/DeferredExportToolbarControls.js +10 -0
- package/dist/Export/DeferredExportToolbarControls.js.map +1 -0
- package/dist/Export/ExportDialog.d.ts +10 -1
- package/dist/Export/ExportDialog.d.ts.map +1 -1
- package/dist/Export/ExportDialog.js +20 -18
- package/dist/Export/ExportDialog.js.map +1 -1
- package/dist/Export/ExportToolbarControls.d.ts +21 -1
- package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
- package/dist/Export/ExportToolbarControls.js +96 -11
- package/dist/Export/ExportToolbarControls.js.map +1 -1
- package/dist/Export/export-destination.d.ts +3 -0
- package/dist/Export/export-destination.d.ts.map +1 -0
- package/dist/Export/export-destination.js +32 -0
- package/dist/Export/export-destination.js.map +1 -0
- package/dist/Export/index.d.ts +8 -4
- package/dist/Export/index.d.ts.map +1 -1
- package/dist/Export/index.js +4 -1
- package/dist/Export/index.js.map +1 -1
- package/dist/Export/public-api.d.ts +12 -0
- package/dist/Export/public-api.d.ts.map +1 -0
- package/dist/Export/public-api.js +9 -0
- package/dist/Export/public-api.js.map +1 -0
- package/dist/Export/run-export.d.ts +3 -1
- package/dist/Export/run-export.d.ts.map +1 -1
- package/dist/Export/run-export.js +30 -13
- package/dist/Export/run-export.js.map +1 -1
- package/dist/ExportDialog-MEUISWZX.js +9 -0
- package/dist/ExportDialog-MEUISWZX.js.map +1 -0
- package/dist/ExportToolbarControls-D2YDWOXW.js +373 -0
- package/dist/ExportToolbarControls-D2YDWOXW.js.map +1 -0
- package/dist/FileExplorer/FileExplorer.d.ts +26 -4
- package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
- package/dist/FileExplorer/FileExplorer.js +204 -29
- package/dist/FileExplorer/FileExplorer.js.map +1 -1
- package/dist/FileExplorer/FileTreeNode.d.ts +24 -3
- package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
- package/dist/FileExplorer/FileTreeNode.js +27 -8
- package/dist/FileExplorer/FileTreeNode.js.map +1 -1
- package/dist/FileExplorer/index.d.ts +1 -1
- package/dist/FileExplorer/index.d.ts.map +1 -1
- package/dist/FileExplorer/useFileTree.d.ts +5 -3
- package/dist/FileExplorer/useFileTree.d.ts.map +1 -1
- package/dist/FileExplorer/useFileTree.js +173 -13
- package/dist/FileExplorer/useFileTree.js.map +1 -1
- package/dist/Git/GitAuthGuidanceDialog.d.ts +18 -0
- package/dist/Git/GitAuthGuidanceDialog.d.ts.map +1 -0
- package/dist/Git/GitAuthGuidanceDialog.js +27 -0
- package/dist/Git/GitAuthGuidanceDialog.js.map +1 -0
- package/dist/Git/GitBranchesDialog.d.ts +13 -0
- package/dist/Git/GitBranchesDialog.d.ts.map +1 -0
- package/dist/Git/GitBranchesDialog.js +59 -0
- package/dist/Git/GitBranchesDialog.js.map +1 -0
- package/dist/Git/GitCloneDialog.d.ts +14 -0
- package/dist/Git/GitCloneDialog.d.ts.map +1 -0
- package/dist/Git/GitCloneDialog.js +79 -0
- package/dist/Git/GitCloneDialog.js.map +1 -0
- package/dist/Git/GitCommitDialog.d.ts +12 -0
- package/dist/Git/GitCommitDialog.d.ts.map +1 -0
- package/dist/Git/GitCommitDialog.js +38 -0
- package/dist/Git/GitCommitDialog.js.map +1 -0
- package/dist/Git/GitConflictsDialog.d.ts +12 -0
- package/dist/Git/GitConflictsDialog.d.ts.map +1 -0
- package/dist/Git/GitConflictsDialog.js +17 -0
- package/dist/Git/GitConflictsDialog.js.map +1 -0
- package/dist/Git/GitContext.d.ts +89 -0
- package/dist/Git/GitContext.d.ts.map +1 -0
- package/dist/Git/GitContext.js +15 -0
- package/dist/Git/GitContext.js.map +1 -0
- package/dist/Git/GitDiffDialog.d.ts +20 -0
- package/dist/Git/GitDiffDialog.d.ts.map +1 -0
- package/dist/Git/GitDiffDialog.js +100 -0
- package/dist/Git/GitDiffDialog.js.map +1 -0
- package/dist/Git/GitDiffView.d.ts +23 -0
- package/dist/Git/GitDiffView.d.ts.map +1 -0
- package/dist/Git/GitDiffView.js +77 -0
- package/dist/Git/GitDiffView.js.map +1 -0
- package/dist/Git/GitHistoryDialog.d.ts +15 -0
- package/dist/Git/GitHistoryDialog.d.ts.map +1 -0
- package/dist/Git/GitHistoryDialog.js +107 -0
- package/dist/Git/GitHistoryDialog.js.map +1 -0
- package/dist/Git/GitStatusBar.d.ts +8 -0
- package/dist/Git/GitStatusBar.d.ts.map +1 -0
- package/dist/Git/GitStatusBar.js +49 -0
- package/dist/Git/GitStatusBar.js.map +1 -0
- package/dist/Git/GitToolbarControl.d.ts +15 -0
- package/dist/Git/GitToolbarControl.d.ts.map +1 -0
- package/dist/Git/GitToolbarControl.js +51 -0
- package/dist/Git/GitToolbarControl.js.map +1 -0
- package/dist/Git/GitUI.d.ts +14 -0
- package/dist/Git/GitUI.d.ts.map +1 -0
- package/dist/Git/GitUI.js +21 -0
- package/dist/Git/GitUI.js.map +1 -0
- package/dist/Git/commit-selection.d.ts +22 -0
- package/dist/Git/commit-selection.d.ts.map +1 -0
- package/dist/Git/commit-selection.js +47 -0
- package/dist/Git/commit-selection.js.map +1 -0
- package/dist/Git/git-clone-utils.d.ts +8 -0
- package/dist/Git/git-clone-utils.d.ts.map +1 -0
- package/dist/Git/git-clone-utils.js +26 -0
- package/dist/Git/git-clone-utils.js.map +1 -0
- package/dist/Git/git-status.d.ts +24 -0
- package/dist/Git/git-status.d.ts.map +1 -0
- package/dist/Git/git-status.js +105 -0
- package/dist/Git/git-status.js.map +1 -0
- package/dist/Git/useGit.d.ts +15 -0
- package/dist/Git/useGit.d.ts.map +1 -0
- package/dist/Git/useGit.js +206 -0
- package/dist/Git/useGit.js.map +1 -0
- package/dist/Git/useGitStatus.d.ts +17 -0
- package/dist/Git/useGitStatus.d.ts.map +1 -0
- package/dist/Git/useGitStatus.js +63 -0
- package/dist/Git/useGitStatus.js.map +1 -0
- package/dist/GitToolbarControl-LMIR4R7I.js +117 -0
- package/dist/GitToolbarControl-LMIR4R7I.js.map +1 -0
- package/dist/GitUI-KGF5MSNV.js +1048 -0
- package/dist/GitUI-KGF5MSNV.js.map +1 -0
- package/dist/LazyEditorShell-EF2BWRXU.js +6 -0
- package/dist/LazyEditorShell-EF2BWRXU.js.map +1 -0
- package/dist/Settings/Settings.d.ts +23 -0
- package/dist/Settings/Settings.d.ts.map +1 -0
- package/dist/Settings/Settings.js +22 -0
- package/dist/Settings/Settings.js.map +1 -0
- package/dist/Settings/public-api.d.ts +3 -0
- package/dist/Settings/public-api.d.ts.map +1 -0
- package/dist/Settings/public-api.js +2 -0
- package/dist/Settings/public-api.js.map +1 -0
- package/dist/WorkspacePicker/WorkspacePicker.d.ts +6 -1
- package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
- package/dist/WorkspacePicker/WorkspacePicker.js +16 -5
- package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
- package/dist/chunk-3SAXDU2U.js +157 -0
- package/dist/chunk-3SAXDU2U.js.map +1 -0
- package/dist/chunk-4XXSWN3T.js +13 -0
- package/dist/chunk-4XXSWN3T.js.map +1 -0
- package/dist/chunk-5A4KEWKT.js +97 -0
- package/dist/chunk-5A4KEWKT.js.map +1 -0
- package/dist/chunk-AOVTHZU7.js +321 -0
- package/dist/chunk-AOVTHZU7.js.map +1 -0
- package/dist/chunk-DD4VFJQI.js +51 -0
- package/dist/chunk-DD4VFJQI.js.map +1 -0
- package/dist/chunk-GSNZSCIK.js +55 -0
- package/dist/chunk-GSNZSCIK.js.map +1 -0
- package/dist/chunk-VHDP4IPL.js +70 -0
- package/dist/chunk-VHDP4IPL.js.map +1 -0
- package/dist/chunk-ZI56BNB7.js +270 -0
- package/dist/chunk-ZI56BNB7.js.map +1 -0
- package/dist/components/Dialog.d.ts +22 -0
- package/dist/components/Dialog.d.ts.map +1 -0
- package/dist/components/Dialog.js +33 -0
- package/dist/components/Dialog.js.map +1 -0
- package/dist/editor.d.ts +10 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +70 -0
- package/dist/editor.js.map +1 -0
- package/dist/export/index.d.ts +87 -0
- package/dist/export/index.js +26 -0
- package/dist/export/index.js.map +1 -0
- package/dist/hooks/index.d.ts +2 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/index.js.map +1 -1
- package/dist/hooks/useDocumentSession.d.ts +16 -0
- package/dist/hooks/useDocumentSession.d.ts.map +1 -0
- package/dist/hooks/useDocumentSession.js +18 -0
- package/dist/hooks/useDocumentSession.js.map +1 -0
- package/dist/icons.d.ts +11 -6
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +21 -20
- package/dist/icons.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/preferences/theme.d.ts +18 -0
- package/dist/preferences/theme.d.ts.map +1 -0
- package/dist/preferences/theme.js +63 -0
- package/dist/preferences/theme.js.map +1 -0
- package/dist/provider-lease.d.ts +10 -0
- package/dist/provider-lease.d.ts.map +1 -0
- package/dist/provider-lease.js +42 -0
- package/dist/provider-lease.js.map +1 -0
- package/dist/run-export-ICTZITXC.js +10 -0
- package/dist/run-export-ICTZITXC.js.map +1 -0
- package/dist/settings/index.d.ts +28 -0
- package/dist/settings/index.js +12 -0
- package/dist/settings/index.js.map +1 -0
- package/package.json +24 -9
- package/src/AppMenu/AppMenu.tsx +169 -99
- package/src/AppMenu/index.ts +1 -1
- package/src/DocBlocksShell/DocBlocksShell.tsx +2157 -537
- package/src/DocBlocksShell/LazyEditorShell.ts +2 -0
- package/src/DocBlocksShell/UpdateAvailableNotice.tsx +60 -0
- package/src/DocBlocksShell/document-title.ts +19 -0
- package/src/DocBlocksShell/workspace-authority-barrier.ts +26 -0
- package/src/Export/DeferredExportToolbarControls.tsx +23 -0
- package/src/Export/ExportDialog.tsx +74 -20
- package/src/Export/ExportToolbarControls.tsx +238 -40
- package/src/Export/export-destination.ts +30 -0
- package/src/Export/index.ts +18 -4
- package/src/Export/public-api.ts +17 -0
- package/src/Export/run-export.ts +36 -11
- package/src/FileExplorer/FileExplorer.tsx +284 -38
- package/src/FileExplorer/FileTreeNode.tsx +100 -8
- package/src/FileExplorer/index.ts +1 -1
- package/src/FileExplorer/useFileTree.ts +190 -16
- package/src/Git/GitAuthGuidanceDialog.tsx +84 -0
- package/src/Git/GitBranchesDialog.tsx +144 -0
- package/src/Git/GitCloneDialog.tsx +200 -0
- package/src/Git/GitCommitDialog.tsx +119 -0
- package/src/Git/GitConflictsDialog.tsx +62 -0
- package/src/Git/GitContext.tsx +88 -0
- package/src/Git/GitDiffDialog.tsx +147 -0
- package/src/Git/GitDiffView.tsx +105 -0
- package/src/Git/GitHistoryDialog.tsx +195 -0
- package/src/Git/GitStatusBar.tsx +157 -0
- package/src/Git/GitToolbarControl.tsx +123 -0
- package/src/Git/GitUI.tsx +68 -0
- package/src/Git/commit-selection.ts +63 -0
- package/src/Git/git-clone-utils.ts +23 -0
- package/src/Git/git-status.ts +113 -0
- package/src/Git/useGit.ts +279 -0
- package/src/Git/useGitStatus.ts +78 -0
- package/src/Settings/Settings.tsx +118 -0
- package/src/Settings/public-api.ts +8 -0
- package/src/WorkspacePicker/WorkspacePicker.tsx +62 -8
- package/src/components/Dialog.tsx +81 -0
- package/src/editor.ts +72 -0
- package/src/hooks/index.ts +2 -1
- package/src/hooks/useDocumentSession.ts +37 -0
- package/src/icons.tsx +33 -55
- package/src/index.ts +35 -5
- package/src/preferences/theme.ts +67 -0
- package/src/provider-lease.ts +52 -0
- package/src/styles/docblocks.css +2174 -79
- package/src/types/pwa-dom.d.ts +31 -0
- package/dist/hooks/useAutoSave.d.ts +0 -15
- package/dist/hooks/useAutoSave.d.ts.map +0 -1
- package/dist/hooks/useAutoSave.js +0 -83
- package/dist/hooks/useAutoSave.js.map +0 -1
- package/dist/monaco-slim.d.ts +0 -19
- package/dist/monaco-slim.d.ts.map +0 -1
- package/dist/monaco-slim.js +0 -19
- package/dist/monaco-slim.js.map +0 -1
- package/src/fonts/fonts.css +0 -362
- package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +0 -93
- package/src/fonts/licenses/LICENSE-crimson-text.txt +0 -93
- package/src/fonts/licenses/LICENSE-dm-sans.txt +0 -93
- package/src/fonts/licenses/LICENSE-dm-serif-display.txt +0 -93
- package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +0 -94
- package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +0 -93
- package/src/fonts/licenses/LICENSE-inter.txt +0 -93
- package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +0 -93
- package/src/fonts/licenses/LICENSE-lora.txt +0 -93
- package/src/fonts/licenses/LICENSE-merriweather.txt +0 -93
- package/src/fonts/licenses/LICENSE-oswald.txt +0 -93
- package/src/fonts/licenses/LICENSE-playfair-display.txt +0 -93
- package/src/fonts/licenses/LICENSE-pt-serif.txt +0 -93
- package/src/fonts/licenses/LICENSE-roboto.txt +0 -93
- package/src/fonts/licenses/LICENSE-source-serif-4.txt +0 -93
- package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
- package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
- package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
- package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
- package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
- package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
- package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
- package/src/fonts/woff2/inter-400.woff2 +0 -0
- package/src/fonts/woff2/inter-400i.woff2 +0 -0
- package/src/fonts/woff2/inter-500.woff2 +0 -0
- package/src/fonts/woff2/inter-600.woff2 +0 -0
- package/src/fonts/woff2/inter-700.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
- package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
- package/src/fonts/woff2/lora-400.woff2 +0 -0
- package/src/fonts/woff2/lora-400i.woff2 +0 -0
- package/src/fonts/woff2/lora-700.woff2 +0 -0
- package/src/fonts/woff2/merriweather-400.woff2 +0 -0
- package/src/fonts/woff2/merriweather-700.woff2 +0 -0
- package/src/fonts/woff2/oswald-400.woff2 +0 -0
- package/src/fonts/woff2/oswald-700.woff2 +0 -0
- package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
- package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
- package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
- package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
- package/src/fonts/woff2/roboto-400.woff2 +0 -0
- package/src/fonts/woff2/roboto-400i.woff2 +0 -0
- package/src/fonts/woff2/roboto-700.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
- package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
- package/src/hooks/useAutoSave.ts +0 -116
- package/src/monaco-slim.ts +0 -20
|
@@ -1,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(() => {
|
|
@@ -75,7 +149,7 @@ function loadLastState() {
|
|
|
75
149
|
}
|
|
76
150
|
/** One-time first-run callout shown over the welcome doc's Play view.
|
|
77
151
|
* Once the user starts writing, switches views themselves, or dismisses
|
|
78
|
-
* it, it never comes back
|
|
152
|
+
* it, it never comes back -- on any workspace. */
|
|
79
153
|
const WELCOME_GATEWAY_KEY = 'docblocks:welcomeGatewayDismissed';
|
|
80
154
|
function isWelcomeGatewayDismissed() {
|
|
81
155
|
try {
|
|
@@ -93,35 +167,14 @@ function markWelcomeGatewayDismissed() {
|
|
|
93
167
|
// ignore quota errors
|
|
94
168
|
}
|
|
95
169
|
}
|
|
96
|
-
const THEME_PREF_KEY = 'docblocks:themePreference';
|
|
97
|
-
function loadThemePreference() {
|
|
98
|
-
try {
|
|
99
|
-
const raw = localStorage.getItem(THEME_PREF_KEY);
|
|
100
|
-
if (raw === 'light' || raw === 'dark' || raw === 'auto')
|
|
101
|
-
return raw;
|
|
102
|
-
}
|
|
103
|
-
catch {
|
|
104
|
-
// ignore
|
|
105
|
-
}
|
|
106
|
-
return 'auto';
|
|
107
|
-
}
|
|
108
|
-
function saveThemePreference(pref) {
|
|
109
|
-
try {
|
|
110
|
-
localStorage.setItem(THEME_PREF_KEY, pref);
|
|
111
|
-
}
|
|
112
|
-
catch {
|
|
113
|
-
// ignore quota errors
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
170
|
const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
|
|
117
|
-
const SIDEBAR_WIDTH_DEFAULT =
|
|
118
|
-
const SIDEBAR_WIDTH_MIN =
|
|
171
|
+
const SIDEBAR_WIDTH_DEFAULT = 320;
|
|
172
|
+
const SIDEBAR_WIDTH_MIN = 320;
|
|
119
173
|
const SIDEBAR_WIDTH_MAX = 600;
|
|
120
|
-
/** Drag below this many pixels and the sidebar collapses entirely
|
|
121
|
-
* the
|
|
122
|
-
*
|
|
123
|
-
|
|
124
|
-
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;
|
|
125
178
|
function loadSidebarWidth() {
|
|
126
179
|
try {
|
|
127
180
|
const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
|
|
@@ -195,11 +248,115 @@ function stripExtension(name) {
|
|
|
195
248
|
return name.replace(/\.[^.]+$/, '');
|
|
196
249
|
}
|
|
197
250
|
function normaliseProviderPath(p) {
|
|
198
|
-
|
|
251
|
+
const canonical = parseWorkspacePath(p);
|
|
252
|
+
return canonical ? `/${canonical}` : '/';
|
|
199
253
|
}
|
|
200
254
|
function sameProviderPath(a, b) {
|
|
201
255
|
return normaliseProviderPath(a) === normaliseProviderPath(b);
|
|
202
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
|
+
}
|
|
203
360
|
/** Portable relative link from one workspace file to another. Walks up
|
|
204
361
|
* from the source's directory and back down to the target so the link
|
|
205
362
|
* survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
|
|
@@ -231,10 +388,12 @@ async function collectMarkdownFiles(fs, root) {
|
|
|
231
388
|
visited.add(dir);
|
|
232
389
|
let entries;
|
|
233
390
|
try {
|
|
234
|
-
entries = await fs
|
|
391
|
+
entries = await readProviderDirectory(fs, dir);
|
|
235
392
|
}
|
|
236
|
-
catch {
|
|
237
|
-
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (error instanceof FsError && error.code === 'not-found')
|
|
395
|
+
return;
|
|
396
|
+
throw error;
|
|
238
397
|
}
|
|
239
398
|
for (const entry of entries) {
|
|
240
399
|
if (entry.kind === 'directory') {
|
|
@@ -256,20 +415,57 @@ async function collectMarkdownFiles(fs, root) {
|
|
|
256
415
|
await walk(root);
|
|
257
416
|
return out;
|
|
258
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
|
+
}
|
|
259
459
|
async function createElectronProviderFromWorkspace(ws) {
|
|
260
460
|
if (!ws.rootPath)
|
|
261
461
|
return null;
|
|
262
462
|
try {
|
|
263
|
-
await getDocBlocksHost().workspaces.register(
|
|
264
|
-
id: ws.id,
|
|
265
|
-
name: ws.name,
|
|
266
|
-
rootPath: ws.rootPath,
|
|
267
|
-
});
|
|
463
|
+
await getDocBlocksHost().workspaces.register(ws.id);
|
|
268
464
|
}
|
|
269
465
|
catch {
|
|
270
466
|
return null;
|
|
271
467
|
}
|
|
272
|
-
return
|
|
468
|
+
return createElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
|
|
273
469
|
}
|
|
274
470
|
function useIsMobile(breakpoint = 768) {
|
|
275
471
|
const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
|
|
@@ -287,16 +483,34 @@ function FolderGlyph() {
|
|
|
287
483
|
function FileGlyph() {
|
|
288
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" })] }));
|
|
289
485
|
}
|
|
290
|
-
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, }) {
|
|
291
487
|
const osTheme = useOsTheme();
|
|
292
488
|
const [themePreference, setThemePreference] = useState(loadThemePreference);
|
|
293
|
-
|
|
489
|
+
const [accentColor, setAccentColor] = useState(loadAccentColor);
|
|
490
|
+
// "System default" (auto) always follows the OS -- the host's theme prop
|
|
294
491
|
// is kept only for API back-compat and does not override OS detection.
|
|
295
492
|
const resolvedTheme = themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
|
|
296
493
|
const handleThemeChange = useCallback((pref) => {
|
|
297
494
|
setThemePreference(pref);
|
|
298
495
|
saveThemePreference(pref);
|
|
299
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
|
+
}, []);
|
|
300
514
|
const [viewPreferences, setViewPreferences] = useState(loadViewPreferences);
|
|
301
515
|
const handleViewPreferencesChange = useCallback((prefs) => {
|
|
302
516
|
setViewPreferences(prefs);
|
|
@@ -304,21 +518,26 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
304
518
|
}, []);
|
|
305
519
|
const isMobile = useIsMobile();
|
|
306
520
|
const [mobileShowEditor, setMobileShowEditor] = useState(false);
|
|
307
|
-
// Sidebar width
|
|
521
|
+
// Sidebar width -- persisted across sessions, dragged via the resizer
|
|
308
522
|
// between sidebar and editor area. We track the "live" width during a
|
|
309
523
|
// drag in a ref so each mousemove doesn't trigger a state update; only
|
|
310
524
|
// setState on commit so React doesn't churn through every pixel.
|
|
311
525
|
const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
|
|
312
526
|
// When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
|
|
313
|
-
// we switch the layout into single-pane "compact" mode
|
|
527
|
+
// we switch the layout into single-pane "compact" mode -- same UX as
|
|
314
528
|
// the mobile narrow-viewport flow, where only the sidebar OR the
|
|
315
529
|
// editor is visible at a time and a back-arrow in the toolbar pops
|
|
316
|
-
// between them. A "Restore split view" button
|
|
530
|
+
// between them. A "Restore split view" button in either pane's header
|
|
317
531
|
// exits compact mode; on real mobile that button is suppressed
|
|
318
532
|
// because there's not enough viewport for side-by-side. Not
|
|
319
533
|
// persisted across reloads. */
|
|
320
534
|
const [compactLayout, setCompactLayout] = useState(false);
|
|
321
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);
|
|
322
541
|
const sidebarRef = useRef(null);
|
|
323
542
|
const dragStateRef = useRef(null);
|
|
324
543
|
const handleResizerPointerDown = useCallback((e) => {
|
|
@@ -340,7 +559,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
340
559
|
return;
|
|
341
560
|
lastRaw = drag.startWidth + (ev.clientX - drag.startX);
|
|
342
561
|
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
|
|
343
|
-
// Below threshold
|
|
562
|
+
// Below threshold -- preview the collapse by snapping to the
|
|
344
563
|
// minimum width and fading the sidebar, so the user can see
|
|
345
564
|
// they've crossed into "release to collapse" territory.
|
|
346
565
|
sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
|
|
@@ -364,8 +583,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
364
583
|
sidebarRef.current.style.opacity = '';
|
|
365
584
|
}
|
|
366
585
|
if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
|
|
367
|
-
// Released below threshold
|
|
368
|
-
// layout focused on the
|
|
586
|
+
// Released below threshold -- switch to compact (single-pane)
|
|
587
|
+
// layout focused on the document pane. Keep the persisted
|
|
369
588
|
// sidebarWidth so exiting compact mode restores it.
|
|
370
589
|
setCompactLayout(true);
|
|
371
590
|
setMobileShowEditor(true);
|
|
@@ -384,8 +603,15 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
384
603
|
document.addEventListener('pointerup', onUp);
|
|
385
604
|
}, [sidebarWidth]);
|
|
386
605
|
const [provider, setProvider] = useState(null);
|
|
606
|
+
const [workspaceStartupError, setWorkspaceStartupError] = useState(null);
|
|
387
607
|
const [activeWorkspaceId, setActiveWorkspaceId] = useState(null);
|
|
388
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]);
|
|
389
615
|
// Re-fetch the descriptor whenever the active id (or its versioning
|
|
390
616
|
// override) changes. `descriptorRefreshKey` is bumped after writes so
|
|
391
617
|
// the resolver picks up the updated override without remounting.
|
|
@@ -404,6 +630,17 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
404
630
|
cancelled = true;
|
|
405
631
|
};
|
|
406
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;
|
|
407
644
|
const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
|
|
408
645
|
const [versioningPreference, setVersioningPreference] = useState(loadVersioningPreference);
|
|
409
646
|
const handleVersioningPreferenceChange = useCallback((pref) => {
|
|
@@ -424,17 +661,211 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
424
661
|
setWorkspaceSettingsOpen(false);
|
|
425
662
|
}, [activeWorkspaceDescriptor]);
|
|
426
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]);
|
|
427
676
|
const [selectedFolder, setSelectedFolder] = useState(null);
|
|
428
677
|
const [folderEntries, setFolderEntries] = useState([]);
|
|
429
|
-
const
|
|
430
|
-
|
|
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]);
|
|
431
695
|
const [explorerKey, setExplorerKey] = useState(0);
|
|
432
696
|
const [initialView, setInitialView] = useState('wysiwyg');
|
|
433
|
-
// First-run gateway over the welcome doc's Play view
|
|
697
|
+
// First-run gateway over the welcome doc's Play view -- see WELCOME_GATEWAY_KEY.
|
|
434
698
|
const [showWelcomeGateway, setShowWelcomeGateway] = useState(false);
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
const
|
|
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]);
|
|
438
869
|
/**
|
|
439
870
|
* Per-file media container: for `notes.md`, images live in
|
|
440
871
|
* `notes_files/` next to the markdown. Created lazily on first write,
|
|
@@ -443,6 +874,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
443
874
|
*/
|
|
444
875
|
const mediaContainerRef = useRef(null);
|
|
445
876
|
const [mediaProvider, setMediaProvider] = useState(null);
|
|
877
|
+
const [mediaEpoch, setMediaEpoch] = useState(0);
|
|
446
878
|
/**
|
|
447
879
|
* Per-document container scoped to `<basename>_files/`. This is what
|
|
448
880
|
* the editor uses for version history (`.versions/` lives here) and
|
|
@@ -463,69 +895,112 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
463
895
|
const pushHash = useCallback((wsId, filePath) => {
|
|
464
896
|
const hash = buildHash(wsId, filePath);
|
|
465
897
|
if (window.location.hash !== hash) {
|
|
466
|
-
skipPopState.current = true;
|
|
467
898
|
window.history.pushState(null, '', hash);
|
|
468
899
|
}
|
|
469
900
|
}, []);
|
|
470
901
|
/** Open a workspace (and optionally a file) from identifiers.
|
|
471
902
|
* Returns the FileSystemProvider on success, or null on failure. */
|
|
472
|
-
const openFromIds = useCallback(async (wsId, filePath, push, view) => {
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
if (!ws)
|
|
903
|
+
const openFromIds = useCallback(async (wsId, filePath, push, view, navigationRequestId) => {
|
|
904
|
+
const requestId = navigationRequestId ?? ++navigationRequestRef.current;
|
|
905
|
+
if (requestId !== navigationRequestRef.current)
|
|
476
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;
|
|
477
911
|
let fsProvider = null;
|
|
478
|
-
if (
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
return null;
|
|
482
|
-
}
|
|
483
|
-
else if (ws.type === 'native') {
|
|
484
|
-
const restored = await restoreNativeFolder(ws.id);
|
|
485
|
-
if (!restored)
|
|
486
|
-
return null;
|
|
487
|
-
fsProvider = restored;
|
|
912
|
+
if (transient) {
|
|
913
|
+
ws = transient.descriptor;
|
|
914
|
+
fsProvider = transient.provider;
|
|
488
915
|
}
|
|
489
916
|
else {
|
|
490
|
-
|
|
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
|
+
}
|
|
491
935
|
}
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (
|
|
498
|
-
|
|
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);
|
|
499
972
|
setSelectedFolder(null);
|
|
500
973
|
setFolderEntries([]);
|
|
501
|
-
setEditorContent(content);
|
|
502
974
|
const effectiveView = view ?? 'wysiwyg';
|
|
503
975
|
setInitialView(effectiveView);
|
|
504
|
-
|
|
505
|
-
|
|
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
|
+
}
|
|
506
981
|
}
|
|
507
982
|
else {
|
|
508
983
|
setSelectedFile(null);
|
|
509
984
|
setSelectedFolder(null);
|
|
510
985
|
setFolderEntries([]);
|
|
511
|
-
setEditorContent('');
|
|
512
|
-
setEditorKey((k) => k + 1);
|
|
513
986
|
}
|
|
987
|
+
if (push) {
|
|
988
|
+
pushHash(ws.id, openedFile);
|
|
989
|
+
}
|
|
990
|
+
return fsProvider;
|
|
514
991
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
setFolderEntries([]);
|
|
519
|
-
setEditorContent('');
|
|
520
|
-
setEditorKey((k) => k + 1);
|
|
521
|
-
}
|
|
522
|
-
if (push) {
|
|
523
|
-
pushHash(ws.id, filePath);
|
|
992
|
+
finally {
|
|
993
|
+
if (!transient && !adopted)
|
|
994
|
+
await getFileSystemProviderV2(fsProvider)?.dispose();
|
|
524
995
|
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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;
|
|
529
1004
|
// If the only file is the welcome doc, auto-select it.
|
|
530
1005
|
// Match either casing so workspaces seeded before the rename
|
|
531
1006
|
// (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
|
|
@@ -533,12 +1008,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
533
1008
|
entries[0].kind === 'file' &&
|
|
534
1009
|
entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md') {
|
|
535
1010
|
const aboutPath = entries[0].path;
|
|
536
|
-
const content = await fs
|
|
537
|
-
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;
|
|
538
1016
|
setSelectedFile(aboutPath);
|
|
539
|
-
setEditorContent(content);
|
|
540
1017
|
setInitialView('preview');
|
|
541
|
-
setEditorKey((k) => k + 1);
|
|
542
1018
|
setExplorerKey((k) => k + 1);
|
|
543
1019
|
pushHash(fs.id, aboutPath);
|
|
544
1020
|
saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
|
|
@@ -553,39 +1029,62 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
553
1029
|
const welcomeContent = [
|
|
554
1030
|
'# Welcome to DocBlocks',
|
|
555
1031
|
'',
|
|
1032
|
+
'Your markdown can do anything.',
|
|
1033
|
+
'',
|
|
556
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.',
|
|
557
1035
|
'',
|
|
558
1036
|
'Simple to write. Beautiful wherever it goes.',
|
|
559
1037
|
'',
|
|
560
1038
|
'## Features',
|
|
561
1039
|
'',
|
|
562
|
-
'- **Rich Markdown Editing**
|
|
563
|
-
'- **Workspaces**
|
|
564
|
-
'- **Useful Everywhere**
|
|
565
|
-
'- **Playback & Video**
|
|
566
|
-
'- **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',
|
|
567
1045
|
'',
|
|
568
1046
|
'## Getting Started',
|
|
569
1047
|
'',
|
|
570
1048
|
'1. Create a new file using the **New File** button in the sidebar',
|
|
571
|
-
'2. Start writing in markdown
|
|
1049
|
+
'2. Start writing in markdown -- the editor supports headings, lists, links, images, and more',
|
|
572
1050
|
'3. Your work is saved automatically',
|
|
573
1051
|
'',
|
|
574
|
-
'Built
|
|
1052
|
+
'Built on [Squiggly Square markdown extensions](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
|
|
575
1053
|
].join('\n');
|
|
576
|
-
|
|
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;
|
|
577
1071
|
setSelectedFile(welcomePath);
|
|
578
|
-
setEditorContent(welcomeContent);
|
|
579
1072
|
setInitialView('preview');
|
|
580
|
-
setEditorKey((k) => k + 1);
|
|
581
1073
|
setExplorerKey((k) => k + 1);
|
|
582
1074
|
pushHash(fs.id, welcomePath);
|
|
583
1075
|
saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
|
|
584
1076
|
if (!isWelcomeGatewayDismissed())
|
|
585
1077
|
setShowWelcomeGateway(true);
|
|
586
|
-
}, [pushHash]);
|
|
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;
|
|
587
1086
|
/** Hide the welcome gateway and never show it again. Safe to call from
|
|
588
|
-
* paths where it may not be showing
|
|
1087
|
+
* paths where it may not be showing -- only persists when it was. */
|
|
589
1088
|
const closeWelcomeGateway = useCallback(() => {
|
|
590
1089
|
setShowWelcomeGateway((showing) => {
|
|
591
1090
|
if (showing)
|
|
@@ -593,26 +1092,64 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
593
1092
|
return false;
|
|
594
1093
|
});
|
|
595
1094
|
}, []);
|
|
596
|
-
/** Gateway CTA
|
|
1095
|
+
/** Gateway CTA -- flip the welcome doc from Play into the editor. */
|
|
597
1096
|
const handleStartWriting = useCallback(() => {
|
|
598
1097
|
closeWelcomeGateway();
|
|
599
1098
|
setInitialView('wysiwyg');
|
|
600
|
-
|
|
1099
|
+
setEditorPresentationEpoch((epoch) => epoch + 1);
|
|
601
1100
|
if (activeWorkspaceId && selectedFile) {
|
|
602
1101
|
saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view: 'wysiwyg' });
|
|
603
1102
|
}
|
|
604
1103
|
}, [closeWelcomeGateway, activeWorkspaceId, selectedFile]);
|
|
605
|
-
// Initialise workspace on mount
|
|
1104
|
+
// Initialise workspace on mount -- restore from hash or last-used
|
|
606
1105
|
useEffect(() => {
|
|
607
|
-
|
|
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;
|
|
608
1138
|
// Try restoring from URL hash first
|
|
609
1139
|
const hashState = parseHash();
|
|
610
1140
|
if (hashState) {
|
|
611
|
-
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;
|
|
612
1149
|
if (restoredProvider) {
|
|
613
1150
|
// If the hash had no file, check whether we should auto-select the welcome doc
|
|
614
1151
|
if (!hashState.filePath) {
|
|
615
|
-
await
|
|
1152
|
+
await startupSeedWelcomeFileRef.current(restoredProvider, requestId);
|
|
616
1153
|
}
|
|
617
1154
|
return;
|
|
618
1155
|
}
|
|
@@ -620,14 +1157,21 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
620
1157
|
// Try restoring last viewed document from localStorage
|
|
621
1158
|
const lastState = loadLastState();
|
|
622
1159
|
if (lastState) {
|
|
623
|
-
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;
|
|
624
1167
|
if (restoredProvider)
|
|
625
1168
|
return;
|
|
626
1169
|
}
|
|
627
1170
|
let fsProvider = null;
|
|
628
|
-
const electron = isElectronHost();
|
|
629
1171
|
const workspaces = await listWorkspaces();
|
|
630
|
-
|
|
1172
|
+
if (!isCurrent())
|
|
1173
|
+
return;
|
|
1174
|
+
// On desktop, hide web-only workspaces (indexeddb/native) -- only
|
|
631
1175
|
// folder-based workspaces are valid.
|
|
632
1176
|
const candidates = electron
|
|
633
1177
|
? workspaces.filter((w) => w.type === 'electron-native')
|
|
@@ -639,36 +1183,56 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
639
1183
|
const p = await createElectronProviderFromWorkspace(ws);
|
|
640
1184
|
if (!p)
|
|
641
1185
|
continue;
|
|
1186
|
+
if (!isCurrent()) {
|
|
1187
|
+
await getFileSystemProviderV2(p)?.dispose();
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
642
1190
|
await touchWorkspace(ws.id);
|
|
1191
|
+
if (!isCurrent()) {
|
|
1192
|
+
await getFileSystemProviderV2(p)?.dispose();
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
643
1195
|
fsProvider = p;
|
|
644
|
-
setProvider(p);
|
|
645
|
-
setActiveWorkspaceId(ws.id);
|
|
646
1196
|
break;
|
|
647
1197
|
}
|
|
648
1198
|
else if (ws.type === 'native') {
|
|
649
|
-
const restored = await restoreNativeFolder(ws.id);
|
|
1199
|
+
const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
|
|
650
1200
|
if (restored) {
|
|
1201
|
+
if (!isCurrent()) {
|
|
1202
|
+
await getFileSystemProviderV2(restored)?.dispose();
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
651
1205
|
await touchWorkspace(ws.id);
|
|
1206
|
+
if (!isCurrent()) {
|
|
1207
|
+
await getFileSystemProviderV2(restored)?.dispose();
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
652
1210
|
fsProvider = restored;
|
|
653
|
-
setProvider(restored);
|
|
654
|
-
setActiveWorkspaceId(ws.id);
|
|
655
1211
|
break;
|
|
656
1212
|
}
|
|
657
1213
|
}
|
|
658
1214
|
else {
|
|
659
|
-
const p =
|
|
1215
|
+
const p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
|
|
1216
|
+
if (!isCurrent()) {
|
|
1217
|
+
await getFileSystemProviderV2(p)?.dispose();
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
660
1220
|
await touchWorkspace(ws.id);
|
|
1221
|
+
if (!isCurrent()) {
|
|
1222
|
+
await getFileSystemProviderV2(p)?.dispose();
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
661
1225
|
fsProvider = p;
|
|
662
|
-
setProvider(p);
|
|
663
|
-
setActiveWorkspaceId(ws.id);
|
|
664
1226
|
break;
|
|
665
1227
|
}
|
|
666
1228
|
}
|
|
667
1229
|
if (!fsProvider) {
|
|
668
1230
|
if (electron) {
|
|
669
1231
|
// Desktop: ask the host for the default folder workspace
|
|
670
|
-
// (creates
|
|
1232
|
+
// (creates DocBlocks in the OS-resolved Documents folder on first launch).
|
|
671
1233
|
const info = await getDocBlocksHost().workspaces.getDefault();
|
|
1234
|
+
if (!isCurrent())
|
|
1235
|
+
return;
|
|
672
1236
|
const descriptor = {
|
|
673
1237
|
id: info.id,
|
|
674
1238
|
name: info.name,
|
|
@@ -677,41 +1241,154 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
677
1241
|
lastOpened: new Date().toISOString(),
|
|
678
1242
|
};
|
|
679
1243
|
await saveWorkspace(descriptor);
|
|
680
|
-
|
|
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
|
+
}
|
|
681
1251
|
fsProvider = p;
|
|
682
|
-
setProvider(p);
|
|
683
|
-
setActiveWorkspaceId(info.id);
|
|
684
1252
|
}
|
|
685
1253
|
else {
|
|
686
1254
|
// Web: create the IndexedDB default workspace
|
|
687
1255
|
const defaultWs = await ensureDefaultWorkspace();
|
|
688
|
-
|
|
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
|
+
}
|
|
689
1263
|
fsProvider = p;
|
|
690
|
-
setProvider(p);
|
|
691
|
-
setActiveWorkspaceId(defaultWs.id);
|
|
692
1264
|
}
|
|
693
1265
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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]);
|
|
700
1295
|
// Handle browser back/forward
|
|
701
1296
|
useEffect(() => {
|
|
702
1297
|
const onPopState = () => {
|
|
703
|
-
if (skipPopState.current) {
|
|
704
|
-
skipPopState.current = false;
|
|
705
|
-
return;
|
|
706
|
-
}
|
|
707
1298
|
const hashState = parseHash();
|
|
708
1299
|
if (hashState) {
|
|
709
|
-
|
|
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
|
+
});
|
|
710
1317
|
}
|
|
711
1318
|
};
|
|
712
1319
|
window.addEventListener('popstate', onPopState);
|
|
713
1320
|
return () => window.removeEventListener('popstate', onPopState);
|
|
714
|
-
}, [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]);
|
|
715
1392
|
// Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
|
|
716
1393
|
useEffect(() => {
|
|
717
1394
|
const handler = (e) => {
|
|
@@ -719,7 +1396,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
719
1396
|
if (target) {
|
|
720
1397
|
const view = target.getAttribute('data-view');
|
|
721
1398
|
if (view) {
|
|
722
|
-
// The user found the view tabs on their own
|
|
1399
|
+
// The user found the view tabs on their own -- the gateway's job is done.
|
|
723
1400
|
closeWelcomeGateway();
|
|
724
1401
|
}
|
|
725
1402
|
if (view && activeWorkspaceId && selectedFile) {
|
|
@@ -730,21 +1407,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
730
1407
|
window.addEventListener('click', handler, true);
|
|
731
1408
|
return () => window.removeEventListener('click', handler, true);
|
|
732
1409
|
}, [activeWorkspaceId, selectedFile, closeWelcomeGateway]);
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
content: savedContent,
|
|
737
|
-
savedAt: Date.now(),
|
|
738
|
-
};
|
|
739
|
-
}, []);
|
|
740
|
-
// Auto-save current file. The returned `flush` is called from the
|
|
741
|
-
// Ctrl/Cmd+S handler below so the user gets immediate confirmation.
|
|
742
|
-
const { flush: flushAutoSave } = useAutoSave(provider, selectedFile, editorContent, 500, handleAutoSaved);
|
|
743
|
-
// Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
|
|
744
|
-
// small "auto-save confirmed" toast. Files are already saved on every
|
|
745
|
-
// keystroke (debounced) — this is purely UX reassurance for users who
|
|
746
|
-
// muscle-memory hit Save.
|
|
747
|
-
const [saveToastVisible, setSaveToastVisible] = useState(false);
|
|
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);
|
|
748
1413
|
const saveToastTimerRef = useRef(null);
|
|
749
1414
|
useEffect(() => {
|
|
750
1415
|
const onKey = (e) => {
|
|
@@ -754,21 +1419,89 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
754
1419
|
return;
|
|
755
1420
|
e.preventDefault();
|
|
756
1421
|
e.stopPropagation();
|
|
757
|
-
void
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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
|
+
})();
|
|
762
1441
|
};
|
|
763
1442
|
window.addEventListener('keydown', onKey, true);
|
|
764
1443
|
return () => window.removeEventListener('keydown', onKey, true);
|
|
765
|
-
}, [
|
|
1444
|
+
}, [documentSession]);
|
|
766
1445
|
useEffect(() => {
|
|
767
1446
|
return () => {
|
|
768
1447
|
if (saveToastTimerRef.current)
|
|
769
1448
|
clearTimeout(saveToastTimerRef.current);
|
|
770
1449
|
};
|
|
771
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]);
|
|
772
1505
|
// Per-file media: for `notes.md` images live in `notes_files/` beside it.
|
|
773
1506
|
// Rebuilds whenever the provider or selected file changes.
|
|
774
1507
|
useEffect(() => {
|
|
@@ -793,9 +1526,9 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
793
1526
|
return () => {
|
|
794
1527
|
mp.dispose();
|
|
795
1528
|
};
|
|
796
|
-
}, [provider, selectedFile]);
|
|
1529
|
+
}, [provider, selectedFile, mediaEpoch]);
|
|
797
1530
|
// Invalidate the document-link candidate cache when the backing
|
|
798
|
-
// workspace changes
|
|
1531
|
+
// workspace changes -- otherwise the link dialog would surface
|
|
799
1532
|
// neighbours from a previously-open workspace.
|
|
800
1533
|
useEffect(() => {
|
|
801
1534
|
mdFileCacheRef.current = null;
|
|
@@ -855,66 +1588,244 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
855
1588
|
}, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
|
|
856
1589
|
// React to external file changes watched by the Electron host (chokidar).
|
|
857
1590
|
useEffect(() => {
|
|
858
|
-
if (!
|
|
1591
|
+
if (!provider)
|
|
859
1592
|
return;
|
|
860
|
-
|
|
1593
|
+
const providerV2 = getFileSystemProviderV2(provider);
|
|
1594
|
+
if (!providerV2?.capabilities.watch)
|
|
861
1595
|
return;
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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;
|
|
865
1607
|
return;
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
+
},
|
|
881
1654
|
});
|
|
882
|
-
|
|
883
|
-
|
|
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]);
|
|
884
1783
|
const handleWorkspaceSelect = useCallback(async (ws) => {
|
|
1784
|
+
const requestId = ++navigationRequestRef.current;
|
|
885
1785
|
await touchWorkspace(ws.id);
|
|
1786
|
+
if (requestId !== navigationRequestRef.current)
|
|
1787
|
+
return;
|
|
886
1788
|
let nextProvider = null;
|
|
887
|
-
|
|
1789
|
+
const transient = getTransientWorkspace(ws.id);
|
|
1790
|
+
if (transient) {
|
|
1791
|
+
nextProvider = transient.provider;
|
|
1792
|
+
}
|
|
1793
|
+
else if (ws.type === 'electron-native') {
|
|
888
1794
|
nextProvider = await createElectronProviderFromWorkspace(ws);
|
|
889
1795
|
if (!nextProvider)
|
|
890
1796
|
return;
|
|
891
1797
|
}
|
|
892
1798
|
else if (ws.type === 'native') {
|
|
893
|
-
const restored = await restoreNativeFolder(ws.id);
|
|
1799
|
+
const restored = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
|
|
894
1800
|
if (!restored) {
|
|
895
|
-
// Permission denied or handle lost
|
|
1801
|
+
// Permission denied or handle lost -- fall through without changing provider
|
|
896
1802
|
return;
|
|
897
1803
|
}
|
|
898
1804
|
nextProvider = restored;
|
|
899
1805
|
}
|
|
900
1806
|
else {
|
|
901
|
-
nextProvider =
|
|
1807
|
+
nextProvider = await createIndexedDbFileSystemProvider(ws.id, ws.name);
|
|
902
1808
|
}
|
|
1809
|
+
if (requestId !== navigationRequestRef.current)
|
|
1810
|
+
return;
|
|
1811
|
+
if (!(await transitionAwayFromDocument(requestId)))
|
|
1812
|
+
return;
|
|
903
1813
|
setProvider(nextProvider);
|
|
904
1814
|
setActiveWorkspaceId(ws.id);
|
|
905
1815
|
setSelectedFile(null);
|
|
906
1816
|
setSelectedFolder(null);
|
|
907
1817
|
setFolderEntries([]);
|
|
908
|
-
setEditorContent('');
|
|
909
|
-
setEditorKey((k) => k + 1);
|
|
910
1818
|
pushHash(ws.id, null);
|
|
911
|
-
}, [pushHash]);
|
|
1819
|
+
}, [pushHash, transitionAwayFromDocument]);
|
|
912
1820
|
const handleOpenFolder = useCallback(async () => {
|
|
1821
|
+
const requestId = ++navigationRequestRef.current;
|
|
913
1822
|
try {
|
|
914
1823
|
if (isElectronHost()) {
|
|
915
1824
|
const info = await getDocBlocksHost().workspaces.pickFolder();
|
|
916
1825
|
if (!info)
|
|
917
1826
|
return; // user cancelled
|
|
1827
|
+
if (requestId !== navigationRequestRef.current)
|
|
1828
|
+
return;
|
|
918
1829
|
const descriptor = {
|
|
919
1830
|
id: info.id,
|
|
920
1831
|
name: info.name,
|
|
@@ -923,18 +1834,22 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
923
1834
|
lastOpened: new Date().toISOString(),
|
|
924
1835
|
};
|
|
925
1836
|
await saveWorkspace(descriptor);
|
|
926
|
-
|
|
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;
|
|
927
1842
|
setProvider(provider);
|
|
928
1843
|
setActiveWorkspaceId(descriptor.id);
|
|
929
1844
|
setSelectedFile(null);
|
|
930
1845
|
setSelectedFolder(null);
|
|
931
1846
|
setFolderEntries([]);
|
|
932
|
-
setEditorContent('');
|
|
933
|
-
setEditorKey((k) => k + 1);
|
|
934
1847
|
pushHash(descriptor.id, null);
|
|
935
1848
|
return;
|
|
936
1849
|
}
|
|
937
|
-
const nativeProvider = await openNativeFolder();
|
|
1850
|
+
const nativeProvider = await (await loadNativeFileSystem()).openNativeFolder();
|
|
1851
|
+
if (requestId !== navigationRequestRef.current)
|
|
1852
|
+
return;
|
|
938
1853
|
const descriptor = {
|
|
939
1854
|
id: nativeProvider.id,
|
|
940
1855
|
name: nativeProvider.label,
|
|
@@ -942,19 +1857,47 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
942
1857
|
lastOpened: new Date().toISOString(),
|
|
943
1858
|
};
|
|
944
1859
|
await saveWorkspace(descriptor);
|
|
1860
|
+
if (requestId !== navigationRequestRef.current)
|
|
1861
|
+
return;
|
|
1862
|
+
if (!(await transitionAwayFromDocument(requestId)))
|
|
1863
|
+
return;
|
|
945
1864
|
setProvider(nativeProvider);
|
|
946
1865
|
setActiveWorkspaceId(descriptor.id);
|
|
947
1866
|
setSelectedFile(null);
|
|
948
1867
|
setSelectedFolder(null);
|
|
949
1868
|
setFolderEntries([]);
|
|
950
|
-
setEditorContent('');
|
|
951
|
-
setEditorKey((k) => k + 1);
|
|
952
1869
|
pushHash(descriptor.id, null);
|
|
953
1870
|
}
|
|
954
1871
|
catch {
|
|
955
1872
|
// User cancelled or API not supported
|
|
956
1873
|
}
|
|
957
|
-
}, [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]);
|
|
958
1901
|
const handleNewFile = useCallback(async () => {
|
|
959
1902
|
if (!provider)
|
|
960
1903
|
return;
|
|
@@ -962,23 +1905,90 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
962
1905
|
if (!name)
|
|
963
1906
|
return;
|
|
964
1907
|
const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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);
|
|
968
1954
|
setInitialView('wysiwyg');
|
|
969
|
-
setEditorKey((k) => k + 1);
|
|
970
1955
|
setExplorerKey((k) => k + 1);
|
|
971
1956
|
closeWelcomeGateway();
|
|
972
1957
|
if (activeWorkspaceId) {
|
|
973
1958
|
pushHash(activeWorkspaceId, '/' + filename);
|
|
974
1959
|
}
|
|
975
|
-
}, [
|
|
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]);
|
|
976
1986
|
const handleRevealWorkspace = useCallback(async () => {
|
|
977
1987
|
if (!isElectronHost() || !activeWorkspaceId)
|
|
978
1988
|
return;
|
|
979
1989
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
980
1990
|
if (ws?.type === 'electron-native' && ws.rootPath) {
|
|
981
|
-
await getDocBlocksHost().shell.revealInFolder(ws.
|
|
1991
|
+
await getDocBlocksHost().shell.revealInFolder(ws.id);
|
|
982
1992
|
}
|
|
983
1993
|
}, [activeWorkspaceId]);
|
|
984
1994
|
// Subscribe to native menu commands (Electron host).
|
|
@@ -997,6 +2007,46 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
997
2007
|
case 'file:revealWorkspace':
|
|
998
2008
|
handleRevealWorkspace();
|
|
999
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;
|
|
1000
2050
|
case 'help:viewOnGitHub':
|
|
1001
2051
|
host.shell.openExternal('https://github.com/bendyline/docblocks');
|
|
1002
2052
|
break;
|
|
@@ -1016,58 +2066,126 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1016
2066
|
}
|
|
1017
2067
|
});
|
|
1018
2068
|
}, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
|
|
1019
|
-
//
|
|
1020
|
-
|
|
1021
|
-
if (!isElectronHost())
|
|
1022
|
-
return;
|
|
1023
|
-
const host = getDocBlocksHost();
|
|
1024
|
-
return host.onOpenRequest(async (req) => {
|
|
1025
|
-
await openFromIds(req.workspaceId, req.path, true);
|
|
1026
|
-
});
|
|
1027
|
-
}, [openFromIds]);
|
|
2069
|
+
// OS open-file / deep-link handling lives after the container helpers it
|
|
2070
|
+
// depends on -- see the `openTransient` + onOpenRequest effect below.
|
|
1028
2071
|
const handleSelect = useCallback(async (path, kind) => {
|
|
1029
2072
|
if (!provider || !activeWorkspaceId)
|
|
1030
2073
|
return;
|
|
2074
|
+
const requestId = ++navigationRequestRef.current;
|
|
1031
2075
|
if (kind === 'directory') {
|
|
2076
|
+
if (!(await transitionAwayFromDocument(requestId)))
|
|
2077
|
+
return;
|
|
2078
|
+
if (requestId !== navigationRequestRef.current)
|
|
2079
|
+
return;
|
|
1032
2080
|
setSelectedFile(null);
|
|
1033
2081
|
setSelectedFolder(path);
|
|
1034
|
-
const entries = await provider
|
|
2082
|
+
const entries = await readProviderDirectory(provider, path);
|
|
2083
|
+
if (requestId !== navigationRequestRef.current)
|
|
2084
|
+
return;
|
|
1035
2085
|
setFolderEntries(entries);
|
|
1036
|
-
setEditorContent('');
|
|
1037
|
-
setEditorKey((k) => k + 1);
|
|
1038
2086
|
pushHash(activeWorkspaceId, null);
|
|
1039
2087
|
}
|
|
1040
2088
|
else {
|
|
1041
|
-
|
|
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;
|
|
1042
2111
|
setSelectedFile(path);
|
|
1043
2112
|
setSelectedFolder(null);
|
|
1044
2113
|
setFolderEntries([]);
|
|
1045
|
-
setEditorContent(content ?? '');
|
|
1046
2114
|
setInitialView('wysiwyg');
|
|
1047
|
-
setEditorKey((k) => k + 1);
|
|
1048
2115
|
closeWelcomeGateway();
|
|
1049
2116
|
pushHash(activeWorkspaceId, path);
|
|
1050
2117
|
saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
|
|
1051
2118
|
if (effectiveCompact)
|
|
1052
2119
|
setMobileShowEditor(true);
|
|
1053
2120
|
}
|
|
1054
|
-
}, [
|
|
1055
|
-
|
|
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) => {
|
|
1056
2150
|
if (!provider)
|
|
1057
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
|
+
}
|
|
1058
2178
|
// If the open file was deleted, clear the editor
|
|
1059
2179
|
if (selectedFile) {
|
|
1060
|
-
const exists = await provider
|
|
2180
|
+
const exists = await providerEntryExists(provider, selectedFile);
|
|
1061
2181
|
if (!exists) {
|
|
1062
2182
|
setSelectedFile(null);
|
|
1063
|
-
setEditorContent('');
|
|
1064
|
-
setEditorKey((k) => k + 1);
|
|
1065
2183
|
if (activeWorkspaceId)
|
|
1066
2184
|
pushHash(activeWorkspaceId, null);
|
|
1067
2185
|
}
|
|
1068
2186
|
}
|
|
1069
2187
|
if (selectedFolder) {
|
|
1070
|
-
const entries = await provider
|
|
2188
|
+
const entries = await readProviderDirectory(provider, selectedFolder);
|
|
1071
2189
|
setFolderEntries(entries);
|
|
1072
2190
|
}
|
|
1073
2191
|
}, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
|
|
@@ -1081,6 +2199,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1081
2199
|
const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
|
|
1082
2200
|
const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
|
|
1083
2201
|
const entries = await source.listFiles();
|
|
2202
|
+
const targetV2 = getFileSystemProviderV2(target);
|
|
1084
2203
|
for (const entry of entries) {
|
|
1085
2204
|
if (entry.path.endsWith('.md'))
|
|
1086
2205
|
continue;
|
|
@@ -1088,7 +2207,16 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1088
2207
|
if (!data)
|
|
1089
2208
|
continue;
|
|
1090
2209
|
const cleanPath = entry.path.replace(/^\/+/, '');
|
|
1091
|
-
|
|
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
|
+
}
|
|
1092
2220
|
}
|
|
1093
2221
|
}, []);
|
|
1094
2222
|
const handleImportFiles = useCallback(async (files) => {
|
|
@@ -1124,7 +2252,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1124
2252
|
else {
|
|
1125
2253
|
continue;
|
|
1126
2254
|
}
|
|
1127
|
-
await provider
|
|
2255
|
+
await writeProviderText(provider, destPath, markdown);
|
|
1128
2256
|
}
|
|
1129
2257
|
catch (err) {
|
|
1130
2258
|
console.error(`Failed to import ${file.name}:`, err);
|
|
@@ -1134,8 +2262,16 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1134
2262
|
setExplorerKey((k) => k + 1);
|
|
1135
2263
|
}, [provider, persistImportedMedia]);
|
|
1136
2264
|
const handleEditorChange = useCallback((source) => {
|
|
1137
|
-
|
|
1138
|
-
|
|
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]);
|
|
1139
2275
|
const handleRenameWorkspace = useCallback(async () => {
|
|
1140
2276
|
if (!activeWorkspaceId)
|
|
1141
2277
|
return;
|
|
@@ -1146,45 +2282,210 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1146
2282
|
if (!newName || newName === ws.name)
|
|
1147
2283
|
return;
|
|
1148
2284
|
await saveWorkspace({ ...ws, name: newName });
|
|
1149
|
-
|
|
1150
|
-
setEditorKey((k) => k + 1);
|
|
2285
|
+
setDescriptorRefreshKey((key) => key + 1);
|
|
1151
2286
|
}, [activeWorkspaceId]);
|
|
1152
2287
|
/**
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
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.
|
|
1156
2291
|
*/
|
|
1157
|
-
const
|
|
1158
|
-
const
|
|
1159
|
-
const
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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.');
|
|
1167
2323
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
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.');
|
|
1176
2334
|
}
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
2335
|
+
if (!isCurrent())
|
|
2336
|
+
return;
|
|
2337
|
+
const version = await sha256Hex(bytes);
|
|
2338
|
+
if (!isCurrent())
|
|
2339
|
+
return;
|
|
2340
|
+
const { zipToContainer } = await import('@bendyline/squisq-formats/container');
|
|
2341
|
+
if (!isCurrent())
|
|
2342
|
+
return;
|
|
2343
|
+
const container = await zipToContainer(bytes);
|
|
2344
|
+
if (!isCurrent())
|
|
2345
|
+
return;
|
|
2346
|
+
const base = req.name.replace(/\.[^.]+$/, '');
|
|
2347
|
+
primaryFile = `${base}.md`;
|
|
2348
|
+
const snapshot = await createDbkWorkspaceSnapshot(container, {
|
|
2349
|
+
targetDocumentPath: primaryFile,
|
|
2350
|
+
});
|
|
2351
|
+
if (!isCurrent())
|
|
2352
|
+
return;
|
|
2353
|
+
mem.replaceContents(snapshot);
|
|
2354
|
+
origin = { kind: 'dbk', resourceId: req.resourceId, version };
|
|
2355
|
+
}
|
|
2356
|
+
const descriptor = {
|
|
2357
|
+
id,
|
|
2358
|
+
name: req.name,
|
|
2359
|
+
type: 'transient',
|
|
2360
|
+
lastOpened: new Date().toISOString(),
|
|
2361
|
+
origin,
|
|
2362
|
+
};
|
|
2363
|
+
registerTransientWorkspace(descriptor, mem);
|
|
2364
|
+
registered = true;
|
|
2365
|
+
opened =
|
|
2366
|
+
(await openFromIds(id, `/${primaryFile}`, true, undefined, navigationRequestId)) !== null;
|
|
2367
|
+
}
|
|
2368
|
+
finally {
|
|
2369
|
+
try {
|
|
2370
|
+
if (!opened && registered && getTransientWorkspace(id)?.provider === mem) {
|
|
2371
|
+
await unregisterTransientWorkspace(id);
|
|
2372
|
+
registered = false;
|
|
2373
|
+
}
|
|
2374
|
+
else if (!registered) {
|
|
2375
|
+
await getFileSystemProviderV2(mem)?.dispose();
|
|
1180
2376
|
}
|
|
1181
2377
|
}
|
|
2378
|
+
finally {
|
|
2379
|
+
if (!opened)
|
|
2380
|
+
await revokeAbandonedResource();
|
|
2381
|
+
}
|
|
1182
2382
|
}
|
|
1183
|
-
}, []);
|
|
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;
|
|
1184
2484
|
const handleDownloadWorkspace = useCallback(async () => {
|
|
1185
2485
|
if (!provider)
|
|
1186
2486
|
return;
|
|
1187
2487
|
try {
|
|
2488
|
+
await documentSession.flush('backup');
|
|
1188
2489
|
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1189
2490
|
import('@bendyline/squisq/storage'),
|
|
1190
2491
|
import('@bendyline/squisq-formats/container'),
|
|
@@ -1204,23 +2505,26 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1204
2505
|
console.error('Failed to download workspace', err);
|
|
1205
2506
|
alert('Failed to download workspace. See console for details.');
|
|
1206
2507
|
}
|
|
1207
|
-
}, [provider,
|
|
2508
|
+
}, [provider, documentSession]);
|
|
1208
2509
|
/**
|
|
1209
2510
|
* Bundle every workspace the host can open without further prompting
|
|
1210
2511
|
* into a single zip, with each workspace nested under its own folder.
|
|
1211
2512
|
* Native (browser-picked) workspaces whose handle hasn't been re-granted
|
|
1212
|
-
* for this session are skipped
|
|
2513
|
+
* for this session are skipped -- restoring them would require a user
|
|
1213
2514
|
* gesture per workspace.
|
|
1214
2515
|
*/
|
|
1215
2516
|
const handleDownloadAllWorkspaces = useCallback(async () => {
|
|
1216
2517
|
try {
|
|
2518
|
+
await documentSession.flush('backup');
|
|
1217
2519
|
const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
|
|
1218
2520
|
import('@bendyline/squisq/storage'),
|
|
1219
2521
|
import('@bendyline/squisq-formats/container'),
|
|
1220
2522
|
]);
|
|
1221
2523
|
const electron = isElectronHost();
|
|
1222
2524
|
const all = await listWorkspaces();
|
|
1223
|
-
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');
|
|
1224
2528
|
if (candidates.length === 0) {
|
|
1225
2529
|
alert('No workspaces to download.');
|
|
1226
2530
|
return;
|
|
@@ -1230,17 +2534,25 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1230
2534
|
const skipped = [];
|
|
1231
2535
|
for (const ws of candidates) {
|
|
1232
2536
|
let p = null;
|
|
2537
|
+
let ownsProvider = false;
|
|
1233
2538
|
try {
|
|
1234
|
-
|
|
2539
|
+
const transient = getTransientWorkspace(ws.id);
|
|
2540
|
+
if (transient) {
|
|
2541
|
+
p = transient.provider;
|
|
2542
|
+
}
|
|
2543
|
+
else if (ws.type === 'electron-native') {
|
|
1235
2544
|
p = await createElectronProviderFromWorkspace(ws);
|
|
2545
|
+
ownsProvider = p !== null;
|
|
1236
2546
|
}
|
|
1237
2547
|
else if (ws.type === 'native') {
|
|
1238
|
-
// Restore without prompting
|
|
2548
|
+
// Restore without prompting -- only succeeds when the browser
|
|
1239
2549
|
// still remembers the granted handle for this origin/session.
|
|
1240
|
-
p = await restoreNativeFolder(ws.id);
|
|
2550
|
+
p = await (await loadNativeFileSystem()).restoreNativeFolder(ws.id);
|
|
2551
|
+
ownsProvider = p !== null;
|
|
1241
2552
|
}
|
|
1242
2553
|
else {
|
|
1243
|
-
p =
|
|
2554
|
+
p = await createIndexedDbFileSystemProvider(ws.id, ws.name);
|
|
2555
|
+
ownsProvider = true;
|
|
1244
2556
|
}
|
|
1245
2557
|
}
|
|
1246
2558
|
catch (err) {
|
|
@@ -1258,7 +2570,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1258
2570
|
folder = `${base} (${suffix++})`;
|
|
1259
2571
|
}
|
|
1260
2572
|
usedFolders.add(folder);
|
|
1261
|
-
|
|
2573
|
+
try {
|
|
2574
|
+
await copyProviderToContainer(p, container, folder);
|
|
2575
|
+
}
|
|
2576
|
+
finally {
|
|
2577
|
+
if (ownsProvider)
|
|
2578
|
+
await getFileSystemProviderV2(p)?.dispose();
|
|
2579
|
+
}
|
|
1262
2580
|
}
|
|
1263
2581
|
if (usedFolders.size === 0) {
|
|
1264
2582
|
alert('No workspaces could be opened for download.');
|
|
@@ -1280,7 +2598,52 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1280
2598
|
console.error('Failed to download all workspaces', err);
|
|
1281
2599
|
alert('Failed to download all workspaces. See console for details.');
|
|
1282
2600
|
}
|
|
1283
|
-
}, [
|
|
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
|
+
}, []);
|
|
1284
2647
|
const handleRemoveWorkspace = useCallback(async () => {
|
|
1285
2648
|
if (!activeWorkspaceId)
|
|
1286
2649
|
return;
|
|
@@ -1289,19 +2652,35 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1289
2652
|
: 'Remove this workspace? This cannot be undone.';
|
|
1290
2653
|
if (!confirm(confirmMsg))
|
|
1291
2654
|
return;
|
|
2655
|
+
const requestId = ++navigationRequestRef.current;
|
|
2656
|
+
if (!(await transitionAwayFromDocument(requestId)))
|
|
2657
|
+
return;
|
|
1292
2658
|
const ws = await getWorkspace(activeWorkspaceId);
|
|
1293
2659
|
if (ws?.type === 'electron-native') {
|
|
1294
2660
|
try {
|
|
1295
2661
|
await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
|
|
1296
2662
|
}
|
|
1297
2663
|
catch {
|
|
1298
|
-
// ignore
|
|
2664
|
+
// ignore -- host cleanup is best-effort
|
|
1299
2665
|
}
|
|
1300
2666
|
}
|
|
1301
|
-
else {
|
|
1302
|
-
|
|
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);
|
|
1303
2680
|
}
|
|
1304
2681
|
await removeWorkspace(activeWorkspaceId);
|
|
2682
|
+
if (requestId !== navigationRequestRef.current)
|
|
2683
|
+
return;
|
|
1305
2684
|
const electron = isElectronHost();
|
|
1306
2685
|
// Switch to most recent remaining workspace or create default
|
|
1307
2686
|
const remaining = (await listWorkspaces()).filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
|
|
@@ -1319,33 +2698,33 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersi
|
|
|
1319
2698
|
lastOpened: new Date().toISOString(),
|
|
1320
2699
|
};
|
|
1321
2700
|
await saveWorkspace(descriptor);
|
|
1322
|
-
const p =
|
|
2701
|
+
const p = await createElectronFileSystemProvider(info.id, info.name, info.rootPath);
|
|
1323
2702
|
setProvider(p);
|
|
1324
2703
|
setActiveWorkspaceId(info.id);
|
|
1325
2704
|
setSelectedFile(null);
|
|
1326
2705
|
setSelectedFolder(null);
|
|
1327
2706
|
setFolderEntries([]);
|
|
1328
|
-
setEditorContent('');
|
|
1329
|
-
setEditorKey((k) => k + 1);
|
|
1330
2707
|
}
|
|
1331
2708
|
else {
|
|
1332
2709
|
const defaultWs = await ensureDefaultWorkspace();
|
|
1333
|
-
const fsProvider =
|
|
2710
|
+
const fsProvider = await createIndexedDbFileSystemProvider(defaultWs.id, defaultWs.name);
|
|
1334
2711
|
setProvider(fsProvider);
|
|
1335
2712
|
setActiveWorkspaceId(defaultWs.id);
|
|
1336
2713
|
setSelectedFile(null);
|
|
1337
2714
|
setSelectedFolder(null);
|
|
1338
2715
|
setFolderEntries([]);
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
2716
|
+
}
|
|
2717
|
+
}, [activeWorkspaceId, handleWorkspaceSelect, transitionAwayFromDocument]);
|
|
2718
|
+
return (_jsx("div", { className: `db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, "data-accent": accentColor, "data-document-status": documentSnapshot.status, children: _jsxs(GitContext.Provider, { value: git, children: [workspaceStartupError && (_jsx("div", { className: "db-save-toast db-save-toast--error", role: "alert", "aria-live": "assertive", children: workspaceStartupError })), saveToast && (_jsx("div", { className: 'db-save-toast db-save-toast--' + saveToast.kind, role: saveToast.kind === 'error' ? 'alert' : 'status', "aria-live": saveToast.kind === 'error' ? 'assertive' : 'polite', children: saveToast.message })), offlineReadyToast && !saveToast && (_jsx("div", { className: "db-save-toast db-save-toast--success", role: "status", "aria-live": "polite", children: "DocBlocks is ready to work offline." })), documentSnapshot.conflict && (_jsxs("div", { className: "db-document-conflict", role: "alert", children: [_jsx("span", { children: "This document changed outside DocBlocks. Your unsaved version is still intact." }), _jsxs("div", { className: "db-document-conflict-actions", children: [_jsx("button", { type: "button", onClick: () => void handleKeepLocalDocument(), children: "Keep mine" }), _jsx("button", { type: "button", onClick: () => void handleUseExternalDocument(), children: "Reload external" })] })] })), storageFull && !documentSnapshot.conflict && (_jsxs("div", { className: "db-storage-full-banner", role: "alert", children: [_jsx("span", { children: "Browser storage is full -- changes can\u2019t be saved. Free up space or back up your work now." }), _jsxs("div", { className: "db-storage-full-banner-actions", children: [_jsx("button", { type: "button", onClick: () => void handleDownloadAllWorkspaces(), children: "Download all workspaces" }), _jsx("button", { type: "button", onClick: () => setStorageFull(false), children: "Dismiss" })] })] })), workspaceSettingsOpen && activeWorkspaceDescriptor && (_jsx(WorkspaceSettingsDialog, { workspace: activeWorkspaceDescriptor, globalVersioningPreference: versioningPreference, onChange: handleWorkspaceVersioningOverrideChange, onClose: () => setWorkspaceSettingsOpen(false) })), _jsxs("div", { style: { display: 'flex', flex: 1, overflow: 'hidden' }, children: [(!effectiveCompact || !mobileShowEditor) && (_jsxs("div", { ref: sidebarRef, className: "db-shell-sidebar", style: effectiveCompact ? undefined : { width: `${sidebarWidth}px` }, children: [_jsxs("div", { className: "db-shell-sidebar-header", children: [_jsx(AppMenu, { logoUrl: logoUrl, themePreference: themePreference, onThemeChange: handleThemeChange, accentColor: accentColor, onAccentColorChange: handleAccentColorChange, versioningPreference: versioningPreference, onVersioningPreferenceChange: handleVersioningPreferenceChange, onDownloadAllWorkspaces: handleDownloadAllWorkspaces, onKeepBrowserData: showBrowserStorageWarning && !browserStoragePersistent
|
|
2719
|
+
? handleKeepBrowserData
|
|
2720
|
+
: undefined, onInstallApp: installPromptEvent ? handleInstallApp : undefined, getStorageEstimate: showBrowserStorageWarning ? getBrowserStorageEstimate : undefined, storagePersistent: showBrowserStorageWarning ? browserStoragePersistent : undefined }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder, onCloneRepository: git.available ? () => git.openDialog({ kind: 'clone' }) : undefined }), _jsx(WorkspaceSettingsButton, { onSettings: handleOpenWorkspaceSettings, onRename: handleRenameWorkspace, onDownload: handleDownloadWorkspace, onRemove: handleRemoveWorkspace }), compactLayout && !isMobile && (_jsx("button", { className: "db-restore-split", onClick: () => setCompactLayout(false), "aria-label": "Restore split view", title: "Restore split view", children: _jsx(SplitViewIcon, {}) }))] }), git.available && (_jsx(Suspense, { fallback: null, children: _jsx(GitUI, { onOpenFile: (path) => void handleSelect(path, 'file'), onWorkspaceCloned: handleWorkspaceCloned }) })), _jsx(FileExplorer, { provider: provider, activeFilePath: selectedFile, onSelect: handleSelect, onTreeMutation: handleTreeMutation, onTreeChange: handleTreeChange, onImportFiles: handleImportFiles }, explorerKey), _jsxs("div", { className: "db-shell-sidebar-footer", children: [_jsx("a", { href: "https://github.com/bendyline/docblocks/blob/main/LICENSE", target: "_blank", rel: "noopener noreferrer", children: "Terms of Use" }), showBrowserStorageWarning && (_jsxs(_Fragment, { children: [_jsx("span", { className: "db-shell-sidebar-footer-separator", "aria-hidden": "true", children: "\u2022" }), _jsx("button", { type: "button", className: "db-shell-sidebar-footer-action", onClick: () => void handleDownloadAllWorkspaces(), title: "Browser docs can get auto-removed. Download all workspaces.", children: "Backup browser docs frequently" })] }))] })] })), !effectiveCompact && (_jsx("div", { className: "db-shell-sidebar-resizer", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize sidebar", onPointerDown: handleResizerPointerDown })), (!effectiveCompact || mobileShowEditor) && (_jsxs("div", { className: updateAvailable && onApplyUpdate && updateStatusBarVisible
|
|
2721
|
+
? 'db-shell-editor-area db-shell-editor-area--has-update'
|
|
2722
|
+
: 'db-shell-editor-area', style: {
|
|
2723
|
+
flex: 1,
|
|
2724
|
+
overflow: 'hidden',
|
|
2725
|
+
display: 'flex',
|
|
2726
|
+
flexDirection: 'column',
|
|
2727
|
+
position: 'relative',
|
|
2728
|
+
}, children: [selectedFile && mediaProvider ? (_jsxs(MediaContext.Provider, { value: mediaProvider, children: [_jsx(Suspense, { fallback: _jsx("div", { className: "db-shell-empty", role: "status", children: "Loading editor\u2026" }), children: _jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, fileName: selectedFile, onChange: handleEditorChange, colorScheme: resolvedTheme, height: "100%", placeholder: editorPlaceholder, outlineWidth: 280, mediaProvider: mediaProvider, documentLinkProvider: documentLinkProvider, workspaceContainer: versionsContainer ?? undefined, allowVersioning: effectiveVersioning, viewPreferences: viewPreferences, onViewPreferencesChange: handleViewPreferencesChange, versionBasename: versionBasename ?? stripExtension(basenameOf(selectedFile)), versioningPrunePolicy: versioningPrunePolicy, versioningAutoSaveIdleMs: versioningAutoSaveIdleMs, onSaveVersion: onSaveVersion, toolbarSlotLeft: effectiveCompact ? (_jsx("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), "aria-label": "Show file list", children: _jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }) })) : undefined, toolbarSlotRight: _jsxs(_Fragment, { children: [compactLayout && !isMobile && (_jsx("button", { className: "db-restore-split", onClick: () => setCompactLayout(false), "aria-label": "Restore split view", title: "Restore split view", children: _jsx(SplitViewIcon, {}) })), git.repo && (_jsx(Suspense, { fallback: null, children: _jsx(GitToolbarControl, { selectedFile: selectedFile }) })), _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current, destinationAdapter: exportDestinationAdapter })] }) }, `${selectedFile}-${editorKey}`) }), showWelcomeGateway && (_jsxs("div", { className: "db-welcome-gateway", role: "note", "aria-label": "Welcome tip", children: [_jsxs("span", { className: "db-welcome-gateway-text", children: ["You\u2019re watching this welcome doc in ", _jsx("strong", { children: "Slideshow" }), " view -- it\u2019s a regular markdown file, and so is everything you\u2019ll write."] }), _jsx("button", { className: "db-welcome-gateway-cta", onClick: handleStartWriting, children: "Start writing" }), _jsx("button", { className: "db-welcome-gateway-dismiss", onClick: closeWelcomeGateway, "aria-label": "Dismiss welcome tip", title: "Dismiss", children: "\u00D7" })] }))] })) : selectedFolder ? (_jsxs("div", { className: "db-folder-view", children: [effectiveCompact && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsxs("div", { className: "db-folder-view-header", children: [_jsx("span", { className: "db-folder-view-icon", children: _jsx(FolderGlyph, {}) }), _jsx("span", { className: "db-folder-view-path", children: selectedFolder })] }), folderEntries.length === 0 ? (_jsx("p", { className: "db-folder-view-empty", children: "This folder is empty." })) : (_jsx("ul", { className: "db-folder-view-list", children: folderEntries.map((entry) => (_jsxs("li", { className: "db-folder-view-item", onClick: () => handleSelect(entry.path, entry.kind), children: [_jsx("span", { className: "db-folder-view-item-icon", children: entry.kind === 'directory' ? _jsx(FolderGlyph, {}) : _jsx(FileGlyph, {}) }), entry.name] }, entry.path))) }))] })) : (_jsxs("div", { className: "db-shell-empty", children: [effectiveCompact && (_jsxs("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: [_jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }), "Back to files"] })), _jsx("p", { children: "Select a file to start editing, or create a new one." })] })), _jsx(UpdateAvailableNotice, { available: updateAvailable, onApplyUpdate: onApplyUpdate, blocked: documentSnapshot.conflict !== null || storageFull, statusBarVisible: updateStatusBarVisible })] }))] })] }) }));
|
|
1350
2729
|
}
|
|
1351
2730
|
//# sourceMappingURL=DocBlocksShell.js.map
|