@bendyline/docblocks-react 1.0.0 → 1.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.
Files changed (133) hide show
  1. package/dist/AppMenu/AppMenu.d.ts +16 -1
  2. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  3. package/dist/AppMenu/AppMenu.js +3 -2
  4. package/dist/AppMenu/AppMenu.js.map +1 -1
  5. package/dist/AppMenu/index.d.ts +1 -1
  6. package/dist/AppMenu/index.d.ts.map +1 -1
  7. package/dist/DocBlocksShell/DocBlocksShell.d.ts +21 -1
  8. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  9. package/dist/DocBlocksShell/DocBlocksShell.js +930 -98
  10. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  11. package/dist/Export/ExportDialog.d.ts.map +1 -1
  12. package/dist/Export/ExportDialog.js +104 -5
  13. package/dist/Export/ExportDialog.js.map +1 -1
  14. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  15. package/dist/Export/ExportToolbarControls.js +44 -3
  16. package/dist/Export/ExportToolbarControls.js.map +1 -1
  17. package/dist/Export/export-options.d.ts +35 -0
  18. package/dist/Export/export-options.d.ts.map +1 -1
  19. package/dist/Export/export-options.js +6 -1
  20. package/dist/Export/export-options.js.map +1 -1
  21. package/dist/Export/run-export.d.ts.map +1 -1
  22. package/dist/Export/run-export.js +163 -91
  23. package/dist/Export/run-export.js.map +1 -1
  24. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  25. package/dist/FileExplorer/FileExplorer.js +17 -2
  26. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  27. package/dist/FileExplorer/FileTreeNode.d.ts.map +1 -1
  28. package/dist/FileExplorer/FileTreeNode.js +34 -8
  29. package/dist/FileExplorer/FileTreeNode.js.map +1 -1
  30. package/dist/WorkspacePicker/WorkspacePicker.d.ts.map +1 -1
  31. package/dist/WorkspacePicker/WorkspacePicker.js +9 -3
  32. package/dist/WorkspacePicker/WorkspacePicker.js.map +1 -1
  33. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
  34. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
  35. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
  36. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
  37. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
  38. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
  39. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
  40. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
  41. package/dist/hooks/useAutoSave.d.ts +8 -2
  42. package/dist/hooks/useAutoSave.d.ts.map +1 -1
  43. package/dist/hooks/useAutoSave.js +65 -30
  44. package/dist/hooks/useAutoSave.js.map +1 -1
  45. package/dist/icons.d.ts +1 -0
  46. package/dist/icons.d.ts.map +1 -1
  47. package/dist/icons.js +3 -0
  48. package/dist/icons.js.map +1 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/preferences/versioning.d.ts +27 -0
  52. package/dist/preferences/versioning.d.ts.map +1 -0
  53. package/dist/preferences/versioning.js +62 -0
  54. package/dist/preferences/versioning.js.map +1 -0
  55. package/package.json +10 -8
  56. package/src/AppMenu/AppMenu.tsx +142 -2
  57. package/src/AppMenu/index.ts +1 -1
  58. package/src/DocBlocksShell/DocBlocksShell.tsx +1194 -125
  59. package/src/Export/ExportDialog.tsx +208 -19
  60. package/src/Export/ExportToolbarControls.tsx +45 -3
  61. package/src/Export/export-options.ts +43 -1
  62. package/src/Export/run-export.ts +190 -91
  63. package/src/FileExplorer/FileExplorer.tsx +31 -9
  64. package/src/FileExplorer/FileTreeNode.tsx +68 -26
  65. package/src/WorkspacePicker/WorkspacePicker.tsx +18 -7
  66. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
  67. package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
  68. package/src/fonts/fonts.css +362 -0
  69. package/src/fonts/licenses/LICENSE-cormorant-garamond.txt +93 -0
  70. package/src/fonts/licenses/LICENSE-crimson-text.txt +93 -0
  71. package/src/fonts/licenses/LICENSE-dm-sans.txt +93 -0
  72. package/src/fonts/licenses/LICENSE-dm-serif-display.txt +93 -0
  73. package/src/fonts/licenses/LICENSE-hanken-grotesk.txt +94 -0
  74. package/src/fonts/licenses/LICENSE-ibm-plex-sans.txt +93 -0
  75. package/src/fonts/licenses/LICENSE-inter.txt +93 -0
  76. package/src/fonts/licenses/LICENSE-jetbrains-mono.txt +93 -0
  77. package/src/fonts/licenses/LICENSE-lora.txt +93 -0
  78. package/src/fonts/licenses/LICENSE-merriweather.txt +93 -0
  79. package/src/fonts/licenses/LICENSE-oswald.txt +93 -0
  80. package/src/fonts/licenses/LICENSE-playfair-display.txt +93 -0
  81. package/src/fonts/licenses/LICENSE-pt-serif.txt +93 -0
  82. package/src/fonts/licenses/LICENSE-roboto.txt +93 -0
  83. package/src/fonts/licenses/LICENSE-source-serif-4.txt +93 -0
  84. package/src/fonts/woff2/cormorant-garamond-400.woff2 +0 -0
  85. package/src/fonts/woff2/cormorant-garamond-700.woff2 +0 -0
  86. package/src/fonts/woff2/crimson-text-400.woff2 +0 -0
  87. package/src/fonts/woff2/crimson-text-400i.woff2 +0 -0
  88. package/src/fonts/woff2/crimson-text-700.woff2 +0 -0
  89. package/src/fonts/woff2/dm-sans-400.woff2 +0 -0
  90. package/src/fonts/woff2/dm-sans-400i.woff2 +0 -0
  91. package/src/fonts/woff2/dm-sans-500.woff2 +0 -0
  92. package/src/fonts/woff2/dm-sans-700.woff2 +0 -0
  93. package/src/fonts/woff2/dm-serif-display-400.woff2 +0 -0
  94. package/src/fonts/woff2/hanken-grotesk-400.woff2 +0 -0
  95. package/src/fonts/woff2/hanken-grotesk-400i.woff2 +0 -0
  96. package/src/fonts/woff2/hanken-grotesk-500.woff2 +0 -0
  97. package/src/fonts/woff2/hanken-grotesk-600.woff2 +0 -0
  98. package/src/fonts/woff2/hanken-grotesk-700.woff2 +0 -0
  99. package/src/fonts/woff2/ibm-plex-sans-400.woff2 +0 -0
  100. package/src/fonts/woff2/ibm-plex-sans-400i.woff2 +0 -0
  101. package/src/fonts/woff2/ibm-plex-sans-500.woff2 +0 -0
  102. package/src/fonts/woff2/ibm-plex-sans-600.woff2 +0 -0
  103. package/src/fonts/woff2/ibm-plex-sans-700.woff2 +0 -0
  104. package/src/fonts/woff2/inter-400.woff2 +0 -0
  105. package/src/fonts/woff2/inter-400i.woff2 +0 -0
  106. package/src/fonts/woff2/inter-500.woff2 +0 -0
  107. package/src/fonts/woff2/inter-600.woff2 +0 -0
  108. package/src/fonts/woff2/inter-700.woff2 +0 -0
  109. package/src/fonts/woff2/jetbrains-mono-400.woff2 +0 -0
  110. package/src/fonts/woff2/jetbrains-mono-400i.woff2 +0 -0
  111. package/src/fonts/woff2/jetbrains-mono-700.woff2 +0 -0
  112. package/src/fonts/woff2/lora-400.woff2 +0 -0
  113. package/src/fonts/woff2/lora-400i.woff2 +0 -0
  114. package/src/fonts/woff2/lora-700.woff2 +0 -0
  115. package/src/fonts/woff2/merriweather-400.woff2 +0 -0
  116. package/src/fonts/woff2/merriweather-700.woff2 +0 -0
  117. package/src/fonts/woff2/oswald-400.woff2 +0 -0
  118. package/src/fonts/woff2/oswald-700.woff2 +0 -0
  119. package/src/fonts/woff2/playfair-display-400.woff2 +0 -0
  120. package/src/fonts/woff2/playfair-display-700.woff2 +0 -0
  121. package/src/fonts/woff2/pt-serif-400.woff2 +0 -0
  122. package/src/fonts/woff2/pt-serif-700.woff2 +0 -0
  123. package/src/fonts/woff2/roboto-400.woff2 +0 -0
  124. package/src/fonts/woff2/roboto-400i.woff2 +0 -0
  125. package/src/fonts/woff2/roboto-700.woff2 +0 -0
  126. package/src/fonts/woff2/source-serif-4-400.woff2 +0 -0
  127. package/src/fonts/woff2/source-serif-4-400i.woff2 +0 -0
  128. package/src/fonts/woff2/source-serif-4-700.woff2 +0 -0
  129. package/src/hooks/useAutoSave.ts +87 -29
  130. package/src/icons.tsx +10 -0
  131. package/src/index.ts +1 -1
  132. package/src/preferences/versioning.ts +69 -0
  133. package/src/styles/docblocks.css +1037 -55
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * DocBlocksShell — top-level layout component.
4
4
  *
@@ -9,15 +9,18 @@ import { useState, useCallback, useEffect, useRef } from 'react';
9
9
  import { EditorShell } from '@bendyline/squisq-editor-react';
10
10
  import '@bendyline/squisq-editor-react/styles';
11
11
  import { MediaContext } from '@bendyline/squisq-react';
12
- import { createMediaProviderFromContainer } from '@bendyline/squisq/storage';
13
- import { IndexedDBFileSystemProvider, IndexedDBContentContainer, openNativeFolder, restoreNativeFolder, removeDirectoryHandle, } from '@bendyline/docblocks/filesystem';
12
+ import { DocumentVersionManager, } from '@bendyline/squisq/versions';
13
+ import { IndexedDBFileSystemProvider, ElectronFileSystemProvider, FileSystemContentContainer, createFileMediaProvider, openNativeFolder, restoreNativeFolder, removeDirectoryHandle, } from '@bendyline/docblocks/filesystem';
14
+ import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
14
15
  import { ensureDefaultWorkspace, getWorkspace, listWorkspaces, removeWorkspace, saveWorkspace, touchWorkspace, } from '@bendyline/docblocks/workspace';
15
16
  import { AppMenu } from '../AppMenu/AppMenu.js';
16
17
  import { FileExplorer } from '../FileExplorer/FileExplorer.js';
17
18
  import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
18
19
  import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
20
+ import { WorkspaceSettingsDialog, } from '../WorkspacePicker/WorkspaceSettingsDialog.js';
19
21
  import { useAutoSave } from '../hooks/useAutoSave.js';
20
22
  import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
23
+ import { loadVersioningPreference, resolveVersioningEnabled, saveVersioningPreference, } from '../preferences/versioning.js';
21
24
  function useOsTheme() {
22
25
  const [dark, setDark] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
23
26
  useEffect(() => {
@@ -50,6 +53,204 @@ function parseHash() {
50
53
  filePath: '/' + decodeURIComponent(raw.slice(slashIdx + 1)),
51
54
  };
52
55
  }
56
+ const LAST_STATE_KEY = 'docblocks:lastState';
57
+ function saveLastState(state) {
58
+ try {
59
+ localStorage.setItem(LAST_STATE_KEY, JSON.stringify(state));
60
+ }
61
+ catch {
62
+ // ignore quota errors
63
+ }
64
+ }
65
+ function loadLastState() {
66
+ try {
67
+ const raw = localStorage.getItem(LAST_STATE_KEY);
68
+ if (!raw)
69
+ return null;
70
+ return JSON.parse(raw);
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ const THEME_PREF_KEY = 'docblocks:themePreference';
77
+ function loadThemePreference() {
78
+ try {
79
+ const raw = localStorage.getItem(THEME_PREF_KEY);
80
+ if (raw === 'light' || raw === 'dark' || raw === 'auto')
81
+ return raw;
82
+ }
83
+ catch {
84
+ // ignore
85
+ }
86
+ return 'auto';
87
+ }
88
+ function saveThemePreference(pref) {
89
+ try {
90
+ localStorage.setItem(THEME_PREF_KEY, pref);
91
+ }
92
+ catch {
93
+ // ignore quota errors
94
+ }
95
+ }
96
+ const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
97
+ const SIDEBAR_WIDTH_DEFAULT = 260;
98
+ const SIDEBAR_WIDTH_MIN = 180;
99
+ const SIDEBAR_WIDTH_MAX = 600;
100
+ /** Drag below this many pixels and the sidebar collapses entirely —
101
+ * the editor takes the full width and a back-arrow appears in the
102
+ * toolbar so the user can pop the sidebar back open. Same UX as
103
+ * the existing mobile narrow-viewport flow. */
104
+ const SIDEBAR_COLLAPSE_THRESHOLD = 120;
105
+ function loadSidebarWidth() {
106
+ try {
107
+ const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
108
+ if (raw === null)
109
+ return SIDEBAR_WIDTH_DEFAULT;
110
+ const n = Number(raw);
111
+ if (!Number.isFinite(n))
112
+ return SIDEBAR_WIDTH_DEFAULT;
113
+ return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n));
114
+ }
115
+ catch {
116
+ return SIDEBAR_WIDTH_DEFAULT;
117
+ }
118
+ }
119
+ function saveSidebarWidth(px) {
120
+ try {
121
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px)));
122
+ }
123
+ catch {
124
+ // ignore quota errors
125
+ }
126
+ }
127
+ const VIEW_PREFS_KEY = 'docblocks:viewPreferences';
128
+ const DEFAULT_VIEW_PREFS = {
129
+ outline: false,
130
+ inlinePreview: true,
131
+ showStatusBar: true,
132
+ };
133
+ function loadViewPreferences() {
134
+ try {
135
+ const raw = localStorage.getItem(VIEW_PREFS_KEY);
136
+ if (!raw)
137
+ return DEFAULT_VIEW_PREFS;
138
+ const parsed = JSON.parse(raw);
139
+ return {
140
+ outline: typeof parsed.outline === 'boolean' ? parsed.outline : DEFAULT_VIEW_PREFS.outline,
141
+ inlinePreview: typeof parsed.inlinePreview === 'boolean'
142
+ ? parsed.inlinePreview
143
+ : DEFAULT_VIEW_PREFS.inlinePreview,
144
+ showStatusBar: typeof parsed.showStatusBar === 'boolean'
145
+ ? parsed.showStatusBar
146
+ : DEFAULT_VIEW_PREFS.showStatusBar,
147
+ };
148
+ }
149
+ catch {
150
+ return DEFAULT_VIEW_PREFS;
151
+ }
152
+ }
153
+ function saveViewPreferences(prefs) {
154
+ try {
155
+ localStorage.setItem(VIEW_PREFS_KEY, JSON.stringify(prefs));
156
+ }
157
+ catch {
158
+ // ignore quota errors
159
+ }
160
+ }
161
+ function dirnameOf(p) {
162
+ const clean = p.replace(/^\/+/, '');
163
+ const idx = clean.lastIndexOf('/');
164
+ return idx === -1 ? '' : clean.slice(0, idx);
165
+ }
166
+ function basenameOf(p) {
167
+ const clean = p.replace(/^\/+/, '');
168
+ const idx = clean.lastIndexOf('/');
169
+ return idx === -1 ? clean : clean.slice(idx + 1);
170
+ }
171
+ /** Strip the extension from a filename (`notes.md` -> `notes`). Matches
172
+ * squisq's `getDocBasename` convention so version snapshot filenames
173
+ * stay readable (`<basename>.<timestamp>.md`). */
174
+ function stripExtension(name) {
175
+ return name.replace(/\.[^.]+$/, '');
176
+ }
177
+ function normaliseProviderPath(p) {
178
+ return '/' + p.replace(/^\/+/, '');
179
+ }
180
+ function sameProviderPath(a, b) {
181
+ return normaliseProviderPath(a) === normaliseProviderPath(b);
182
+ }
183
+ /** Portable relative link from one workspace file to another. Walks up
184
+ * from the source's directory and back down to the target so the link
185
+ * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
186
+ * `resume.md` for siblings at the workspace root). */
187
+ function relativeMarkdownLink(fromFile, toFile) {
188
+ const fromParts = fromFile.replace(/^\/+/, '').split('/').filter(Boolean);
189
+ const toParts = toFile.replace(/^\/+/, '').split('/').filter(Boolean);
190
+ const fromDir = fromParts.slice(0, -1);
191
+ const toDir = toParts.slice(0, -1);
192
+ const toBase = toParts[toParts.length - 1] ?? '';
193
+ let common = 0;
194
+ while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
195
+ common++;
196
+ }
197
+ const ups = fromDir.length - common;
198
+ const downs = [...toDir.slice(common), toBase];
199
+ const parts = [...Array(ups).fill('..'), ...downs];
200
+ return parts.length === 0 ? toBase : parts.join('/');
201
+ }
202
+ /** Recursively collect all `.md` files reachable from `root`. Skips
203
+ * Word-style `*_files/` asset companions, dotfiles, and `node_modules`
204
+ * so the candidate list stays workspace-meaningful. */
205
+ async function collectMarkdownFiles(fs, root) {
206
+ const out = [];
207
+ const visited = new Set();
208
+ async function walk(dir) {
209
+ if (visited.has(dir))
210
+ return;
211
+ visited.add(dir);
212
+ let entries;
213
+ try {
214
+ entries = await fs.readDirectory(dir);
215
+ }
216
+ catch {
217
+ return;
218
+ }
219
+ for (const entry of entries) {
220
+ if (entry.kind === 'directory') {
221
+ const lower = entry.name.toLowerCase();
222
+ if (lower.startsWith('.'))
223
+ continue;
224
+ if (lower === 'node_modules')
225
+ continue;
226
+ if (lower.endsWith('_files'))
227
+ continue;
228
+ await walk(entry.path);
229
+ }
230
+ else if (entry.kind === 'file') {
231
+ if (entry.name.toLowerCase().endsWith('.md'))
232
+ out.push(entry);
233
+ }
234
+ }
235
+ }
236
+ await walk(root);
237
+ return out;
238
+ }
239
+ async function createElectronProviderFromWorkspace(ws) {
240
+ if (!ws.rootPath)
241
+ return null;
242
+ try {
243
+ await getDocBlocksHost().workspaces.register({
244
+ id: ws.id,
245
+ name: ws.name,
246
+ rootPath: ws.rootPath,
247
+ });
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
253
+ }
53
254
  function useIsMobile(breakpoint = 768) {
54
255
  const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia(`(max-width: ${breakpoint}px)`).matches);
55
256
  useEffect(() => {
@@ -60,13 +261,148 @@ function useIsMobile(breakpoint = 768) {
60
261
  }, [breakpoint]);
61
262
  return isMobile;
62
263
  }
63
- export function DocBlocksShell({ theme = 'auto', logoUrl }) {
264
+ function FolderGlyph() {
265
+ 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" }) }));
266
+ }
267
+ function FileGlyph() {
268
+ return (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("path", { d: "M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" }), _jsx("path", { d: "M14 3v5h5" })] }));
269
+ }
270
+ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl, allowVersioning = true, versionBasename, versioningPrunePolicy, versioningAutoSaveIdleMs, onSaveVersion, versioningRef, }) {
64
271
  const osTheme = useOsTheme();
65
- const resolvedTheme = theme === 'auto' ? osTheme : theme;
272
+ const [themePreference, setThemePreference] = useState(loadThemePreference);
273
+ // "System default" (auto) always follows the OS — the host's theme prop
274
+ // is kept only for API back-compat and does not override OS detection.
275
+ const resolvedTheme = themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
276
+ const handleThemeChange = useCallback((pref) => {
277
+ setThemePreference(pref);
278
+ saveThemePreference(pref);
279
+ }, []);
280
+ const [viewPreferences, setViewPreferences] = useState(loadViewPreferences);
281
+ const handleViewPreferencesChange = useCallback((prefs) => {
282
+ setViewPreferences(prefs);
283
+ saveViewPreferences(prefs);
284
+ }, []);
66
285
  const isMobile = useIsMobile();
67
286
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
287
+ // Sidebar width — persisted across sessions, dragged via the resizer
288
+ // between sidebar and editor area. We track the "live" width during a
289
+ // drag in a ref so each mousemove doesn't trigger a state update; only
290
+ // setState on commit so React doesn't churn through every pixel.
291
+ const [sidebarWidth, setSidebarWidth] = useState(loadSidebarWidth);
292
+ // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
293
+ // we switch the layout into single-pane "compact" mode — same UX as
294
+ // the mobile narrow-viewport flow, where only the sidebar OR the
295
+ // editor is visible at a time and a back-arrow in the toolbar pops
296
+ // between them. A "Restore split view" button on the editor toolbar
297
+ // exits compact mode; on real mobile that button is suppressed
298
+ // because there's not enough viewport for side-by-side. Not
299
+ // persisted across reloads. */
300
+ const [compactLayout, setCompactLayout] = useState(false);
301
+ const effectiveCompact = isMobile || compactLayout;
302
+ const sidebarRef = useRef(null);
303
+ const dragStateRef = useRef(null);
304
+ const handleResizerPointerDown = useCallback((e) => {
305
+ if (e.button !== 0)
306
+ return;
307
+ dragStateRef.current = { startX: e.clientX, startWidth: sidebarWidth };
308
+ e.preventDefault();
309
+ // Disable text selection + flip the body cursor for the duration
310
+ // of the drag so the col-resize cursor stays visible even when the
311
+ // pointer slips off the 7px hit area. The custom cursor lives in
312
+ // the CSS class so Windows' white-cursor preference doesn't make
313
+ // the dragging cursor invisible against the light chrome.
314
+ document.body.style.userSelect = 'none';
315
+ document.body.classList.add('db-resizing-sidebar');
316
+ let lastRaw = sidebarWidth;
317
+ const onMove = (ev) => {
318
+ const drag = dragStateRef.current;
319
+ if (!drag)
320
+ return;
321
+ lastRaw = drag.startWidth + (ev.clientX - drag.startX);
322
+ if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
323
+ // Below threshold — preview the collapse by snapping to the
324
+ // minimum width and fading the sidebar, so the user can see
325
+ // they've crossed into "release to collapse" territory.
326
+ sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
327
+ sidebarRef.current.style.opacity = '0.45';
328
+ return;
329
+ }
330
+ const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, lastRaw));
331
+ if (sidebarRef.current) {
332
+ // Update the DOM directly during the drag for jank-free
333
+ // dragging; React state syncs on release.
334
+ sidebarRef.current.style.width = `${clamped}px`;
335
+ sidebarRef.current.style.opacity = '';
336
+ }
337
+ };
338
+ const onUp = () => {
339
+ document.removeEventListener('pointermove', onMove);
340
+ document.removeEventListener('pointerup', onUp);
341
+ document.body.style.userSelect = '';
342
+ document.body.classList.remove('db-resizing-sidebar');
343
+ if (sidebarRef.current) {
344
+ sidebarRef.current.style.opacity = '';
345
+ }
346
+ if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
347
+ // Released below threshold — switch to compact (single-pane)
348
+ // layout focused on the editor. Keep the persisted
349
+ // sidebarWidth so exiting compact mode restores it.
350
+ setCompactLayout(true);
351
+ setMobileShowEditor(true);
352
+ }
353
+ else {
354
+ const finalWidth = sidebarRef.current?.getBoundingClientRect().width;
355
+ if (finalWidth) {
356
+ const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, Math.round(finalWidth)));
357
+ setSidebarWidth(clamped);
358
+ saveSidebarWidth(clamped);
359
+ }
360
+ }
361
+ dragStateRef.current = null;
362
+ };
363
+ document.addEventListener('pointermove', onMove);
364
+ document.addEventListener('pointerup', onUp);
365
+ }, [sidebarWidth]);
68
366
  const [provider, setProvider] = useState(null);
69
367
  const [activeWorkspaceId, setActiveWorkspaceId] = useState(null);
368
+ const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] = useState(null);
369
+ // Re-fetch the descriptor whenever the active id (or its versioning
370
+ // override) changes. `descriptorRefreshKey` is bumped after writes so
371
+ // the resolver picks up the updated override without remounting.
372
+ const [descriptorRefreshKey, setDescriptorRefreshKey] = useState(0);
373
+ useEffect(() => {
374
+ let cancelled = false;
375
+ if (!activeWorkspaceId) {
376
+ setActiveWorkspaceDescriptor(null);
377
+ return;
378
+ }
379
+ void getWorkspace(activeWorkspaceId).then((ws) => {
380
+ if (!cancelled)
381
+ setActiveWorkspaceDescriptor(ws);
382
+ });
383
+ return () => {
384
+ cancelled = true;
385
+ };
386
+ }, [activeWorkspaceId, descriptorRefreshKey]);
387
+ const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
388
+ const [versioningPreference, setVersioningPreference] = useState(loadVersioningPreference);
389
+ const handleVersioningPreferenceChange = useCallback((pref) => {
390
+ setVersioningPreference(pref);
391
+ saveVersioningPreference(pref);
392
+ }, []);
393
+ const effectiveVersioning = allowVersioning && resolveVersioningEnabled(activeWorkspaceDescriptor, versioningPreference);
394
+ const handleOpenWorkspaceSettings = useCallback(() => {
395
+ if (!activeWorkspaceDescriptor)
396
+ return;
397
+ setWorkspaceSettingsOpen(true);
398
+ }, [activeWorkspaceDescriptor]);
399
+ const handleWorkspaceVersioningOverrideChange = useCallback(async (override) => {
400
+ if (!activeWorkspaceDescriptor)
401
+ return;
402
+ await saveWorkspace({ ...activeWorkspaceDescriptor, versioningOverride: override });
403
+ setDescriptorRefreshKey((k) => k + 1);
404
+ setWorkspaceSettingsOpen(false);
405
+ }, [activeWorkspaceDescriptor]);
70
406
  const [selectedFile, setSelectedFile] = useState(null);
71
407
  const [selectedFolder, setSelectedFolder] = useState(null);
72
408
  const [folderEntries, setFolderEntries] = useState([]);
@@ -76,15 +412,31 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
76
412
  const [initialView, setInitialView] = useState('wysiwyg');
77
413
  /** Suppress popstate handling during programmatic navigation. */
78
414
  const skipPopState = useRef(false);
79
- /** Persistent media container backed by IndexedDB (survives page refresh). */
415
+ const lastLocalSaveRef = useRef(null);
416
+ /**
417
+ * Per-file media container: for `notes.md`, images live in
418
+ * `notes_files/` next to the markdown. Created lazily on first write,
419
+ * picked up automatically if it already exists. Set up by the effect
420
+ * below whenever the selected file (or backing provider) changes.
421
+ */
80
422
  const mediaContainerRef = useRef(null);
81
423
  const [mediaProvider, setMediaProvider] = useState(null);
82
- /** Set up persistent media container for a workspace. */
83
- const setupMediaContainer = useCallback((workspaceId) => {
84
- const mc = new IndexedDBContentContainer(workspaceId);
85
- mediaContainerRef.current = mc;
86
- setMediaProvider(createMediaProviderFromContainer(mc));
87
- }, []);
424
+ /**
425
+ * Per-document container scoped to `<basename>_files/`. This is what
426
+ * the editor uses for version history (`.versions/` lives here) and
427
+ * for audio mapping (MP3 / timing.json discovery). Distinct from
428
+ * `mediaContainerRef` which is scoped to the parent directory so the
429
+ * media provider can write `notes_files/image.png` paths that stay
430
+ * portable in the markdown.
431
+ */
432
+ const versionsContainerRef = useRef(null);
433
+ const [versionsContainer, setVersionsContainer] = useState(null);
434
+ /** Cache of .md files in the active workspace, used to power the
435
+ * squisq link-dialog's document picker. Lazily populated on first
436
+ * provider call; cleared whenever the backing filesystem changes so
437
+ * workspace switches don't surface stale neighbours. A pending Promise
438
+ * during in-flight walks lets concurrent calls share the same scan. */
439
+ const mdFileCacheRef = useRef(null);
88
440
  /** Push a new history entry with the given hash. */
89
441
  const pushHash = useCallback((wsId, filePath) => {
90
442
  const hash = buildHash(wsId, filePath);
@@ -93,17 +445,23 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
93
445
  window.history.pushState(null, '', hash);
94
446
  }
95
447
  }, []);
96
- /** Open a workspace (and optionally a file) from identifiers. */
97
- const openFromIds = useCallback(async (wsId, filePath, push) => {
448
+ /** Open a workspace (and optionally a file) from identifiers.
449
+ * Returns the FileSystemProvider on success, or null on failure. */
450
+ const openFromIds = useCallback(async (wsId, filePath, push, view) => {
98
451
  const workspaces = await listWorkspaces();
99
452
  const ws = workspaces.find((w) => w.id === wsId);
100
453
  if (!ws)
101
- return false;
454
+ return null;
102
455
  let fsProvider = null;
103
- if (ws.type === 'native') {
456
+ if (ws.type === 'electron-native') {
457
+ fsProvider = await createElectronProviderFromWorkspace(ws);
458
+ if (!fsProvider)
459
+ return null;
460
+ }
461
+ else if (ws.type === 'native') {
104
462
  const restored = await restoreNativeFolder(ws.id);
105
463
  if (!restored)
106
- return false;
464
+ return null;
107
465
  fsProvider = restored;
108
466
  }
109
467
  else {
@@ -112,7 +470,6 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
112
470
  await touchWorkspace(ws.id);
113
471
  setProvider(fsProvider);
114
472
  setActiveWorkspaceId(ws.id);
115
- setupMediaContainer(ws.id);
116
473
  if (filePath) {
117
474
  const content = await fsProvider.readFile(filePath);
118
475
  if (content !== null) {
@@ -120,8 +477,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
120
477
  setSelectedFolder(null);
121
478
  setFolderEntries([]);
122
479
  setEditorContent(content);
123
- setInitialView('wysiwyg');
480
+ const effectiveView = view ?? 'wysiwyg';
481
+ setInitialView(effectiveView);
124
482
  setEditorKey((k) => k + 1);
483
+ saveLastState({ workspaceId: wsId, filePath, view: effectiveView });
125
484
  }
126
485
  else {
127
486
  setSelectedFile(null);
@@ -141,41 +500,45 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
141
500
  if (push) {
142
501
  pushHash(ws.id, filePath);
143
502
  }
144
- return true;
145
- }, [pushHash, setupMediaContainer]);
503
+ return fsProvider;
504
+ }, [pushHash]);
146
505
  const seedWelcomeFile = useCallback(async (fs) => {
147
506
  const entries = await fs.readDirectory('/');
148
- // If the only file is the welcome doc, auto-select it
507
+ // If the only file is the welcome doc, auto-select it.
508
+ // Match either casing so workspaces seeded before the rename
509
+ // (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
149
510
  if (entries.length === 1 &&
150
511
  entries[0].kind === 'file' &&
151
- entries[0].path === '/aboutDocblocks.md') {
152
- const content = await fs.readFile('/aboutDocblocks.md');
512
+ entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md') {
513
+ const aboutPath = entries[0].path;
514
+ const content = await fs.readFile(aboutPath);
153
515
  if (content !== null) {
154
- setSelectedFile('/aboutDocblocks.md');
516
+ setSelectedFile(aboutPath);
155
517
  setEditorContent(content);
156
518
  setInitialView('preview');
157
519
  setEditorKey((k) => k + 1);
158
520
  setExplorerKey((k) => k + 1);
159
- pushHash(fs.id, '/aboutDocblocks.md');
521
+ pushHash(fs.id, aboutPath);
522
+ saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
160
523
  }
161
524
  return;
162
525
  }
163
526
  if (entries.length > 0)
164
527
  return;
165
- const welcomePath = '/aboutDocblocks.md';
528
+ const welcomePath = '/aboutDocBlocks.md';
166
529
  const welcomeContent = [
167
530
  '# Welcome to DocBlocks',
168
531
  '',
169
- 'DocBlocks is a browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser.',
532
+ 'DocBlocks is a free browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser. What you write here can become a Word or PDF doc, a slide deck, an e-book, or a video.',
533
+ '',
534
+ 'Simple to write. Beautiful wherever it goes.',
170
535
  '',
171
536
  '## Features',
172
537
  '',
173
- '- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
174
- '- **Workspaces** — Organize your documents into separate workspaces',
538
+ '- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime. Use section annotations to change the visualization for blocks of content.',
539
+ '- **Workspaces** — Organize your documents into separate workspaces in the browser or on your device.',
540
+ '- **Useful Everywhere** — Your content is usable across multiple formats — Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
175
541
  '- **Playback & Video** — Preview your documents as rich visual presentations and export them as MP4 video',
176
- '- **Export Anywhere** — Export documents to PDF, Word, PowerPoint, HTML, or Markdown with theme options',
177
- '- **Local Storage** — Your documents are stored in your browser using temporary browser storage (backup often!)',
178
- '- **Device Folders** — Create workspaces based on folders on your computer',
179
542
  '- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
180
543
  '',
181
544
  '## Getting Started',
@@ -184,7 +547,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
184
547
  '2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
185
548
  '3. Your work is saved automatically',
186
549
  '',
187
- 'Built with [Squiggly Square](https://github.com/nicoth-in/squisq) by [Bendyline](https://bendyline.com).',
550
+ 'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
188
551
  ].join('\n');
189
552
  await fs.writeFile(welcomePath, welcomeContent);
190
553
  setSelectedFile(welcomePath);
@@ -193,6 +556,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
193
556
  setEditorKey((k) => k + 1);
194
557
  setExplorerKey((k) => k + 1);
195
558
  pushHash(fs.id, welcomePath);
559
+ saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
196
560
  }, [pushHash]);
197
561
  // Initialise workspace on mount — restore from hash or last-used
198
562
  useEffect(() => {
@@ -200,23 +564,50 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
200
564
  // Try restoring from URL hash first
201
565
  const hashState = parseHash();
202
566
  if (hashState) {
203
- const ok = await openFromIds(hashState.workspaceId, hashState.filePath, false);
204
- if (ok)
567
+ const restoredProvider = await openFromIds(hashState.workspaceId, hashState.filePath, false);
568
+ if (restoredProvider) {
569
+ // If the hash had no file, check whether we should auto-select the welcome doc
570
+ if (!hashState.filePath) {
571
+ await seedWelcomeFile(restoredProvider);
572
+ }
573
+ return;
574
+ }
575
+ }
576
+ // Try restoring last viewed document from localStorage
577
+ const lastState = loadLastState();
578
+ if (lastState) {
579
+ const restoredProvider = await openFromIds(lastState.workspaceId, lastState.filePath, true, lastState.view);
580
+ if (restoredProvider)
205
581
  return;
206
582
  }
207
583
  let fsProvider = null;
584
+ const electron = isElectronHost();
208
585
  const workspaces = await listWorkspaces();
586
+ // On desktop, hide web-only workspaces (indexeddb/native) — only
587
+ // folder-based workspaces are valid.
588
+ const candidates = electron
589
+ ? workspaces.filter((w) => w.type === 'electron-native')
590
+ : workspaces.filter((w) => w.type !== 'electron-native');
209
591
  // Pick the most recently opened workspace
210
- const sorted = [...workspaces].sort((a, b) => (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''));
592
+ const sorted = [...candidates].sort((a, b) => (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''));
211
593
  for (const ws of sorted) {
212
- if (ws.type === 'native') {
594
+ if (ws.type === 'electron-native') {
595
+ const p = await createElectronProviderFromWorkspace(ws);
596
+ if (!p)
597
+ continue;
598
+ await touchWorkspace(ws.id);
599
+ fsProvider = p;
600
+ setProvider(p);
601
+ setActiveWorkspaceId(ws.id);
602
+ break;
603
+ }
604
+ else if (ws.type === 'native') {
213
605
  const restored = await restoreNativeFolder(ws.id);
214
606
  if (restored) {
215
607
  await touchWorkspace(ws.id);
216
608
  fsProvider = restored;
217
609
  setProvider(restored);
218
610
  setActiveWorkspaceId(ws.id);
219
- setupMediaContainer(ws.id);
220
611
  break;
221
612
  }
222
613
  }
@@ -226,25 +617,42 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
226
617
  fsProvider = p;
227
618
  setProvider(p);
228
619
  setActiveWorkspaceId(ws.id);
229
- setupMediaContainer(ws.id);
230
620
  break;
231
621
  }
232
622
  }
233
623
  if (!fsProvider) {
234
- // No workspaces yet — create default
235
- const defaultWs = await ensureDefaultWorkspace();
236
- const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
237
- fsProvider = p;
238
- setProvider(p);
239
- setActiveWorkspaceId(defaultWs.id);
240
- setupMediaContainer(defaultWs.id);
624
+ if (electron) {
625
+ // Desktop: ask the host for the default folder workspace
626
+ // (creates ~/Documents/DocBlocks on first launch).
627
+ const info = await getDocBlocksHost().workspaces.getDefault();
628
+ const descriptor = {
629
+ id: info.id,
630
+ name: info.name,
631
+ type: 'electron-native',
632
+ rootPath: info.rootPath,
633
+ lastOpened: new Date().toISOString(),
634
+ };
635
+ await saveWorkspace(descriptor);
636
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
637
+ fsProvider = p;
638
+ setProvider(p);
639
+ setActiveWorkspaceId(info.id);
640
+ }
641
+ else {
642
+ // Web: create the IndexedDB default workspace
643
+ const defaultWs = await ensureDefaultWorkspace();
644
+ const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
645
+ fsProvider = p;
646
+ setProvider(p);
647
+ setActiveWorkspaceId(defaultWs.id);
648
+ }
241
649
  }
242
650
  // Set initial hash
243
651
  pushHash(fsProvider.id, null);
244
652
  // Seed welcome file if workspace is empty
245
653
  await seedWelcomeFile(fsProvider);
246
654
  })();
247
- }, [openFromIds, pushHash, seedWelcomeFile, setupMediaContainer]);
655
+ }, [openFromIds, pushHash, seedWelcomeFile]);
248
656
  // Handle browser back/forward
249
657
  useEffect(() => {
250
658
  const onPopState = () => {
@@ -260,23 +668,191 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
260
668
  window.addEventListener('popstate', onPopState);
261
669
  return () => window.removeEventListener('popstate', onPopState);
262
670
  }, [openFromIds]);
263
- // Auto-save current file
264
- useAutoSave(provider, selectedFile, editorContent);
671
+ // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
672
+ useEffect(() => {
673
+ const handler = (e) => {
674
+ const target = e.target.closest?.('[data-view]');
675
+ if (target) {
676
+ const view = target.getAttribute('data-view');
677
+ if (view && activeWorkspaceId && selectedFile) {
678
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
679
+ }
680
+ }
681
+ };
682
+ window.addEventListener('click', handler, true);
683
+ return () => window.removeEventListener('click', handler, true);
684
+ }, [activeWorkspaceId, selectedFile]);
685
+ const handleAutoSaved = useCallback((filePath, savedContent) => {
686
+ lastLocalSaveRef.current = {
687
+ filePath: normaliseProviderPath(filePath),
688
+ content: savedContent,
689
+ savedAt: Date.now(),
690
+ };
691
+ }, []);
692
+ // Auto-save current file. The returned `flush` is called from the
693
+ // Ctrl/Cmd+S handler below so the user gets immediate confirmation.
694
+ const { flush: flushAutoSave } = useAutoSave(provider, selectedFile, editorContent, 500, handleAutoSaved);
695
+ // Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
696
+ // small "auto-save confirmed" toast. Files are already saved on every
697
+ // keystroke (debounced) — this is purely UX reassurance for users who
698
+ // muscle-memory hit Save.
699
+ const [saveToastVisible, setSaveToastVisible] = useState(false);
700
+ const saveToastTimerRef = useRef(null);
701
+ useEffect(() => {
702
+ const onKey = (e) => {
703
+ const sKey = e.key === 's' || e.key === 'S';
704
+ const accel = e.ctrlKey || e.metaKey;
705
+ if (!sKey || !accel || e.altKey)
706
+ return;
707
+ e.preventDefault();
708
+ e.stopPropagation();
709
+ void flushAutoSave().catch(() => undefined);
710
+ setSaveToastVisible(true);
711
+ if (saveToastTimerRef.current)
712
+ clearTimeout(saveToastTimerRef.current);
713
+ saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
714
+ };
715
+ window.addEventListener('keydown', onKey, true);
716
+ return () => window.removeEventListener('keydown', onKey, true);
717
+ }, [flushAutoSave]);
718
+ useEffect(() => {
719
+ return () => {
720
+ if (saveToastTimerRef.current)
721
+ clearTimeout(saveToastTimerRef.current);
722
+ };
723
+ }, []);
724
+ // Per-file media: for `notes.md` images live in `notes_files/` beside it.
725
+ // Rebuilds whenever the provider or selected file changes.
726
+ useEffect(() => {
727
+ if (!provider || !selectedFile) {
728
+ mediaContainerRef.current = null;
729
+ versionsContainerRef.current = null;
730
+ setMediaProvider(null);
731
+ setVersionsContainer(null);
732
+ return;
733
+ }
734
+ const parentDir = dirnameOf(selectedFile);
735
+ const base = basenameOf(selectedFile);
736
+ const baseNoExt = base.replace(/\.[^.]+$/, '');
737
+ const container = new FileSystemContentContainer(provider, parentDir);
738
+ const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
739
+ const vContainer = new FileSystemContentContainer(provider, vPrefix);
740
+ const mp = createFileMediaProvider(container, base);
741
+ mediaContainerRef.current = container;
742
+ versionsContainerRef.current = vContainer;
743
+ setMediaProvider(mp);
744
+ setVersionsContainer(vContainer);
745
+ return () => {
746
+ mp.dispose();
747
+ };
748
+ }, [provider, selectedFile]);
749
+ // Invalidate the document-link candidate cache when the backing
750
+ // workspace changes — otherwise the link dialog would surface
751
+ // neighbours from a previously-open workspace.
752
+ useEffect(() => {
753
+ mdFileCacheRef.current = null;
754
+ }, [provider]);
755
+ /** Powers the squisq link dialog's "Browse documents" picker. Returns
756
+ * workspace `.md` neighbours filtered by `query`, with paths expressed
757
+ * relative to the currently-open document so the link survives folder
758
+ * moves. The first call seeds an in-memory cache; subsequent calls
759
+ * filter against it. */
760
+ const documentLinkProvider = useCallback(async (query) => {
761
+ if (!provider || !selectedFile)
762
+ return [];
763
+ if (!mdFileCacheRef.current) {
764
+ mdFileCacheRef.current = collectMarkdownFiles(provider, '').catch(() => []);
765
+ }
766
+ const entries = await mdFileCacheRef.current;
767
+ const q = query.trim().toLowerCase();
768
+ const candidates = [];
769
+ for (const entry of entries) {
770
+ if (entry.kind !== 'file')
771
+ continue;
772
+ if (sameProviderPath(entry.path, selectedFile))
773
+ continue;
774
+ const label = entry.name.replace(/\.md$/i, '');
775
+ const path = relativeMarkdownLink(selectedFile, entry.path);
776
+ if (q && !label.toLowerCase().includes(q) && !path.toLowerCase().includes(q))
777
+ continue;
778
+ const dir = dirnameOf(entry.path);
779
+ candidates.push(dir ? { path, label, description: dir } : { path, label });
780
+ }
781
+ // Stable alphabetical order keeps the picker predictable across
782
+ // re-opens; the dialog can re-sort or rank on its own if needed.
783
+ candidates.sort((a, b) => a.label.localeCompare(b.label));
784
+ return candidates;
785
+ }, [provider, selectedFile]);
786
+ // Expose a DocumentVersionManager via versioningRef when requested.
787
+ useEffect(() => {
788
+ const ref = versioningRef;
789
+ if (!ref)
790
+ return;
791
+ const assign = (mgr) => {
792
+ if (typeof ref === 'function')
793
+ ref(mgr);
794
+ else
795
+ ref.current = mgr;
796
+ };
797
+ if (!effectiveVersioning || !versionsContainer || !selectedFile) {
798
+ assign(null);
799
+ return;
800
+ }
801
+ const base = stripExtension(basenameOf(selectedFile));
802
+ const mgr = new DocumentVersionManager(versionsContainer, {
803
+ basename: versionBasename ?? base,
804
+ });
805
+ assign(mgr);
806
+ return () => assign(null);
807
+ }, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
808
+ // React to external file changes watched by the Electron host (chokidar).
809
+ useEffect(() => {
810
+ if (!isElectronHost())
811
+ return;
812
+ if (!provider || !(provider instanceof ElectronFileSystemProvider))
813
+ return;
814
+ const unwatch = provider.watch((changedPath) => {
815
+ setExplorerKey((k) => k + 1);
816
+ if (!selectedFile || !sameProviderPath(changedPath, selectedFile))
817
+ return;
818
+ // If the open file's contents changed on disk, reload it (best-effort).
819
+ (async () => {
820
+ const content = await provider.readFile(selectedFile);
821
+ if (content === null || content === editorContent)
822
+ return;
823
+ const localSave = lastLocalSaveRef.current;
824
+ if (localSave &&
825
+ sameProviderPath(localSave.filePath, selectedFile) &&
826
+ localSave.content === content &&
827
+ Date.now() - localSave.savedAt < 5000) {
828
+ return;
829
+ }
830
+ setEditorContent(content);
831
+ setEditorKey((k) => k + 1);
832
+ })();
833
+ });
834
+ return unwatch;
835
+ }, [provider, selectedFile, editorContent]);
265
836
  const handleWorkspaceSelect = useCallback(async (ws) => {
266
837
  await touchWorkspace(ws.id);
267
- if (ws.type === 'native') {
838
+ let nextProvider = null;
839
+ if (ws.type === 'electron-native') {
840
+ nextProvider = await createElectronProviderFromWorkspace(ws);
841
+ if (!nextProvider)
842
+ return;
843
+ }
844
+ else if (ws.type === 'native') {
268
845
  const restored = await restoreNativeFolder(ws.id);
269
- if (restored) {
270
- setProvider(restored);
271
- }
272
- else {
846
+ if (!restored) {
273
847
  // Permission denied or handle lost — fall through without changing provider
274
848
  return;
275
849
  }
850
+ nextProvider = restored;
276
851
  }
277
852
  else {
278
- setProvider(new IndexedDBFileSystemProvider(ws.id, ws.name));
853
+ nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
279
854
  }
855
+ setProvider(nextProvider);
280
856
  setActiveWorkspaceId(ws.id);
281
857
  setSelectedFile(null);
282
858
  setSelectedFolder(null);
@@ -284,10 +860,32 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
284
860
  setEditorContent('');
285
861
  setEditorKey((k) => k + 1);
286
862
  pushHash(ws.id, null);
287
- setupMediaContainer(ws.id);
288
- }, [pushHash, setupMediaContainer]);
863
+ }, [pushHash]);
289
864
  const handleOpenFolder = useCallback(async () => {
290
865
  try {
866
+ if (isElectronHost()) {
867
+ const info = await getDocBlocksHost().workspaces.pickFolder();
868
+ if (!info)
869
+ return; // user cancelled
870
+ const descriptor = {
871
+ id: info.id,
872
+ name: info.name,
873
+ type: 'electron-native',
874
+ rootPath: info.rootPath,
875
+ lastOpened: new Date().toISOString(),
876
+ };
877
+ await saveWorkspace(descriptor);
878
+ const provider = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
879
+ setProvider(provider);
880
+ setActiveWorkspaceId(descriptor.id);
881
+ setSelectedFile(null);
882
+ setSelectedFolder(null);
883
+ setFolderEntries([]);
884
+ setEditorContent('');
885
+ setEditorKey((k) => k + 1);
886
+ pushHash(descriptor.id, null);
887
+ return;
888
+ }
291
889
  const nativeProvider = await openNativeFolder();
292
890
  const descriptor = {
293
891
  id: nativeProvider.id,
@@ -304,12 +902,104 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
304
902
  setEditorContent('');
305
903
  setEditorKey((k) => k + 1);
306
904
  pushHash(descriptor.id, null);
307
- setupMediaContainer(descriptor.id);
308
905
  }
309
906
  catch {
310
907
  // User cancelled or API not supported
311
908
  }
312
- }, [pushHash, setupMediaContainer]);
909
+ }, [pushHash]);
910
+ const handleNewFile = useCallback(async () => {
911
+ if (!provider)
912
+ return;
913
+ const name = prompt('New document name:', 'Untitled.md');
914
+ if (!name)
915
+ return;
916
+ const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
917
+ await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
918
+ setSelectedFile('/' + filename);
919
+ setEditorContent('# ' + filename.replace(/\.[^.]+$/, '') + '\n');
920
+ setInitialView('wysiwyg');
921
+ setEditorKey((k) => k + 1);
922
+ setExplorerKey((k) => k + 1);
923
+ if (activeWorkspaceId) {
924
+ pushHash(activeWorkspaceId, '/' + filename);
925
+ }
926
+ }, [provider, activeWorkspaceId, pushHash]);
927
+ const handleRevealWorkspace = useCallback(async () => {
928
+ if (!isElectronHost() || !activeWorkspaceId)
929
+ return;
930
+ const ws = await getWorkspace(activeWorkspaceId);
931
+ if (ws?.type === 'electron-native' && ws.rootPath) {
932
+ await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
933
+ }
934
+ }, [activeWorkspaceId]);
935
+ // Subscribe to native menu commands (Electron host).
936
+ useEffect(() => {
937
+ if (!isElectronHost())
938
+ return;
939
+ const host = getDocBlocksHost();
940
+ return host.onMenuCommand((cmd) => {
941
+ switch (cmd) {
942
+ case 'file:new':
943
+ handleNewFile();
944
+ break;
945
+ case 'file:openFolder':
946
+ handleOpenFolder();
947
+ break;
948
+ case 'file:revealWorkspace':
949
+ handleRevealWorkspace();
950
+ break;
951
+ case 'help:viewOnGitHub':
952
+ host.shell.openExternal('https://github.com/bendyline/docblocks');
953
+ break;
954
+ case 'help:checkForUpdates':
955
+ host.updater.checkForUpdates().catch(() => {
956
+ // errors surface via updater.onStatus
957
+ });
958
+ break;
959
+ case 'help:about':
960
+ (async () => {
961
+ const version = await host.updater.getVersion();
962
+ alert(`DocBlocks ${version}`);
963
+ })();
964
+ break;
965
+ default:
966
+ break;
967
+ }
968
+ });
969
+ }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
970
+ // Subscribe to open-file / deep-link requests from the OS.
971
+ useEffect(() => {
972
+ if (!isElectronHost())
973
+ return;
974
+ const host = getDocBlocksHost();
975
+ return host.onOpenRequest(async (req) => {
976
+ if (req.filePath) {
977
+ const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
978
+ const match = workspaces.find((w) => req.filePath.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath);
979
+ if (match && match.rootPath) {
980
+ const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
981
+ await openFromIds(match.id, rel, true);
982
+ }
983
+ }
984
+ else if (req.url) {
985
+ try {
986
+ const u = new URL(req.url);
987
+ const path = u.searchParams.get('path');
988
+ if (path) {
989
+ const workspaces = (await listWorkspaces()).filter((w) => w.type === 'electron-native' && w.rootPath);
990
+ const match = workspaces.find((w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath);
991
+ if (match && match.rootPath) {
992
+ const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
993
+ await openFromIds(match.id, rel, true);
994
+ }
995
+ }
996
+ }
997
+ catch {
998
+ // bad URL, ignore
999
+ }
1000
+ }
1001
+ });
1002
+ }, [openFromIds]);
313
1003
  const handleSelect = useCallback(async (path, kind) => {
314
1004
  if (!provider || !activeWorkspaceId)
315
1005
  return;
@@ -331,10 +1021,11 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
331
1021
  setInitialView('wysiwyg');
332
1022
  setEditorKey((k) => k + 1);
333
1023
  pushHash(activeWorkspaceId, path);
334
- if (isMobile)
1024
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
1025
+ if (effectiveCompact)
335
1026
  setMobileShowEditor(true);
336
1027
  }
337
- }, [provider, activeWorkspaceId, pushHash, isMobile]);
1028
+ }, [provider, activeWorkspaceId, pushHash, effectiveCompact]);
338
1029
  const handleTreeChange = useCallback(async () => {
339
1030
  if (!provider)
340
1031
  return;
@@ -354,19 +1045,24 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
354
1045
  setFolderEntries(entries);
355
1046
  }
356
1047
  }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
357
- /** Copy non-markdown files from an imported container into persistent media storage. */
358
- const persistContainerMedia = useCallback(async (source) => {
359
- const mc = mediaContainerRef.current;
360
- if (!mc)
361
- return;
1048
+ /**
1049
+ * Copy non-markdown files from an imported container into the target
1050
+ * markdown file's `_files/` sibling folder, preserving the source's
1051
+ * relative structure.
1052
+ */
1053
+ const persistImportedMedia = useCallback(async (source, target, importedMarkdownPath) => {
1054
+ const parentDir = dirnameOf(importedMarkdownPath);
1055
+ const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
1056
+ const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
362
1057
  const entries = await source.listFiles();
363
1058
  for (const entry of entries) {
364
1059
  if (entry.path.endsWith('.md'))
365
1060
  continue;
366
1061
  const data = await source.readFile(entry.path);
367
- if (data) {
368
- await mc.writeFile(entry.path, new Uint8Array(data), entry.mimeType);
369
- }
1062
+ if (!data)
1063
+ continue;
1064
+ const cleanPath = entry.path.replace(/^\/+/, '');
1065
+ await target.writeBinary(`${mediaRoot}/${cleanPath}`, new Uint8Array(data));
370
1066
  }
371
1067
  }, []);
372
1068
  const handleImportFiles = useCallback(async (files) => {
@@ -385,19 +1081,19 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
385
1081
  const { docxToContainer } = await import('@bendyline/squisq-formats/docx');
386
1082
  const container = await docxToContainer(await file.arrayBuffer());
387
1083
  markdown = (await container.readDocument()) ?? '';
388
- await persistContainerMedia(container);
1084
+ await persistImportedMedia(container, provider, destPath);
389
1085
  }
390
1086
  else if (ext === '.pdf') {
391
1087
  const { pdfToContainer } = await import('@bendyline/squisq-formats/pdf');
392
1088
  const container = await pdfToContainer(await file.arrayBuffer());
393
1089
  markdown = (await container.readDocument()) ?? '';
394
- await persistContainerMedia(container);
1090
+ await persistImportedMedia(container, provider, destPath);
395
1091
  }
396
1092
  else if (ext === '.dbk' || ext === '.zip') {
397
1093
  const { zipToContainer } = await import('@bendyline/squisq-formats/container');
398
1094
  const container = await zipToContainer(await file.arrayBuffer());
399
1095
  markdown = (await container.readDocument()) ?? '';
400
- await persistContainerMedia(container);
1096
+ await persistImportedMedia(container, provider, destPath);
401
1097
  }
402
1098
  else {
403
1099
  continue;
@@ -408,14 +1104,9 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
408
1104
  console.error(`Failed to import ${file.name}:`, err);
409
1105
  }
410
1106
  }
411
- // Refresh media provider once after all imports (not per-file)
412
- const mc = mediaContainerRef.current;
413
- if (mc) {
414
- setMediaProvider(createMediaProviderFromContainer(mc));
415
- }
416
- // Refresh file tree
1107
+ // Refresh file tree so the new markdown files + _files folders show up.
417
1108
  setExplorerKey((k) => k + 1);
418
- }, [provider, persistContainerMedia]);
1109
+ }, [provider, persistImportedMedia]);
419
1110
  const handleEditorChange = useCallback((source) => {
420
1111
  setEditorContent(source);
421
1112
  }, []);
@@ -431,44 +1122,186 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
431
1122
  await saveWorkspace({ ...ws, name: newName });
432
1123
  // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
433
1124
  setEditorKey((k) => k + 1);
434
- }, [activeWorkspaceId, provider]);
1125
+ }, [activeWorkspaceId]);
1126
+ /**
1127
+ * Walk a FileSystemProvider and copy every file into `container` under
1128
+ * `pathPrefix` (no leading slash; empty string for the root). Used by
1129
+ * both single- and all-workspace downloads.
1130
+ */
1131
+ const copyProviderToContainer = useCallback(async (src, container, pathPrefix) => {
1132
+ const encoder = new TextEncoder();
1133
+ const stack = ['/'];
1134
+ while (stack.length > 0) {
1135
+ const dir = stack.pop();
1136
+ const entries = await src.readDirectory(dir);
1137
+ for (const entry of entries) {
1138
+ if (entry.kind === 'directory') {
1139
+ stack.push(entry.path);
1140
+ continue;
1141
+ }
1142
+ const rel = entry.path.replace(/^\/+/, '');
1143
+ const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
1144
+ // Files may be stored as text (writeFile) or binary (writeBinary);
1145
+ // try binary first, fall back to text and encode as UTF-8.
1146
+ const binary = await src.readBinary(entry.path);
1147
+ if (binary) {
1148
+ await container.writeFile(zipPath, binary);
1149
+ continue;
1150
+ }
1151
+ const text = await src.readFile(entry.path);
1152
+ if (text !== null) {
1153
+ await container.writeFile(zipPath, encoder.encode(text));
1154
+ }
1155
+ }
1156
+ }
1157
+ }, []);
435
1158
  const handleDownloadWorkspace = useCallback(async () => {
436
1159
  if (!provider)
437
1160
  return;
438
1161
  try {
439
- const entries = await provider.readDirectory('/');
440
- const lines = [];
441
- for (const entry of entries) {
442
- if (entry.kind === 'file') {
443
- const content = await provider.readFile(entry.path);
444
- lines.push(`--- ${entry.path} ---\n${content ?? ''}\n`);
1162
+ const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1163
+ import('@bendyline/squisq/storage'),
1164
+ import('@bendyline/squisq-formats/container'),
1165
+ ]);
1166
+ const container = new MemoryContentContainer();
1167
+ await copyProviderToContainer(provider, container, '');
1168
+ const blob = await containerToZip(container);
1169
+ const url = URL.createObjectURL(blob);
1170
+ const a = document.createElement('a');
1171
+ a.href = url;
1172
+ const safeName = (provider.label || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
1173
+ a.download = `${safeName}.zip`;
1174
+ a.click();
1175
+ URL.revokeObjectURL(url);
1176
+ }
1177
+ catch (err) {
1178
+ console.error('Failed to download workspace', err);
1179
+ alert('Failed to download workspace. See console for details.');
1180
+ }
1181
+ }, [provider, copyProviderToContainer]);
1182
+ /**
1183
+ * Bundle every workspace the host can open without further prompting
1184
+ * into a single zip, with each workspace nested under its own folder.
1185
+ * Native (browser-picked) workspaces whose handle hasn't been re-granted
1186
+ * for this session are skipped — restoring them would require a user
1187
+ * gesture per workspace.
1188
+ */
1189
+ const handleDownloadAllWorkspaces = useCallback(async () => {
1190
+ try {
1191
+ const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1192
+ import('@bendyline/squisq/storage'),
1193
+ import('@bendyline/squisq-formats/container'),
1194
+ ]);
1195
+ const electron = isElectronHost();
1196
+ const all = await listWorkspaces();
1197
+ const candidates = all.filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
1198
+ if (candidates.length === 0) {
1199
+ alert('No workspaces to download.');
1200
+ return;
1201
+ }
1202
+ const container = new MemoryContentContainer();
1203
+ const usedFolders = new Set();
1204
+ const skipped = [];
1205
+ for (const ws of candidates) {
1206
+ let p = null;
1207
+ try {
1208
+ if (ws.type === 'electron-native') {
1209
+ p = await createElectronProviderFromWorkspace(ws);
1210
+ }
1211
+ else if (ws.type === 'native') {
1212
+ // Restore without prompting — only succeeds when the browser
1213
+ // still remembers the granted handle for this origin/session.
1214
+ p = await restoreNativeFolder(ws.id);
1215
+ }
1216
+ else {
1217
+ p = new IndexedDBFileSystemProvider(ws.id, ws.name);
1218
+ }
445
1219
  }
1220
+ catch (err) {
1221
+ console.warn(`Skipping workspace ${ws.name}:`, err);
1222
+ }
1223
+ if (!p) {
1224
+ skipped.push(ws.name);
1225
+ continue;
1226
+ }
1227
+ // Pick a unique, filesystem-safe folder name per workspace.
1228
+ const base = (ws.name || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
1229
+ let folder = base;
1230
+ let suffix = 2;
1231
+ while (usedFolders.has(folder)) {
1232
+ folder = `${base} (${suffix++})`;
1233
+ }
1234
+ usedFolders.add(folder);
1235
+ await copyProviderToContainer(p, container, folder);
1236
+ }
1237
+ if (usedFolders.size === 0) {
1238
+ alert('No workspaces could be opened for download.');
1239
+ return;
446
1240
  }
447
- const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
1241
+ const blob = await containerToZip(container);
448
1242
  const url = URL.createObjectURL(blob);
449
1243
  const a = document.createElement('a');
450
1244
  a.href = url;
451
- a.download = 'workspace.txt';
1245
+ const stamp = new Date().toISOString().slice(0, 10);
1246
+ a.download = `docblocks-workspaces-${stamp}.zip`;
452
1247
  a.click();
453
1248
  URL.revokeObjectURL(url);
1249
+ if (skipped.length > 0) {
1250
+ alert(`Downloaded ${usedFolders.size} workspace(s). Skipped ${skipped.length} that require re-granting access: ${skipped.join(', ')}.`);
1251
+ }
454
1252
  }
455
- catch {
456
- // ignore
1253
+ catch (err) {
1254
+ console.error('Failed to download all workspaces', err);
1255
+ alert('Failed to download all workspaces. See console for details.');
457
1256
  }
458
- }, [provider]);
1257
+ }, [copyProviderToContainer]);
459
1258
  const handleRemoveWorkspace = useCallback(async () => {
460
1259
  if (!activeWorkspaceId)
461
1260
  return;
462
- if (!confirm('Remove this workspace? This cannot be undone.'))
1261
+ const confirmMsg = isElectronHost()
1262
+ ? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
1263
+ : 'Remove this workspace? This cannot be undone.';
1264
+ if (!confirm(confirmMsg))
463
1265
  return;
464
- await removeDirectoryHandle(activeWorkspaceId);
1266
+ const ws = await getWorkspace(activeWorkspaceId);
1267
+ if (ws?.type === 'electron-native') {
1268
+ try {
1269
+ await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
1270
+ }
1271
+ catch {
1272
+ // ignore — host cleanup is best-effort
1273
+ }
1274
+ }
1275
+ else {
1276
+ await removeDirectoryHandle(activeWorkspaceId);
1277
+ }
465
1278
  await removeWorkspace(activeWorkspaceId);
1279
+ const electron = isElectronHost();
466
1280
  // Switch to most recent remaining workspace or create default
467
- const remaining = await listWorkspaces();
1281
+ const remaining = (await listWorkspaces()).filter((w) => electron ? w.type === 'electron-native' : w.type !== 'electron-native');
468
1282
  if (remaining.length > 0) {
469
1283
  const next = remaining[0];
470
1284
  await handleWorkspaceSelect(next);
471
1285
  }
1286
+ else if (electron) {
1287
+ const info = await getDocBlocksHost().workspaces.getDefault();
1288
+ const descriptor = {
1289
+ id: info.id,
1290
+ name: info.name,
1291
+ type: 'electron-native',
1292
+ rootPath: info.rootPath,
1293
+ lastOpened: new Date().toISOString(),
1294
+ };
1295
+ await saveWorkspace(descriptor);
1296
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
1297
+ setProvider(p);
1298
+ setActiveWorkspaceId(info.id);
1299
+ setSelectedFile(null);
1300
+ setSelectedFolder(null);
1301
+ setFolderEntries([]);
1302
+ setEditorContent('');
1303
+ setEditorKey((k) => k + 1);
1304
+ }
472
1305
  else {
473
1306
  const defaultWs = await ensureDefaultWorkspace();
474
1307
  const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
@@ -479,9 +1312,8 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }) {
479
1312
  setFolderEntries([]);
480
1313
  setEditorContent('');
481
1314
  setEditorKey((k) => k + 1);
482
- setupMediaContainer(defaultWs.id);
483
1315
  }
484
- }, [activeWorkspaceId, handleWorkspaceSelect, setupMediaContainer]);
485
- return (_jsx("div", { className: `db-shell${isMobile ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, children: _jsxs("div", { style: { display: 'flex', flex: 1, overflow: 'hidden' }, children: [(!isMobile || !mobileShowEditor) && (_jsxs("div", { className: "db-shell-sidebar", children: [_jsxs("div", { className: "db-shell-sidebar-header", children: [_jsx(AppMenu, { logoUrl: logoUrl }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder }), _jsx(WorkspaceSettingsButton, { onRename: handleRenameWorkspace, onDownload: handleDownloadWorkspace, onRemove: handleRemoveWorkspace })] }), _jsx(FileExplorer, { provider: provider, onSelect: handleSelect, onTreeChange: handleTreeChange, onImportFiles: handleImportFiles }, explorerKey), _jsx("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" }) })] })), (!isMobile || mobileShowEditor) && (_jsx("div", { style: { flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }, children: selectedFile && mediaProvider ? (_jsx(MediaContext.Provider, { value: mediaProvider, children: _jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, onChange: handleEditorChange, theme: resolvedTheme, height: "100%", mediaProvider: mediaProvider, container: mediaContainerRef.current ?? undefined, toolbarSlotLeft: isMobile ? (_jsx("button", { className: "db-mobile-back", onClick: () => setMobileShowEditor(false), children: _jsx("span", { className: "db-mobile-back-arrow", children: "\u2190" }) })) : undefined, toolbarSlotRight: _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current }) }, `${selectedFile}-${editorKey}`) })) : selectedFolder ? (_jsxs("div", { className: "db-folder-view", children: [isMobile && (_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: "\uD83D\uDCC1" }), _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' ? '📁' : '📄' }), entry.name] }, entry.path))) }))] })) : (_jsx("div", { className: "db-shell-empty", children: _jsx("p", { children: "Select a file to start editing, or create a new one." }) })) }))] }) }));
1316
+ }, [activeWorkspaceId, handleWorkspaceSelect]);
1317
+ return (_jsxs("div", { className: `db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`, "data-theme": resolvedTheme, children: [saveToastVisible && (_jsx("div", { className: "db-save-toast", role: "status", "aria-live": "polite", children: "Autosaved. You're all set." })), 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, versioningPreference: versioningPreference, onVersioningPreferenceChange: handleVersioningPreferenceChange, onDownloadAllWorkspaces: handleDownloadAllWorkspaces }), _jsx(WorkspacePicker, { activeWorkspaceId: activeWorkspaceId, onSelect: handleWorkspaceSelect, onOpenFolder: handleOpenFolder }), _jsx(WorkspaceSettingsButton, { onSettings: handleOpenWorkspaceSettings, onRename: handleRenameWorkspace, onDownload: handleDownloadWorkspace, onRemove: handleRemoveWorkspace })] }), _jsx(FileExplorer, { provider: provider, onSelect: handleSelect, onTreeChange: handleTreeChange, onImportFiles: handleImportFiles }, explorerKey), _jsx("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" }) })] })), !effectiveCompact && (_jsx("div", { className: "db-shell-sidebar-resizer", role: "separator", "aria-orientation": "vertical", "aria-label": "Resize sidebar", onPointerDown: handleResizerPointerDown })), (!effectiveCompact || mobileShowEditor) && (_jsx("div", { style: { flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }, children: selectedFile && mediaProvider ? (_jsx(MediaContext.Provider, { value: mediaProvider, children: _jsx(EditorShell, { initialMarkdown: editorContent, initialView: initialView, articleId: selectedFile, fileName: selectedFile, onChange: handleEditorChange, theme: resolvedTheme, height: "100%", outlineWidth: 280, mediaProvider: mediaProvider, documentLinkProvider: documentLinkProvider, container: 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: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round", children: [_jsx("rect", { x: "1.5", y: "2.5", width: "13", height: "11", rx: "1" }), _jsx("line", { x1: "6", y1: "2.5", x2: "6", y2: "13.5" })] }) })), _jsx(ExportToolbarControls, { selectedFile: selectedFile, mediaContainer: mediaContainerRef.current })] }) }, `${selectedFile}-${editorKey}`) })) : 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." })] })) }))] })] }));
486
1318
  }
487
1319
  //# sourceMappingURL=DocBlocksShell.js.map