@bendyline/docblocks-react 1.1.0 → 1.1.2

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 (64) hide show
  1. package/README.md +23 -14
  2. package/dist/AppMenu/AppMenu.d.ts +11 -1
  3. package/dist/AppMenu/AppMenu.d.ts.map +1 -1
  4. package/dist/AppMenu/AppMenu.js +2 -2
  5. package/dist/AppMenu/AppMenu.js.map +1 -1
  6. package/dist/DocBlocksShell/DocBlocksShell.d.ts +23 -3
  7. package/dist/DocBlocksShell/DocBlocksShell.d.ts.map +1 -1
  8. package/dist/DocBlocksShell/DocBlocksShell.js +621 -94
  9. package/dist/DocBlocksShell/DocBlocksShell.js.map +1 -1
  10. package/dist/Export/ExportDialog.d.ts.map +1 -1
  11. package/dist/Export/ExportDialog.js +123 -7
  12. package/dist/Export/ExportDialog.js.map +1 -1
  13. package/dist/Export/ExportToolbarControls.d.ts.map +1 -1
  14. package/dist/Export/ExportToolbarControls.js +108 -17
  15. package/dist/Export/ExportToolbarControls.js.map +1 -1
  16. package/dist/Export/export-options.d.ts +35 -0
  17. package/dist/Export/export-options.d.ts.map +1 -1
  18. package/dist/Export/export-options.js +6 -1
  19. package/dist/Export/export-options.js.map +1 -1
  20. package/dist/Export/run-export.d.ts.map +1 -1
  21. package/dist/Export/run-export.js +185 -97
  22. package/dist/Export/run-export.js.map +1 -1
  23. package/dist/Export/transform-summaries.d.ts +7 -0
  24. package/dist/Export/transform-summaries.d.ts.map +1 -0
  25. package/dist/Export/transform-summaries.js +6 -0
  26. package/dist/Export/transform-summaries.js.map +1 -0
  27. package/dist/FileExplorer/FileExplorer.d.ts.map +1 -1
  28. package/dist/FileExplorer/FileExplorer.js +17 -2
  29. package/dist/FileExplorer/FileExplorer.js.map +1 -1
  30. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts +2 -1
  31. package/dist/WorkspacePicker/WorkspaceSettingsButton.d.ts.map +1 -1
  32. package/dist/WorkspacePicker/WorkspaceSettingsButton.js +2 -2
  33. package/dist/WorkspacePicker/WorkspaceSettingsButton.js.map +1 -1
  34. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts +20 -0
  35. package/dist/WorkspacePicker/WorkspaceSettingsDialog.d.ts.map +1 -0
  36. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js +36 -0
  37. package/dist/WorkspacePicker/WorkspaceSettingsDialog.js.map +1 -0
  38. package/dist/hooks/useAutoSave.d.ts +8 -2
  39. package/dist/hooks/useAutoSave.d.ts.map +1 -1
  40. package/dist/hooks/useAutoSave.js +65 -30
  41. package/dist/hooks/useAutoSave.js.map +1 -1
  42. package/dist/monaco-slim.d.ts +19 -0
  43. package/dist/monaco-slim.d.ts.map +1 -0
  44. package/dist/monaco-slim.js +19 -0
  45. package/dist/monaco-slim.js.map +1 -0
  46. package/dist/preferences/versioning.d.ts +27 -0
  47. package/dist/preferences/versioning.d.ts.map +1 -0
  48. package/dist/preferences/versioning.js +62 -0
  49. package/dist/preferences/versioning.js.map +1 -0
  50. package/package.json +14 -10
  51. package/src/AppMenu/AppMenu.tsx +64 -1
  52. package/src/DocBlocksShell/DocBlocksShell.tsx +839 -116
  53. package/src/Export/ExportDialog.tsx +236 -26
  54. package/src/Export/ExportToolbarControls.tsx +151 -21
  55. package/src/Export/export-options.ts +43 -1
  56. package/src/Export/run-export.ts +208 -97
  57. package/src/Export/transform-summaries.ts +14 -0
  58. package/src/FileExplorer/FileExplorer.tsx +31 -9
  59. package/src/WorkspacePicker/WorkspaceSettingsButton.tsx +9 -0
  60. package/src/WorkspacePicker/WorkspaceSettingsDialog.tsx +121 -0
  61. package/src/hooks/useAutoSave.ts +87 -29
  62. package/src/monaco-slim.ts +20 -0
  63. package/src/preferences/versioning.ts +69 -0
  64. package/src/styles/docblocks.css +995 -53
@@ -8,11 +8,7 @@ import type {
8
8
  MarkdownInlineNode,
9
9
  } from '@bendyline/squisq/markdown';
10
10
  import { parseMarkdown } from '@bendyline/squisq/markdown';
11
- import { markdownToDoc } from '@bendyline/squisq/doc';
12
- import { applyTransform } from '@bendyline/squisq/transform';
13
- import { markdownDocToDocx } from '@bendyline/squisq-formats/docx';
14
- import { markdownDocToPdf } from '@bendyline/squisq-formats/pdf';
15
- import { docToPptx } from '@bendyline/squisq-formats/pptx';
11
+ import type { Doc } from '@bendyline/squisq/schemas';
16
12
  import type { ContentContainer } from '@bendyline/squisq/storage';
17
13
  import type { ExportOptions, ExportFormat } from './export-options.js';
18
14
  import { FORMAT_EXTENSIONS } from './export-options.js';
@@ -57,14 +53,14 @@ export async function runExport(
57
53
  }
58
54
 
59
55
  if (options.format === 'html') {
60
- const html = wrapMarkdownAsHtml(markdown);
61
- downloadBlob(new Blob([html], { type: MIME_TYPES.html }), filename);
56
+ await runHtmlExport(markdown, filename, options, mediaContainer, selectedFile);
62
57
  return;
63
58
  }
64
59
 
65
60
  const doc = parseMarkdown(markdown);
66
61
 
67
62
  if (options.format === 'docx') {
63
+ const { markdownDocToDocx } = await import('@bendyline/squisq-formats/docx');
68
64
  const images = mediaContainer ? await resolveImages(doc, mediaContainer) : undefined;
69
65
  const buf = await markdownDocToDocx(doc, { themeId, images });
70
66
  downloadBlob(new Blob([buf], { type: MIME_TYPES.docx }), filename);
@@ -72,6 +68,7 @@ export async function runExport(
72
68
  }
73
69
 
74
70
  if (options.format === 'pdf') {
71
+ const { markdownDocToPdf } = await import('@bendyline/squisq-formats/pdf');
75
72
  const buf = await markdownDocToPdf(doc, {
76
73
  themeId,
77
74
  pageSize: options.pageSize,
@@ -81,7 +78,12 @@ export async function runExport(
81
78
  }
82
79
 
83
80
  if (options.format === 'pptx') {
84
- // Use the full transform pipeline: markdown Doc transform PPTX
81
+ const [{ markdownToDoc }, { applyTransform }, { docToPptx }] = await Promise.all([
82
+ import('@bendyline/squisq/doc'),
83
+ import('@bendyline/squisq/transform'),
84
+ import('@bendyline/squisq-formats/pptx'),
85
+ ]);
86
+ // Use the full transform pipeline: markdown -> Doc -> transform -> PPTX
85
87
  const baseDoc = markdownToDoc(doc);
86
88
  const transformed = applyTransform(baseDoc, options.transformStyle);
87
89
  const enrichedDoc = transformed.doc;
@@ -94,94 +96,146 @@ export async function runExport(
94
96
  }
95
97
  }
96
98
 
97
- /** Wrap raw markdown in a basic styled HTML page. */
98
- function wrapMarkdownAsHtml(markdown: string): string {
99
- // Convert markdown to simple HTML via the parser
100
- const doc = parseMarkdown(markdown);
101
- // Build a minimal representation from the parsed nodes
102
- const lines: string[] = [];
103
- for (const node of doc.children) {
104
- lines.push(nodeToHtml(node));
105
- }
106
- const body = lines.join('\n');
107
- return `<!DOCTYPE html>
108
- <html lang="en">
109
- <head>
110
- <meta charset="UTF-8">
111
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
112
- <title>Document</title>
113
- <style>
114
- body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 2em auto; padding: 0 1em; line-height: 1.6; color: #1f2937; }
115
- h1, h2, h3, h4, h5, h6 { margin-top: 1.5em; margin-bottom: 0.5em; }
116
- pre { background: #f3f4f6; padding: 1em; border-radius: 4px; overflow-x: auto; }
117
- code { background: #f3f4f6; padding: 0.15em 0.3em; border-radius: 3px; font-size: 0.9em; }
118
- pre code { background: none; padding: 0; }
119
- blockquote { border-left: 3px solid #d1d5db; margin-left: 0; padding-left: 1em; color: #6b7280; }
120
- img { max-width: 100%; }
121
- a { color: #3b82f6; }
122
- </style>
123
- </head>
124
- <body>
125
- ${body}
126
- </body>
127
- </html>`;
128
- }
99
+ /** Run the HTML export branch handles all four htmlStyle × htmlBundle combinations. */
100
+ async function runHtmlExport(
101
+ markdown: string,
102
+ htmlFilename: string,
103
+ options: ExportOptions,
104
+ mediaContainer?: ContentContainer | null,
105
+ selectedFile?: string | null,
106
+ ): Promise<void> {
107
+ const baseName = htmlFilename.replace(/\.html$/, '');
108
+ const zipName = `${baseName}.zip`;
109
+ // When `entryAsIndex` is on, a single-doc HTML download lands on
110
+ // disk as `index.html` so it drops straight into a static host. The
111
+ // recursive ZIP path doesn't care — squisq's bundle helpers handle
112
+ // the inside-zip rename and the cross-doc link rewrite themselves.
113
+ const singleHtmlFilename = options.entryAsIndex ? 'index.html' : htmlFilename;
114
+ const themeId = options.themeId !== 'standard' ? options.themeId : undefined;
129
115
 
130
- /** Convert a markdown AST node to HTML (basic). */
131
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
132
- function nodeToHtml(node: any): string {
133
- if (!node) return '';
134
- switch (node.type) {
135
- case 'heading': {
136
- const tag = `h${node.depth ?? 1}`;
137
- return `<${tag}>${childrenToHtml(node)}</${tag}>`;
116
+ // Recursive HTML bundle: when the user opted in, ask squisq to walk
117
+ // this doc's relative .md links and emit a ZIP with every reachable
118
+ // page rendered + cross-doc hrefs rewritten. Two pipelines:
119
+ // • Plain → `markdownDocsToPlainHtmlBundle` (semantic HTML)
120
+ // • Rendered → `markdownDocsToHtmlBundle` (SquisqPlayer-rendered,
121
+ // shared `squisq-player.js`, Doc-tree link rewrites)
122
+ // Both consume the workspace container to resolve sibling files —
123
+ // `mediaContainer` is already scoped to the selected file's parent
124
+ // dir, which is exactly the bundle's scope root. Falls back to the
125
+ // single-doc path if either prerequisite is missing rather than
126
+ // silently skipping the recursion.
127
+ if (options.includeLinkedDocs && mediaContainer && selectedFile) {
128
+ const entryPath = basenameForBundle(selectedFile);
129
+ if (options.htmlStyle === 'rendered') {
130
+ const [{ markdownDocsToHtmlBundle }, { PLAYER_BUNDLE }] = await Promise.all([
131
+ import('@bendyline/squisq-formats/html'),
132
+ import('@bendyline/squisq-react/standalone-source'),
133
+ ]);
134
+ const blob = await markdownDocsToHtmlBundle({
135
+ entryPath,
136
+ readDocument: (path) => readDocumentFromContainer(mediaContainer, path),
137
+ readBinary: (path) => mediaContainer.readFile(path),
138
+ playerScript: PLAYER_BUNDLE,
139
+ title: baseName,
140
+ themeId,
141
+ mode: 'static',
142
+ entryAsIndex: options.entryAsIndex,
143
+ });
144
+ downloadBlob(blob, zipName);
145
+ return;
138
146
  }
139
- case 'paragraph':
140
- return `<p>${childrenToHtml(node)}</p>`;
141
- case 'text':
142
- return escapeHtml(node.value ?? '');
143
- case 'strong':
144
- return `<strong>${childrenToHtml(node)}</strong>`;
145
- case 'emphasis':
146
- return `<em>${childrenToHtml(node)}</em>`;
147
- case 'delete':
148
- return `<del>${childrenToHtml(node)}</del>`;
149
- case 'inlineCode':
150
- return `<code>${escapeHtml(node.value ?? '')}</code>`;
151
- case 'code':
152
- return `<pre><code>${escapeHtml(node.value ?? '')}</code></pre>`;
153
- case 'blockquote':
154
- return `<blockquote>${childrenToHtml(node)}</blockquote>`;
155
- case 'list': {
156
- const tag = node.ordered ? 'ol' : 'ul';
157
- return `<${tag}>${childrenToHtml(node)}</${tag}>`;
147
+ const { markdownDocsToPlainHtmlBundle } = await import('@bendyline/squisq-formats/html');
148
+ const blob = await markdownDocsToPlainHtmlBundle({
149
+ entryPath,
150
+ readDocument: (path) => readDocumentFromContainer(mediaContainer, path),
151
+ readBinary: (path) => mediaContainer.readFile(path),
152
+ title: baseName,
153
+ themeId,
154
+ entryAsIndex: options.entryAsIndex,
155
+ });
156
+ downloadBlob(blob, zipName);
157
+ return;
158
+ }
159
+
160
+ if (options.htmlStyle === 'rendered') {
161
+ const [{ markdownToDoc }, { docToHtml, docToHtmlZip, collectImagePaths }, { PLAYER_BUNDLE }] =
162
+ await Promise.all([
163
+ import('@bendyline/squisq/doc'),
164
+ import('@bendyline/squisq-formats/html'),
165
+ import('@bendyline/squisq-react/standalone-source'),
166
+ ]);
167
+ const mdDoc = parseMarkdown(markdown);
168
+ const baseDoc = markdownToDoc(mdDoc);
169
+ if (themeId) baseDoc.themeId = themeId;
170
+
171
+ const images = mediaContainer
172
+ ? await resolveDocImages(baseDoc, mediaContainer, collectImagePaths)
173
+ : undefined;
174
+
175
+ if (options.htmlBundle === 'zip') {
176
+ const blob = await docToHtmlZip(baseDoc, {
177
+ playerScript: PLAYER_BUNDLE,
178
+ images,
179
+ mode: 'static',
180
+ title: baseName,
181
+ });
182
+ downloadBlob(blob, zipName);
183
+ return;
158
184
  }
159
- case 'listItem':
160
- return `<li>${childrenToHtml(node)}</li>`;
161
- case 'link':
162
- return `<a href="${escapeAttr(node.url ?? '')}">${childrenToHtml(node)}</a>`;
163
- case 'image':
164
- return `<img src="${escapeAttr(node.url ?? '')}" alt="${escapeAttr(node.alt ?? '')}" />`;
165
- case 'thematicBreak':
166
- return '<hr />';
167
- default:
168
- return node.children ? childrenToHtml(node) : escapeHtml(node.value ?? '');
185
+
186
+ const html = docToHtml(baseDoc, {
187
+ playerScript: PLAYER_BUNDLE,
188
+ images,
189
+ mode: 'static',
190
+ title: baseName,
191
+ });
192
+ downloadBlob(new Blob([html], { type: MIME_TYPES.html }), singleHtmlFilename);
193
+ return;
169
194
  }
170
- }
171
195
 
172
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
173
- function childrenToHtml(node: any): string {
174
- if (!node.children) return node.value ? escapeHtml(node.value) : '';
175
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
176
- return node.children.map((c: any) => nodeToHtml(c)).join('');
177
- }
196
+ // Plain semantic HTML
197
+ const mdDoc = parseMarkdown(markdown);
198
+ const referencedImages = collectMarkdownImageUrls(mdDoc);
199
+ const localImages = referencedImages.filter((u) => !isExternalUrl(u));
178
200
 
179
- function escapeHtml(s: string): string {
180
- return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
181
- }
201
+ if (options.htmlBundle === 'zip' && mediaContainer && localImages.length > 0) {
202
+ const [{ markdownDocToPlainHtml }, { MemoryContentContainer }, { containerToZip }] =
203
+ await Promise.all([
204
+ import('@bendyline/squisq-formats/html'),
205
+ import('@bendyline/squisq/storage'),
206
+ import('@bendyline/squisq-formats/container'),
207
+ ]);
208
+ // Mirror the markdown's relative paths inside the zip so <img src="..."> still resolves.
209
+ const html = markdownDocToPlainHtml(mdDoc, { title: baseName, themeId });
210
+ const container = new MemoryContentContainer();
211
+ await container.writeFile('index.html', new TextEncoder().encode(html));
212
+ for (const url of localImages) {
213
+ const data = await mediaContainer.readFile(url);
214
+ if (!data) continue;
215
+ const cleanPath = url.replace(/^\/+/, '');
216
+ await container.writeFile(cleanPath, new Uint8Array(data));
217
+ }
218
+ const blob = await containerToZip(container);
219
+ downloadBlob(blob, zipName);
220
+ return;
221
+ }
182
222
 
183
- function escapeAttr(s: string): string {
184
- return escapeHtml(s).replace(/"/g, '&quot;');
223
+ const { markdownDocToPlainHtml } = await import('@bendyline/squisq-formats/html');
224
+ // Single-file plain HTML — embed local images as base64 data URIs.
225
+ const inlineMap = new Map<string, string>();
226
+ if (mediaContainer) {
227
+ for (const url of localImages) {
228
+ const data = await mediaContainer.readFile(url);
229
+ if (!data) continue;
230
+ inlineMap.set(url, arrayBufferToDataUrl(data, url));
231
+ }
232
+ }
233
+ const html = markdownDocToPlainHtml(mdDoc, {
234
+ title: baseName,
235
+ images: inlineMap.size > 0 ? inlineMap : undefined,
236
+ themeId,
237
+ });
238
+ downloadBlob(new Blob([html], { type: MIME_TYPES.html }), singleHtmlFilename);
185
239
  }
186
240
 
187
241
  const IMAGE_MIME: Record<string, string> = {
@@ -191,15 +245,37 @@ const IMAGE_MIME: Record<string, string> = {
191
245
  gif: 'image/gif',
192
246
  webp: 'image/webp',
193
247
  bmp: 'image/bmp',
248
+ svg: 'image/svg+xml',
194
249
  };
195
250
 
251
+ function isExternalUrl(url: string): boolean {
252
+ return (
253
+ url.startsWith('http://') ||
254
+ url.startsWith('https://') ||
255
+ url.startsWith('data:') ||
256
+ url.startsWith('blob:')
257
+ );
258
+ }
259
+
260
+ function arrayBufferToDataUrl(data: ArrayBuffer, url: string): string {
261
+ const ext = url.slice(url.lastIndexOf('.') + 1).toLowerCase();
262
+ const mime = IMAGE_MIME[ext] ?? 'application/octet-stream';
263
+ // btoa requires a binary string; chunk to avoid call-stack issues on large files.
264
+ const bytes = new Uint8Array(data);
265
+ let binary = '';
266
+ const CHUNK = 0x8000;
267
+ for (let i = 0; i < bytes.length; i += CHUNK) {
268
+ binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
269
+ }
270
+ return `data:${mime};base64,${btoa(binary)}`;
271
+ }
272
+
196
273
  /** Walk the markdown AST and collect all image URLs. */
197
- function collectImageUrls(doc: MarkdownDocument): string[] {
274
+ function collectMarkdownImageUrls(doc: MarkdownDocument): string[] {
198
275
  const urls: string[] = [];
199
276
  function walkBlocks(nodes: MarkdownBlockNode[]): void {
200
277
  for (const node of nodes) {
201
278
  if ('children' in node && Array.isArray(node.children)) {
202
- // Check if children are inline or block nodes
203
279
  for (const child of node.children as (MarkdownBlockNode | MarkdownInlineNode)[]) {
204
280
  if (child.type === 'image') {
205
281
  urls.push((child as { url: string }).url);
@@ -211,22 +287,20 @@ function collectImageUrls(doc: MarkdownDocument): string[] {
211
287
  }
212
288
  }
213
289
  walkBlocks(doc.children);
214
- return urls;
290
+ return Array.from(new Set(urls));
215
291
  }
216
292
 
217
- /** Resolve image URLs to binary data from a ContentContainer. */
293
+ /** Resolve image URLs (markdown → docx) to binary data from a ContentContainer. */
218
294
  async function resolveImages(
219
295
  doc: MarkdownDocument,
220
296
  container: ContentContainer,
221
297
  ): Promise<Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>> {
222
- const urls = collectImageUrls(doc);
298
+ const urls = collectMarkdownImageUrls(doc);
223
299
  const map = new Map<string, { data: ArrayBuffer | Uint8Array; contentType: string }>();
224
300
 
225
301
  for (const url of urls) {
226
302
  if (map.has(url)) continue;
227
- // Skip external URLs
228
- if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:'))
229
- continue;
303
+ if (isExternalUrl(url)) continue;
230
304
  const data = await container.readFile(url);
231
305
  if (!data) continue;
232
306
  const ext = url.slice(url.lastIndexOf('.') + 1).toLowerCase();
@@ -236,3 +310,40 @@ async function resolveImages(
236
310
 
237
311
  return map;
238
312
  }
313
+
314
+ /** Container-relative path for the bundle's entry document. `mediaContainer`
315
+ * is already scoped to the selected file's parent directory, so the bundle
316
+ * needs the basename ("docs/home.md" → "home.md"). */
317
+ function basenameForBundle(selectedFile: string): string {
318
+ const clean = selectedFile.replace(/^\/+/, '');
319
+ const idx = clean.lastIndexOf('/');
320
+ return idx === -1 ? clean : clean.slice(idx + 1);
321
+ }
322
+
323
+ /** Read a UTF-8 text document from a ContentContainer. Returns null when
324
+ * the file is missing — preserves the bundle's "abort with error" contract
325
+ * for unreachable .md links. */
326
+ async function readDocumentFromContainer(
327
+ container: ContentContainer,
328
+ path: string,
329
+ ): Promise<string | null> {
330
+ const data = await container.readFile(path);
331
+ if (!data) return null;
332
+ return new TextDecoder('utf-8').decode(data);
333
+ }
334
+
335
+ /** Resolve image paths referenced by a squisq Doc to ArrayBuffers for HTML export. */
336
+ async function resolveDocImages(
337
+ doc: Doc,
338
+ container: ContentContainer,
339
+ collectImagePaths: (doc: Doc) => Iterable<string>,
340
+ ): Promise<Map<string, ArrayBuffer>> {
341
+ const paths = collectImagePaths(doc);
342
+ const map = new Map<string, ArrayBuffer>();
343
+ for (const path of paths) {
344
+ const data = await container.readFile(path);
345
+ if (!data) continue;
346
+ map.set(path, data);
347
+ }
348
+ return map;
349
+ }
@@ -0,0 +1,14 @@
1
+ export interface ExportSummaryOption {
2
+ id: string;
3
+ name: string;
4
+ description?: string;
5
+ }
6
+
7
+ let transformStyleSummariesPromise: Promise<ExportSummaryOption[]> | null = null;
8
+
9
+ export function loadTransformStyleSummaries(): Promise<ExportSummaryOption[]> {
10
+ transformStyleSummariesPromise ??= import('@bendyline/squisq/transform').then(
11
+ ({ getTransformStyleSummaries }) => getTransformStyleSummaries(),
12
+ );
13
+ return transformStyleSummariesPromise;
14
+ }
@@ -13,6 +13,22 @@ import { NewFileIcon, NewFolderIcon, RefreshIcon } from '../icons.js';
13
13
 
14
14
  const SUPPORTED_EXTENSIONS = new Set(['.txt', '.md', '.docx', '.pdf', '.dbk', '.zip']);
15
15
 
16
+ /**
17
+ * Hide auto-generated `<basename>_files/` companion folders from the
18
+ * tree. They hold per-document images and version snapshots — useful
19
+ * to the editor, noisy in the user-facing file list. The folders still
20
+ * exist on disk; we just don't surface them in the explorer.
21
+ */
22
+ function isHiddenEntry(entry: FileSystemEntry): boolean {
23
+ if (entry.kind !== 'directory') return false;
24
+ const name = entry.path.replace(/^\/+/, '').split('/').pop() ?? '';
25
+ return name.endsWith('_files');
26
+ }
27
+
28
+ function filterVisible(entries: FileSystemEntry[]): FileSystemEntry[] {
29
+ return entries.filter((e) => !isHiddenEntry(e));
30
+ }
31
+
16
32
  export interface FileExplorerProps {
17
33
  /** The filesystem to display. */
18
34
  provider: FileSystemProvider | null;
@@ -154,7 +170,7 @@ export function FileExplorer({
154
170
 
155
171
  const renderEntries = useCallback(
156
172
  (entries: FileSystemEntry[], depth: number): React.ReactNode => {
157
- return entries.map((entry) => (
173
+ return filterVisible(entries).map((entry) => (
158
174
  <FileTreeNode
159
175
  key={entry.path}
160
176
  entry={entry}
@@ -245,16 +261,22 @@ export function FileExplorer({
245
261
  </div>
246
262
  )}
247
263
 
248
- {/* Tree */}
249
- <div className="db-tree" role="tree">
250
- {tree.loading ? (
264
+ {/* Tree — role="tree" only when there are real treeitem children; the
265
+ loading/empty states get a status role so axe doesn't flag the
266
+ tree as missing required children. */}
267
+ {tree.loading ? (
268
+ <div className="db-tree" role="status" aria-live="polite">
251
269
  <div className="db-tree-loading">Loading...</div>
252
- ) : tree.entries.length === 0 ? (
270
+ </div>
271
+ ) : filterVisible(tree.entries).length === 0 ? (
272
+ <div className="db-tree">
253
273
  <div className="db-tree-empty">No files yet</div>
254
- ) : (
255
- renderEntries(tree.entries, 0)
256
- )}
257
- </div>
274
+ </div>
275
+ ) : (
276
+ <div className="db-tree" role="tree">
277
+ {renderEntries(tree.entries, 0)}
278
+ </div>
279
+ )}
258
280
  </div>
259
281
  );
260
282
  }
@@ -6,12 +6,14 @@ import { useState, useCallback, useRef, useEffect } from 'react';
6
6
  import { WorkspaceIcon } from '../icons.js';
7
7
 
8
8
  export interface WorkspaceSettingsButtonProps {
9
+ onSettings: () => void;
9
10
  onRename: () => void;
10
11
  onDownload: () => void;
11
12
  onRemove: () => void;
12
13
  }
13
14
 
14
15
  export function WorkspaceSettingsButton({
16
+ onSettings,
15
17
  onRename,
16
18
  onDownload,
17
19
  onRemove,
@@ -50,6 +52,13 @@ export function WorkspaceSettingsButton({
50
52
 
51
53
  {isOpen && (
52
54
  <div className="db-ws-settings-dropdown" role="menu">
55
+ <button
56
+ className="db-ws-settings-item"
57
+ role="menuitem"
58
+ onClick={() => handleAction(onSettings)}
59
+ >
60
+ Workspace settings…
61
+ </button>
53
62
  <button
54
63
  className="db-ws-settings-item"
55
64
  role="menuitem"
@@ -0,0 +1,121 @@
1
+ /**
2
+ * WorkspaceSettingsDialog — per-workspace settings modal.
3
+ *
4
+ * Lets the user override the global versioning preference for a single
5
+ * workspace (or fall back to inheriting it). The override is stored on
6
+ * the WorkspaceDescriptor and persisted via `saveWorkspace`.
7
+ */
8
+
9
+ import { useCallback, useEffect } from 'react';
10
+ import type { WorkspaceDescriptor } from '@bendyline/docblocks/workspace';
11
+ import {
12
+ isLocalWorkspaceType,
13
+ resolveVersioningEnabled,
14
+ type VersioningPreference,
15
+ } from '../preferences/versioning.js';
16
+
17
+ export type WorkspaceVersioningOverride = NonNullable<WorkspaceDescriptor['versioningOverride']>;
18
+
19
+ export interface WorkspaceSettingsDialogProps {
20
+ workspace: WorkspaceDescriptor;
21
+ /** Current global versioning preference — used to label "Inherit". */
22
+ globalVersioningPreference: VersioningPreference;
23
+ /** Apply a new override and close the dialog. */
24
+ onChange: (override: WorkspaceVersioningOverride) => void;
25
+ onClose: () => void;
26
+ }
27
+
28
+ const VERSIONING_LABELS: Record<VersioningPreference, string> = {
29
+ on: 'on',
30
+ 'browser-only': 'on for browser workspaces, off for local folders',
31
+ off: 'off',
32
+ };
33
+
34
+ export function WorkspaceSettingsDialog({
35
+ workspace,
36
+ globalVersioningPreference,
37
+ onChange,
38
+ onClose,
39
+ }: WorkspaceSettingsDialogProps) {
40
+ // Treat absent/undefined as 'inherit' for the radio state.
41
+ const current: WorkspaceVersioningOverride = workspace.versioningOverride ?? 'inherit';
42
+
43
+ useEffect(() => {
44
+ const onKey = (e: KeyboardEvent) => {
45
+ if (e.key === 'Escape') onClose();
46
+ };
47
+ window.addEventListener('keydown', onKey);
48
+ return () => window.removeEventListener('keydown', onKey);
49
+ }, [onClose]);
50
+
51
+ const handleBackdropClick = useCallback(
52
+ (e: React.MouseEvent) => {
53
+ if (e.target === e.currentTarget) onClose();
54
+ },
55
+ [onClose],
56
+ );
57
+
58
+ const isLocal = isLocalWorkspaceType(workspace.type);
59
+ const inheritedEnabled = resolveVersioningEnabled(
60
+ { ...workspace, versioningOverride: 'inherit' },
61
+ globalVersioningPreference,
62
+ );
63
+ const inheritLabel = `Inherit default — currently ${VERSIONING_LABELS[globalVersioningPreference]} (${inheritedEnabled ? 'on' : 'off'} for this workspace)`;
64
+
65
+ return (
66
+ <div className="db-dialog-overlay" onClick={handleBackdropClick}>
67
+ <div className="db-dialog" role="dialog" aria-label="Workspace settings">
68
+ <div className="db-dialog-header">
69
+ <h2 className="db-dialog-title">Workspace settings</h2>
70
+ <button type="button" className="db-dialog-close" onClick={onClose} aria-label="Close">
71
+ &times;
72
+ </button>
73
+ </div>
74
+ <div className="db-dialog-body">
75
+ <p className="db-settings-hint">
76
+ <strong>{workspace.name}</strong> &middot;{' '}
77
+ {isLocal ? 'Local folder workspace' : 'Browser workspace'}
78
+ </p>
79
+
80
+ <fieldset className="db-settings-fieldset">
81
+ <legend className="db-settings-legend">Version history</legend>
82
+ <p className="db-settings-hint">
83
+ Controls whether DocBlocks keeps prior revisions in{' '}
84
+ <code>&lt;name&gt;_files/.versions/</code> for documents in this workspace.
85
+ </p>
86
+ <label className="db-settings-radio">
87
+ <input
88
+ type="radio"
89
+ name="ws-versioning"
90
+ value="inherit"
91
+ checked={current === 'inherit'}
92
+ onChange={() => onChange('inherit')}
93
+ />
94
+ {inheritLabel}
95
+ </label>
96
+ <label className="db-settings-radio">
97
+ <input
98
+ type="radio"
99
+ name="ws-versioning"
100
+ value="on"
101
+ checked={current === 'on'}
102
+ onChange={() => onChange('on')}
103
+ />
104
+ On for this workspace
105
+ </label>
106
+ <label className="db-settings-radio">
107
+ <input
108
+ type="radio"
109
+ name="ws-versioning"
110
+ value="off"
111
+ checked={current === 'off'}
112
+ onChange={() => onChange('off')}
113
+ />
114
+ Off for this workspace
115
+ </label>
116
+ </fieldset>
117
+ </div>
118
+ </div>
119
+ </div>
120
+ );
121
+ }