@weniatall/dsh-file-viewer 0.3.14 → 0.3.18

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 (3) hide show
  1. package/README.md +2 -0
  2. package/lib/client.js +255 -86
  3. package/package.json +6 -1
package/README.md CHANGED
@@ -94,6 +94,8 @@ dsh plugin --profile web add <release-tgz-url>
94
94
 
95
95
  桌面端(Windows)抽屉会自动避让窗口右上角的原生标题栏按钮(最小化 / 最大化 / 关闭):宿主在 `<html>` 上标记 `data-windows-titlebar` 并给出 `--dsh-windows-titlebar-height`,抽屉顶部从其下方开始,因此标题栏里的图标不会再压在窗口按钮下面;窗口进入全屏后该条带消失,抽屉相应贴到顶边。
96
96
 
97
+ 这条偏移带 `!important`:抽屉为退出宿主"居中卡片化"而声明的 `inset:0!important` 会展开出同样重要的 `top:0`,普通声明赢不过它——这正是该问题在 v0.3.6 之后复发的原因。现已由测试锁死层叠结果,规范见仓库根目录 `AGENTS.md` 的「桌面版兼容」一节。
98
+
97
99
  ## Git 面板
98
100
 
99
101
  左侧栏顶部有两个页签:**文件**(目录树)与 **Git**(源代码管理)。Git 页签按 VSCode 源代码管理面板的方式组织:仓库 → 分支 → 提交记录 → 该提交的改动文件。
package/lib/client.js CHANGED
@@ -41,17 +41,6 @@ window.__ModuleLoader__.load({
41
41
  const IconRefreshOutline16 = primitives.IconRefreshOutline16 || primitives.IconRefreshOutlineRegular;
42
42
 
43
43
  // 统一风格的矢量线性图标 (16x16, 统一 currentColor 与线条粗细)
44
- function SvgIconWrap({ size = 15 }) {
45
- return react.createElement("svg", {
46
- width: size, height: size, viewBox: "0 0 16 16", fill: "none",
47
- stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round"
48
- },
49
- react.createElement("path", { d: "M2.5 4h11" }),
50
- react.createElement("path", { d: "M2.5 8h7.5a2.5 2.5 0 0 1 2.5 2.5v0a2.5 2.5 0 0 1-2.5 2.5H5" }),
51
- react.createElement("path", { d: "M7 10.5L4.5 13 7 15.5" })
52
- );
53
- }
54
-
55
44
  /** Outline / table-of-contents glyph: short bars stepping in by level. */
56
45
  function SvgIconOutline({ size = 15 }) {
57
46
  return react.createElement("svg", {
@@ -219,9 +208,19 @@ window.__ModuleLoader__.load({
219
208
  // Host ReadBlock/JsonTree now require localized copy for banners, copy
220
209
  // buttons and expand/collapse. Missing these throws during render:
221
210
  // "Cannot read properties of undefined (reading 'copy'|'window')".
211
+ // The host's ReadBlock banner renders a wrap toggle beside the copy button
212
+ // and names it from `labels.wrapLabel` / `labels.unwrapLabel`. Those two
213
+ // keys are easy to miss, and a blanket "复制" fallback here made the wrap
214
+ // toggle announce itself as "复制" — the wrong control with the wrong verb.
215
+ // Every key the banner needs is therefore named explicitly, and an unknown
216
+ // key now resolves to an empty string instead of borrowing another
217
+ // control's label.
222
218
  const READ_BLOCK_LABELS = new Proxy({
223
219
  copy: "复制",
224
220
  copied: "已复制",
221
+ copyFailed: "复制失败",
222
+ wrapLabel: "自动换行",
223
+ unwrapLabel: "取消自动换行",
225
224
  window: (shown, total) => `第 ${shown} / ${total} 行`,
226
225
  collapse: "收起",
227
226
  expand: (hidden) => `展开其余 ${hidden} 行`,
@@ -233,7 +232,7 @@ window.__ModuleLoader__.load({
233
232
  if (typeof prop === "string" && (prop === "window" || prop === "expand" || prop.endsWith("Aria"))) {
234
233
  return (...args) => String(args[0] ?? "");
235
234
  }
236
- return "复制";
235
+ return "";
237
236
  }
238
237
  });
239
238
  const JSON_TREE_LABELS = new Proxy({
@@ -261,6 +260,9 @@ window.__ModuleLoader__.load({
261
260
  const DIFF_BLOCK_LABELS = new Proxy({
262
261
  copy: "复制",
263
262
  copied: "已复制",
263
+ copyFailed: "复制失败",
264
+ wrapLabel: "自动换行",
265
+ unwrapLabel: "取消自动换行",
264
266
  files: (count) => `${count} 个文件`,
265
267
  expand: (hidden) => `展开其余 ${hidden} 行`,
266
268
  collapse: "收起",
@@ -272,7 +274,7 @@ window.__ModuleLoader__.load({
272
274
  if (typeof prop === "string" && (prop === "files" || prop === "expand" || prop.endsWith("Aria"))) {
273
275
  return (...args) => String(args[0] ?? "");
274
276
  }
275
- return "复制";
277
+ return "";
276
278
  }
277
279
  });
278
280
 
@@ -367,6 +369,15 @@ window.__ModuleLoader__.load({
367
369
  const DEFAULT_GIT_CHANGES_HEIGHT = 180;
368
370
  const MIN_GIT_CHANGES_HEIGHT = 64;
369
371
  const MAX_GIT_CHANGES_HEIGHT = 800;
372
+ // Reading size for the preview. A file's own text is what the reader spends
373
+ // their time on, so it gets its own, remembered size instead of inheriting
374
+ // the chat's. Bounded so the control cannot produce an unreadable pane in
375
+ // either direction, and stored per browser like every other view pref.
376
+ const FONT_SIZE_KEY = "dsh-file-viewer.font-size";
377
+ const DEFAULT_FONT_SIZE = 13;
378
+ const MIN_FONT_SIZE = 11;
379
+ const MAX_FONT_SIZE = 20;
380
+ const FONT_SIZE_STEP = 1;
370
381
  /**
371
382
  * Smallest width the drawer may be dragged to.
372
383
  *
@@ -432,8 +443,22 @@ window.__ModuleLoader__.load({
432
443
  header controls could never be clicked. Start the drawer below the
433
444
  strip instead, which also leaves the strip free to drag the window.
434
445
  The host sets data-fullscreen while the window is fullscreen and the
435
- strip is hidden, so the offset has to drop then. */
436
- html[data-windows-titlebar]:not([data-fullscreen]) .fv-scrim{top:var(--dsh-windows-titlebar-height,40px)}
446
+ strip is hidden, so the offset has to drop then.
447
+
448
+ The declaration carries !important on purpose. It is not defensive
449
+ padding: the drawer's own opt-out above neutralises the host's
450
+ modal-card treatment with an important inset, and the longhand top that
451
+ shorthand expands to outranks any normal declaration — including this
452
+ one. Without !important the strip wins and the drawer slides back under
453
+ the caption buttons, which is exactly the regression this rule was
454
+ written for (fixed in 0.2.8, broken again once 0.3.6 added the
455
+ important inset, because the guard test only checked that the rule
456
+ exists rather than that it wins the cascade).
457
+
458
+ The role="dialog" qualifier keeps this at least as specific as the inset
459
+ rule it has to beat, so the outcome is decided by the important flag and
460
+ the attribute count rather than by sheet order. */
461
+ html[data-windows-titlebar]:not([data-fullscreen]) .fv-scrim[role="dialog"]{top:var(--dsh-windows-titlebar-height,40px)!important}
437
462
  @keyframes fv-fade{from{opacity:0}to{opacity:1}}
438
463
  @keyframes fv-slide{from{transform:translateX(100%)}to{transform:translateX(0)}}
439
464
  @media(prefers-reduced-motion:reduce){.fv-scrim,.fv-shell{animation:none}}
@@ -568,6 +593,24 @@ window.__ModuleLoader__.load({
568
593
  .fv-sheet-tabs-end{display:inline-flex;align-items:center;gap:8px;margin-left:auto;flex:none}
569
594
  .fv-sheet-tabs .fv-btn-back{width:26px;height:26px;font-size:16px;line-height:24px;border-radius:6px;margin-right:2px}
570
595
  .fv-main-size{font-size:11px;color:var(--dsw-alias-label-caption);white-space:nowrap}
596
+ /* Reading-size stepper. Deliberately a compact group: the range is 11–20px,
597
+ so a slider would be all friction and no precision. */
598
+ .fv-font-size{display:inline-flex;align-items:center;gap:0;height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-alias-bg-layer-1,var(--dsw-alias-bg-base,#fff));overflow:hidden;flex:none}
599
+ .fv-font-size-btn{display:inline-flex;align-items:center;justify-content:center;height:100%;min-width:30px;padding:0 8px;border:none;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;font-weight:600;line-height:1;cursor:pointer;transition:background-color .12s,color .12s}
600
+ .fv-font-size-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
601
+ .fv-font-size-btn:disabled{opacity:.38;cursor:default}
602
+ .fv-font-size-value{min-width:38px;padding:0 2px;text-align:center;color:var(--dsw-alias-label-caption);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap}
603
+ /* The size reaches the preview through one custom property, so every
604
+ viewer that renders text picks it up without repeating the value.
605
+ The host's code card declares the font shorthand, which resets both size
606
+ and line height; the structural selectors below carry one element plus
607
+ one attribute, so they outrank that single class and win regardless of
608
+ sheet order. Line height is re-declared beside the size, otherwise the
609
+ host's fixed value would make larger text overlap. */
610
+ .fv-content,.fv-content-wrap{font-size:var(--fv-font-size,13px)}
611
+ .fv-content div[class*="_card_"],.fv-content div[class*="_body_"]{font-size:var(--fv-font-size,13px);line-height:1.65}
612
+ .fv-content div[class*="_line_"]{font-size:var(--fv-font-size,13px);line-height:1.65}
613
+ .fv-content div[class*="_line_"] span[class*="_content_"]{font-size:inherit;line-height:inherit}
571
614
  .fv-content .fv-sheet-tabs{position:sticky;top:0;margin:-12px -12px 10px;padding:12px 12px 8px}
572
615
  .fv-tab{height:28px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:none;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}
573
616
  .fv-tab[aria-selected="true"]{border-color:transparent;background:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff)}
@@ -591,7 +634,7 @@ window.__ModuleLoader__.load({
591
634
  .fv-diff-view{display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-base,#fff);overflow:hidden;font-family:var(--dsw-font-code,monospace);margin:4px 0}
592
635
  .fv-diff-toolbar{display:flex;align-items:center;padding:8px 12px;background:var(--dsw-alias-bg-layer-1,rgba(0,0,0,.02));border-bottom:1px solid var(--dsw-alias-border-l1);font-family:var(--dsw-font-family);font-size:12px}
593
636
  .fv-diff-source-tag{font-size:11px;font-weight:600;padding:2px 8px;border-radius:6px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 12%,transparent);color:var(--dsw-alias-brand-primary,#4d6bfe)}
594
- .fv-diff-table{display:flex;flex-direction:column;overflow-x:auto;min-width:0;font-size:12px;line-height:20px;font-family:var(--dsw-font-code,monospace)}
637
+ .fv-diff-table{display:flex;flex-direction:column;overflow-x:auto;min-width:0;font-size:var(--fv-font-size,12px);line-height:1.65;font-family:var(--dsw-font-code,monospace)}
595
638
  .fv-diff-line{display:flex;min-width:max-content;align-items:stretch;transition:background-color .08s}
596
639
  .fv-diff-num{flex:none;width:44px;padding:0 6px;text-align:right;user-select:none;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-caption);opacity:.65;font-size:11px}
597
640
  .fv-diff-num-new{border-right:1px solid var(--dsw-alias-border-l1)}
@@ -724,6 +767,10 @@ window.__ModuleLoader__.load({
724
767
  .fv-content[data-wrap="on"] div[class*="_line_"]{align-items:flex-start}
725
768
  .fv-content[data-wrap="on"] span[class*="_gutter_"]{flex:none;white-space:pre}
726
769
  .fv-content[data-wrap="on"] span[class*="_content_"]{min-width:0;flex:1 1 auto;white-space:pre-wrap;overflow-wrap:anywhere}
770
+ /* Tooltip 气泡:浅色模式下防止宿主静态白色文字在浅色背景上隐形 */
771
+ span[class*="_bubble_"],
772
+ [data-portal] span[class*="_bubble_"]{color:var(--dsw-alias-label-primary,#0f1115)!important;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.08))!important;box-shadow:var(--dsw-shadow-lv2,0 2px 8px rgba(0,0,0,.12))!important}
773
+ span[class*="_bubble_"] span[class*="_label_"]{color:inherit!important}
727
774
  .fv-context-backdrop{position:fixed;inset:0;z-index:99;background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;touch-action:none}
728
775
  /* 菜单是实底卡片,不吃主题的毛玻璃。
729
776
  玻璃主题(glass.css)里有一条规则,给 [role="menu"] 套上
@@ -1213,6 +1260,42 @@ window.__ModuleLoader__.load({
1213
1260
  } catch { /* storage is unavailable */ }
1214
1261
  }
1215
1262
 
1263
+ /**
1264
+ * The preview's reading size, clamped on read.
1265
+ *
1266
+ * Storage survives upgrades and can be hand-edited, so an out-of-range
1267
+ * value falls back to the default rather than producing a pane nobody can
1268
+ * read (or one so large the code never fits).
1269
+ * @returns {number} a font size in px.
1270
+ */
1271
+ function readFontSize() {
1272
+ try {
1273
+ const raw = globalThis.localStorage?.getItem(FONT_SIZE_KEY);
1274
+ const parsed = Number(raw);
1275
+ if (Number.isFinite(parsed) && parsed >= MIN_FONT_SIZE && parsed <= MAX_FONT_SIZE) {
1276
+ return Math.round(parsed);
1277
+ }
1278
+ } catch { /* storage is unavailable */ }
1279
+ return DEFAULT_FONT_SIZE;
1280
+ }
1281
+
1282
+ function writeFontSize(size) {
1283
+ try {
1284
+ if (typeof size === "number" && Number.isFinite(size)) {
1285
+ globalThis.localStorage?.setItem(FONT_SIZE_KEY, String(Math.round(size)));
1286
+ } else {
1287
+ globalThis.localStorage?.removeItem(FONT_SIZE_KEY);
1288
+ }
1289
+ } catch { /* storage is unavailable */ }
1290
+ }
1291
+
1292
+ /** Clamp a requested size onto the supported range. */
1293
+ function clampFontSize(size) {
1294
+ const value = Number(size);
1295
+ if (!Number.isFinite(value)) return DEFAULT_FONT_SIZE;
1296
+ return Math.round(Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, value)));
1297
+ }
1298
+
1216
1299
  /**
1217
1300
  * Read the per-file language overrides.
1218
1301
  *
@@ -2165,6 +2248,10 @@ window.__ModuleLoader__.load({
2165
2248
  total: 0,
2166
2249
  hasMore: false,
2167
2250
  });
2251
+ // The working tree's pending changes belong on screen immediately;
2252
+ // load them alongside the first page of history so the section does
2253
+ // not stay on "loading" waiting for an explicit click.
2254
+ loadChanges();
2168
2255
  // Returned so a caller that awaits loadRepos also observes the first
2169
2256
  // page of history, which is what makes the panel testable end to end.
2170
2257
  return loadCommits(store.get().branch, 0);
@@ -2211,6 +2298,7 @@ window.__ModuleLoader__.load({
2211
2298
  detailStatus: "idle",
2212
2299
  notice: "",
2213
2300
  });
2301
+ loadChanges();
2214
2302
  return loadCommits(store.get().branch, 0);
2215
2303
  };
2216
2304
 
@@ -3759,6 +3847,52 @@ window.__ModuleLoader__.load({
3759
3847
  : null);
3760
3848
  }
3761
3849
 
3850
+ /**
3851
+ * Reading-size stepper for the preview pane.
3852
+ *
3853
+ * A file's own text is what the reader spends their time on, so its size is
3854
+ * adjustable on its own and remembered per browser. The control is a
3855
+ * minus/value/plus group rather than a slider: the useful range is small and
3856
+ * discrete, and a slider at this width would be impossible to hit.
3857
+ *
3858
+ * @param {{value: number, onChange: Function, disabled?: boolean}} props
3859
+ */
3860
+ function FontSizeControl({ value, onChange, disabled }) {
3861
+ const size = clampFontSize(value);
3862
+ const atMin = size <= MIN_FONT_SIZE;
3863
+ const atMax = size >= MAX_FONT_SIZE;
3864
+ const step = (delta) => {
3865
+ const next = clampFontSize(size + delta);
3866
+ if (next !== size) onChange(next);
3867
+ };
3868
+ return react.createElement("div", {
3869
+ className: "fv-font-size",
3870
+ role: "group",
3871
+ "aria-label": "文件内字号",
3872
+ },
3873
+ react.createElement("button", {
3874
+ type: "button",
3875
+ className: "fv-font-size-btn",
3876
+ "aria-label": "减小字号",
3877
+ title: "减小字号",
3878
+ disabled: disabled === true || atMin,
3879
+ onClick: (e) => { e.stopPropagation(); step(-FONT_SIZE_STEP); },
3880
+ }, "A−"),
3881
+ react.createElement("span", {
3882
+ className: "fv-font-size-value",
3883
+ title: "当前字号",
3884
+ "aria-live": "polite",
3885
+ }, size + "px"),
3886
+ react.createElement("button", {
3887
+ type: "button",
3888
+ className: "fv-font-size-btn",
3889
+ "aria-label": "增大字号",
3890
+ title: "增大字号",
3891
+ disabled: disabled === true || atMax,
3892
+ onClick: (e) => { e.stopPropagation(); step(FONT_SIZE_STEP); },
3893
+ }, "A+"));
3894
+ }
3895
+
3762
3896
  function apply(ctx) {
3763
3897
  ensureStyles(typeof document === "undefined" ? null : document);
3764
3898
  // `null` is closed; an object is open. Carrying the requesting session lets
@@ -4254,7 +4388,7 @@ window.__ModuleLoader__.load({
4254
4388
 
4255
4389
  /** Line-numbered diff viewer with dual line number gutters. */
4256
4390
  /** Text, Markdown and JSON: one windowed read, three renderings. */
4257
- function TextView({ meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast }) {
4391
+ function TextView({ meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast, fontSize, onFontSizeChange }) {
4258
4392
  // Callers mount this with a per-file key, so paging state starts fresh
4259
4393
  // for every file instead of leaking the previous file's window into the
4260
4394
  // first request.
@@ -4544,17 +4678,23 @@ window.__ModuleLoader__.load({
4544
4678
  ? react.createElement(PathCopyDropdown, { meta, root, onToast })
4545
4679
  : null;
4546
4680
 
4547
- const tabs = showTabs || Boolean(backControl || langControl || sizeControl || copyControl)
4681
+ // Reading size belongs with the content it resizes, not in the drawer
4682
+ // header: it applies to this pane, and the stepper is next to the text.
4683
+ const fontControl = react.createElement(FontSizeControl, {
4684
+ value: fontSize,
4685
+ onChange: onFontSizeChange,
4686
+ });
4687
+
4688
+ const tabs = showTabs || Boolean(backControl || langControl || sizeControl || copyControl || fontControl)
4548
4689
  ? react.createElement("div", { className: "fv-sheet-tabs", role: "tablist" },
4549
4690
  react.createElement("div", { className: "fv-sheet-tabs-start" },
4550
4691
  backControl,
4551
4692
  ...tabButtons,
4552
4693
  copyControl),
4553
- (sizeControl || langControl)
4554
- ? react.createElement("div", { className: "fv-sheet-tabs-end" },
4555
- sizeControl,
4556
- langControl)
4557
- : null)
4694
+ react.createElement("div", { className: "fv-sheet-tabs-end" },
4695
+ fontControl,
4696
+ sizeControl,
4697
+ langControl))
4558
4698
  : null;
4559
4699
 
4560
4700
 
@@ -4580,7 +4720,13 @@ window.__ModuleLoader__.load({
4580
4720
 
4581
4721
  return react.createElement(react.Fragment, null,
4582
4722
  tabs,
4583
- react.createElement("div", { className: "fv-content-wrap" },
4723
+ react.createElement("div", {
4724
+ className: "fv-content-wrap",
4725
+ // The size reaches the rendered text through one inherited custom
4726
+ // property, so Markdown, JSON and plain source all follow it without
4727
+ // each view having to know about the setting.
4728
+ style: { "--fv-font-size": fontSize + "px" },
4729
+ },
4584
4730
  react.createElement("div", {
4585
4731
  className: "fv-content",
4586
4732
  "data-wrap": wrapping ? "on" : "off",
@@ -4621,10 +4767,18 @@ window.__ModuleLoader__.load({
4621
4767
  }
4622
4768
 
4623
4769
  /** Pick the viewer for a file and keep unsupported formats useful. */
4624
- function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo, loading, backControl, sizeControl, langControl, onToast }) {
4770
+ function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo, loading, backControl, sizeControl, langControl, onToast, fontSize, onFontSizeChange }) {
4625
4771
  const copyControl = meta !== null
4626
4772
  ? react.createElement(PathCopyDropdown, { meta, root, onToast })
4627
4773
  : null;
4774
+ // Only views that render text have a size to adjust; an image or a PDF
4775
+ // has its own zoom, and a sheet's cells are not prose.
4776
+ const textish = meta !== null
4777
+ && !meta.tooLarge
4778
+ && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json");
4779
+ const fontControl = textish
4780
+ ? react.createElement(FontSizeControl, { value: fontSize, onChange: onFontSizeChange })
4781
+ : null;
4628
4782
 
4629
4783
  if (meta === null) {
4630
4784
  // A tab exists but its metadata is still in flight. Saying "pick a
@@ -4672,10 +4826,10 @@ window.__ModuleLoader__.load({
4672
4826
  react.createElement("span", { className: "fv-history-hint" }, "这是历史版本,不是工作区当前内容。"))
4673
4827
  : null;
4674
4828
 
4675
- const headBar = (backControl || sizeControl || copyControl)
4829
+ const headBar = (backControl || sizeControl || copyControl || fontControl)
4676
4830
  ? react.createElement("div", { className: "fv-sheet-tabs", role: "toolbar" },
4677
4831
  react.createElement("div", { className: "fv-sheet-tabs-start" }, backControl, copyControl),
4678
- sizeControl ? react.createElement("div", { className: "fv-sheet-tabs-end" }, sizeControl) : null)
4832
+ react.createElement("div", { className: "fv-sheet-tabs-end" }, fontControl, sizeControl))
4679
4833
  : null;
4680
4834
 
4681
4835
  if (fromHistory && meta.kind !== "text" && meta.kind !== "markdown" && meta.kind !== "json") {
@@ -4692,7 +4846,7 @@ window.__ModuleLoader__.load({
4692
4846
  return react.createElement("div", { className: "fv-content" }, headBar, react.createElement(DocView, { key, meta, root }));
4693
4847
  }
4694
4848
  return react.createElement(react.Fragment, null, banner,
4695
- react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast }));
4849
+ react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast, fontSize, onFontSizeChange }));
4696
4850
  }
4697
4851
 
4698
4852
 
@@ -5133,9 +5287,22 @@ window.__ModuleLoader__.load({
5133
5287
  setShellWidth(clamped);
5134
5288
  writeShellWidth(clamped);
5135
5289
  };
5136
- // Soft wrap for source views. Off by default: code is written with
5137
- // meaningful line breaks and wrapping obscures them.
5138
- const [wrap, setWrap] = react.useState(false);
5290
+ // Soft wrap is owned entirely by the host ReadBlock banner, which
5291
+ // renders its own toggle beside the copy button and reports the state
5292
+ // through `labels.wrapLabel` / `labels.unwrapLabel`. The viewer used to
5293
+ // keep a second, parallel switch in its header; two controls for one
5294
+ // flag could disagree, so the header's is gone and this flag is no
5295
+ // longer state — there is nothing left to toggle from here.
5296
+ const wrap = false;
5297
+ // Reading size for the preview, remembered per browser. Kept here rather
5298
+ // than inside TextView because the stepper sits in the pane's toolbar
5299
+ // while the text it sizes lives one level down.
5300
+ const [fontSize, setFontSize] = react.useState(readFontSize);
5301
+ const changeFontSize = (next) => {
5302
+ const clamped = clampFontSize(next);
5303
+ setFontSize(clamped);
5304
+ writeFontSize(clamped);
5305
+ };
5139
5306
  // The language override now lives on the tab it applies to (see
5140
5307
  // `localeTagOfActive`), so switching tabs switches the pin with it.
5141
5308
  const [fullscreen, setFullscreen] = react.useState(false);
@@ -5250,10 +5417,9 @@ window.__ModuleLoader__.load({
5250
5417
  // tab, and discovery spawns a git process per candidate repository.
5251
5418
  react.useEffect(() => {
5252
5419
  if (!open || side !== "git" || root === "") return;
5253
- git.ensure();
5254
- // The working-tree list is part of the panel's first paint, not a
5255
- // second click: "what is uncommitted" is the first thing to look at.
5256
- git.loadChanges();
5420
+ git.ensure().then(() => {
5421
+ git.loadChanges();
5422
+ }).catch(() => {});
5257
5423
  }, [open, side, root]);
5258
5424
  const [toast, setToast] = react.useState(null);
5259
5425
  const toastTimer = react.useRef(null);
@@ -5504,18 +5670,16 @@ window.__ModuleLoader__.load({
5504
5670
  treeGeneration.current += 1;
5505
5671
  setNodes(new Map());
5506
5672
  if (rootChanged) {
5507
- // Tabs describe paths inside the root that opened them; carrying
5508
- // one across a root switch would point it at a different file with
5509
- // the same name, so the whole set is dropped with the old tree.
5510
- // (The open path already cleared them; this covers a root switched
5511
- // from the picker while the drawer stays open.)
5512
- tabsRef.current = null;
5513
- setTabs(null);
5514
- activeTabIdRef.current = "";
5515
- setActiveTabId("");
5516
- setTabMenu(null);
5673
+ // Only the tree is rebuilt here. Tabs are deliberately NOT touched:
5674
+ // this effect re-runs on the very commit that resolved `root`, i.e.
5675
+ // *after* the roots effect above has already cleared the previous
5676
+ // visit's tabs and opened the one the arrival path asked for.
5677
+ // Clearing them here wiped that brand-new tab, so the first click on
5678
+ // a file in a conversation (a path or a changes card) opened an empty
5679
+ // pane and only the second click — now that `treeBuiltForRef` and
5680
+ // `visitContextRef` both already name this root — loaded anything.
5681
+ // A root switched from the picker resets tabs in `chooseRoot`.
5517
5682
  setExpanded(new Set());
5518
- setPane("tree");
5519
5683
  }
5520
5684
  const chain = revealChanged && reveal !== "" ? [...ancestorsOf(reveal), reveal] : [];
5521
5685
  if (chain.length > 0) {
@@ -5668,6 +5832,35 @@ window.__ModuleLoader__.load({
5668
5832
  }
5669
5833
  };
5670
5834
 
5835
+ /**
5836
+ * Switch the root from the header picker.
5837
+ *
5838
+ * A root switch is the one thing that invalidates every open tab: their
5839
+ * ids and paths only mean something inside the root that opened them, so
5840
+ * carrying them over would point at a different file with the same name.
5841
+ * The reset lives here, on the reader's own action, rather than in the
5842
+ * tree-building effect — that effect also re-runs on the commit that
5843
+ * resolves `root` for an arrival, and clearing there wiped the tab the
5844
+ * arrival had just opened (first click on a file did nothing, second one
5845
+ * worked because the guards had caught up by then).
5846
+ */
5847
+ const chooseRoot = (next) => {
5848
+ const resolved = typeof next === "string" ? next : "";
5849
+ if (resolved === "" || resolved === root) return;
5850
+ tabsRef.current = null;
5851
+ setTabs(null);
5852
+ activeTabIdRef.current = "";
5853
+ setActiveTabId("");
5854
+ setTabMenu(null);
5855
+ setPane("tree");
5856
+ setExpanded(new Set());
5857
+ // The arrival context belongs to the root that is being left; naming it
5858
+ // here keeps the next roots response from treating the switch as an
5859
+ // arrival that has to be reset all over again.
5860
+ visitContextRef.current = resolved;
5861
+ setRoot(resolved);
5862
+ };
5863
+
5671
5864
  /** Patch one tab by id. Used by asynchronous reads that outlive a switch. */
5672
5865
  const patchTab = (id, mutator) => {
5673
5866
  const current = tabList();
@@ -5773,30 +5966,12 @@ window.__ModuleLoader__.load({
5773
5966
  }).catch(() => {});
5774
5967
  };
5775
5968
 
5776
- /**
5777
- * The top toolbar's refresh: the tree, the Git panel, and the tab on
5778
- * screen.
5779
- *
5780
- * The tab is refreshed too because that is what the reader is looking
5781
- * at — refreshing the tree around a stale preview would leave the file
5782
- * they care about unchanged.
5783
- */
5784
- const refresh = () => {
5785
- reloadWorkingTree();
5786
- if (side === "git") {
5787
- const state = git.store.get();
5788
- // The commit to come back to belongs to the tab on screen, not to
5789
- // whichever detail the controller happens to hold.
5790
- const keepSha = activeTab !== null && activeTab.kind === "commit"
5791
- ? activeTab.id.slice("commit:".length)
5792
- : "";
5793
- if (state.root !== root) git.reset(root);
5794
- git.loadRepos().then(() => {
5795
- if (keepSha !== "") git.openCommit(keepSha);
5796
- });
5797
- }
5798
- if (activeTabId !== "") refreshTab(activeTabId);
5799
- };
5969
+ // Refreshing is offered per subject now: the tab's own context menu
5970
+ // re-reads the file/commit on screen, the Git panel has its own
5971
+ // "refresh working tree" control, and the sidebar re-lists a directory
5972
+ // when it is reopened. The old header button duplicated all three at
5973
+ // once, which made a single click re-read things the reader was not
5974
+ // looking at.
5800
5975
 
5801
5976
  /** Open one commit in its own tab, or activate the tab already showing it. */
5802
5977
  const openCommit = (commit) => {
@@ -6198,25 +6373,17 @@ window.__ModuleLoader__.load({
6198
6373
  className: "fv-root-select",
6199
6374
  value: root,
6200
6375
  "aria-label": "工作区与安全路径",
6201
- onChange: (event) => setRoot(event.target.value),
6376
+ onChange: (event) => chooseRoot(event.target.value),
6202
6377
  }, ...renderRootOptions(roots))
6203
6378
  : react.createElement("span", { className: "fv-crumbs" }, activeRoot ? activeRoot.label : ""),
6204
6379
  // The tree shows structure, so the header carries the selected
6205
6380
  // file's path instead of navigable breadcrumbs.
6206
6381
  react.createElement("div", { className: "fv-crumbs fv-file-path", title: showCommit ? "" : (meta === null ? "" : meta.path) },
6207
6382
  showCommit ? "" : (meta === null ? "" : meta.path)),
6208
- // Only offered where it changes anything: images, PDFs, sheets and
6209
- // documents do not scroll sideways the way source lines do.
6210
- !showCommit && meta !== null && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
6211
- ? react.createElement("button", {
6212
- type: "button",
6213
- className: "fv-icon-button",
6214
- "aria-pressed": wrap ? "true" : "false",
6215
- "aria-label": wrap ? "取消自动换行" : "自动换行",
6216
- title: wrap ? "取消自动换行" : "自动换行",
6217
- onClick: () => setWrap((current) => !current),
6218
- }, react.createElement(SvgIconWrap, { size: 15 }))
6219
- : null,
6383
+ // Soft wrap is offered by the code banner itself (next to its copy
6384
+ // button), so the header no longer carries a second toggle for the
6385
+ // same setting — two switches for one flag only invited them to
6386
+ // drift apart. Refresh lives in the tab's context menu likewise.
6220
6387
  !showCommit && meta !== null && meta.kind !== "directory"
6221
6388
  ? react.createElement("button", {
6222
6389
  type: "button",
@@ -6226,9 +6393,6 @@ window.__ModuleLoader__.load({
6226
6393
  onClick: () => handleDownloadItem(meta),
6227
6394
  }, react.createElement(SvgIconDownload, { size: 15 }))
6228
6395
  : null,
6229
- react.createElement("button", {
6230
- type: "button", className: "fv-icon-button", "aria-label": "刷新", title: "刷新", onClick: refresh,
6231
- }, react.createElement(SvgIconRefresh, { size: 15 })),
6232
6396
  react.createElement("button", {
6233
6397
  type: "button",
6234
6398
  className: "fv-icon-button",
@@ -6412,6 +6576,8 @@ window.__ModuleLoader__.load({
6412
6576
  sizeControl,
6413
6577
  langControl,
6414
6578
  onToast: showToast,
6579
+ fontSize,
6580
+ onFontSizeChange: changeFontSize,
6415
6581
  })
6416
6582
  : react.createElement(FileView, {
6417
6583
  meta,
@@ -6431,6 +6597,8 @@ window.__ModuleLoader__.load({
6431
6597
  sizeControl,
6432
6598
  langControl,
6433
6599
  onToast: showToast,
6600
+ fontSize,
6601
+ onFontSizeChange: changeFontSize,
6434
6602
  })))),
6435
6603
  contextMenu !== null
6436
6604
  ? react.createElement(ContextMenu, {
@@ -6770,6 +6938,7 @@ window.__ModuleLoader__.load({
6770
6938
  MARKDOWN_LABELS, MARKDOWN_CODE_LABELS, READ_BLOCK_LABELS, JSON_TREE_LABELS, DIFF_BLOCK_LABELS, ErrorBoundary,
6771
6939
  openViewerForPath, wrapWorkspaceOpenPath, wrapConnectionRpc, wrapChangesOpenFetch, wrapSidebarRight, parseFileResource, setupGlobalFileClickInterceptor, extractClickedPath, normalizeClickedPath, looksLikeFilePath, isFileOpenControl, isFileMentionCode, renderRootOptions,
6772
6940
  triggerDownload, performDownload, isDesktopShell, SvgIconDownload, SvgIconCopy, PathCopyDropdown, resolveAbsPath, extractRelativeAndAbsolutePaths, isAbsolutePath, SvgIconBranch, SvgIconCommit,
6941
+ FONT_SIZE_KEY, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, FONT_SIZE_STEP, readFontSize, writeFontSize, clampFontSize, FontSizeControl,
6773
6942
  // Git panel: controller, views and the small formatters they share.
6774
6943
  GIT_PAGE_SIZE, createGitController, GitSidebar, CommitDetail, WorkspaceChangeDetail, DiffView, kindOfPath,
6775
6944
  formatCommitDate, formatCommitTimestamp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weniatall/dsh-file-viewer",
3
- "version": "0.3.14",
3
+ "version": "0.3.18",
4
4
  "description": "Workspace file viewer and Git panel for DeepSeek Harness: file tree, syntax-highlighted code, images, PDF, Excel and Word, plus local/remote branches, paged commit history, commit diffs, working-tree changes and changed-file navigation",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -70,6 +70,11 @@
70
70
  "engines": {
71
71
  "node": ">=20.3"
72
72
  },
73
+ "devDependencies": {
74
+ "jsdom": "29.1.1",
75
+ "react": "18.3.1",
76
+ "react-dom": "18.3.1"
77
+ },
73
78
  "scripts": {
74
79
  "test": "node --test test/*.test.mjs && node ../../scripts/check-host-settings.mjs plugins/dsh-file-viewer file-viewer"
75
80
  }