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