@jaxzhou/dsh-file-explorer 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -23,14 +23,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
23
23
  var index_exports = {};
24
24
  __export(index_exports, {
25
25
  FILES_VIEW_ID: () => FILES_VIEW_ID,
26
+ RETAINED_PREVIEWS: () => RETAINED_PREVIEWS,
26
27
  apply: () => apply,
28
+ canExportPdf: () => canExportPdf,
27
29
  extensionOf: () => extensionOf,
28
30
  hasSourceToggle: () => hasSourceToggle,
29
31
  inject: () => inject,
30
32
  name: () => name,
31
33
  parseJsonDocument: () => parseJsonDocument,
32
34
  previewFormatFor: () => previewFormatFor,
33
- previewLines: () => previewLines
35
+ previewLines: () => previewLines,
36
+ tabLabels: () => tabLabels
34
37
  });
35
38
  module.exports = __toCommonJS(index_exports);
36
39
 
@@ -98,10 +101,14 @@ function previewFormatFor(path) {
98
101
  if (lang === void 0) return { kind: "text", lang: void 0, mediaType: void 0 };
99
102
  if (lang === "json") return { kind: "json", lang, mediaType: void 0 };
100
103
  if (lang === "markdown") return { kind: "markdown", lang, mediaType: void 0 };
104
+ if (lang === "html") return { kind: "html", lang, mediaType: void 0 };
101
105
  return { kind: "code", lang, mediaType: void 0 };
102
106
  }
103
107
  function hasSourceToggle(format) {
104
- return format.kind === "markdown" || format.kind === "json";
108
+ return format.kind === "markdown" || format.kind === "html" || format.kind === "json";
109
+ }
110
+ function canExportPdf(format) {
111
+ return format.kind === "markdown" || format.kind === "html";
105
112
  }
106
113
 
107
114
  // src/client/FilesView.tsx
@@ -121,6 +128,24 @@ function pathParts(path) {
121
128
  if (at < 0) return { directory: "", name: path };
122
129
  return { directory: path.slice(0, at + 1), name: path.slice(at + 1) };
123
130
  }
131
+ function tabLabels(open) {
132
+ const twins = /* @__PURE__ */ new Map();
133
+ for (const path of open) {
134
+ const { name: name2 } = pathParts(path);
135
+ twins.set(name2, (twins.get(name2) ?? 0) + 1);
136
+ }
137
+ const labels = {};
138
+ for (const path of open) {
139
+ const { directory, name: name2 } = pathParts(path);
140
+ if ((twins.get(name2) ?? 0) < 2) {
141
+ labels[path] = name2;
142
+ continue;
143
+ }
144
+ const parent = pathParts(directory.replace(/[/\\]+$/, "")).name;
145
+ labels[path] = parent === "" ? name2 : `${parent}/${name2}`;
146
+ }
147
+ return labels;
148
+ }
124
149
  function previewLines(page) {
125
150
  return page.lines === 0 ? [] : page.text.split("\n");
126
151
  }
@@ -213,21 +238,32 @@ function Entry({
213
238
  ] });
214
239
  }
215
240
  if (entry.type === "file") {
216
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "dsh-fe-item", "data-files-entry": "file", "data-files-path": path, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
217
- "button",
241
+ const isOpen = tree.open.has(path);
242
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
243
+ "li",
218
244
  {
219
- type: "button",
220
- className: "dsh-fe-row",
221
- "aria-current": tree.state.selected === path,
222
- onClick: () => {
223
- tree.onOpen(path);
224
- },
225
- children: [
226
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.FileTypeIcon, { kind: (0, import_dsh_client_ui_primitives.classifyFileType)(entry.name), size: 16 }),
227
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-name", children: entry.name })
228
- ]
245
+ className: "dsh-fe-item",
246
+ "data-files-entry": "file",
247
+ "data-files-path": path,
248
+ "data-files-open": isOpen || void 0,
249
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
250
+ "button",
251
+ {
252
+ type: "button",
253
+ className: "dsh-fe-row",
254
+ "aria-current": tree.state.active === path,
255
+ onClick: () => {
256
+ tree.onOpen(path);
257
+ },
258
+ children: [
259
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.FileTypeIcon, { kind: (0, import_dsh_client_ui_primitives.classifyFileType)(entry.name), size: 16 }),
260
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-name", children: entry.name }),
261
+ isOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-open-dot", "aria-hidden": "true" })
262
+ ]
263
+ }
264
+ )
229
265
  }
230
- ) });
266
+ );
231
267
  }
232
268
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("li", { className: "dsh-fe-item", "data-files-entry": "other", "data-files-path": path, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-row dsh-fe-row-static", title: tree.t("tree.other"), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-name", children: entry.name }) }) });
233
269
  }
@@ -248,10 +284,30 @@ function HighlightedSource({
248
284
  }
249
285
  ) });
250
286
  }
287
+ function HtmlFrame({ html, title }) {
288
+ const url = (0, import_react.useMemo)(
289
+ () => URL.createObjectURL(new Blob([html], { type: "text/html" })),
290
+ [html]
291
+ );
292
+ (0, import_react.useEffect)(() => () => {
293
+ URL.revokeObjectURL(url);
294
+ }, [url]);
295
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
296
+ "iframe",
297
+ {
298
+ className: "dsh-fe-html-frame",
299
+ src: url,
300
+ sandbox: "allow-scripts",
301
+ title,
302
+ "data-preview-html": true
303
+ }
304
+ );
305
+ }
251
306
  function FormattedBody({
252
307
  page,
253
308
  format,
254
309
  jsonDocument,
310
+ proseRef,
255
311
  t
256
312
  }) {
257
313
  const copyLabel = t("code.copy");
@@ -296,7 +352,10 @@ function FormattedBody({
296
352
  t
297
353
  ]);
298
354
  if (format.kind === "markdown") {
299
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-prose", "data-preview-format": "markdown", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.MarkdownText, { text: page.text, labels: markdownLabels }) });
355
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-prose", ref: proseRef, "data-preview-format": "markdown", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.MarkdownText, { text: page.text, labels: markdownLabels }) });
356
+ }
357
+ if (format.kind === "html") {
358
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-html", "data-preview-format": "html", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HtmlFrame, { html: page.text, title: t("preview.htmlFrame") }) });
300
359
  }
301
360
  if (jsonDocument === void 0) return null;
302
361
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-json", "data-preview-format": "json", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
@@ -309,17 +368,81 @@ function FormattedBody({
309
368
  }
310
369
  ) });
311
370
  }
371
+ function printableHtml(source, title) {
372
+ const parsed = new DOMParser().parseFromString(source, "text/html");
373
+ for (const script of [...parsed.querySelectorAll("script")]) script.remove();
374
+ for (const element of [...parsed.querySelectorAll("*")]) {
375
+ for (const attribute of [...element.attributes]) {
376
+ const name2 = attribute.name.toLowerCase();
377
+ if (name2.startsWith("on")) element.removeAttribute(attribute.name);
378
+ else if (/^\s*javascript:/i.test(attribute.value)) element.removeAttribute(attribute.name);
379
+ }
380
+ }
381
+ if (parsed.title === "") {
382
+ const tag = parsed.createElement("title");
383
+ tag.textContent = title;
384
+ parsed.head.append(tag);
385
+ }
386
+ return `<!doctype html>${parsed.documentElement.outerHTML}`;
387
+ }
388
+ function appStyleTags() {
389
+ return [...document.querySelectorAll('style, link[rel="stylesheet"]')].map((node) => node.outerHTML).join("\n");
390
+ }
391
+ function styledDocument(title, body) {
392
+ const safeTitle = title.replace(/[<&]/g, (character) => character === "<" ? "&lt;" : "&amp;");
393
+ return [
394
+ '<!doctype html><html><head><meta charset="utf-8">',
395
+ `<title>${safeTitle}</title>`,
396
+ appStyleTags(),
397
+ "<style>body{margin:0;padding:28px 32px;background:#fff;color:#111;",
398
+ "print-color-adjust:exact;-webkit-print-color-adjust:exact}</style>",
399
+ `</head><body>${body}</body></html>`
400
+ ].join("");
401
+ }
402
+ function createPrintFrame() {
403
+ const frame = document.createElement("iframe");
404
+ frame.setAttribute("aria-hidden", "true");
405
+ frame.setAttribute("data-preview-print", "");
406
+ frame.style.cssText = "position:fixed;left:-10000px;top:0;width:794px;height:1123px;border:0";
407
+ document.body.appendChild(frame);
408
+ return frame;
409
+ }
410
+ function printDocument(html) {
411
+ for (const stale of document.querySelectorAll("iframe[data-preview-print]")) stale.remove();
412
+ const frame = createPrintFrame();
413
+ const doc = frame.contentDocument;
414
+ if (doc === null) return;
415
+ doc.open();
416
+ doc.write(html);
417
+ doc.close();
418
+ const view = frame.contentWindow;
419
+ if (view === null) return;
420
+ view.addEventListener("afterprint", () => {
421
+ frame.remove();
422
+ }, { once: true });
423
+ view.focus();
424
+ view.print();
425
+ }
426
+ function bodyKindOf(content, format, mode, jsonWalkable) {
427
+ if (content !== null && content.kind === "image") return "image";
428
+ if (!hasSourceToggle(format)) return "source";
429
+ if ((mode ?? "rendered") === "source") return "source";
430
+ if (format.kind === "json" && !jsonWalkable) return "source";
431
+ return "rendered";
432
+ }
312
433
  function PreviewBody({
313
- state,
434
+ path,
314
435
  content,
315
436
  format,
316
437
  body,
317
438
  jsonDocument,
318
439
  jsonFallback,
440
+ wrap,
441
+ proseRef,
319
442
  t
320
443
  }) {
321
444
  if (content.kind === "image") {
322
- const { name: name2 } = pathParts(state.selected ?? "");
445
+ const { name: name2 } = pathParts(path);
323
446
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-scroll dsh-fe-imagehost", "data-preview-state": "ready", "data-preview-format": "image", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", { className: "dsh-fe-image", src: content.image.dataUrl, alt: name2, "data-preview-image": true }) });
324
447
  }
325
448
  const page = content.page;
@@ -346,19 +469,21 @@ function PreviewBody({
346
469
  "data-preview-body": body,
347
470
  children: [
348
471
  jsonFallback && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-fe-note dsh-fe-note-error", "data-preview-row": "invalid-json", children: t("preview.jsonInvalid") }),
349
- body === "rendered" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FormattedBody, { page, format, jsonDocument, t }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HighlightedSource, { page, lang: format.lang, wrap: state.wrap, t }),
472
+ body === "rendered" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
473
+ FormattedBody,
474
+ {
475
+ page,
476
+ format,
477
+ jsonDocument,
478
+ proseRef,
479
+ t
480
+ }
481
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HighlightedSource, { page, lang: format.lang, wrap, t }),
350
482
  !page.eof && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-fe-note", "data-preview-row": "truncated", children: t("preview.truncated", { lines: page.lines }) })
351
483
  ]
352
484
  }
353
485
  );
354
486
  }
355
- function bodyKindOf(content, format, mode, jsonWalkable) {
356
- if (content !== null && content.kind === "image") return "image";
357
- if (!hasSourceToggle(format)) return "source";
358
- if ((mode ?? "rendered") === "source") return "source";
359
- if (format.kind === "json" && !jsonWalkable) return "source";
360
- return "rendered";
361
- }
362
487
  function FilesView({
363
488
  sessionId,
364
489
  useSessions,
@@ -370,32 +495,50 @@ function FilesView({
370
495
  }) {
371
496
  const cwd = useSessions((sessions) => sessions.byId[sessionId]?.cwd);
372
497
  const state = useStore((store) => store);
373
- const jsonDocument = (0, import_react.useMemo)(() => {
374
- if (state.selected === null) return void 0;
375
- if ((state.mode ?? "rendered") === "source") return void 0;
376
- if (previewFormatFor(state.selected).kind !== "json") return void 0;
377
- const preview2 = state.preview;
378
- if (preview2.kind !== "ready" || preview2.content.kind !== "text") return void 0;
379
- return parseJsonDocument(preview2.content.page.text);
380
- }, [state.mode, state.preview, state.selected]);
498
+ const activeTabRef = (0, import_react.useRef)(null);
499
+ const proseRef = (0, import_react.useRef)(null);
500
+ const [copied, setCopied] = (0, import_react.useState)(null);
501
+ const copyTimer = (0, import_react.useRef)(null);
502
+ (0, import_react.useEffect)(() => () => {
503
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current);
504
+ }, []);
381
505
  (0, import_react.useEffect)(() => {
382
506
  if (cwd === void 0 || state.root === cwd) return;
383
507
  actions.start(cwd);
384
508
  list(cwd);
385
509
  }, [actions, cwd, list, state.root]);
510
+ const active = state.active;
511
+ const format = active === null ? null : previewFormatFor(active);
512
+ const content = active !== null && state.previews[active]?.kind === "ready" ? state.previews[active].content : null;
513
+ const jsonDocument = (0, import_react.useMemo)(() => {
514
+ if (active === null || content === null || content.kind !== "text") return void 0;
515
+ if ((state.modes[active] ?? "rendered") === "source") return void 0;
516
+ if (previewFormatFor(active).kind !== "json") return void 0;
517
+ return parseJsonDocument(content.page.text);
518
+ }, [active, content, state.modes]);
519
+ (0, import_react.useEffect)(() => {
520
+ if (active === null) return;
521
+ if (state.previews[active] !== void 0) return;
522
+ read(active);
523
+ }, [active, read, state.previews]);
524
+ (0, import_react.useEffect)(() => {
525
+ activeTabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
526
+ }, [active]);
386
527
  if (cwd === void 0) {
387
528
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-root", "data-files-state": "no-workspace", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-status", children: t("tree.noWorkspace") }) });
388
529
  }
389
530
  if (state.root === null) return null;
531
+ const openSet = new Set(state.open);
390
532
  const tree = {
391
533
  state,
534
+ open: openSet,
392
535
  onToggle: (path) => {
393
536
  const loaded = state.levels[path] !== void 0;
394
537
  actions.toggled(path);
395
538
  if (!loaded) list(path);
396
539
  },
397
540
  onOpen: (path) => {
398
- read(path);
541
+ actions.openFile(path);
399
542
  },
400
543
  t
401
544
  };
@@ -404,25 +547,46 @@ function FilesView({
404
547
  for (const path of state.expanded) list(path);
405
548
  };
406
549
  const reloadPreview = () => {
407
- if (state.selected !== null) read(state.selected);
550
+ if (active !== null) read(active);
551
+ };
552
+ const copyActive = () => {
553
+ if (active === null || content === null || content.kind !== "text") return;
554
+ const path = active;
555
+ const text = content.page.text;
556
+ void (0, import_dsh_client_ui_primitives.writeClipboard)(text).then((ok) => {
557
+ if (!ok) return;
558
+ setCopied(path);
559
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current);
560
+ copyTimer.current = setTimeout(() => {
561
+ setCopied(null);
562
+ }, 1e3);
563
+ });
564
+ };
565
+ const exportPdf = () => {
566
+ if (active === null || format === null || content === null || content.kind !== "text") return;
567
+ if (!canExportPdf(format)) return;
568
+ const name2 = pathParts(active).name;
569
+ if (format.kind === "html") {
570
+ printDocument(printableHtml(content.page.text, name2));
571
+ return;
572
+ }
573
+ const prose = proseRef.current;
574
+ if (prose === null) return;
575
+ printDocument(styledDocument(name2, prose.outerHTML));
576
+ };
577
+ const closeTab = (path) => {
578
+ actions.closeFile(path);
408
579
  };
409
580
  const root = pathParts(state.root);
410
- const selected = state.selected === null ? null : pathParts(state.selected);
411
- const preview = state.preview;
412
- const format = state.selected === null ? null : previewFormatFor(state.selected);
413
- const content = preview.kind === "ready" ? preview.content : null;
414
- const fallbackFormat = { kind: "text", lang: void 0, mediaType: void 0 };
415
- const body = bodyKindOf(
416
- content,
417
- format ?? fallbackFormat,
418
- state.mode,
419
- jsonDocument !== void 0
420
- );
421
- const jsonFallback = format?.kind === "json" && (state.mode ?? "rendered") === "rendered" && jsonDocument === void 0 && content !== null && content.kind === "text";
422
- const meta = preview.kind !== "ready" ? null : preview.content.kind === "text" ? [
423
- t("preview.lines", { lines: preview.content.page.lines }),
424
- preview.content.page.bytes === void 0 ? null : (0, import_dsh_client_ui_primitives.fileSizeText)(preview.content.page.bytes)
425
- ].filter((part) => part !== null).join(" \xB7 ") : preview.content.image.bytes === void 0 ? null : (0, import_dsh_client_ui_primitives.fileSizeText)(preview.content.image.bytes);
581
+ const labels = tabLabels(state.open);
582
+ const wrap = active === null ? true : state.wraps[active] ?? true;
583
+ const preview = active === null ? void 0 : state.previews[active];
584
+ const body = bodyKindOf(content, format ?? { kind: "text", lang: void 0, mediaType: void 0 }, active === null ? void 0 : state.modes[active], jsonDocument !== void 0);
585
+ const jsonFallback = format?.kind === "json" && (active === null ? "rendered" : state.modes[active] ?? "rendered") === "rendered" && jsonDocument === void 0 && content !== null && content.kind === "text";
586
+ const meta = content === null ? null : content.kind === "text" ? [
587
+ t("preview.lines", { lines: content.page.lines }),
588
+ content.page.bytes === void 0 ? null : (0, import_dsh_client_ui_primitives.fileSizeText)(content.page.bytes)
589
+ ].filter((part) => part !== null).join(" \xB7 ") : content.image.bytes === void 0 ? null : (0, import_dsh_client_ui_primitives.fileSizeText)(content.image.bytes);
426
590
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
427
591
  "div",
428
592
  {
@@ -453,13 +617,63 @@ function FilesView({
453
617
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-scroll", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", { className: "dsh-fe-level", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Level, { path: state.root, tree }) }) })
454
618
  ] }),
455
619
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-fe-preview", "data-files-pane": "preview", children: [
456
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-fe-head", children: [
457
- selected === null ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-path dsh-fe-path-muted", children: t("preview.title") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-path", title: state.selected ?? void 0, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
458
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-path-muted", children: selected.directory }),
459
- selected.name
460
- ] }) }),
461
- meta !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-meta", "data-preview-meta": true, children: meta }),
462
- format !== null && hasSourceToggle(format) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
620
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-fe-head dsh-fe-tabhead", children: [
621
+ state.open.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-path dsh-fe-path-muted", children: t("preview.title") }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-tabs", role: "tablist", "aria-label": t("preview.tabs"), "data-preview-tabs": true, children: state.open.map((path) => {
622
+ const isActive = path === active;
623
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dsh-fe-tab", role: "presentation", "data-preview-tab": path, "data-active": isActive || void 0, children: [
624
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
625
+ "button",
626
+ {
627
+ type: "button",
628
+ role: "tab",
629
+ "aria-selected": isActive,
630
+ className: "dsh-fe-tab-label",
631
+ title: path,
632
+ ref: isActive ? activeTabRef : void 0,
633
+ "data-preview-tab-open": path,
634
+ onClick: () => {
635
+ actions.activate(path);
636
+ },
637
+ onAuxClick: (event) => {
638
+ if (event.button === 1) closeTab(path);
639
+ },
640
+ children: [
641
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.FileTypeIcon, { kind: (0, import_dsh_client_ui_primitives.classifyFileType)(path), size: 14 }),
642
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-tab-name", children: labels[path] })
643
+ ]
644
+ }
645
+ ),
646
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
647
+ "button",
648
+ {
649
+ type: "button",
650
+ className: "dsh-fe-tab-close",
651
+ "aria-label": t("preview.close", { name: labels[path] }),
652
+ title: t("preview.close", { name: labels[path] }),
653
+ "data-preview-tab-close": path,
654
+ onClick: () => {
655
+ closeTab(path);
656
+ },
657
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: "\xD7" })
658
+ }
659
+ )
660
+ ] }, path);
661
+ }) }),
662
+ active !== null && body === "source" && format?.lang !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-lang", "data-preview-lang-badge": true, children: format.lang }),
663
+ content !== null && meta !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-meta", "data-preview-meta": true, children: meta }),
664
+ content !== null && content.kind === "text" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
665
+ "button",
666
+ {
667
+ type: "button",
668
+ className: "dsh-fe-tool",
669
+ "aria-label": t("preview.copy"),
670
+ title: t("preview.copy"),
671
+ "data-preview-copy": true,
672
+ onClick: copyActive,
673
+ children: copied === active ? t("preview.copied") : t("preview.copy")
674
+ }
675
+ ),
676
+ active !== null && format !== null && hasSourceToggle(format) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
463
677
  "button",
464
678
  {
465
679
  type: "button",
@@ -469,27 +683,39 @@ function FilesView({
469
683
  title: body === "source" ? t("preview.rendered") : t("preview.source"),
470
684
  "data-preview-mode-toggle": true,
471
685
  onClick: () => {
472
- actions.setMode(body === "source" ? "rendered" : "source");
686
+ actions.setMode(active, body === "source" ? "rendered" : "source");
473
687
  },
474
688
  children: body === "source" ? t("preview.rendered") : t("preview.source")
475
689
  }
476
690
  ),
477
- body === "source" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
691
+ active !== null && body === "rendered" && format !== null && canExportPdf(format) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
478
692
  "button",
479
693
  {
480
694
  type: "button",
481
695
  className: "dsh-fe-tool",
482
- "aria-pressed": state.wrap,
483
- "aria-label": state.wrap ? t("preview.nowrap") : t("preview.wrap"),
484
- title: state.wrap ? t("preview.nowrap") : t("preview.wrap"),
696
+ "aria-label": t("preview.pdf"),
697
+ title: t("preview.pdf"),
698
+ "data-preview-pdf": true,
699
+ onClick: exportPdf,
700
+ children: t("preview.pdf")
701
+ }
702
+ ),
703
+ active !== null && body === "source" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
704
+ "button",
705
+ {
706
+ type: "button",
707
+ className: "dsh-fe-tool",
708
+ "aria-pressed": wrap,
709
+ "aria-label": wrap ? t("preview.nowrap") : t("preview.wrap"),
710
+ title: wrap ? t("preview.nowrap") : t("preview.wrap"),
485
711
  "data-preview-wrap-toggle": true,
486
712
  onClick: () => {
487
- actions.setWrap(!state.wrap);
713
+ actions.setWrap(active, !wrap);
488
714
  },
489
- children: state.wrap ? "\u21B5" : "\u2192"
715
+ children: wrap ? "\u21B5" : "\u2192"
490
716
  }
491
717
  ),
492
- state.selected !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
718
+ active !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
493
719
  "button",
494
720
  {
495
721
  type: "button",
@@ -502,21 +728,23 @@ function FilesView({
502
728
  }
503
729
  )
504
730
  ] }),
505
- preview.kind === "loading" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-status", children: t("preview.loading") }),
506
- preview.kind === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-fe-scroll", "data-preview-state": "failed", children: [
731
+ active === null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-status", children: t("preview.placeholder") }),
732
+ active !== null && (preview === void 0 || preview.kind === "loading") && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-status", children: t("preview.loading") }),
733
+ active !== null && preview?.kind === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-fe-scroll", "data-preview-state": "failed", children: [
507
734
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "dsh-fe-note dsh-fe-note-error", "data-preview-code": preview.failure.code, children: previewFailureLine(t, preview.failure) }),
508
735
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-fe-tool", onClick: reloadPreview, children: t("preview.reload") })
509
736
  ] }),
510
- state.selected === null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-status", children: t("preview.placeholder") }),
511
- preview.kind === "ready" && content !== null && format !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
737
+ active !== null && content !== null && format !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
512
738
  PreviewBody,
513
739
  {
514
- state,
740
+ path: active,
515
741
  content,
516
742
  format,
517
743
  body,
518
744
  jsonDocument,
519
745
  jsonFallback,
746
+ wrap,
747
+ proseRef,
520
748
  t
521
749
  }
522
750
  )
@@ -529,16 +757,16 @@ function FilesView({
529
757
  // src/client/face.ts
530
758
  var PREVIEW_LINES = 2e3;
531
759
  function filesFace(remote, sessionId, signal, actions) {
532
- const generations = /* @__PURE__ */ new Map();
533
- let previewGeneration = 0;
760
+ const listingGenerations = /* @__PURE__ */ new Map();
761
+ const readGenerations = /* @__PURE__ */ new Map();
534
762
  return {
535
763
  list(path) {
536
764
  if (signal.aborted) return;
537
- const generation = (generations.get(path) ?? 0) + 1;
538
- generations.set(path, generation);
765
+ const generation = (listingGenerations.get(path) ?? 0) + 1;
766
+ listingGenerations.set(path, generation);
539
767
  actions.loading(path);
540
768
  void remote.workspaceFiles.list(sessionId, path, signal).then((result) => {
541
- if (signal.aborted || generations.get(path) !== generation) return;
769
+ if (signal.aborted || listingGenerations.get(path) !== generation) return;
542
770
  if (result.ok) {
543
771
  actions.loaded(path, {
544
772
  entries: result.value.entries,
@@ -551,12 +779,12 @@ function filesFace(remote, sessionId, signal, actions) {
551
779
  },
552
780
  read(path) {
553
781
  if (signal.aborted) return;
554
- previewGeneration += 1;
555
- const generation = previewGeneration;
782
+ const generation = (readGenerations.get(path) ?? 0) + 1;
783
+ readGenerations.set(path, generation);
556
784
  const format = previewFormatFor(path);
557
- actions.selecting(path);
785
+ actions.reading(path);
558
786
  const settle = (write) => {
559
- if (signal.aborted || previewGeneration !== generation) return;
787
+ if (signal.aborted || readGenerations.get(path) !== generation) return;
560
788
  write();
561
789
  };
562
790
  if (format.kind === "image") {
@@ -631,7 +859,13 @@ var zh = {
631
859
  "json.expandNode": "\u5C55\u5F00",
632
860
  "json.copyTitle": "{action}",
633
861
  "preview.title": "\u9884\u89C8",
862
+ "preview.tabs": "\u6253\u5F00\u7684\u6587\u4EF6",
863
+ "preview.close": "\u5173\u95ED {name}",
634
864
  "preview.loading": "\u6B63\u5728\u8BFB\u53D6\u2026",
865
+ "preview.copy": "\u590D\u5236",
866
+ "preview.copied": "\u5DF2\u590D\u5236",
867
+ "preview.pdf": "\u5BFC\u51FA PDF",
868
+ "preview.htmlFrame": "HTML \u9884\u89C8",
635
869
  "preview.reload": "\u91CD\u65B0\u8BFB\u53D6",
636
870
  "preview.truncated": "\u6587\u4EF6\u8F83\u957F\uFF0C\u53EA\u663E\u793A\u524D {lines} \u884C\u3002",
637
871
  "preview.wrap": "\u81EA\u52A8\u6362\u884C",
@@ -676,7 +910,13 @@ var en = {
676
910
  "json.expandNode": "Expand",
677
911
  "json.copyTitle": "{action}",
678
912
  "preview.title": "Preview",
913
+ "preview.tabs": "Open files",
914
+ "preview.close": "Close {name}",
679
915
  "preview.loading": "Reading\u2026",
916
+ "preview.copy": "Copy",
917
+ "preview.copied": "Copied",
918
+ "preview.pdf": "Export PDF",
919
+ "preview.htmlFrame": "HTML preview",
680
920
  "preview.reload": "Reload",
681
921
  "preview.truncated": "This file is long, showing the first {lines} lines.",
682
922
  "preview.wrap": "Wrap long lines",
@@ -691,27 +931,36 @@ var en = {
691
931
 
692
932
  // src/client/store.ts
693
933
  var import_dsh_client_store = require("@deepseek-ai/dsh-client-store");
934
+ var RETAINED_PREVIEWS = 5;
694
935
  function emptyState() {
695
936
  return {
696
937
  root: null,
697
938
  levels: {},
698
939
  expanded: [],
699
- selected: null,
700
- preview: { kind: "idle" },
701
- wrap: true,
702
- mode: null
940
+ open: [],
941
+ active: null,
942
+ previews: {},
943
+ retained: [],
944
+ modes: {},
945
+ wraps: {}
703
946
  };
704
947
  }
948
+ function retain(d, path) {
949
+ d.retained = [path, ...d.retained.filter((candidate) => candidate !== path)];
950
+ const keep = /* @__PURE__ */ new Set([...d.active === null ? [] : [d.active], ...d.retained.slice(0, RETAINED_PREVIEWS)]);
951
+ for (const candidate of Object.keys(d.previews)) {
952
+ if (!keep.has(candidate)) delete d.previews[candidate];
953
+ }
954
+ d.retained = d.retained.filter((candidate) => keep.has(candidate));
955
+ }
705
956
  function createFilesStore() {
706
957
  return (0, import_dsh_client_store.defineStore)({
707
958
  init: emptyState,
708
959
  actions: {
709
960
  start: (d, root) => {
961
+ Object.assign(d, emptyState());
710
962
  d.root = root;
711
- d.levels = {};
712
963
  d.expanded = [root];
713
- d.selected = null;
714
- d.preview = { kind: "idle" };
715
964
  },
716
965
  loading: (d, path) => {
717
966
  d.levels[path] = { kind: "loading" };
@@ -730,28 +979,48 @@ function createFilesStore() {
730
979
  reset: (d) => {
731
980
  d.levels = {};
732
981
  },
733
- selecting: (d, path) => {
734
- d.selected = path;
735
- d.preview = { kind: "loading", path };
736
- d.mode = null;
982
+ openFile: (d, path) => {
983
+ if (!d.open.includes(path)) d.open.push(path);
984
+ d.active = path;
985
+ },
986
+ activate: (d, path) => {
987
+ if (!d.open.includes(path)) return;
988
+ d.active = path;
989
+ retain(d, path);
990
+ },
991
+ closeFile: (d, path) => {
992
+ const at = d.open.indexOf(path);
993
+ if (at < 0) return;
994
+ d.open.splice(at, 1);
995
+ delete d.previews[path];
996
+ delete d.modes[path];
997
+ delete d.wraps[path];
998
+ d.retained = d.retained.filter((candidate) => candidate !== path);
999
+ if (d.active !== path) return;
1000
+ d.active = d.open[at] ?? d.open[at - 1] ?? null;
1001
+ },
1002
+ reading: (d, path) => {
1003
+ if (!d.open.includes(path)) return;
1004
+ d.previews[path] = { kind: "loading" };
737
1005
  },
738
1006
  previewLoaded: (d, path, content) => {
739
- if (d.selected !== path) return;
740
- d.preview = { kind: "ready", path, content };
1007
+ if (!d.open.includes(path)) return;
1008
+ d.previews[path] = { kind: "ready", content };
1009
+ retain(d, path);
741
1010
  },
742
1011
  previewFailed: (d, path, failure) => {
743
- if (d.selected !== path) return;
744
- d.preview = { kind: "failed", path, failure };
1012
+ if (!d.open.includes(path)) return;
1013
+ d.previews[path] = { kind: "failed", failure };
745
1014
  },
746
- clearPreview: (d) => {
747
- d.selected = null;
748
- d.preview = { kind: "idle" };
1015
+ dropPreview: (d, path) => {
1016
+ delete d.previews[path];
1017
+ d.retained = d.retained.filter((candidate) => candidate !== path);
749
1018
  },
750
- setWrap: (d, wrap) => {
751
- d.wrap = wrap;
1019
+ setWrap: (d, path, wrap) => {
1020
+ d.wraps[path] = wrap;
752
1021
  },
753
- setMode: (d, mode) => {
754
- d.mode = mode;
1022
+ setMode: (d, path, mode) => {
1023
+ d.modes[path] = mode;
755
1024
  }
756
1025
  }
757
1026
  });
@@ -809,9 +1078,26 @@ var CSS = `
809
1078
  box-sizing: border-box;
810
1079
  height: 38px;
811
1080
  padding: 0 6px 0 14px;
1081
+ /* The row is filled rather than transparent, so the pane's chrome reads as
1082
+ chrome instead of as the first line of the file. The palette's layer tokens
1083
+ are all pure white in light mode (bg-base, bg-layer-1/2/3 share a value), so
1084
+ the only token that separates a surface from the content in BOTH themes is
1085
+ this wash: 4% ink in light, 8% in dark. See the note in CONTRIBUTING.md. */
1086
+ background: var(--dsw-alias-bg-skeleton);
812
1087
  border-bottom: 0.5px solid var(--dsw-alias-border-l3);
813
1088
  }
814
1089
 
1090
+ /* A grammar badge for the source body, which is where the language used to be
1091
+ named: the code block's own banner is hidden in this pane so there is exactly
1092
+ one copy control, in this row. */
1093
+ .dsh-fe-lang {
1094
+ flex: 0 0 auto;
1095
+ color: var(--dsw-alias-label-tertiary);
1096
+ font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);
1097
+ font-size: 11px;
1098
+ white-space: nowrap;
1099
+ }
1100
+
815
1101
  .dsh-fe-path {
816
1102
  flex: 1 1 auto;
817
1103
  min-width: 0;
@@ -974,7 +1260,15 @@ var CSS = `
974
1260
  margin: 0;
975
1261
  }
976
1262
 
977
- .dsh-fe-source .md-code-block > [data-code-block-banner] > div:first-child:empty {
1263
+ /* One copy control per pane, in the header row above: the code block's own
1264
+ banner would be a second, inside the scrollport, and it scrolls away. The
1265
+ grammar it named is shown as the header's badge instead. Markdown fences keep
1266
+ their banners \u2014 there, a banner belongs to the fence, not to the file.
1267
+
1268
+ The banner sits inside a sticky wrapper, hence the descendant match plus the
1269
+ :has rule that takes the wrapper out with it. */
1270
+ .dsh-fe-source [data-code-block-banner],
1271
+ .dsh-fe-source .md-code-block > div:has(> [data-code-block-banner]) {
978
1272
  display: none;
979
1273
  }
980
1274
 
@@ -999,6 +1293,143 @@ var CSS = `
999
1293
  overflow-wrap: break-word;
1000
1294
  }
1001
1295
 
1296
+ /* The preview pane's header row carries the open tabs and, at its end, the
1297
+ active tab's controls. The tabs take the room that is left and scroll; the
1298
+ controls never shrink away. */
1299
+ .dsh-fe-tabhead {
1300
+ gap: 8px;
1301
+ padding: 0 6px 0 8px;
1302
+ }
1303
+
1304
+ .dsh-fe-tabs {
1305
+ display: flex;
1306
+ flex: 1 1 auto;
1307
+ gap: 2px;
1308
+ align-items: stretch;
1309
+ min-width: 90px;
1310
+ height: 100%;
1311
+ overflow-x: auto;
1312
+ overflow-y: hidden;
1313
+ scrollbar-width: none;
1314
+ }
1315
+
1316
+ .dsh-fe-tabs::-webkit-scrollbar {
1317
+ height: 0;
1318
+ }
1319
+
1320
+ .dsh-fe-tab {
1321
+ display: flex;
1322
+ flex: 0 0 auto;
1323
+ align-items: center;
1324
+ min-width: 0;
1325
+ max-width: 220px;
1326
+ border-radius: 8px;
1327
+ }
1328
+
1329
+ .dsh-fe-tab:hover {
1330
+ background: var(--dsw-alias-interactive-bg-hover);
1331
+ }
1332
+
1333
+ .dsh-fe-tab[data-active] {
1334
+ background: var(--dsw-alias-interactive-bg-active);
1335
+ }
1336
+
1337
+ .dsh-fe-tab-label {
1338
+ display: flex;
1339
+ gap: 6px;
1340
+ align-items: center;
1341
+ min-width: 0;
1342
+ padding: 5px 4px 5px 8px;
1343
+ color: var(--dsw-alias-label-secondary);
1344
+ font: inherit;
1345
+ font-size: 12px;
1346
+ line-height: 1;
1347
+ background: transparent;
1348
+ border: 0;
1349
+ border-radius: 8px;
1350
+ cursor: pointer;
1351
+ }
1352
+
1353
+ .dsh-fe-tab[data-active] .dsh-fe-tab-label {
1354
+ color: var(--dsw-alias-label-primary);
1355
+ }
1356
+
1357
+ .dsh-fe-tab-name {
1358
+ min-width: 0;
1359
+ overflow: hidden;
1360
+ white-space: nowrap;
1361
+ text-overflow: ellipsis;
1362
+ }
1363
+
1364
+ /* The close control appears for the tab under the pointer and for the active
1365
+ tab, so a full strip stays readable. */
1366
+ .dsh-fe-tab-close {
1367
+ display: flex;
1368
+ flex: 0 0 auto;
1369
+ align-items: center;
1370
+ justify-content: center;
1371
+ width: 18px;
1372
+ height: 18px;
1373
+ margin-right: 5px;
1374
+ padding: 0;
1375
+ color: var(--dsw-alias-label-tertiary);
1376
+ font: inherit;
1377
+ font-size: 14px;
1378
+ line-height: 1;
1379
+ background: transparent;
1380
+ border: 0;
1381
+ border-radius: 5px;
1382
+ cursor: pointer;
1383
+ opacity: 0;
1384
+ }
1385
+
1386
+ .dsh-fe-tab:hover .dsh-fe-tab-close,
1387
+ .dsh-fe-tab[data-active] .dsh-fe-tab-close,
1388
+ .dsh-fe-tab-close:focus-visible {
1389
+ opacity: 1;
1390
+ }
1391
+
1392
+ .dsh-fe-tab-close:hover {
1393
+ color: var(--dsw-alias-label-primary);
1394
+ background: var(--dsw-alias-interactive-bg-hover);
1395
+ }
1396
+
1397
+ /* A file already open in a tab keeps a quiet marker in the tree, so the reader
1398
+ can see where the strip came from without it competing with the active row. */
1399
+ .dsh-fe-open-dot {
1400
+ flex: 0 0 auto;
1401
+ width: 5px;
1402
+ height: 5px;
1403
+ margin-left: auto;
1404
+ background: var(--dsw-alias-brand-primary);
1405
+ border-radius: 50%;
1406
+ opacity: 0.6;
1407
+ }
1408
+
1409
+ /* An HTML file is drawn by the browser, in a frame that fills the pane. The
1410
+ white canvas is the page's own, not this pane's: an unstyled document still
1411
+ reads as a document rather than as a hole in the app.
1412
+
1413
+ The host takes its height from the scrollport's content box, so the frame ends
1414
+ exactly where the pane's bottom inset begins and the scrollport itself never
1415
+ scrolls \u2014 a page scrolls inside its own frame. */
1416
+ .dsh-fe-html {
1417
+ display: flex;
1418
+ height: 100%;
1419
+ min-height: 0;
1420
+ min-width: 0;
1421
+ }
1422
+
1423
+ .dsh-fe-html-frame {
1424
+ flex: 1 1 auto;
1425
+ width: 100%;
1426
+ height: 100%;
1427
+ min-width: 0;
1428
+ min-height: 0;
1429
+ border: 0;
1430
+ background: #fff;
1431
+ }
1432
+
1002
1433
  /* Rendered Markdown: prose lays out in normal white space, with the pane's
1003
1434
  insets and no monospace inheritance from the source view. */
1004
1435
  .dsh-fe-prose {