@jaxzhou/dsh-file-explorer 0.1.0 → 0.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.
package/lib/client.js CHANGED
@@ -23,6 +23,7 @@ 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,
27
28
  extensionOf: () => extensionOf,
28
29
  hasSourceToggle: () => hasSourceToggle,
@@ -30,7 +31,8 @@ __export(index_exports, {
30
31
  name: () => name,
31
32
  parseJsonDocument: () => parseJsonDocument,
32
33
  previewFormatFor: () => previewFormatFor,
33
- previewLines: () => previewLines
34
+ previewLines: () => previewLines,
35
+ tabLabels: () => tabLabels
34
36
  });
35
37
  module.exports = __toCommonJS(index_exports);
36
38
 
@@ -121,6 +123,24 @@ function pathParts(path) {
121
123
  if (at < 0) return { directory: "", name: path };
122
124
  return { directory: path.slice(0, at + 1), name: path.slice(at + 1) };
123
125
  }
126
+ function tabLabels(open) {
127
+ const twins = /* @__PURE__ */ new Map();
128
+ for (const path of open) {
129
+ const { name: name2 } = pathParts(path);
130
+ twins.set(name2, (twins.get(name2) ?? 0) + 1);
131
+ }
132
+ const labels = {};
133
+ for (const path of open) {
134
+ const { directory, name: name2 } = pathParts(path);
135
+ if ((twins.get(name2) ?? 0) < 2) {
136
+ labels[path] = name2;
137
+ continue;
138
+ }
139
+ const parent = pathParts(directory.replace(/[/\\]+$/, "")).name;
140
+ labels[path] = parent === "" ? name2 : `${parent}/${name2}`;
141
+ }
142
+ return labels;
143
+ }
124
144
  function previewLines(page) {
125
145
  return page.lines === 0 ? [] : page.text.split("\n");
126
146
  }
@@ -213,21 +233,32 @@ function Entry({
213
233
  ] });
214
234
  }
215
235
  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",
236
+ const isOpen = tree.open.has(path);
237
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
238
+ "li",
218
239
  {
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
- ]
240
+ className: "dsh-fe-item",
241
+ "data-files-entry": "file",
242
+ "data-files-path": path,
243
+ "data-files-open": isOpen || void 0,
244
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
245
+ "button",
246
+ {
247
+ type: "button",
248
+ className: "dsh-fe-row",
249
+ "aria-current": tree.state.active === path,
250
+ onClick: () => {
251
+ tree.onOpen(path);
252
+ },
253
+ children: [
254
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.FileTypeIcon, { kind: (0, import_dsh_client_ui_primitives.classifyFileType)(entry.name), size: 16 }),
255
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-name", children: entry.name }),
256
+ isOpen && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-open-dot", "aria-hidden": "true" })
257
+ ]
258
+ }
259
+ )
229
260
  }
230
- ) });
261
+ );
231
262
  }
232
263
  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
264
  }
@@ -309,17 +340,25 @@ function FormattedBody({
309
340
  }
310
341
  ) });
311
342
  }
343
+ function bodyKindOf(content, format, mode, jsonWalkable) {
344
+ if (content !== null && content.kind === "image") return "image";
345
+ if (!hasSourceToggle(format)) return "source";
346
+ if ((mode ?? "rendered") === "source") return "source";
347
+ if (format.kind === "json" && !jsonWalkable) return "source";
348
+ return "rendered";
349
+ }
312
350
  function PreviewBody({
313
- state,
351
+ path,
314
352
  content,
315
353
  format,
316
354
  body,
317
355
  jsonDocument,
318
356
  jsonFallback,
357
+ wrap,
319
358
  t
320
359
  }) {
321
360
  if (content.kind === "image") {
322
- const { name: name2 } = pathParts(state.selected ?? "");
361
+ const { name: name2 } = pathParts(path);
323
362
  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
363
  }
325
364
  const page = content.page;
@@ -346,19 +385,12 @@ function PreviewBody({
346
385
  "data-preview-body": body,
347
386
  children: [
348
387
  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 }),
388
+ 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, t }),
350
389
  !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
390
  ]
352
391
  }
353
392
  );
354
393
  }
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
394
  function FilesView({
363
395
  sessionId,
364
396
  useSessions,
@@ -370,32 +402,49 @@ function FilesView({
370
402
  }) {
371
403
  const cwd = useSessions((sessions) => sessions.byId[sessionId]?.cwd);
372
404
  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]);
405
+ const activeTabRef = (0, import_react.useRef)(null);
406
+ const [copied, setCopied] = (0, import_react.useState)(null);
407
+ const copyTimer = (0, import_react.useRef)(null);
408
+ (0, import_react.useEffect)(() => () => {
409
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current);
410
+ }, []);
381
411
  (0, import_react.useEffect)(() => {
382
412
  if (cwd === void 0 || state.root === cwd) return;
383
413
  actions.start(cwd);
384
414
  list(cwd);
385
415
  }, [actions, cwd, list, state.root]);
416
+ const active = state.active;
417
+ const format = active === null ? null : previewFormatFor(active);
418
+ const content = active !== null && state.previews[active]?.kind === "ready" ? state.previews[active].content : null;
419
+ const jsonDocument = (0, import_react.useMemo)(() => {
420
+ if (active === null || content === null || content.kind !== "text") return void 0;
421
+ if ((state.modes[active] ?? "rendered") === "source") return void 0;
422
+ if (previewFormatFor(active).kind !== "json") return void 0;
423
+ return parseJsonDocument(content.page.text);
424
+ }, [active, content, state.modes]);
425
+ (0, import_react.useEffect)(() => {
426
+ if (active === null) return;
427
+ if (state.previews[active] !== void 0) return;
428
+ read(active);
429
+ }, [active, read, state.previews]);
430
+ (0, import_react.useEffect)(() => {
431
+ activeTabRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" });
432
+ }, [active]);
386
433
  if (cwd === void 0) {
387
434
  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
435
  }
389
436
  if (state.root === null) return null;
437
+ const openSet = new Set(state.open);
390
438
  const tree = {
391
439
  state,
440
+ open: openSet,
392
441
  onToggle: (path) => {
393
442
  const loaded = state.levels[path] !== void 0;
394
443
  actions.toggled(path);
395
444
  if (!loaded) list(path);
396
445
  },
397
446
  onOpen: (path) => {
398
- read(path);
447
+ actions.openFile(path);
399
448
  },
400
449
  t
401
450
  };
@@ -404,25 +453,34 @@ function FilesView({
404
453
  for (const path of state.expanded) list(path);
405
454
  };
406
455
  const reloadPreview = () => {
407
- if (state.selected !== null) read(state.selected);
456
+ if (active !== null) read(active);
457
+ };
458
+ const copyActive = () => {
459
+ if (active === null || content === null || content.kind !== "text") return;
460
+ const path = active;
461
+ const text = content.page.text;
462
+ void (0, import_dsh_client_ui_primitives.writeClipboard)(text).then((ok) => {
463
+ if (!ok) return;
464
+ setCopied(path);
465
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current);
466
+ copyTimer.current = setTimeout(() => {
467
+ setCopied(null);
468
+ }, 1e3);
469
+ });
470
+ };
471
+ const closeTab = (path) => {
472
+ actions.closeFile(path);
408
473
  };
409
474
  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);
475
+ const labels = tabLabels(state.open);
476
+ const wrap = active === null ? true : state.wraps[active] ?? true;
477
+ const preview = active === null ? void 0 : state.previews[active];
478
+ const body = bodyKindOf(content, format ?? { kind: "text", lang: void 0, mediaType: void 0 }, active === null ? void 0 : state.modes[active], jsonDocument !== void 0);
479
+ const jsonFallback = format?.kind === "json" && (active === null ? "rendered" : state.modes[active] ?? "rendered") === "rendered" && jsonDocument === void 0 && content !== null && content.kind === "text";
480
+ const meta = content === null ? null : content.kind === "text" ? [
481
+ t("preview.lines", { lines: content.page.lines }),
482
+ content.page.bytes === void 0 ? null : (0, import_dsh_client_ui_primitives.fileSizeText)(content.page.bytes)
483
+ ].filter((part) => part !== null).join(" \xB7 ") : content.image.bytes === void 0 ? null : (0, import_dsh_client_ui_primitives.fileSizeText)(content.image.bytes);
426
484
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
427
485
  "div",
428
486
  {
@@ -453,13 +511,63 @@ function FilesView({
453
511
  /* @__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
512
  ] }),
455
513
  /* @__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)(
514
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-fe-head dsh-fe-tabhead", children: [
515
+ 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) => {
516
+ const isActive = path === active;
517
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { className: "dsh-fe-tab", role: "presentation", "data-preview-tab": path, "data-active": isActive || void 0, children: [
518
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
519
+ "button",
520
+ {
521
+ type: "button",
522
+ role: "tab",
523
+ "aria-selected": isActive,
524
+ className: "dsh-fe-tab-label",
525
+ title: path,
526
+ ref: isActive ? activeTabRef : void 0,
527
+ "data-preview-tab-open": path,
528
+ onClick: () => {
529
+ actions.activate(path);
530
+ },
531
+ onAuxClick: (event) => {
532
+ if (event.button === 1) closeTab(path);
533
+ },
534
+ children: [
535
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.FileTypeIcon, { kind: (0, import_dsh_client_ui_primitives.classifyFileType)(path), size: 14 }),
536
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-tab-name", children: labels[path] })
537
+ ]
538
+ }
539
+ ),
540
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
541
+ "button",
542
+ {
543
+ type: "button",
544
+ className: "dsh-fe-tab-close",
545
+ "aria-label": t("preview.close", { name: labels[path] }),
546
+ title: t("preview.close", { name: labels[path] }),
547
+ "data-preview-tab-close": path,
548
+ onClick: () => {
549
+ closeTab(path);
550
+ },
551
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", children: "\xD7" })
552
+ }
553
+ )
554
+ ] }, path);
555
+ }) }),
556
+ 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 }),
557
+ content !== null && meta !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "dsh-fe-meta", "data-preview-meta": true, children: meta }),
558
+ content !== null && content.kind === "text" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
559
+ "button",
560
+ {
561
+ type: "button",
562
+ className: "dsh-fe-tool",
563
+ "aria-label": t("preview.copy"),
564
+ title: t("preview.copy"),
565
+ "data-preview-copy": true,
566
+ onClick: copyActive,
567
+ children: copied === active ? t("preview.copied") : t("preview.copy")
568
+ }
569
+ ),
570
+ active !== null && format !== null && hasSourceToggle(format) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
463
571
  "button",
464
572
  {
465
573
  type: "button",
@@ -469,27 +577,27 @@ function FilesView({
469
577
  title: body === "source" ? t("preview.rendered") : t("preview.source"),
470
578
  "data-preview-mode-toggle": true,
471
579
  onClick: () => {
472
- actions.setMode(body === "source" ? "rendered" : "source");
580
+ actions.setMode(active, body === "source" ? "rendered" : "source");
473
581
  },
474
582
  children: body === "source" ? t("preview.rendered") : t("preview.source")
475
583
  }
476
584
  ),
477
- body === "source" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
585
+ active !== null && body === "source" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
478
586
  "button",
479
587
  {
480
588
  type: "button",
481
589
  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"),
590
+ "aria-pressed": wrap,
591
+ "aria-label": wrap ? t("preview.nowrap") : t("preview.wrap"),
592
+ title: wrap ? t("preview.nowrap") : t("preview.wrap"),
485
593
  "data-preview-wrap-toggle": true,
486
594
  onClick: () => {
487
- actions.setWrap(!state.wrap);
595
+ actions.setWrap(active, !wrap);
488
596
  },
489
- children: state.wrap ? "\u21B5" : "\u2192"
597
+ children: wrap ? "\u21B5" : "\u2192"
490
598
  }
491
599
  ),
492
- state.selected !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
600
+ active !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
493
601
  "button",
494
602
  {
495
603
  type: "button",
@@ -502,21 +610,22 @@ function FilesView({
502
610
  }
503
611
  )
504
612
  ] }),
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: [
613
+ active === null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-status", children: t("preview.placeholder") }),
614
+ active !== null && (preview === void 0 || preview.kind === "loading") && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "dsh-fe-status", children: t("preview.loading") }),
615
+ active !== null && preview?.kind === "failed" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dsh-fe-scroll", "data-preview-state": "failed", children: [
507
616
  /* @__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
617
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", className: "dsh-fe-tool", onClick: reloadPreview, children: t("preview.reload") })
509
618
  ] }),
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)(
619
+ active !== null && content !== null && format !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
512
620
  PreviewBody,
513
621
  {
514
- state,
622
+ path: active,
515
623
  content,
516
624
  format,
517
625
  body,
518
626
  jsonDocument,
519
627
  jsonFallback,
628
+ wrap,
520
629
  t
521
630
  }
522
631
  )
@@ -529,16 +638,16 @@ function FilesView({
529
638
  // src/client/face.ts
530
639
  var PREVIEW_LINES = 2e3;
531
640
  function filesFace(remote, sessionId, signal, actions) {
532
- const generations = /* @__PURE__ */ new Map();
533
- let previewGeneration = 0;
641
+ const listingGenerations = /* @__PURE__ */ new Map();
642
+ const readGenerations = /* @__PURE__ */ new Map();
534
643
  return {
535
644
  list(path) {
536
645
  if (signal.aborted) return;
537
- const generation = (generations.get(path) ?? 0) + 1;
538
- generations.set(path, generation);
646
+ const generation = (listingGenerations.get(path) ?? 0) + 1;
647
+ listingGenerations.set(path, generation);
539
648
  actions.loading(path);
540
649
  void remote.workspaceFiles.list(sessionId, path, signal).then((result) => {
541
- if (signal.aborted || generations.get(path) !== generation) return;
650
+ if (signal.aborted || listingGenerations.get(path) !== generation) return;
542
651
  if (result.ok) {
543
652
  actions.loaded(path, {
544
653
  entries: result.value.entries,
@@ -551,12 +660,12 @@ function filesFace(remote, sessionId, signal, actions) {
551
660
  },
552
661
  read(path) {
553
662
  if (signal.aborted) return;
554
- previewGeneration += 1;
555
- const generation = previewGeneration;
663
+ const generation = (readGenerations.get(path) ?? 0) + 1;
664
+ readGenerations.set(path, generation);
556
665
  const format = previewFormatFor(path);
557
- actions.selecting(path);
666
+ actions.reading(path);
558
667
  const settle = (write) => {
559
- if (signal.aborted || previewGeneration !== generation) return;
668
+ if (signal.aborted || readGenerations.get(path) !== generation) return;
560
669
  write();
561
670
  };
562
671
  if (format.kind === "image") {
@@ -631,7 +740,11 @@ var zh = {
631
740
  "json.expandNode": "\u5C55\u5F00",
632
741
  "json.copyTitle": "{action}",
633
742
  "preview.title": "\u9884\u89C8",
743
+ "preview.tabs": "\u6253\u5F00\u7684\u6587\u4EF6",
744
+ "preview.close": "\u5173\u95ED {name}",
634
745
  "preview.loading": "\u6B63\u5728\u8BFB\u53D6\u2026",
746
+ "preview.copy": "\u590D\u5236",
747
+ "preview.copied": "\u5DF2\u590D\u5236",
635
748
  "preview.reload": "\u91CD\u65B0\u8BFB\u53D6",
636
749
  "preview.truncated": "\u6587\u4EF6\u8F83\u957F\uFF0C\u53EA\u663E\u793A\u524D {lines} \u884C\u3002",
637
750
  "preview.wrap": "\u81EA\u52A8\u6362\u884C",
@@ -676,7 +789,11 @@ var en = {
676
789
  "json.expandNode": "Expand",
677
790
  "json.copyTitle": "{action}",
678
791
  "preview.title": "Preview",
792
+ "preview.tabs": "Open files",
793
+ "preview.close": "Close {name}",
679
794
  "preview.loading": "Reading\u2026",
795
+ "preview.copy": "Copy",
796
+ "preview.copied": "Copied",
680
797
  "preview.reload": "Reload",
681
798
  "preview.truncated": "This file is long, showing the first {lines} lines.",
682
799
  "preview.wrap": "Wrap long lines",
@@ -691,27 +808,36 @@ var en = {
691
808
 
692
809
  // src/client/store.ts
693
810
  var import_dsh_client_store = require("@deepseek-ai/dsh-client-store");
811
+ var RETAINED_PREVIEWS = 5;
694
812
  function emptyState() {
695
813
  return {
696
814
  root: null,
697
815
  levels: {},
698
816
  expanded: [],
699
- selected: null,
700
- preview: { kind: "idle" },
701
- wrap: true,
702
- mode: null
817
+ open: [],
818
+ active: null,
819
+ previews: {},
820
+ retained: [],
821
+ modes: {},
822
+ wraps: {}
703
823
  };
704
824
  }
825
+ function retain(d, path) {
826
+ d.retained = [path, ...d.retained.filter((candidate) => candidate !== path)];
827
+ const keep = /* @__PURE__ */ new Set([...d.active === null ? [] : [d.active], ...d.retained.slice(0, RETAINED_PREVIEWS)]);
828
+ for (const candidate of Object.keys(d.previews)) {
829
+ if (!keep.has(candidate)) delete d.previews[candidate];
830
+ }
831
+ d.retained = d.retained.filter((candidate) => keep.has(candidate));
832
+ }
705
833
  function createFilesStore() {
706
834
  return (0, import_dsh_client_store.defineStore)({
707
835
  init: emptyState,
708
836
  actions: {
709
837
  start: (d, root) => {
838
+ Object.assign(d, emptyState());
710
839
  d.root = root;
711
- d.levels = {};
712
840
  d.expanded = [root];
713
- d.selected = null;
714
- d.preview = { kind: "idle" };
715
841
  },
716
842
  loading: (d, path) => {
717
843
  d.levels[path] = { kind: "loading" };
@@ -730,28 +856,48 @@ function createFilesStore() {
730
856
  reset: (d) => {
731
857
  d.levels = {};
732
858
  },
733
- selecting: (d, path) => {
734
- d.selected = path;
735
- d.preview = { kind: "loading", path };
736
- d.mode = null;
859
+ openFile: (d, path) => {
860
+ if (!d.open.includes(path)) d.open.push(path);
861
+ d.active = path;
862
+ },
863
+ activate: (d, path) => {
864
+ if (!d.open.includes(path)) return;
865
+ d.active = path;
866
+ retain(d, path);
867
+ },
868
+ closeFile: (d, path) => {
869
+ const at = d.open.indexOf(path);
870
+ if (at < 0) return;
871
+ d.open.splice(at, 1);
872
+ delete d.previews[path];
873
+ delete d.modes[path];
874
+ delete d.wraps[path];
875
+ d.retained = d.retained.filter((candidate) => candidate !== path);
876
+ if (d.active !== path) return;
877
+ d.active = d.open[at] ?? d.open[at - 1] ?? null;
878
+ },
879
+ reading: (d, path) => {
880
+ if (!d.open.includes(path)) return;
881
+ d.previews[path] = { kind: "loading" };
737
882
  },
738
883
  previewLoaded: (d, path, content) => {
739
- if (d.selected !== path) return;
740
- d.preview = { kind: "ready", path, content };
884
+ if (!d.open.includes(path)) return;
885
+ d.previews[path] = { kind: "ready", content };
886
+ retain(d, path);
741
887
  },
742
888
  previewFailed: (d, path, failure) => {
743
- if (d.selected !== path) return;
744
- d.preview = { kind: "failed", path, failure };
889
+ if (!d.open.includes(path)) return;
890
+ d.previews[path] = { kind: "failed", failure };
745
891
  },
746
- clearPreview: (d) => {
747
- d.selected = null;
748
- d.preview = { kind: "idle" };
892
+ dropPreview: (d, path) => {
893
+ delete d.previews[path];
894
+ d.retained = d.retained.filter((candidate) => candidate !== path);
749
895
  },
750
- setWrap: (d, wrap) => {
751
- d.wrap = wrap;
896
+ setWrap: (d, path, wrap) => {
897
+ d.wraps[path] = wrap;
752
898
  },
753
- setMode: (d, mode) => {
754
- d.mode = mode;
899
+ setMode: (d, path, mode) => {
900
+ d.modes[path] = mode;
755
901
  }
756
902
  }
757
903
  });
@@ -809,9 +955,26 @@ var CSS = `
809
955
  box-sizing: border-box;
810
956
  height: 38px;
811
957
  padding: 0 6px 0 14px;
958
+ /* The row is filled rather than transparent, so the pane's chrome reads as
959
+ chrome instead of as the first line of the file. The palette's layer tokens
960
+ are all pure white in light mode (bg-base, bg-layer-1/2/3 share a value), so
961
+ the only token that separates a surface from the content in BOTH themes is
962
+ this wash: 4% ink in light, 8% in dark. See the note in CONTRIBUTING.md. */
963
+ background: var(--dsw-alias-bg-skeleton);
812
964
  border-bottom: 0.5px solid var(--dsw-alias-border-l3);
813
965
  }
814
966
 
967
+ /* A grammar badge for the source body, which is where the language used to be
968
+ named: the code block's own banner is hidden in this pane so there is exactly
969
+ one copy control, in this row. */
970
+ .dsh-fe-lang {
971
+ flex: 0 0 auto;
972
+ color: var(--dsw-alias-label-tertiary);
973
+ font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace);
974
+ font-size: 11px;
975
+ white-space: nowrap;
976
+ }
977
+
815
978
  .dsh-fe-path {
816
979
  flex: 1 1 auto;
817
980
  min-width: 0;
@@ -974,7 +1137,15 @@ var CSS = `
974
1137
  margin: 0;
975
1138
  }
976
1139
 
977
- .dsh-fe-source .md-code-block > [data-code-block-banner] > div:first-child:empty {
1140
+ /* One copy control per pane, in the header row above: the code block's own
1141
+ banner would be a second, inside the scrollport, and it scrolls away. The
1142
+ grammar it named is shown as the header's badge instead. Markdown fences keep
1143
+ their banners \u2014 there, a banner belongs to the fence, not to the file.
1144
+
1145
+ The banner sits inside a sticky wrapper, hence the descendant match plus the
1146
+ :has rule that takes the wrapper out with it. */
1147
+ .dsh-fe-source [data-code-block-banner],
1148
+ .dsh-fe-source .md-code-block > div:has(> [data-code-block-banner]) {
978
1149
  display: none;
979
1150
  }
980
1151
 
@@ -999,6 +1170,119 @@ var CSS = `
999
1170
  overflow-wrap: break-word;
1000
1171
  }
1001
1172
 
1173
+ /* The preview pane's header row carries the open tabs and, at its end, the
1174
+ active tab's controls. The tabs take the room that is left and scroll; the
1175
+ controls never shrink away. */
1176
+ .dsh-fe-tabhead {
1177
+ gap: 8px;
1178
+ padding: 0 6px 0 8px;
1179
+ }
1180
+
1181
+ .dsh-fe-tabs {
1182
+ display: flex;
1183
+ flex: 1 1 auto;
1184
+ gap: 2px;
1185
+ align-items: stretch;
1186
+ min-width: 90px;
1187
+ height: 100%;
1188
+ overflow-x: auto;
1189
+ overflow-y: hidden;
1190
+ scrollbar-width: none;
1191
+ }
1192
+
1193
+ .dsh-fe-tabs::-webkit-scrollbar {
1194
+ height: 0;
1195
+ }
1196
+
1197
+ .dsh-fe-tab {
1198
+ display: flex;
1199
+ flex: 0 0 auto;
1200
+ align-items: center;
1201
+ min-width: 0;
1202
+ max-width: 220px;
1203
+ border-radius: 8px;
1204
+ }
1205
+
1206
+ .dsh-fe-tab:hover {
1207
+ background: var(--dsw-alias-interactive-bg-hover);
1208
+ }
1209
+
1210
+ .dsh-fe-tab[data-active] {
1211
+ background: var(--dsw-alias-interactive-bg-active);
1212
+ }
1213
+
1214
+ .dsh-fe-tab-label {
1215
+ display: flex;
1216
+ gap: 6px;
1217
+ align-items: center;
1218
+ min-width: 0;
1219
+ padding: 5px 4px 5px 8px;
1220
+ color: var(--dsw-alias-label-secondary);
1221
+ font: inherit;
1222
+ font-size: 12px;
1223
+ line-height: 1;
1224
+ background: transparent;
1225
+ border: 0;
1226
+ border-radius: 8px;
1227
+ cursor: pointer;
1228
+ }
1229
+
1230
+ .dsh-fe-tab[data-active] .dsh-fe-tab-label {
1231
+ color: var(--dsw-alias-label-primary);
1232
+ }
1233
+
1234
+ .dsh-fe-tab-name {
1235
+ min-width: 0;
1236
+ overflow: hidden;
1237
+ white-space: nowrap;
1238
+ text-overflow: ellipsis;
1239
+ }
1240
+
1241
+ /* The close control appears for the tab under the pointer and for the active
1242
+ tab, so a full strip stays readable. */
1243
+ .dsh-fe-tab-close {
1244
+ display: flex;
1245
+ flex: 0 0 auto;
1246
+ align-items: center;
1247
+ justify-content: center;
1248
+ width: 18px;
1249
+ height: 18px;
1250
+ margin-right: 5px;
1251
+ padding: 0;
1252
+ color: var(--dsw-alias-label-tertiary);
1253
+ font: inherit;
1254
+ font-size: 14px;
1255
+ line-height: 1;
1256
+ background: transparent;
1257
+ border: 0;
1258
+ border-radius: 5px;
1259
+ cursor: pointer;
1260
+ opacity: 0;
1261
+ }
1262
+
1263
+ .dsh-fe-tab:hover .dsh-fe-tab-close,
1264
+ .dsh-fe-tab[data-active] .dsh-fe-tab-close,
1265
+ .dsh-fe-tab-close:focus-visible {
1266
+ opacity: 1;
1267
+ }
1268
+
1269
+ .dsh-fe-tab-close:hover {
1270
+ color: var(--dsw-alias-label-primary);
1271
+ background: var(--dsw-alias-interactive-bg-hover);
1272
+ }
1273
+
1274
+ /* A file already open in a tab keeps a quiet marker in the tree, so the reader
1275
+ can see where the strip came from without it competing with the active row. */
1276
+ .dsh-fe-open-dot {
1277
+ flex: 0 0 auto;
1278
+ width: 5px;
1279
+ height: 5px;
1280
+ margin-left: auto;
1281
+ background: var(--dsw-alias-brand-primary);
1282
+ border-radius: 50%;
1283
+ opacity: 0.6;
1284
+ }
1285
+
1002
1286
  /* Rendered Markdown: prose lays out in normal white space, with the pane's
1003
1287
  insets and no monospace inheritance from the source view. */
1004
1288
  .dsh-fe-prose {