@bendyline/docblocks-react 1.1.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 (53) hide show
  1. package/dist/AppMenu/AppMenu.d.ts +11 -1
  2. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  3. package/dist/AppMenu/AppMenu.js +2 -2
  4. package/dist/AppMenu/AppMenu.js.map +1 -1
  5. package/dist/DocBlocksShell/DocBlocksShell.d.ts +21 -1
  6. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  7. package/dist/DocBlocksShell/DocBlocksShell.js +562 -67
  8. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  9. package/dist/Export/ExportDialog.d.ts.map +1 -1
  10. package/dist/Export/ExportDialog.js +104 -5
  11. package/dist/Export/ExportDialog.js.map +1 -1
  12. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  13. package/dist/Export/ExportToolbarControls.js +44 -3
  14. package/dist/Export/ExportToolbarControls.js.map +1 -1
  15. package/dist/Export/export-options.d.ts +35 -0
  16. package/dist/Export/export-options.d.ts.map +1 -1
  17. package/dist/Export/export-options.js +6 -1
  18. package/dist/Export/export-options.js.map +1 -1
  19. package/dist/Export/run-export.d.ts.map +1 -1
  20. package/dist/Export/run-export.js +163 -91
  21. package/dist/Export/run-export.js.map +1 -1
  22. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  23. package/dist/FileExplorer/FileExplorer.js +17 -2
  24. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  25. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
  26. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
  27. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
  28. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
  29. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
  30. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
  31. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
  32. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
  33. package/dist/hooks/useAutoSave.d.ts +8 -2
  34. package/dist/hooks/useAutoSave.d.ts.map +1 -1
  35. package/dist/hooks/useAutoSave.js +65 -30
  36. package/dist/hooks/useAutoSave.js.map +1 -1
  37. package/dist/preferences/versioning.d.ts +27 -0
  38. package/dist/preferences/versioning.d.ts.map +1 -0
  39. package/dist/preferences/versioning.js +62 -0
  40. package/dist/preferences/versioning.js.map +1 -0
  41. package/package.json +10 -8
  42. package/src/AppMenu/AppMenu.tsx +64 -1
  43. package/src/DocBlocksShell/DocBlocksShell.tsx +756 -80
  44. package/src/Export/ExportDialog.tsx +208 -19
  45. package/src/Export/ExportToolbarControls.tsx +45 -3
  46. package/src/Export/export-options.ts +43 -1
  47. package/src/Export/run-export.ts +190 -91
  48. package/src/FileExplorer/FileExplorer.tsx +31 -9
  49. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
  50. package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
  51. package/src/hooks/useAutoSave.ts +87 -29
  52. package/src/preferences/versioning.ts +69 -0
  53. package/src/styles/docblocks.css +920 -53
@@ -7,10 +7,21 @@
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';
20
+ import {
21
+ DocumentVersionManager,
22
+ type PrunePolicy,
23
+ type SaveVersionResult,
24
+ } from '@bendyline/squisq/versions';
14
25
  import type { FileSystemProvider, FileSystemEntry } from '@bendyline/docblocks/filesystem';
15
26
  import {
16
27
  IndexedDBFileSystemProvider,
@@ -22,7 +33,7 @@ import {
22
33
  removeDirectoryHandle,
23
34
  } from '@bendyline/docblocks/filesystem';
24
35
  import type { ContentContainer } from '@bendyline/squisq/storage';
25
- import { isElectronHost, getDocblocksHost } from '@bendyline/docblocks/host';
36
+ import { isElectronHost, getDocBlocksHost } from '@bendyline/docblocks/host';
26
37
  import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
27
38
  import {
28
39
  ensureDefaultWorkspace,
@@ -36,14 +47,43 @@ import { AppMenu, type ThemePreference } from '../AppMenu/AppMenu.js';
36
47
  import { FileExplorer } from '../FileExplorer/FileExplorer.js';
37
48
  import { WorkspacePicker } from '../WorkspacePicker/WorkspacePicker.js';
38
49
  import { WorkspaceSettingsButton } from '../WorkspacePicker/WorkspaceSettingsButton.js';
50
+ import {
51
+ WorkspaceSettingsDialog,
52
+ type WorkspaceVersioningOverride,
53
+ } from '../WorkspacePicker/WorkspaceSettingsDialog.js';
39
54
  import { useAutoSave } from '../hooks/useAutoSave.js';
40
55
  import { ExportToolbarControls } from '../Export/ExportToolbarControls.js';
56
+ import {
57
+ loadVersioningPreference,
58
+ resolveVersioningEnabled,
59
+ saveVersioningPreference,
60
+ type VersioningPreference,
61
+ } from '../preferences/versioning.js';
41
62
 
42
63
  export interface DocBlocksShellProps {
43
64
  /** Optional theme override. Omit or pass 'auto' to follow OS preference. */
44
65
  theme?: EditorTheme | 'auto';
45
66
  /** Optional logo image URL for the app menu. */
46
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>;
47
87
  }
48
88
 
49
89
  function useOsTheme(): 'light' | 'dark' {
@@ -128,6 +168,73 @@ function saveThemePreference(pref: ThemePreference): void {
128
168
  }
129
169
  }
130
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
+
131
238
  function dirnameOf(p: string): string {
132
239
  const clean = p.replace(/^\/+/, '');
133
240
  const idx = clean.lastIndexOf('/');
@@ -140,6 +247,93 @@ function basenameOf(p: string): string {
140
247
  return idx === -1 ? clean : clean.slice(idx + 1);
141
248
  }
142
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
+
143
337
  function useIsMobile(breakpoint = 768): boolean {
144
338
  const [isMobile, setIsMobile] = useState(
145
339
  () =>
@@ -185,7 +379,16 @@ function FileGlyph() {
185
379
  );
186
380
  }
187
381
 
188
- export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlocksShellProps) {
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) {
189
392
  const osTheme = useOsTheme();
190
393
  const [themePreference, setThemePreference] = useState<ThemePreference>(loadThemePreference);
191
394
  // "System default" (auto) always follows the OS — the host's theme prop
@@ -197,10 +400,142 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
197
400
  setThemePreference(pref);
198
401
  saveThemePreference(pref);
199
402
  }, []);
403
+
404
+ const [viewPreferences, setViewPreferences] = useState<ViewPreferences>(loadViewPreferences);
405
+ const handleViewPreferencesChange = useCallback((prefs: ViewPreferences) => {
406
+ setViewPreferences(prefs);
407
+ saveViewPreferences(prefs);
408
+ }, []);
200
409
  const isMobile = useIsMobile();
201
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
+ );
202
493
  const [provider, setProvider] = useState<FileSystemProvider | null>(null);
203
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
+ );
204
539
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
205
540
  const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
206
541
  const [folderEntries, setFolderEntries] = useState<FileSystemEntry[]>([]);
@@ -210,6 +545,11 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
210
545
  const [initialView, setInitialView] = useState<EditorView>('wysiwyg');
211
546
  /** Suppress popstate handling during programmatic navigation. */
212
547
  const skipPopState = useRef(false);
548
+ const lastLocalSaveRef = useRef<{
549
+ filePath: string;
550
+ content: string;
551
+ savedAt: number;
552
+ } | null>(null);
213
553
 
214
554
  /**
215
555
  * Per-file media container: for `notes.md`, images live in
@@ -219,6 +559,23 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
219
559
  */
220
560
  const mediaContainerRef = useRef<ContentContainer | null>(null);
221
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);
572
+
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);
222
579
 
223
580
  /** Push a new history entry with the given hash. */
224
581
  const pushHash = useCallback((wsId: string, filePath?: string | null) => {
@@ -244,13 +601,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
244
601
 
245
602
  let fsProvider: FileSystemProvider | null = null;
246
603
  if (ws.type === 'electron-native') {
247
- if (!ws.rootPath) return null;
248
- await getDocblocksHost().workspaces.register({
249
- id: ws.id,
250
- name: ws.name,
251
- rootPath: ws.rootPath,
252
- });
253
- fsProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
604
+ fsProvider = await createElectronProviderFromWorkspace(ws);
605
+ if (!fsProvider) return null;
254
606
  } else if (ws.type === 'native') {
255
607
  const restored = await restoreNativeFolder(ws.id);
256
608
  if (!restored) return null;
@@ -300,11 +652,13 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
300
652
  async (fs: FileSystemProvider) => {
301
653
  const entries = await fs.readDirectory('/');
302
654
 
303
- // 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).
304
658
  if (
305
659
  entries.length === 1 &&
306
660
  entries[0].kind === 'file' &&
307
- entries[0].path.replace(/^\//, '') === 'aboutDocblocks.md'
661
+ entries[0].path.replace(/^\//, '').toLowerCase() === 'aboutdocblocks.md'
308
662
  ) {
309
663
  const aboutPath = entries[0].path;
310
664
  const content = await fs.readFile(aboutPath);
@@ -322,20 +676,20 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
322
676
 
323
677
  if (entries.length > 0) return;
324
678
 
325
- const welcomePath = '/aboutDocblocks.md';
679
+ const welcomePath = '/aboutDocBlocks.md';
326
680
  const welcomeContent = [
327
681
  '# Welcome to DocBlocks',
328
682
  '',
329
- '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.',
330
686
  '',
331
687
  '## Features',
332
688
  '',
333
- '- **Rich Markdown Editing** — Write in a visual editor or switch to raw markdown anytime',
334
- '- **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.',
335
692
  '- **Playback & Video** — Preview your documents as rich visual presentations and export them as MP4 video',
336
- '- **Export Anywhere** — Export documents to PDF, Word, PowerPoint, HTML, or Markdown with theme options',
337
- '- **Local Storage** — Your documents are stored in your browser using temporary browser storage (backup often!)',
338
- '- **Device Folders** — Create workspaces based on folders on your computer',
339
693
  '- **No BS** — Free, no ads, no accounts, no tracking - everything runs locally in your browser',
340
694
  '',
341
695
  '## Getting Started',
@@ -344,7 +698,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
344
698
  '2. Start writing in markdown — the editor supports headings, lists, links, images, and more',
345
699
  '3. Your work is saved automatically',
346
700
  '',
347
- '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).',
348
702
  ].join('\n');
349
703
 
350
704
  await fs.writeFile(welcomePath, welcomeContent);
@@ -406,13 +760,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
406
760
  );
407
761
  for (const ws of sorted) {
408
762
  if (ws.type === 'electron-native') {
409
- if (!ws.rootPath) continue;
410
- await getDocblocksHost().workspaces.register({
411
- id: ws.id,
412
- name: ws.name,
413
- rootPath: ws.rootPath,
414
- });
415
- const p = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
763
+ const p = await createElectronProviderFromWorkspace(ws);
764
+ if (!p) continue;
416
765
  await touchWorkspace(ws.id);
417
766
  fsProvider = p;
418
767
  setProvider(p);
@@ -441,7 +790,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
441
790
  if (electron) {
442
791
  // Desktop: ask the host for the default folder workspace
443
792
  // (creates ~/Documents/DocBlocks on first launch).
444
- const info = await getDocblocksHost().workspaces.getDefault();
793
+ const info = await getDocBlocksHost().workspaces.getDefault();
445
794
  const descriptor: WorkspaceDescriptor = {
446
795
  id: info.id,
447
796
  name: info.name,
@@ -503,44 +852,161 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
503
852
  return () => window.removeEventListener('click', handler, true);
504
853
  }, [activeWorkspaceId, selectedFile]);
505
854
 
506
- // Auto-save current file
507
- useAutoSave(provider, selectedFile, editorContent);
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
+ }, []);
508
899
 
509
900
  // Per-file media: for `notes.md` images live in `notes_files/` beside it.
510
901
  // Rebuilds whenever the provider or selected file changes.
511
902
  useEffect(() => {
512
903
  if (!provider || !selectedFile) {
513
904
  mediaContainerRef.current = null;
905
+ versionsContainerRef.current = null;
514
906
  setMediaProvider(null);
907
+ setVersionsContainer(null);
515
908
  return;
516
909
  }
517
910
  const parentDir = dirnameOf(selectedFile);
518
911
  const base = basenameOf(selectedFile);
912
+ const baseNoExt = base.replace(/\.[^.]+$/, '');
519
913
  const container = new FileSystemContentContainer(provider, parentDir);
914
+ const vPrefix = parentDir ? `${parentDir}/${baseNoExt}_files` : `${baseNoExt}_files`;
915
+ const vContainer = new FileSystemContentContainer(provider, vPrefix);
520
916
  const mp = createFileMediaProvider(container, base);
521
917
  mediaContainerRef.current = container;
918
+ versionsContainerRef.current = vContainer;
522
919
  setMediaProvider(mp);
920
+ setVersionsContainer(vContainer);
523
921
  return () => {
524
922
  mp.dispose();
525
923
  };
526
924
  }, [provider, selectedFile]);
527
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
+
528
984
  // React to external file changes watched by the Electron host (chokidar).
529
985
  useEffect(() => {
530
986
  if (!isElectronHost()) return;
531
987
  if (!provider || !(provider instanceof ElectronFileSystemProvider)) return;
532
- const unwatch = provider.watch(() => {
988
+ const unwatch = provider.watch((changedPath) => {
533
989
  setExplorerKey((k) => k + 1);
990
+ if (!selectedFile || !sameProviderPath(changedPath, selectedFile)) return;
991
+
534
992
  // If the open file's contents changed on disk, reload it (best-effort).
535
- if (selectedFile) {
536
- (async () => {
537
- const content = await provider.readFile(selectedFile);
538
- if (content !== null && content !== editorContent) {
539
- setEditorContent(content);
540
- setEditorKey((k) => k + 1);
541
- }
542
- })();
543
- }
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
+ })();
544
1010
  });
545
1011
  return unwatch;
546
1012
  }, [provider, selectedFile, editorContent]);
@@ -550,13 +1016,8 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
550
1016
  await touchWorkspace(ws.id);
551
1017
  let nextProvider: FileSystemProvider | null = null;
552
1018
  if (ws.type === 'electron-native') {
553
- if (!ws.rootPath) return;
554
- await getDocblocksHost().workspaces.register({
555
- id: ws.id,
556
- name: ws.name,
557
- rootPath: ws.rootPath,
558
- });
559
- nextProvider = new ElectronFileSystemProvider(ws.id, ws.name, ws.rootPath);
1019
+ nextProvider = await createElectronProviderFromWorkspace(ws);
1020
+ if (!nextProvider) return;
560
1021
  } else if (ws.type === 'native') {
561
1022
  const restored = await restoreNativeFolder(ws.id);
562
1023
  if (!restored) {
@@ -582,7 +1043,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
582
1043
  const handleOpenFolder = useCallback(async () => {
583
1044
  try {
584
1045
  if (isElectronHost()) {
585
- const info = await getDocblocksHost().workspaces.pickFolder();
1046
+ const info = await getDocBlocksHost().workspaces.pickFolder();
586
1047
  if (!info) return; // user cancelled
587
1048
  const descriptor: WorkspaceDescriptor = {
588
1049
  id: info.id,
@@ -645,14 +1106,14 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
645
1106
  if (!isElectronHost() || !activeWorkspaceId) return;
646
1107
  const ws = await getWorkspace(activeWorkspaceId);
647
1108
  if (ws?.type === 'electron-native' && ws.rootPath) {
648
- await getDocblocksHost().shell.revealInFolder(ws.rootPath);
1109
+ await getDocBlocksHost().shell.revealInFolder(ws.rootPath);
649
1110
  }
650
1111
  }, [activeWorkspaceId]);
651
1112
 
652
1113
  // Subscribe to native menu commands (Electron host).
653
1114
  useEffect(() => {
654
1115
  if (!isElectronHost()) return;
655
- const host = getDocblocksHost();
1116
+ const host = getDocBlocksHost();
656
1117
  return host.onMenuCommand((cmd) => {
657
1118
  switch (cmd) {
658
1119
  case 'file:new':
@@ -687,7 +1148,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
687
1148
  // Subscribe to open-file / deep-link requests from the OS.
688
1149
  useEffect(() => {
689
1150
  if (!isElectronHost()) return;
690
- const host = getDocblocksHost();
1151
+ const host = getDocBlocksHost();
691
1152
  return host.onOpenRequest(async (req) => {
692
1153
  if (req.filePath) {
693
1154
  const workspaces = (await listWorkspaces()).filter(
@@ -745,10 +1206,10 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
745
1206
  setEditorKey((k) => k + 1);
746
1207
  pushHash(activeWorkspaceId, path);
747
1208
  saveLastState({ workspaceId: activeWorkspaceId, filePath: path, view: 'wysiwyg' });
748
- if (isMobile) setMobileShowEditor(true);
1209
+ if (effectiveCompact) setMobileShowEditor(true);
749
1210
  }
750
1211
  },
751
- [provider, activeWorkspaceId, pushHash, isMobile],
1212
+ [provider, activeWorkspaceId, pushHash, effectiveCompact],
752
1213
  );
753
1214
 
754
1215
  const handleTreeChange = useCallback(async () => {
@@ -855,28 +1316,157 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
855
1316
  setEditorKey((k) => k + 1);
856
1317
  }, [activeWorkspaceId]);
857
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
+ );
1358
+
858
1359
  const handleDownloadWorkspace = useCallback(async () => {
859
1360
  if (!provider) return;
860
1361
  try {
861
- const entries = await provider.readDirectory('/');
862
- const lines: string[] = [];
863
- for (const entry of entries) {
864
- if (entry.kind === 'file') {
865
- const content = await provider.readFile(entry.path);
866
- 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);
867
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++})`;
1440
+ }
1441
+ usedFolders.add(folder);
1442
+
1443
+ await copyProviderToContainer(p, container, folder);
868
1444
  }
869
- 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);
870
1452
  const url = URL.createObjectURL(blob);
871
1453
  const a = document.createElement('a');
872
1454
  a.href = url;
873
- a.download = 'workspace.txt';
1455
+ const stamp = new Date().toISOString().slice(0, 10);
1456
+ a.download = `docblocks-workspaces-${stamp}.zip`;
874
1457
  a.click();
875
1458
  URL.revokeObjectURL(url);
876
- } catch {
877
- // 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.');
878
1468
  }
879
- }, [provider]);
1469
+ }, [copyProviderToContainer]);
880
1470
 
881
1471
  const handleRemoveWorkspace = useCallback(async () => {
882
1472
  if (!activeWorkspaceId) return;
@@ -888,7 +1478,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
888
1478
  const ws = await getWorkspace(activeWorkspaceId);
889
1479
  if (ws?.type === 'electron-native') {
890
1480
  try {
891
- await getDocblocksHost().workspaces.unregister(activeWorkspaceId);
1481
+ await getDocBlocksHost().workspaces.unregister(activeWorkspaceId);
892
1482
  } catch {
893
1483
  // ignore — host cleanup is best-effort
894
1484
  }
@@ -906,7 +1496,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
906
1496
  const next = remaining[0];
907
1497
  await handleWorkspaceSelect(next);
908
1498
  } else if (electron) {
909
- const info = await getDocblocksHost().workspaces.getDefault();
1499
+ const info = await getDocBlocksHost().workspaces.getDefault();
910
1500
  const descriptor: WorkspaceDescriptor = {
911
1501
  id: info.id,
912
1502
  name: info.name,
@@ -937,17 +1527,42 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
937
1527
  }, [activeWorkspaceId, handleWorkspaceSelect]);
938
1528
 
939
1529
  return (
940
- <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
+ )}
941
1547
  {/* Main area */}
942
1548
  <div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
943
- {/* Left sidebar — hidden on mobile when editor is shown */}
944
- {(!isMobile || !mobileShowEditor) && (
945
- <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
+ >
946
1558
  <div className="db-shell-sidebar-header">
947
1559
  <AppMenu
948
1560
  logoUrl={logoUrl}
949
1561
  themePreference={themePreference}
950
1562
  onThemeChange={handleThemeChange}
1563
+ versioningPreference={versioningPreference}
1564
+ onVersioningPreferenceChange={handleVersioningPreferenceChange}
1565
+ onDownloadAllWorkspaces={handleDownloadAllWorkspaces}
951
1566
  />
952
1567
  <WorkspacePicker
953
1568
  activeWorkspaceId={activeWorkspaceId}
@@ -955,6 +1570,7 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
955
1570
  onOpenFolder={handleOpenFolder}
956
1571
  />
957
1572
  <WorkspaceSettingsButton
1573
+ onSettings={handleOpenWorkspaceSettings}
958
1574
  onRename={handleRenameWorkspace}
959
1575
  onDownload={handleDownloadWorkspace}
960
1576
  onRemove={handleRemoveWorkspace}
@@ -979,8 +1595,20 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
979
1595
  </div>
980
1596
  )}
981
1597
 
982
- {/* Editor area hidden on mobile when sidebar is shown */}
983
- {(!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) && (
984
1612
  <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
985
1613
  {selectedFile && mediaProvider ? (
986
1614
  <MediaContext.Provider value={mediaProvider}>
@@ -989,29 +1617,71 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
989
1617
  initialMarkdown={editorContent}
990
1618
  initialView={initialView}
991
1619
  articleId={selectedFile}
1620
+ fileName={selectedFile}
992
1621
  onChange={handleEditorChange}
993
1622
  theme={resolvedTheme}
994
1623
  height="100%"
1624
+ outlineWidth={280}
995
1625
  mediaProvider={mediaProvider}
996
- 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}
997
1635
  toolbarSlotLeft={
998
- isMobile ? (
999
- <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
+ >
1000
1642
  <span className="db-mobile-back-arrow">&larr;</span>
1001
1643
  </button>
1002
1644
  ) : undefined
1003
1645
  }
1004
1646
  toolbarSlotRight={
1005
- <ExportToolbarControls
1006
- selectedFile={selectedFile}
1007
- mediaContainer={mediaContainerRef.current}
1008
- />
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
+ </>
1009
1679
  }
1010
1680
  />
1011
1681
  </MediaContext.Provider>
1012
1682
  ) : selectedFolder ? (
1013
1683
  <div className="db-folder-view">
1014
- {isMobile && (
1684
+ {effectiveCompact && (
1015
1685
  <button className="db-mobile-back" onClick={() => setMobileShowEditor(false)}>
1016
1686
  <span className="db-mobile-back-arrow">&larr;</span>
1017
1687
  Back to files
@@ -1044,6 +1714,12 @@ export function DocBlocksShell({ theme: _themeProp = 'auto', logoUrl }: DocBlock
1044
1714
  </div>
1045
1715
  ) : (
1046
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
+ )}
1047
1723
  <p>Select a file to start editing, or create a new one.</p>
1048
1724
  </div>
1049
1725
  )}