@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
@@ -7,19 +7,33 @@
7
7
 
8
8
  import { useState, useCallback, useEffect, useRef } from 'react';
9
9
  import { EditorShell } from '@bendyline/squisq-editor-react';
10
- import type { EditorTheme, EditorView } from '@bendyline/squisq-editor-react';
10
+ import type {
11
+ EditorTheme,
12
+ EditorView,
13
+ ViewPreferences,
14
+ DocumentLinkProvider,
15
+ DocumentLinkCandidate,
16
+ } from '@bendyline/squisq-editor-react';
11
17
  import '@bendyline/squisq-editor-react/styles';
12
18
  import { MediaContext } from '@bendyline/squisq-react';
13
19
  import type { MediaProvider } from '@bendyline/squisq/schemas';
14
- import { createMediaProviderFromContainer } from '@bendyline/squisq/storage';
20
+ import {
21
+ DocumentVersionManager,
22
+ type PrunePolicy,
23
+ type SaveVersionResult,
24
+ } from '@bendyline/squisq/versions';
15
25
  import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
16
26
  import {
17
27
  IndexedDBFileSystemProvider,
18
- IndexedDBContentContainer,
28
+ ElectronFileSystemProvider,
29
+ FileSystemContentContainer,
30
+ createFileMediaProvider,
19
31
  openNativeFolder,
20
32
  restoreNativeFolder,
21
33
  removeDirectoryHandle,
22
34
  } from '@bendyline/docblocks/filesystem';
35
+ import type { ContentContainer } from '@bendyline/squisq/storage';
36
+ import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
23
37
  import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
24
38
  import {
25
39
  ensureDefaultWorkspace,
@@ -29,18 +43,47 @@ import {
29
43
  saveWorkspace,
30
44
  touchWorkspace,
31
45
  } from '@bendyline/docblocks/workspace';
32
- import { AppMenu } from '../AppMenu/AppMenu.js';
46
+ import { AppMenu, type ThemePreference } from '../AppMenu/AppMenu.js';
33
47
  import { FileExplorer } from '../FileExplorer/FileExplorer.js';
34
48
  import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
35
49
  import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
50
+ import {
51
+ WorkspaceSettingsDialog,
52
+ type WorkspaceVersioningOverride,
53
+ } from '../WorkspacePicker/WorkspaceSettingsDialog.js';
36
54
  import { useAutoSave } from '../hooks/useAutoSave.js';
37
55
  import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
56
+ import {
57
+ loadVersioningPreference,
58
+ resolveVersioningEnabled,
59
+ saveVersioningPreference,
60
+ type VersioningPreference,
61
+ } from '../preferences/versioning.js';
38
62
 
39
63
  export interface DocBlocksShellProps {
40
64
  /** Optional theme override. Omit or pass 'auto' to follow OS preference. */
41
65
  theme?: EditorTheme | 'auto';
42
66
  /** Optional logo image URL for the app menu. */
43
67
  logoUrl?: string;
68
+ /**
69
+ * Enable document version history. Snapshots are written under
70
+ * `<basename>_files/.versions/` next to each markdown file (i.e., the
71
+ * per-document container). Defaults to `true`.
72
+ */
73
+ allowVersioning?: boolean;
74
+ /**
75
+ * Override the document basename used in version filenames. Defaults
76
+ * to the basename of the currently selected file.
77
+ */
78
+ versionBasename?: string;
79
+ /** Prune policy applied after each successful save. Default: keep last 50. */
80
+ versioningPrunePolicy?: PrunePolicy;
81
+ /** Idle delay (ms) before the editor auto-saves a version. `0` disables. Default 5000. */
82
+ versioningAutoSaveIdleMs?: number;
83
+ /** Notified after each `saveVersion` attempt (saved=true and saved=false). */
84
+ onSaveVersion?: (result: SaveVersionResult) => void;
85
+ /** Optional escape hatch for hosts that want imperative access to the version manager. */
86
+ versioningRef?: React.Ref<DocumentVersionManager | null>;
44
87
  }
45
88
 
46
89
  function useOsTheme(): 'light' | 'dark' {
@@ -79,6 +122,218 @@ function parseHash(): { workspaceId: string; filePath: string | null } | null {
79
122
  };
80
123
  }
81
124
 
125
+ const LAST_STATE_KEY = 'docblocks:lastState';
126
+
127
+ interface LastState {
128
+ workspaceId: string;
129
+ filePath: string;
130
+ view: EditorView;
131
+ }
132
+
133
+ function saveLastState(state: LastState): void {
134
+ try {
135
+ localStorage.setItem(LAST_STATE_KEY, JSON.stringify(state));
136
+ } catch {
137
+ // ignore quota errors
138
+ }
139
+ }
140
+
141
+ function loadLastState(): LastState | null {
142
+ try {
143
+ const raw = localStorage.getItem(LAST_STATE_KEY);
144
+ if (!raw) return null;
145
+ return JSON.parse(raw);
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ const THEME_PREF_KEY = 'docblocks:themePreference';
152
+
153
+ function loadThemePreference(): ThemePreference {
154
+ try {
155
+ const raw = localStorage.getItem(THEME_PREF_KEY);
156
+ if (raw === 'light' || raw === 'dark' || raw === 'auto') return raw;
157
+ } catch {
158
+ // ignore
159
+ }
160
+ return 'auto';
161
+ }
162
+
163
+ function saveThemePreference(pref: ThemePreference): void {
164
+ try {
165
+ localStorage.setItem(THEME_PREF_KEY, pref);
166
+ } catch {
167
+ // ignore quota errors
168
+ }
169
+ }
170
+
171
+ const SIDEBAR_WIDTH_KEY = 'docblocks:sidebarWidth';
172
+ const SIDEBAR_WIDTH_DEFAULT = 260;
173
+ const SIDEBAR_WIDTH_MIN = 180;
174
+ const SIDEBAR_WIDTH_MAX = 600;
175
+ /** Drag below this many pixels and the sidebar collapses entirely —
176
+ * the editor takes the full width and a back-arrow appears in the
177
+ * toolbar so the user can pop the sidebar back open. Same UX as
178
+ * the existing mobile narrow-viewport flow. */
179
+ const SIDEBAR_COLLAPSE_THRESHOLD = 120;
180
+
181
+ function loadSidebarWidth(): number {
182
+ try {
183
+ const raw = localStorage.getItem(SIDEBAR_WIDTH_KEY);
184
+ if (raw === null) return SIDEBAR_WIDTH_DEFAULT;
185
+ const n = Number(raw);
186
+ if (!Number.isFinite(n)) return SIDEBAR_WIDTH_DEFAULT;
187
+ return Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, n));
188
+ } catch {
189
+ return SIDEBAR_WIDTH_DEFAULT;
190
+ }
191
+ }
192
+
193
+ function saveSidebarWidth(px: number): void {
194
+ try {
195
+ localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(px)));
196
+ } catch {
197
+ // ignore quota errors
198
+ }
199
+ }
200
+
201
+ const VIEW_PREFS_KEY = 'docblocks:viewPreferences';
202
+
203
+ const DEFAULT_VIEW_PREFS: ViewPreferences = {
204
+ outline: false,
205
+ inlinePreview: true,
206
+ showStatusBar: true,
207
+ };
208
+
209
+ function loadViewPreferences(): ViewPreferences {
210
+ try {
211
+ const raw = localStorage.getItem(VIEW_PREFS_KEY);
212
+ if (!raw) return DEFAULT_VIEW_PREFS;
213
+ const parsed = JSON.parse(raw) as Partial<ViewPreferences>;
214
+ return {
215
+ outline: typeof parsed.outline === 'boolean' ? parsed.outline : DEFAULT_VIEW_PREFS.outline,
216
+ inlinePreview:
217
+ typeof parsed.inlinePreview === 'boolean'
218
+ ? parsed.inlinePreview
219
+ : DEFAULT_VIEW_PREFS.inlinePreview,
220
+ showStatusBar:
221
+ typeof parsed.showStatusBar === 'boolean'
222
+ ? parsed.showStatusBar
223
+ : DEFAULT_VIEW_PREFS.showStatusBar,
224
+ };
225
+ } catch {
226
+ return DEFAULT_VIEW_PREFS;
227
+ }
228
+ }
229
+
230
+ function saveViewPreferences(prefs: ViewPreferences): void {
231
+ try {
232
+ localStorage.setItem(VIEW_PREFS_KEY, JSON.stringify(prefs));
233
+ } catch {
234
+ // ignore quota errors
235
+ }
236
+ }
237
+
238
+ function dirnameOf(p: string): string {
239
+ const clean = p.replace(/^\/+/, '');
240
+ const idx = clean.lastIndexOf('/');
241
+ return idx === -1 ? '' : clean.slice(0, idx);
242
+ }
243
+
244
+ function basenameOf(p: string): string {
245
+ const clean = p.replace(/^\/+/, '');
246
+ const idx = clean.lastIndexOf('/');
247
+ return idx === -1 ? clean : clean.slice(idx + 1);
248
+ }
249
+
250
+ /** Strip the extension from a filename (`notes.md` -> `notes`). Matches
251
+ * squisq's `getDocBasename` convention so version snapshot filenames
252
+ * stay readable (`<basename>.<timestamp>.md`). */
253
+ function stripExtension(name: string): string {
254
+ return name.replace(/\.[^.]+$/, '');
255
+ }
256
+
257
+ function normaliseProviderPath(p: string): string {
258
+ return '/' + p.replace(/^\/+/, '');
259
+ }
260
+
261
+ function sameProviderPath(a: string, b: string): boolean {
262
+ return normaliseProviderPath(a) === normaliseProviderPath(b);
263
+ }
264
+
265
+ /** Portable relative link from one workspace file to another. Walks up
266
+ * from the source's directory and back down to the target so the link
267
+ * survives folder reshuffles (`../sibling.md`, `subfolder/child.md`,
268
+ * `resume.md` for siblings at the workspace root). */
269
+ function relativeMarkdownLink(fromFile: string, toFile: string): string {
270
+ const fromParts = fromFile.replace(/^\/+/, '').split('/').filter(Boolean);
271
+ const toParts = toFile.replace(/^\/+/, '').split('/').filter(Boolean);
272
+ const fromDir = fromParts.slice(0, -1);
273
+ const toDir = toParts.slice(0, -1);
274
+ const toBase = toParts[toParts.length - 1] ?? '';
275
+ let common = 0;
276
+ while (common < fromDir.length && common < toDir.length && fromDir[common] === toDir[common]) {
277
+ common++;
278
+ }
279
+ const ups = fromDir.length - common;
280
+ const downs = [...toDir.slice(common), toBase];
281
+ const parts = [...Array(ups).fill('..'), ...downs];
282
+ return parts.length === 0 ? toBase : parts.join('/');
283
+ }
284
+
285
+ /** Recursively collect all `.md` files reachable from `root`. Skips
286
+ * Word-style `*_files/` asset companions, dotfiles, and `node_modules`
287
+ * so the candidate list stays workspace-meaningful. */
288
+ async function collectMarkdownFiles(
289
+ fs: FileSystemProvider,
290
+ root: string,
291
+ ): Promise<FileSystemEntry[]> {
292
+ const out: FileSystemEntry[] = [];
293
+ const visited = new Set<string>();
294
+
295
+ async function walk(dir: string): Promise<void> {
296
+ if (visited.has(dir)) return;
297
+ visited.add(dir);
298
+ let entries: FileSystemEntry[];
299
+ try {
300
+ entries = await fs.readDirectory(dir);
301
+ } catch {
302
+ return;
303
+ }
304
+ for (const entry of entries) {
305
+ if (entry.kind === 'directory') {
306
+ const lower = entry.name.toLowerCase();
307
+ if (lower.startsWith('.')) continue;
308
+ if (lower === 'node_modules') continue;
309
+ if (lower.endsWith('_files')) continue;
310
+ await walk(entry.path);
311
+ } else if (entry.kind === 'file') {
312
+ if (entry.name.toLowerCase().endsWith('.md')) out.push(entry);
313
+ }
314
+ }
315
+ }
316
+
317
+ await walk(root);
318
+ return out;
319
+ }
320
+
321
+ async function createElectronProviderFromWorkspace(
322
+ ws: WorkspaceDescriptor,
323
+ ): Promise<ElectronFileSystemProvider | null> {
324
+ if (!ws.rootPath) return null;
325
+ try {
326
+ await getDocBlocksHost().workspaces.register({
327
+ id: ws.id,
328
+ name: ws.name,
329
+ rootPath: ws.rootPath,
330
+ });
331
+ } catch {
332
+ return null;
333
+ }
334
+ return new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
335
+ }
336
+
82
337
  function useIsMobile(breakpoint = 768): boolean {
83
338
  const [isMobile, setIsMobile] = useState(
84
339
  () =>
@@ -93,13 +348,194 @@ function useIsMobile(breakpoint = 768): boolean {
93
348
  return isMobile;
94
349
  }
95
350
 
96
- export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps) {
351
+ function FolderGlyph() {
352
+ return (
353
+ <svg
354
+ viewBox="0 0 24 24"
355
+ fill="none"
356
+ stroke="currentColor"
357
+ strokeWidth="1.5"
358
+ strokeLinejoin="round"
359
+ aria-hidden="true"
360
+ >
361
+ <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" />
362
+ </svg>
363
+ );
364
+ }
365
+
366
+ function FileGlyph() {
367
+ return (
368
+ <svg
369
+ viewBox="0 0 24 24"
370
+ fill="none"
371
+ stroke="currentColor"
372
+ strokeWidth="1.5"
373
+ strokeLinejoin="round"
374
+ aria-hidden="true"
375
+ >
376
+ <path d="M6 3h8l5 5v12a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z" />
377
+ <path d="M14 3v5h5" />
378
+ </svg>
379
+ );
380
+ }
381
+
382
+ export function DocBlocksShell({
383
+ theme: _themeProp = 'auto',
384
+ logoUrl,
385
+ allowVersioning = true,
386
+ versionBasename,
387
+ versioningPrunePolicy,
388
+ versioningAutoSaveIdleMs,
389
+ onSaveVersion,
390
+ versioningRef,
391
+ }: DocBlocksShellProps) {
97
392
  const osTheme = useOsTheme();
98
- const resolvedTheme: 'light' | 'dark' = theme === 'auto' ? osTheme : theme;
393
+ const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
394
+ // "System default" (auto) always follows the OS — the host's theme prop
395
+ // is kept only for API back-compat and does not override OS detection.
396
+ const resolvedTheme: 'light' | 'dark' =
397
+ themePreference === 'light' || themePreference === 'dark' ? themePreference : osTheme;
398
+
399
+ const handleThemeChange = useCallback((pref: ThemePreference) => {
400
+ setThemePreference(pref);
401
+ saveThemePreference(pref);
402
+ }, []);
403
+
404
+ const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
405
+ const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
406
+ setViewPreferences(prefs);
407
+ saveViewPreferences(prefs);
408
+ }, []);
99
409
  const isMobile = useIsMobile();
100
410
  const [mobileShowEditor, setMobileShowEditor] = useState(false);
411
+ // Sidebar width — persisted across sessions, dragged via the resizer
412
+ // between sidebar and editor area. We track the "live" width during a
413
+ // drag in a ref so each mousemove doesn't trigger a state update; only
414
+ // setState on commit so React doesn't churn through every pixel.
415
+ const [sidebarWidth, setSidebarWidth] = useState<number>(loadSidebarWidth);
416
+ // When the user drags the resizer below SIDEBAR_COLLAPSE_THRESHOLD,
417
+ // we switch the layout into single-pane "compact" mode — same UX as
418
+ // the mobile narrow-viewport flow, where only the sidebar OR the
419
+ // editor is visible at a time and a back-arrow in the toolbar pops
420
+ // between them. A "Restore split view" button on the editor toolbar
421
+ // exits compact mode; on real mobile that button is suppressed
422
+ // because there's not enough viewport for side-by-side. Not
423
+ // persisted across reloads. */
424
+ const [compactLayout, setCompactLayout] = useState(false);
425
+ const effectiveCompact = isMobile || compactLayout;
426
+ const sidebarRef = useRef<HTMLDivElement>(null);
427
+ const dragStateRef = useRef<{ startX: number; startWidth: number } | null>(null);
428
+ const handleResizerPointerDown = useCallback(
429
+ (e: React.PointerEvent<HTMLDivElement>) => {
430
+ if (e.button !== 0) return;
431
+ dragStateRef.current = { startX: e.clientX, startWidth: sidebarWidth };
432
+ e.preventDefault();
433
+ // Disable text selection + flip the body cursor for the duration
434
+ // of the drag so the col-resize cursor stays visible even when the
435
+ // pointer slips off the 7px hit area. The custom cursor lives in
436
+ // the CSS class so Windows' white-cursor preference doesn't make
437
+ // the dragging cursor invisible against the light chrome.
438
+ document.body.style.userSelect = 'none';
439
+ document.body.classList.add('db-resizing-sidebar');
440
+ let lastRaw = sidebarWidth;
441
+ const onMove = (ev: PointerEvent) => {
442
+ const drag = dragStateRef.current;
443
+ if (!drag) return;
444
+ lastRaw = drag.startWidth + (ev.clientX - drag.startX);
445
+ if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD && sidebarRef.current) {
446
+ // Below threshold — preview the collapse by snapping to the
447
+ // minimum width and fading the sidebar, so the user can see
448
+ // they've crossed into "release to collapse" territory.
449
+ sidebarRef.current.style.width = `${SIDEBAR_WIDTH_MIN}px`;
450
+ sidebarRef.current.style.opacity = '0.45';
451
+ return;
452
+ }
453
+ const clamped = Math.min(SIDEBAR_WIDTH_MAX, Math.max(SIDEBAR_WIDTH_MIN, lastRaw));
454
+ if (sidebarRef.current) {
455
+ // Update the DOM directly during the drag for jank-free
456
+ // dragging; React state syncs on release.
457
+ sidebarRef.current.style.width = `${clamped}px`;
458
+ sidebarRef.current.style.opacity = '';
459
+ }
460
+ };
461
+ const onUp = () => {
462
+ document.removeEventListener('pointermove', onMove);
463
+ document.removeEventListener('pointerup', onUp);
464
+ document.body.style.userSelect = '';
465
+ document.body.classList.remove('db-resizing-sidebar');
466
+ if (sidebarRef.current) {
467
+ sidebarRef.current.style.opacity = '';
468
+ }
469
+ if (lastRaw < SIDEBAR_COLLAPSE_THRESHOLD) {
470
+ // Released below threshold — switch to compact (single-pane)
471
+ // layout focused on the editor. Keep the persisted
472
+ // sidebarWidth so exiting compact mode restores it.
473
+ setCompactLayout(true);
474
+ setMobileShowEditor(true);
475
+ } else {
476
+ const finalWidth = sidebarRef.current?.getBoundingClientRect().width;
477
+ if (finalWidth) {
478
+ const clamped = Math.min(
479
+ SIDEBAR_WIDTH_MAX,
480
+ Math.max(SIDEBAR_WIDTH_MIN, Math.round(finalWidth)),
481
+ );
482
+ setSidebarWidth(clamped);
483
+ saveSidebarWidth(clamped);
484
+ }
485
+ }
486
+ dragStateRef.current = null;
487
+ };
488
+ document.addEventListener('pointermove', onMove);
489
+ document.addEventListener('pointerup', onUp);
490
+ },
491
+ [sidebarWidth],
492
+ );
101
493
  const [provider, setProvider] = useState<FileSystemProvider | null>(null);
102
494
  const [activeWorkspaceId, setActiveWorkspaceId] = useState<string | null>(null);
495
+ const [activeWorkspaceDescriptor, setActiveWorkspaceDescriptor] =
496
+ useState<WorkspaceDescriptor | null>(null);
497
+ // Re-fetch the descriptor whenever the active id (or its versioning
498
+ // override) changes. `descriptorRefreshKey` is bumped after writes so
499
+ // the resolver picks up the updated override without remounting.
500
+ const [descriptorRefreshKey, setDescriptorRefreshKey] = useState(0);
501
+ useEffect(() => {
502
+ let cancelled = false;
503
+ if (!activeWorkspaceId) {
504
+ setActiveWorkspaceDescriptor(null);
505
+ return;
506
+ }
507
+ void getWorkspace(activeWorkspaceId).then((ws) => {
508
+ if (!cancelled) setActiveWorkspaceDescriptor(ws);
509
+ });
510
+ return () => {
511
+ cancelled = true;
512
+ };
513
+ }, [activeWorkspaceId, descriptorRefreshKey]);
514
+
515
+ const [workspaceSettingsOpen, setWorkspaceSettingsOpen] = useState(false);
516
+ const [versioningPreference, setVersioningPreference] =
517
+ useState<VersioningPreference>(loadVersioningPreference);
518
+ const handleVersioningPreferenceChange = useCallback((pref: VersioningPreference) => {
519
+ setVersioningPreference(pref);
520
+ saveVersioningPreference(pref);
521
+ }, []);
522
+ const effectiveVersioning =
523
+ allowVersioning && resolveVersioningEnabled(activeWorkspaceDescriptor, versioningPreference);
524
+
525
+ const handleOpenWorkspaceSettings = useCallback(() => {
526
+ if (!activeWorkspaceDescriptor) return;
527
+ setWorkspaceSettingsOpen(true);
528
+ }, [activeWorkspaceDescriptor]);
529
+
530
+ const handleWorkspaceVersioningOverrideChange = useCallback(
531
+ async (override: WorkspaceVersioningOverride) => {
532
+ if (!activeWorkspaceDescriptor) return;
533
+ await saveWorkspace({ ...activeWorkspaceDescriptor, versioningOverride: override });
534
+ setDescriptorRefreshKey((k) => k + 1);
535
+ setWorkspaceSettingsOpen(false);
536
+ },
537
+ [activeWorkspaceDescriptor],
538
+ );
103
539
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
104
540
  const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
105
541
  const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
@@ -109,17 +545,37 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
109
545
  const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
110
546
  /** Suppress popstate handling during programmatic navigation. */
111
547
  const skipPopState = useRef(false);
548
+ const lastLocalSaveRef = useRef<{
549
+ filePath: string;
550
+ content: string;
551
+ savedAt: number;
552
+ } | null>(null);
112
553
 
113
- /** Persistent media container backed by IndexedDB (survives page refresh). */
114
- const mediaContainerRef = useRef<IndexedDBContentContainer | null>(null);
554
+ /**
555
+ * Per-file media container: for `notes.md`, images live in
556
+ * `notes_files/` next to the markdown. Created lazily on first write,
557
+ * picked up automatically if it already exists. Set up by the effect
558
+ * below whenever the selected file (or backing provider) changes.
559
+ */
560
+ const mediaContainerRef = useRef<ContentContainer | null>(null);
115
561
  const [mediaProvider, setMediaProvider] = useState<MediaProvider | null>(null);
562
+ /**
563
+ * Per-document container scoped to `<basename>_files/`. This is what
564
+ * the editor uses for version history (`.versions/` lives here) and
565
+ * for audio mapping (MP3 / timing.json discovery). Distinct from
566
+ * `mediaContainerRef` which is scoped to the parent directory so the
567
+ * media provider can write `notes_files/image.png` paths that stay
568
+ * portable in the markdown.
569
+ */
570
+ const versionsContainerRef = useRef<ContentContainer | null>(null);
571
+ const [versionsContainer, setVersionsContainer] = useState<ContentContainer | null>(null);
116
572
 
117
- /** Set up persistent media container for a workspace. */
118
- const setupMediaContainer = useCallback((workspaceId: string) => {
119
- const mc = new IndexedDBContentContainer(workspaceId);
120
- mediaContainerRef.current = mc;
121
- setMediaProvider(createMediaProviderFromContainer(mc));
122
- }, []);
573
+ /** Cache of .md files in the active workspace, used to power the
574
+ * squisq link-dialog's document picker. Lazily populated on first
575
+ * provider call; cleared whenever the backing filesystem changes so
576
+ * workspace switches don't surface stale neighbours. A pending Promise
577
+ * during in-flight walks lets concurrent calls share the same scan. */
578
+ const mdFileCacheRef = useRef<Promise<FileSystemEntry[]> | null>(null);
123
579
 
124
580
  /** Push a new history entry with the given hash. */
125
581
  const pushHash = useCallback((wsId: string, filePath?: string | null) => {
@@ -130,17 +586,26 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
130
586
  }
131
587
  }, []);
132
588
 
133
- /** Open a workspace (and optionally a file) from identifiers. */
589
+ /** Open a workspace (and optionally a file) from identifiers.
590
+ * Returns the FileSystemProvider on success, or null on failure. */
134
591
  const openFromIds = useCallback(
135
- async (wsId: string, filePath: string | null, push: boolean) => {
592
+ async (
593
+ wsId: string,
594
+ filePath: string | null,
595
+ push: boolean,
596
+ view?: EditorView,
597
+ ): Promise<FileSystemProvider | null> => {
136
598
  const workspaces = await listWorkspaces();
137
599
  const ws = workspaces.find((w) => w.id === wsId);
138
- if (!ws) return false;
600
+ if (!ws) return null;
139
601
 
140
602
  let fsProvider: FileSystemProvider | null = null;
141
- if (ws.type === 'native') {
603
+ if (ws.type === 'electron-native') {
604
+ fsProvider = await createElectronProviderFromWorkspace(ws);
605
+ if (!fsProvider) return null;
606
+ } else if (ws.type === 'native') {
142
607
  const restored = await restoreNativeFolder(ws.id);
143
- if (!restored) return false;
608
+ if (!restored) return null;
144
609
  fsProvider = restored;
145
610
  } else {
146
611
  fsProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
@@ -149,8 +614,6 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
149
614
  setProvider(fsProvider);
150
615
  setActiveWorkspaceId(ws.id);
151
616
 
152
- setupMediaContainer(ws.id);
153
-
154
617
  if (filePath) {
155
618
  const content = await fsProvider.readFile(filePath);
156
619
  if (content !== null) {
@@ -158,8 +621,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
158
621
  setSelectedFolder(null);
159
622
  setFolderEntries([]);
160
623
  setEditorContent(content);
161
- setInitialView('wysiwyg');
624
+ const effectiveView = view ?? 'wysiwyg';
625
+ setInitialView(effectiveView);
162
626
  setEditorKey((k) => k + 1);
627
+ saveLastState({ workspaceId: wsId, filePath, view: effectiveView });
163
628
  } else {
164
629
  setSelectedFile(null);
165
630
  setSelectedFolder(null);
@@ -178,49 +643,53 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
178
643
  if (push) {
179
644
  pushHash(ws.id, filePath);
180
645
  }
181
- return true;
646
+ return fsProvider;
182
647
  },
183
- [pushHash, setupMediaContainer],
648
+ [pushHash],
184
649
  );
185
650
 
186
651
  const seedWelcomeFile = useCallback(
187
652
  async (fs: FileSystemProvider) => {
188
653
  const entries = await fs.readDirectory('/');
189
654
 
190
- // If the only file is the welcome doc, auto-select it
655
+ // If the only file is the welcome doc, auto-select it.
656
+ // Match either casing so workspaces seeded before the rename
657
+ // (aboutDocblocks.md) keep working alongside new ones (aboutDocBlocks.md).
191
658
  if (
192
659
  entries.length === 1 &&
193
660
  entries[0].kind === 'file' &&
194
- entries[0].path === '/aboutDocblocks.md'
661
+ entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
195
662
  ) {
196
- const content = await fs.readFile('/aboutDocblocks.md');
663
+ const aboutPath = entries[0].path;
664
+ const content = await fs.readFile(aboutPath);
197
665
  if (content !== null) {
198
- setSelectedFile('/aboutDocblocks.md');
666
+ setSelectedFile(aboutPath);
199
667
  setEditorContent(content);
200
668
  setInitialView('preview');
201
669
  setEditorKey((k) => k + 1);
202
670
  setExplorerKey((k) => k + 1);
203
- pushHash(fs.id, '/aboutDocblocks.md');
671
+ pushHash(fs.id, aboutPath);
672
+ saveLastState({ workspaceId: fs.id, filePath: aboutPath, view: 'preview' });
204
673
  }
205
674
  return;
206
675
  }
207
676
 
208
677
  if (entries.length > 0) return;
209
678
 
210
- const welcomePath = '/aboutDocblocks.md';
679
+ const welcomePath = '/aboutDocBlocks.md';
211
680
  const welcomeContent = [
212
681
  '# Welcome to DocBlocks',
213
682
  '',
214
- 'DocBlocks is a browser-based markdown document editor that lets you create, organize, and manage your documents right in the browser.',
683
+ '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.',
684
+ '',
685
+ 'Simple to write. Beautiful wherever it goes.',
215
686
  '',
216
687
  '## Features',
217
688
  '',
218
- '- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
219
- '- **Workspaces** — Organize your documents into separate workspaces',
689
+ '- **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.',
690
+ '- **Workspaces** — Organize your documents into separate workspaces in the browser or on your device.',
691
+ '- **Useful Everywhere** — Your content is usable across multiple formats — Microsoft Word .docx, PowerPoint, PDF, HTML, EPUB e-books, and Markdown.',
220
692
  '- **Playback & Video** — Preview your documents as rich visual presentations and export them as MP4 video',
221
- '- **Export Anywhere** — Export documents to PDF, Word, PowerPoint, HTML, or Markdown with theme options',
222
- '- **Local Storage** — Your documents are stored in your browser using temporary browser storage (backup often!)',
223
- '- **Device Folders** — Create workspaces based on folders on your computer',
224
693
  '- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
225
694
  '',
226
695
  '## Getting Started',
@@ -229,7 +698,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
229
698
  '2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
230
699
  '3. Your work is saved automatically',
231
700
  '',
232
- 'Built with [Squiggly Square](https://github.com/nicoth-in/squisq) by [Bendyline](https://bendyline.com).',
701
+ 'Built with [Squiggly Square](https://github.com/bendyline/squisq) by [Bendyline](https://bendyline.com).',
233
702
  ].join('\n');
234
703
 
235
704
  await fs.writeFile(welcomePath, welcomeContent);
@@ -239,6 +708,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
239
708
  setEditorKey((k) => k + 1);
240
709
  setExplorerKey((k) => k + 1);
241
710
  pushHash(fs.id, welcomePath);
711
+ saveLastState({ workspaceId: fs.id, filePath: welcomePath, view: 'preview' });
242
712
  },
243
713
  [pushHash],
244
714
  );
@@ -249,27 +719,61 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
249
719
  // Try restoring from URL hash first
250
720
  const hashState = parseHash();
251
721
  if (hashState) {
252
- const ok = await openFromIds(hashState.workspaceId, hashState.filePath, false);
253
- if (ok) return;
722
+ const restoredProvider = await openFromIds(
723
+ hashState.workspaceId,
724
+ hashState.filePath,
725
+ false,
726
+ );
727
+ if (restoredProvider) {
728
+ // If the hash had no file, check whether we should auto-select the welcome doc
729
+ if (!hashState.filePath) {
730
+ await seedWelcomeFile(restoredProvider);
731
+ }
732
+ return;
733
+ }
734
+ }
735
+
736
+ // Try restoring last viewed document from localStorage
737
+ const lastState = loadLastState();
738
+ if (lastState) {
739
+ const restoredProvider = await openFromIds(
740
+ lastState.workspaceId,
741
+ lastState.filePath,
742
+ true,
743
+ lastState.view,
744
+ );
745
+ if (restoredProvider) return;
254
746
  }
255
747
 
256
748
  let fsProvider: FileSystemProvider | null = null;
257
749
 
750
+ const electron = isElectronHost();
258
751
  const workspaces = await listWorkspaces();
752
+ // On desktop, hide web-only workspaces (indexeddb/native) — only
753
+ // folder-based workspaces are valid.
754
+ const candidates = electron
755
+ ? workspaces.filter((w) => w.type === 'electron-native')
756
+ : workspaces.filter((w) => w.type !== 'electron-native');
259
757
  // Pick the most recently opened workspace
260
- const sorted = [...workspaces].sort((a, b) =>
758
+ const sorted = [...candidates].sort((a, b) =>
261
759
  (b.lastOpened ?? '').localeCompare(a.lastOpened ?? ''),
262
760
  );
263
761
  for (const ws of sorted) {
264
- if (ws.type === 'native') {
762
+ if (ws.type === 'electron-native') {
763
+ const p = await createElectronProviderFromWorkspace(ws);
764
+ if (!p) continue;
765
+ await touchWorkspace(ws.id);
766
+ fsProvider = p;
767
+ setProvider(p);
768
+ setActiveWorkspaceId(ws.id);
769
+ break;
770
+ } else if (ws.type === 'native') {
265
771
  const restored = await restoreNativeFolder(ws.id);
266
772
  if (restored) {
267
773
  await touchWorkspace(ws.id);
268
774
  fsProvider = restored;
269
775
  setProvider(restored);
270
776
  setActiveWorkspaceId(ws.id);
271
-
272
- setupMediaContainer(ws.id);
273
777
  break;
274
778
  }
275
779
  } else {
@@ -278,21 +782,35 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
278
782
  fsProvider = p;
279
783
  setProvider(p);
280
784
  setActiveWorkspaceId(ws.id);
281
-
282
- setupMediaContainer(ws.id);
283
785
  break;
284
786
  }
285
787
  }
286
788
 
287
789
  if (!fsProvider) {
288
- // No workspaces yet — create default
289
- const defaultWs = await ensureDefaultWorkspace();
290
- const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
291
- fsProvider = p;
292
- setProvider(p);
293
- setActiveWorkspaceId(defaultWs.id);
294
-
295
- setupMediaContainer(defaultWs.id);
790
+ if (electron) {
791
+ // Desktop: ask the host for the default folder workspace
792
+ // (creates ~/Documents/DocBlocks on first launch).
793
+ const info = await getDocBlocksHost().workspaces.getDefault();
794
+ const descriptor: WorkspaceDescriptor = {
795
+ id: info.id,
796
+ name: info.name,
797
+ type: 'electron-native',
798
+ rootPath: info.rootPath,
799
+ lastOpened: new Date().toISOString(),
800
+ };
801
+ await saveWorkspace(descriptor);
802
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
803
+ fsProvider = p;
804
+ setProvider(p);
805
+ setActiveWorkspaceId(info.id);
806
+ } else {
807
+ // Web: create the IndexedDB default workspace
808
+ const defaultWs = await ensureDefaultWorkspace();
809
+ const p = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
810
+ fsProvider = p;
811
+ setProvider(p);
812
+ setActiveWorkspaceId(defaultWs.id);
813
+ }
296
814
  }
297
815
 
298
816
  // Set initial hash
@@ -301,7 +819,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
301
819
  // Seed welcome file if workspace is empty
302
820
  await seedWelcomeFile(fsProvider);
303
821
  })();
304
- }, [openFromIds, pushHash, seedWelcomeFile, setupMediaContainer]);
822
+ }, [openFromIds, pushHash, seedWelcomeFile]);
305
823
 
306
824
  // Handle browser back/forward
307
825
  useEffect(() => {
@@ -319,23 +837,198 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
319
837
  return () => window.removeEventListener('popstate', onPopState);
320
838
  }, [openFromIds]);
321
839
 
322
- // Auto-save current file
323
- useAutoSave(provider, selectedFile, editorContent);
840
+ // Track view mode changes (Editor/Raw/Play tabs) and persist to localStorage
841
+ useEffect(() => {
842
+ const handler = (e: MouseEvent) => {
843
+ const target = (e.target as HTMLElement).closest?.('[data-view]');
844
+ if (target) {
845
+ const view = target.getAttribute('data-view') as EditorView;
846
+ if (view && activeWorkspaceId && selectedFile) {
847
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: selectedFile, view });
848
+ }
849
+ }
850
+ };
851
+ window.addEventListener('click', handler, true);
852
+ return () => window.removeEventListener('click', handler, true);
853
+ }, [activeWorkspaceId, selectedFile]);
854
+
855
+ const handleAutoSaved = useCallback((filePath: string, savedContent: string) => {
856
+ lastLocalSaveRef.current = {
857
+ filePath: normaliseProviderPath(filePath),
858
+ content: savedContent,
859
+ savedAt: Date.now(),
860
+ };
861
+ }, []);
862
+
863
+ // Auto-save current file. The returned `flush` is called from the
864
+ // Ctrl/Cmd+S handler below so the user gets immediate confirmation.
865
+ const { flush: flushAutoSave } = useAutoSave(
866
+ provider,
867
+ selectedFile,
868
+ editorContent,
869
+ 500,
870
+ handleAutoSaved,
871
+ );
872
+
873
+ // Comfort-blanket Ctrl/Cmd+S: flushes any pending autosave and pops a
874
+ // small "auto-save confirmed" toast. Files are already saved on every
875
+ // keystroke (debounced) — this is purely UX reassurance for users who
876
+ // muscle-memory hit Save.
877
+ const [saveToastVisible, setSaveToastVisible] = useState(false);
878
+ const saveToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
879
+ useEffect(() => {
880
+ const onKey = (e: KeyboardEvent) => {
881
+ const sKey = e.key === 's' || e.key === 'S';
882
+ const accel = e.ctrlKey || e.metaKey;
883
+ if (!sKey || !accel || e.altKey) return;
884
+ e.preventDefault();
885
+ e.stopPropagation();
886
+ void flushAutoSave().catch(() => undefined);
887
+ setSaveToastVisible(true);
888
+ if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
889
+ saveToastTimerRef.current = setTimeout(() => setSaveToastVisible(false), 1800);
890
+ };
891
+ window.addEventListener('keydown', onKey, true);
892
+ return () => window.removeEventListener('keydown', onKey, true);
893
+ }, [flushAutoSave]);
894
+ useEffect(() => {
895
+ return () => {
896
+ if (saveToastTimerRef.current) clearTimeout(saveToastTimerRef.current);
897
+ };
898
+ }, []);
899
+
900
+ // Per-file media: for `notes.md` images live in `notes_files/` beside it.
901
+ // Rebuilds whenever the provider or selected file changes.
902
+ useEffect(() => {
903
+ if (!provider || !selectedFile) {
904
+ mediaContainerRef.current = null;
905
+ versionsContainerRef.current = null;
906
+ setMediaProvider(null);
907
+ setVersionsContainer(null);
908
+ return;
909
+ }
910
+ const parentDir = dirnameOf(selectedFile);
911
+ const base = basenameOf(selectedFile);
912
+ const baseNoExt = base.replace(/\.[^.]+$/, '');
913
+ const container = new FileSystemContentContainer(provider, parentDir);
914
+ const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
915
+ const vContainer = new FileSystemContentContainer(provider, vPrefix);
916
+ const mp = createFileMediaProvider(container, base);
917
+ mediaContainerRef.current = container;
918
+ versionsContainerRef.current = vContainer;
919
+ setMediaProvider(mp);
920
+ setVersionsContainer(vContainer);
921
+ return () => {
922
+ mp.dispose();
923
+ };
924
+ }, [provider, selectedFile]);
925
+
926
+ // Invalidate the document-link candidate cache when the backing
927
+ // workspace changes — otherwise the link dialog would surface
928
+ // neighbours from a previously-open workspace.
929
+ useEffect(() => {
930
+ mdFileCacheRef.current = null;
931
+ }, [provider]);
932
+
933
+ /** Powers the squisq link dialog's "Browse documents" picker. Returns
934
+ * workspace `.md` neighbours filtered by `query`, with paths expressed
935
+ * relative to the currently-open document so the link survives folder
936
+ * moves. The first call seeds an in-memory cache; subsequent calls
937
+ * filter against it. */
938
+ const documentLinkProvider = useCallback<DocumentLinkProvider>(
939
+ async (query: string): Promise<DocumentLinkCandidate[]> => {
940
+ if (!provider || !selectedFile) return [];
941
+ if (!mdFileCacheRef.current) {
942
+ mdFileCacheRef.current = collectMarkdownFiles(provider, '').catch(() => []);
943
+ }
944
+ const entries = await mdFileCacheRef.current;
945
+ const q = query.trim().toLowerCase();
946
+ const candidates: DocumentLinkCandidate[] = [];
947
+ for (const entry of entries) {
948
+ if (entry.kind !== 'file') continue;
949
+ if (sameProviderPath(entry.path, selectedFile)) continue;
950
+ const label = entry.name.replace(/\.md$/i, '');
951
+ const path = relativeMarkdownLink(selectedFile, entry.path);
952
+ if (q && !label.toLowerCase().includes(q) && !path.toLowerCase().includes(q)) continue;
953
+ const dir = dirnameOf(entry.path);
954
+ candidates.push(dir ? { path, label, description: dir } : { path, label });
955
+ }
956
+ // Stable alphabetical order keeps the picker predictable across
957
+ // re-opens; the dialog can re-sort or rank on its own if needed.
958
+ candidates.sort((a, b) => a.label.localeCompare(b.label));
959
+ return candidates;
960
+ },
961
+ [provider, selectedFile],
962
+ );
963
+
964
+ // Expose a DocumentVersionManager via versioningRef when requested.
965
+ useEffect(() => {
966
+ const ref = versioningRef;
967
+ if (!ref) return;
968
+ const assign = (mgr: DocumentVersionManager | null) => {
969
+ if (typeof ref === 'function') ref(mgr);
970
+ else (ref as React.MutableRefObject<DocumentVersionManager | null>).current = mgr;
971
+ };
972
+ if (!effectiveVersioning || !versionsContainer || !selectedFile) {
973
+ assign(null);
974
+ return;
975
+ }
976
+ const base = stripExtension(basenameOf(selectedFile));
977
+ const mgr = new DocumentVersionManager(versionsContainer, {
978
+ basename: versionBasename ?? base,
979
+ });
980
+ assign(mgr);
981
+ return () => assign(null);
982
+ }, [versioningRef, effectiveVersioning, versionBasename, selectedFile, versionsContainer]);
983
+
984
+ // React to external file changes watched by the Electron host (chokidar).
985
+ useEffect(() => {
986
+ if (!isElectronHost()) return;
987
+ if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
988
+ const unwatch = provider.watch((changedPath) => {
989
+ setExplorerKey((k) => k + 1);
990
+ if (!selectedFile || !sameProviderPath(changedPath, selectedFile)) return;
991
+
992
+ // If the open file's contents changed on disk, reload it (best-effort).
993
+ (async () => {
994
+ const content = await provider.readFile(selectedFile);
995
+ if (content === null || content === editorContent) return;
996
+
997
+ const localSave = lastLocalSaveRef.current;
998
+ if (
999
+ localSave &&
1000
+ sameProviderPath(localSave.filePath, selectedFile) &&
1001
+ localSave.content === content &&
1002
+ Date.now() - localSave.savedAt < 5000
1003
+ ) {
1004
+ return;
1005
+ }
1006
+
1007
+ setEditorContent(content);
1008
+ setEditorKey((k) => k + 1);
1009
+ })();
1010
+ });
1011
+ return unwatch;
1012
+ }, [provider, selectedFile, editorContent]);
324
1013
 
325
1014
  const handleWorkspaceSelect = useCallback(
326
1015
  async (ws: WorkspaceDescriptor) => {
327
1016
  await touchWorkspace(ws.id);
328
- if (ws.type === 'native') {
1017
+ let nextProvider: FileSystemProvider | null = null;
1018
+ if (ws.type === 'electron-native') {
1019
+ nextProvider = await createElectronProviderFromWorkspace(ws);
1020
+ if (!nextProvider) return;
1021
+ } else if (ws.type === 'native') {
329
1022
  const restored = await restoreNativeFolder(ws.id);
330
- if (restored) {
331
- setProvider(restored);
332
- } else {
1023
+ if (!restored) {
333
1024
  // Permission denied or handle lost — fall through without changing provider
334
1025
  return;
335
1026
  }
1027
+ nextProvider = restored;
336
1028
  } else {
337
- setProvider(new IndexedDBFileSystemProvider(ws.id, ws.name));
1029
+ nextProvider = new IndexedDBFileSystemProvider(ws.id, ws.name);
338
1030
  }
1031
+ setProvider(nextProvider);
339
1032
  setActiveWorkspaceId(ws.id);
340
1033
  setSelectedFile(null);
341
1034
  setSelectedFolder(null);
@@ -343,14 +1036,35 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
343
1036
  setEditorContent('');
344
1037
  setEditorKey((k) => k + 1);
345
1038
  pushHash(ws.id, null);
346
-
347
- setupMediaContainer(ws.id);
348
1039
  },
349
- [pushHash, setupMediaContainer],
1040
+ [pushHash],
350
1041
  );
351
1042
 
352
1043
  const handleOpenFolder = useCallback(async () => {
353
1044
  try {
1045
+ if (isElectronHost()) {
1046
+ const info = await getDocBlocksHost().workspaces.pickFolder();
1047
+ if (!info) return; // user cancelled
1048
+ const descriptor: WorkspaceDescriptor = {
1049
+ id: info.id,
1050
+ name: info.name,
1051
+ type: 'electron-native',
1052
+ rootPath: info.rootPath,
1053
+ lastOpened: new Date().toISOString(),
1054
+ };
1055
+ await saveWorkspace(descriptor);
1056
+ const provider = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
1057
+ setProvider(provider);
1058
+ setActiveWorkspaceId(descriptor.id);
1059
+ setSelectedFile(null);
1060
+ setSelectedFolder(null);
1061
+ setFolderEntries([]);
1062
+ setEditorContent('');
1063
+ setEditorKey((k) => k + 1);
1064
+ pushHash(descriptor.id, null);
1065
+ return;
1066
+ }
1067
+
354
1068
  const nativeProvider = await openNativeFolder();
355
1069
  const descriptor: WorkspaceDescriptor = {
356
1070
  id: nativeProvider.id,
@@ -367,12 +1081,108 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
367
1081
  setEditorContent('');
368
1082
  setEditorKey((k) => k + 1);
369
1083
  pushHash(descriptor.id, null);
370
-
371
- setupMediaContainer(descriptor.id);
372
1084
  } catch {
373
1085
  // User cancelled or API not supported
374
1086
  }
375
- }, [pushHash, setupMediaContainer]);
1087
+ }, [pushHash]);
1088
+
1089
+ const handleNewFile = useCallback(async () => {
1090
+ if (!provider) return;
1091
+ const name = prompt('New document name:', 'Untitled.md');
1092
+ if (!name) return;
1093
+ const filename = /\.[a-zA-Z0-9]+$/.test(name) ? name : `${name}.md`;
1094
+ await provider.writeFile(filename, '# ' + filename.replace(/\.[^.]+$/, '') + '\n');
1095
+ setSelectedFile('/' + filename);
1096
+ setEditorContent('# ' + filename.replace(/\.[^.]+$/, '') + '\n');
1097
+ setInitialView('wysiwyg');
1098
+ setEditorKey((k) => k + 1);
1099
+ setExplorerKey((k) => k + 1);
1100
+ if (activeWorkspaceId) {
1101
+ pushHash(activeWorkspaceId, '/' + filename);
1102
+ }
1103
+ }, [provider, activeWorkspaceId, pushHash]);
1104
+
1105
+ const handleRevealWorkspace = useCallback(async () => {
1106
+ if (!isElectronHost() || !activeWorkspaceId) return;
1107
+ const ws = await getWorkspace(activeWorkspaceId);
1108
+ if (ws?.type === 'electron-native' && ws.rootPath) {
1109
+ await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
1110
+ }
1111
+ }, [activeWorkspaceId]);
1112
+
1113
+ // Subscribe to native menu commands (Electron host).
1114
+ useEffect(() => {
1115
+ if (!isElectronHost()) return;
1116
+ const host = getDocBlocksHost();
1117
+ return host.onMenuCommand((cmd) => {
1118
+ switch (cmd) {
1119
+ case 'file:new':
1120
+ handleNewFile();
1121
+ break;
1122
+ case 'file:openFolder':
1123
+ handleOpenFolder();
1124
+ break;
1125
+ case 'file:revealWorkspace':
1126
+ handleRevealWorkspace();
1127
+ break;
1128
+ case 'help:viewOnGitHub':
1129
+ host.shell.openExternal('https://github.com/bendyline/docblocks');
1130
+ break;
1131
+ case 'help:checkForUpdates':
1132
+ host.updater.checkForUpdates().catch(() => {
1133
+ // errors surface via updater.onStatus
1134
+ });
1135
+ break;
1136
+ case 'help:about':
1137
+ (async () => {
1138
+ const version = await host.updater.getVersion();
1139
+ alert(`DocBlocks ${version}`);
1140
+ })();
1141
+ break;
1142
+ default:
1143
+ break;
1144
+ }
1145
+ });
1146
+ }, [handleNewFile, handleOpenFolder, handleRevealWorkspace]);
1147
+
1148
+ // Subscribe to open-file / deep-link requests from the OS.
1149
+ useEffect(() => {
1150
+ if (!isElectronHost()) return;
1151
+ const host = getDocBlocksHost();
1152
+ return host.onOpenRequest(async (req) => {
1153
+ if (req.filePath) {
1154
+ const workspaces = (await listWorkspaces()).filter(
1155
+ (w) => w.type === 'electron-native' && w.rootPath,
1156
+ );
1157
+ const match = workspaces.find(
1158
+ (w) => req.filePath!.startsWith((w.rootPath ?? '') + '/') || req.filePath === w.rootPath,
1159
+ );
1160
+ if (match && match.rootPath) {
1161
+ const rel = '/' + req.filePath.slice(match.rootPath.length).replace(/^\/+/, '');
1162
+ await openFromIds(match.id, rel, true);
1163
+ }
1164
+ } else if (req.url) {
1165
+ try {
1166
+ const u = new URL(req.url);
1167
+ const path = u.searchParams.get('path');
1168
+ if (path) {
1169
+ const workspaces = (await listWorkspaces()).filter(
1170
+ (w) => w.type === 'electron-native' && w.rootPath,
1171
+ );
1172
+ const match = workspaces.find(
1173
+ (w) => path.startsWith((w.rootPath ?? '') + '/') || path === w.rootPath,
1174
+ );
1175
+ if (match && match.rootPath) {
1176
+ const rel = '/' + path.slice(match.rootPath.length).replace(/^\/+/, '');
1177
+ await openFromIds(match.id, rel, true);
1178
+ }
1179
+ }
1180
+ } catch {
1181
+ // bad URL, ignore
1182
+ }
1183
+ }
1184
+ });
1185
+ }, [openFromIds]);
376
1186
 
377
1187
  const handleSelect = useCallback(
378
1188
  async (path: string, kind: 'file' | 'directory') => {
@@ -395,10 +1205,11 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
395
1205
  setInitialView('wysiwyg');
396
1206
  setEditorKey((k) => k + 1);
397
1207
  pushHash(activeWorkspaceId, path);
398
- if (isMobile) setMobileShowEditor(true);
1208
+ saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
1209
+ if (effectiveCompact) setMobileShowEditor(true);
399
1210
  }
400
1211
  },
401
- [provider, activeWorkspaceId, pushHash, isMobile],
1212
+ [provider, activeWorkspaceId, pushHash, effectiveCompact],
402
1213
  );
403
1214
 
404
1215
  const handleTreeChange = useCallback(async () => {
@@ -419,21 +1230,30 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
419
1230
  }
420
1231
  }, [provider, selectedFile, selectedFolder, activeWorkspaceId, pushHash]);
421
1232
 
422
- /** Copy non-markdown files from an imported container into persistent media storage. */
423
- const persistContainerMedia = useCallback(
424
- async (source: {
425
- listFiles(): Promise<Array<{ path: string; mimeType: string }>>;
426
- readFile(path: string): Promise<ArrayBuffer | null>;
427
- }) => {
428
- const mc = mediaContainerRef.current;
429
- if (!mc) return;
1233
+ /**
1234
+ * Copy non-markdown files from an imported container into the target
1235
+ * markdown file's `_files/` sibling folder, preserving the source's
1236
+ * relative structure.
1237
+ */
1238
+ const persistImportedMedia = useCallback(
1239
+ async (
1240
+ source: {
1241
+ listFiles(): Promise<Array<{ path: string; mimeType: string }>>;
1242
+ readFile(path: string): Promise<ArrayBuffer | null>;
1243
+ },
1244
+ target: FileSystemProvider,
1245
+ importedMarkdownPath: string,
1246
+ ) => {
1247
+ const parentDir = dirnameOf(importedMarkdownPath);
1248
+ const folder = basenameOf(importedMarkdownPath).replace(/\.[^.]+$/, '') + '_files';
1249
+ const mediaRoot = parentDir ? `${parentDir}/${folder}` : folder;
430
1250
  const entries = await source.listFiles();
431
1251
  for (const entry of entries) {
432
1252
  if (entry.path.endsWith('.md')) continue;
433
1253
  const data = await source.readFile(entry.path);
434
- if (data) {
435
- await mc.writeFile(entry.path, new Uint8Array(data), entry.mimeType);
436
- }
1254
+ if (!data) continue;
1255
+ const cleanPath = entry.path.replace(/^\/+/, '');
1256
+ await target.writeBinary(`${mediaRoot}/${cleanPath}`, new Uint8Array(data));
437
1257
  }
438
1258
  },
439
1259
  [],
@@ -455,17 +1275,17 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
455
1275
  const { docxToContainer } = await import('@bendyline/squisq-formats/docx');
456
1276
  const container = await docxToContainer(await file.arrayBuffer());
457
1277
  markdown = (await container.readDocument()) ?? '';
458
- await persistContainerMedia(container);
1278
+ await persistImportedMedia(container, provider, destPath);
459
1279
  } else if (ext === '.pdf') {
460
1280
  const { pdfToContainer } = await import('@bendyline/squisq-formats/pdf');
461
1281
  const container = await pdfToContainer(await file.arrayBuffer());
462
1282
  markdown = (await container.readDocument()) ?? '';
463
- await persistContainerMedia(container);
1283
+ await persistImportedMedia(container, provider, destPath);
464
1284
  } else if (ext === '.dbk' || ext === '.zip') {
465
1285
  const { zipToContainer } = await import('@bendyline/squisq-formats/container');
466
1286
  const container = await zipToContainer(await file.arrayBuffer());
467
1287
  markdown = (await container.readDocument()) ?? '';
468
- await persistContainerMedia(container);
1288
+ await persistImportedMedia(container, provider, destPath);
469
1289
  } else {
470
1290
  continue;
471
1291
  }
@@ -475,15 +1295,10 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
475
1295
  console.error(`Failed to import ${file.name}:`, err);
476
1296
  }
477
1297
  }
478
- // Refresh media provider once after all imports (not per-file)
479
- const mc = mediaContainerRef.current;
480
- if (mc) {
481
- setMediaProvider(createMediaProviderFromContainer(mc));
482
- }
483
- // Refresh file tree
1298
+ // Refresh file tree so the new markdown files + _files folders show up.
484
1299
  setExplorerKey((k) => k + 1);
485
1300
  },
486
- [provider, persistContainerMedia],
1301
+ [provider, persistImportedMedia],
487
1302
  );
488
1303
 
489
1304
  const handleEditorChange = useCallback((source: string) => {
@@ -499,41 +1314,205 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
499
1314
  await saveWorkspace({ ...ws, name: newName });
500
1315
  // Bump key to trigger re-render — workspace name is read from the descriptor, not the provider
501
1316
  setEditorKey((k) => k + 1);
502
- }, [activeWorkspaceId, provider]);
1317
+ }, [activeWorkspaceId]);
1318
+
1319
+ /**
1320
+ * Walk a FileSystemProvider and copy every file into `container` under
1321
+ * `pathPrefix` (no leading slash; empty string for the root). Used by
1322
+ * both single- and all-workspace downloads.
1323
+ */
1324
+ const copyProviderToContainer = useCallback(
1325
+ async (
1326
+ src: FileSystemProvider,
1327
+ container: { writeFile: (path: string, data: ArrayBuffer | Uint8Array) => Promise<void> },
1328
+ pathPrefix: string,
1329
+ ): Promise<void> => {
1330
+ const encoder = new TextEncoder();
1331
+ const stack: string[] = ['/'];
1332
+ while (stack.length > 0) {
1333
+ const dir = stack.pop()!;
1334
+ const entries = await src.readDirectory(dir);
1335
+ for (const entry of entries) {
1336
+ if (entry.kind === 'directory') {
1337
+ stack.push(entry.path);
1338
+ continue;
1339
+ }
1340
+ const rel = entry.path.replace(/^\/+/, '');
1341
+ const zipPath = pathPrefix ? `${pathPrefix}/${rel}` : rel;
1342
+ // Files may be stored as text (writeFile) or binary (writeBinary);
1343
+ // try binary first, fall back to text and encode as UTF-8.
1344
+ const binary = await src.readBinary(entry.path);
1345
+ if (binary) {
1346
+ await container.writeFile(zipPath, binary);
1347
+ continue;
1348
+ }
1349
+ const text = await src.readFile(entry.path);
1350
+ if (text !== null) {
1351
+ await container.writeFile(zipPath, encoder.encode(text));
1352
+ }
1353
+ }
1354
+ }
1355
+ },
1356
+ [],
1357
+ );
503
1358
 
504
1359
  const handleDownloadWorkspace = useCallback(async () => {
505
1360
  if (!provider) return;
506
1361
  try {
507
- const entries = await provider.readDirectory('/');
508
- const lines: string[] = [];
509
- for (const entry of entries) {
510
- if (entry.kind === 'file') {
511
- const content = await provider.readFile(entry.path);
512
- lines.push(`--- ${entry.path} ---\n${content ?? ''}\n`);
1362
+ const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1363
+ import('@bendyline/squisq/storage'),
1364
+ import('@bendyline/squisq-formats/container'),
1365
+ ]);
1366
+
1367
+ const container = new MemoryContentContainer();
1368
+ await copyProviderToContainer(provider, container, '');
1369
+
1370
+ const blob = await containerToZip(container);
1371
+ const url = URL.createObjectURL(blob);
1372
+ const a = document.createElement('a');
1373
+ a.href = url;
1374
+ const safeName =
1375
+ (provider.label || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
1376
+ a.download = `${safeName}.zip`;
1377
+ a.click();
1378
+ URL.revokeObjectURL(url);
1379
+ } catch (err) {
1380
+ console.error('Failed to download workspace', err);
1381
+ alert('Failed to download workspace. See console for details.');
1382
+ }
1383
+ }, [provider, copyProviderToContainer]);
1384
+
1385
+ /**
1386
+ * Bundle every workspace the host can open without further prompting
1387
+ * into a single zip, with each workspace nested under its own folder.
1388
+ * Native (browser-picked) workspaces whose handle hasn't been re-granted
1389
+ * for this session are skipped — restoring them would require a user
1390
+ * gesture per workspace.
1391
+ */
1392
+ const handleDownloadAllWorkspaces = useCallback(async () => {
1393
+ try {
1394
+ const [{ MemoryContentContainer }, { containerToZip }] = await Promise.all([
1395
+ import('@bendyline/squisq/storage'),
1396
+ import('@bendyline/squisq-formats/container'),
1397
+ ]);
1398
+
1399
+ const electron = isElectronHost();
1400
+ const all = await listWorkspaces();
1401
+ const candidates = all.filter((w) =>
1402
+ electron ? w.type === 'electron-native' : w.type !== 'electron-native',
1403
+ );
1404
+ if (candidates.length === 0) {
1405
+ alert('No workspaces to download.');
1406
+ return;
1407
+ }
1408
+
1409
+ const container = new MemoryContentContainer();
1410
+ const usedFolders = new Set<string>();
1411
+ const skipped: string[] = [];
1412
+
1413
+ for (const ws of candidates) {
1414
+ let p: FileSystemProvider | null = null;
1415
+ try {
1416
+ if (ws.type === 'electron-native') {
1417
+ p = await createElectronProviderFromWorkspace(ws);
1418
+ } else if (ws.type === 'native') {
1419
+ // Restore without prompting — only succeeds when the browser
1420
+ // still remembers the granted handle for this origin/session.
1421
+ p = await restoreNativeFolder(ws.id);
1422
+ } else {
1423
+ p = new IndexedDBFileSystemProvider(ws.id, ws.name);
1424
+ }
1425
+ } catch (err) {
1426
+ console.warn(`Skipping workspace ${ws.name}:`, err);
1427
+ }
1428
+
1429
+ if (!p) {
1430
+ skipped.push(ws.name);
1431
+ continue;
1432
+ }
1433
+
1434
+ // Pick a unique, filesystem-safe folder name per workspace.
1435
+ const base = (ws.name || 'workspace').replace(/[^a-z0-9_\- ]/gi, '_').trim() || 'workspace';
1436
+ let folder = base;
1437
+ let suffix = 2;
1438
+ while (usedFolders.has(folder)) {
1439
+ folder = `${base} (${suffix++})`;
513
1440
  }
1441
+ usedFolders.add(folder);
1442
+
1443
+ await copyProviderToContainer(p, container, folder);
514
1444
  }
515
- const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
1445
+
1446
+ if (usedFolders.size === 0) {
1447
+ alert('No workspaces could be opened for download.');
1448
+ return;
1449
+ }
1450
+
1451
+ const blob = await containerToZip(container);
516
1452
  const url = URL.createObjectURL(blob);
517
1453
  const a = document.createElement('a');
518
1454
  a.href = url;
519
- a.download = 'workspace.txt';
1455
+ const stamp = new Date().toISOString().slice(0, 10);
1456
+ a.download = `docblocks-workspaces-${stamp}.zip`;
520
1457
  a.click();
521
1458
  URL.revokeObjectURL(url);
522
- } catch {
523
- // ignore
1459
+
1460
+ if (skipped.length > 0) {
1461
+ alert(
1462
+ `Downloaded ${usedFolders.size} workspace(s). Skipped ${skipped.length} that require re-granting access: ${skipped.join(', ')}.`,
1463
+ );
1464
+ }
1465
+ } catch (err) {
1466
+ console.error('Failed to download all workspaces', err);
1467
+ alert('Failed to download all workspaces. See console for details.');
524
1468
  }
525
- }, [provider]);
1469
+ }, [copyProviderToContainer]);
526
1470
 
527
1471
  const handleRemoveWorkspace = useCallback(async () => {
528
1472
  if (!activeWorkspaceId) return;
529
- if (!confirm('Remove this workspace? This cannot be undone.')) return;
530
- await removeDirectoryHandle(activeWorkspaceId);
1473
+ const confirmMsg = isElectronHost()
1474
+ ? 'Remove this workspace from DocBlocks? The files on disk will not be deleted.'
1475
+ : 'Remove this workspace? This cannot be undone.';
1476
+ if (!confirm(confirmMsg)) return;
1477
+
1478
+ const ws = await getWorkspace(activeWorkspaceId);
1479
+ if (ws?.type === 'electron-native') {
1480
+ try {
1481
+ await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
1482
+ } catch {
1483
+ // ignore — host cleanup is best-effort
1484
+ }
1485
+ } else {
1486
+ await removeDirectoryHandle(activeWorkspaceId);
1487
+ }
531
1488
  await removeWorkspace(activeWorkspaceId);
1489
+
1490
+ const electron = isElectronHost();
532
1491
  // Switch to most recent remaining workspace or create default
533
- const remaining = await listWorkspaces();
1492
+ const remaining = (await listWorkspaces()).filter((w) =>
1493
+ electron ? w.type === 'electron-native' : w.type !== 'electron-native',
1494
+ );
534
1495
  if (remaining.length > 0) {
535
1496
  const next = remaining[0];
536
1497
  await handleWorkspaceSelect(next);
1498
+ } else if (electron) {
1499
+ const info = await getDocBlocksHost().workspaces.getDefault();
1500
+ const descriptor: WorkspaceDescriptor = {
1501
+ id: info.id,
1502
+ name: info.name,
1503
+ type: 'electron-native',
1504
+ rootPath: info.rootPath,
1505
+ lastOpened: new Date().toISOString(),
1506
+ };
1507
+ await saveWorkspace(descriptor);
1508
+ const p = new ElectronFileSystemProvider(info.id, info.name, info.rootPath);
1509
+ setProvider(p);
1510
+ setActiveWorkspaceId(info.id);
1511
+ setSelectedFile(null);
1512
+ setSelectedFolder(null);
1513
+ setFolderEntries([]);
1514
+ setEditorContent('');
1515
+ setEditorKey((k) => k + 1);
537
1516
  } else {
538
1517
  const defaultWs = await ensureDefaultWorkspace();
539
1518
  const fsProvider = new IndexedDBFileSystemProvider(defaultWs.id, defaultWs.name);
@@ -544,26 +1523,54 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
544
1523
  setFolderEntries([]);
545
1524
  setEditorContent('');
546
1525
  setEditorKey((k) => k + 1);
547
-
548
- setupMediaContainer(defaultWs.id);
549
1526
  }
550
- }, [activeWorkspaceId, handleWorkspaceSelect, setupMediaContainer]);
1527
+ }, [activeWorkspaceId, handleWorkspaceSelect]);
551
1528
 
552
1529
  return (
553
- <div className={`db-shell${isMobile ? ' db-shell--mobile' : ''}`} data-theme={resolvedTheme}>
1530
+ <div
1531
+ className={`db-shell${effectiveCompact ? ' db-shell--mobile' : ''}`}
1532
+ data-theme={resolvedTheme}
1533
+ >
1534
+ {saveToastVisible && (
1535
+ <div className="db-save-toast" role="status" aria-live="polite">
1536
+ Autosaved. You're all set.
1537
+ </div>
1538
+ )}
1539
+ {workspaceSettingsOpen && activeWorkspaceDescriptor && (
1540
+ <WorkspaceSettingsDialog
1541
+ workspace={activeWorkspaceDescriptor}
1542
+ globalVersioningPreference={versioningPreference}
1543
+ onChange={handleWorkspaceVersioningOverrideChange}
1544
+ onClose={() => setWorkspaceSettingsOpen(false)}
1545
+ />
1546
+ )}
554
1547
  {/* Main area */}
555
1548
  <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
556
- {/* Left sidebar — hidden on mobile when editor is shown */}
557
- {(!isMobile || !mobileShowEditor) && (
558
- <div className="db-shell-sidebar">
1549
+ {/* Left sidebar — hidden in compact layout when the editor is
1550
+ showing (compact = real mobile narrow viewport OR the user
1551
+ dragged the resizer below SIDEBAR_COLLAPSE_THRESHOLD). */}
1552
+ {(!effectiveCompact || !mobileShowEditor) && (
1553
+ <div
1554
+ ref={sidebarRef}
1555
+ className="db-shell-sidebar"
1556
+ style={effectiveCompact ? undefined : { width: `${sidebarWidth}px` }}
1557
+ >
559
1558
  <div className="db-shell-sidebar-header">
560
- <AppMenu logoUrl={logoUrl} />
1559
+ <AppMenu
1560
+ logoUrl={logoUrl}
1561
+ themePreference={themePreference}
1562
+ onThemeChange={handleThemeChange}
1563
+ versioningPreference={versioningPreference}
1564
+ onVersioningPreferenceChange={handleVersioningPreferenceChange}
1565
+ onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
1566
+ />
561
1567
  <WorkspacePicker
562
1568
  activeWorkspaceId={activeWorkspaceId}
563
1569
  onSelect={handleWorkspaceSelect}
564
1570
  onOpenFolder={handleOpenFolder}
565
1571
  />
566
1572
  <WorkspaceSettingsButton
1573
+ onSettings={handleOpenWorkspaceSettings}
567
1574
  onRename={handleRenameWorkspace}
568
1575
  onDownload={handleDownloadWorkspace}
569
1576
  onRemove={handleRemoveWorkspace}
@@ -588,8 +1595,20 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
588
1595
  </div>
589
1596
  )}
590
1597
 
591
- {/* Editor area hidden on mobile when sidebar is shown */}
592
- {(!isMobile || mobileShowEditor) && (
1598
+ {/* Resize handle between sidebar and editor hidden whenever
1599
+ the layout is compact (no sidebar to resize). */}
1600
+ {!effectiveCompact && (
1601
+ <div
1602
+ className="db-shell-sidebar-resizer"
1603
+ role="separator"
1604
+ aria-orientation="vertical"
1605
+ aria-label="Resize sidebar"
1606
+ onPointerDown={handleResizerPointerDown}
1607
+ />
1608
+ )}
1609
+
1610
+ {/* Editor area — hidden in compact layout when the sidebar is showing. */}
1611
+ {(!effectiveCompact || mobileShowEditor) && (
593
1612
  <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
594
1613
  {selectedFile && mediaProvider ? (
595
1614
  <MediaContext.Provider value={mediaProvider}>
@@ -598,36 +1617,80 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
598
1617
  initialMarkdown={editorContent}
599
1618
  initialView={initialView}
600
1619
  articleId={selectedFile}
1620
+ fileName={selectedFile}
601
1621
  onChange={handleEditorChange}
602
1622
  theme={resolvedTheme}
603
1623
  height="100%"
1624
+ outlineWidth={280}
604
1625
  mediaProvider={mediaProvider}
605
- container={mediaContainerRef.current ?? undefined}
1626
+ documentLinkProvider={documentLinkProvider}
1627
+ container={versionsContainer ?? undefined}
1628
+ allowVersioning={effectiveVersioning}
1629
+ viewPreferences={viewPreferences}
1630
+ onViewPreferencesChange={handleViewPreferencesChange}
1631
+ versionBasename={versionBasename ?? stripExtension(basenameOf(selectedFile))}
1632
+ versioningPrunePolicy={versioningPrunePolicy}
1633
+ versioningAutoSaveIdleMs={versioningAutoSaveIdleMs}
1634
+ onSaveVersion={onSaveVersion}
606
1635
  toolbarSlotLeft={
607
- isMobile ? (
608
- <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
1636
+ effectiveCompact ? (
1637
+ <button
1638
+ className="db-mobile-back"
1639
+ onClick={() => setMobileShowEditor(false)}
1640
+ aria-label="Show file list"
1641
+ >
609
1642
  <span className="db-mobile-back-arrow">&larr;</span>
610
1643
  </button>
611
1644
  ) : undefined
612
1645
  }
613
1646
  toolbarSlotRight={
614
- <ExportToolbarControls
615
- selectedFile={selectedFile}
616
- mediaContainer={mediaContainerRef.current}
617
- />
1647
+ <>
1648
+ {/* Restore split view — only relevant when compact
1649
+ layout was manually triggered on a wide viewport.
1650
+ On real mobile, side-by-side doesn't fit so the
1651
+ button is suppressed. */}
1652
+ {compactLayout && !isMobile && (
1653
+ <button
1654
+ className="db-restore-split"
1655
+ onClick={() => setCompactLayout(false)}
1656
+ aria-label="Restore split view"
1657
+ title="Restore split view"
1658
+ >
1659
+ <svg
1660
+ width="16"
1661
+ height="16"
1662
+ viewBox="0 0 16 16"
1663
+ fill="none"
1664
+ stroke="currentColor"
1665
+ strokeWidth="1.5"
1666
+ strokeLinecap="round"
1667
+ strokeLinejoin="round"
1668
+ >
1669
+ <rect x="1.5" y="2.5" width="13" height="11" rx="1" />
1670
+ <line x1="6" y1="2.5" x2="6" y2="13.5" />
1671
+ </svg>
1672
+ </button>
1673
+ )}
1674
+ <ExportToolbarControls
1675
+ selectedFile={selectedFile}
1676
+ mediaContainer={mediaContainerRef.current}
1677
+ />
1678
+ </>
618
1679
  }
619
1680
  />
620
1681
  </MediaContext.Provider>
621
1682
  ) : selectedFolder ? (
622
1683
  <div className="db-folder-view">
623
- {isMobile && (
1684
+ {effectiveCompact && (
624
1685
  <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
625
1686
  <span className="db-mobile-back-arrow">&larr;</span>
626
1687
  Back to files
627
1688
  </button>
628
1689
  )}
629
1690
  <div className="db-folder-view-header">
630
- <span className="db-folder-view-icon">📁</span>
1691
+ <span className="db-folder-view-icon">
1692
+ <FolderGlyph />
1693
+ </span>
631
1694
  <span className="db-folder-view-path">{selectedFolder}</span>
632
1695
  </div>
633
1696
  {folderEntries.length === 0 ? (
@@ -641,7 +1704,7 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
641
1704
  onClick={() => handleSelect(entry.path, entry.kind)}
642
1705
  >
643
1706
  <span className="db-folder-view-item-icon">
644
- {entry.kind === 'directory' ? '📁' : '📄'}
1707
+ {entry.kind === 'directory' ? <FolderGlyph /> : <FileGlyph />}
645
1708
  </span>
646
1709
  {entry.name}
647
1710
  </li>
@@ -651,6 +1714,12 @@ export function DocBlocksShell({ theme = 'auto', logoUrl }: DocBlocksShellProps)
651
1714
  </div>
652
1715
  ) : (
653
1716
  <div className="db-shell-empty">
1717
+ {effectiveCompact && (
1718
+ <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
1719
+ <span className="db-mobile-back-arrow">&larr;</span>
1720
+ Back to files
1721
+ </button>
1722
+ )}
654
1723
  <p>Select a file to start editing, or create a new one.</p>
655
1724
  </div>
656
1725
  )}