@weniatall/dsh-file-viewer 0.3.13 → 0.3.17

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 (2) hide show
  1. package/lib/client.js +240 -79
  2. package/package.json +1 -1
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
  *
@@ -568,6 +579,24 @@ window.__ModuleLoader__.load({
568
579
  .fv-sheet-tabs-end{display:inline-flex;align-items:center;gap:8px;margin-left:auto;flex:none}
569
580
  .fv-sheet-tabs .fv-btn-back{width:26px;height:26px;font-size:16px;line-height:24px;border-radius:6px;margin-right:2px}
570
581
  .fv-main-size{font-size:11px;color:var(--dsw-alias-label-caption);white-space:nowrap}
582
+ /* Reading-size stepper. Deliberately a compact group: the range is 11–20px,
583
+ so a slider would be all friction and no precision. */
584
+ .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}
585
+ .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}
586
+ .fv-font-size-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
587
+ .fv-font-size-btn:disabled{opacity:.38;cursor:default}
588
+ .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}
589
+ /* The size reaches the preview through one custom property, so every
590
+ viewer that renders text picks it up without repeating the value.
591
+ The host's code card declares the font shorthand, which resets both size
592
+ and line height; the structural selectors below carry one element plus
593
+ one attribute, so they outrank that single class and win regardless of
594
+ sheet order. Line height is re-declared beside the size, otherwise the
595
+ host's fixed value would make larger text overlap. */
596
+ .fv-content,.fv-content-wrap{font-size:var(--fv-font-size,13px)}
597
+ .fv-content div[class*="_card_"],.fv-content div[class*="_body_"]{font-size:var(--fv-font-size,13px);line-height:1.65}
598
+ .fv-content div[class*="_line_"]{font-size:var(--fv-font-size,13px);line-height:1.65}
599
+ .fv-content div[class*="_line_"] span[class*="_content_"]{font-size:inherit;line-height:inherit}
571
600
  .fv-content .fv-sheet-tabs{position:sticky;top:0;margin:-12px -12px 10px;padding:12px 12px 8px}
572
601
  .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
602
  .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 +620,7 @@ window.__ModuleLoader__.load({
591
620
  .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
621
  .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
622
  .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)}
623
+ .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
624
  .fv-diff-line{display:flex;min-width:max-content;align-items:stretch;transition:background-color .08s}
596
625
  .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
626
  .fv-diff-num-new{border-right:1px solid var(--dsw-alias-border-l1)}
@@ -724,6 +753,10 @@ window.__ModuleLoader__.load({
724
753
  .fv-content[data-wrap="on"] div[class*="_line_"]{align-items:flex-start}
725
754
  .fv-content[data-wrap="on"] span[class*="_gutter_"]{flex:none;white-space:pre}
726
755
  .fv-content[data-wrap="on"] span[class*="_content_"]{min-width:0;flex:1 1 auto;white-space:pre-wrap;overflow-wrap:anywhere}
756
+ /* Tooltip 气泡:浅色模式下防止宿主静态白色文字在浅色背景上隐形 */
757
+ span[class*="_bubble_"],
758
+ [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}
759
+ span[class*="_bubble_"] span[class*="_label_"]{color:inherit!important}
727
760
  .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
761
  /* 菜单是实底卡片,不吃主题的毛玻璃。
729
762
  玻璃主题(glass.css)里有一条规则,给 [role="menu"] 套上
@@ -1213,6 +1246,42 @@ window.__ModuleLoader__.load({
1213
1246
  } catch { /* storage is unavailable */ }
1214
1247
  }
1215
1248
 
1249
+ /**
1250
+ * The preview's reading size, clamped on read.
1251
+ *
1252
+ * Storage survives upgrades and can be hand-edited, so an out-of-range
1253
+ * value falls back to the default rather than producing a pane nobody can
1254
+ * read (or one so large the code never fits).
1255
+ * @returns {number} a font size in px.
1256
+ */
1257
+ function readFontSize() {
1258
+ try {
1259
+ const raw = globalThis.localStorage?.getItem(FONT_SIZE_KEY);
1260
+ const parsed = Number(raw);
1261
+ if (Number.isFinite(parsed) && parsed >= MIN_FONT_SIZE && parsed <= MAX_FONT_SIZE) {
1262
+ return Math.round(parsed);
1263
+ }
1264
+ } catch { /* storage is unavailable */ }
1265
+ return DEFAULT_FONT_SIZE;
1266
+ }
1267
+
1268
+ function writeFontSize(size) {
1269
+ try {
1270
+ if (typeof size === "number" && Number.isFinite(size)) {
1271
+ globalThis.localStorage?.setItem(FONT_SIZE_KEY, String(Math.round(size)));
1272
+ } else {
1273
+ globalThis.localStorage?.removeItem(FONT_SIZE_KEY);
1274
+ }
1275
+ } catch { /* storage is unavailable */ }
1276
+ }
1277
+
1278
+ /** Clamp a requested size onto the supported range. */
1279
+ function clampFontSize(size) {
1280
+ const value = Number(size);
1281
+ if (!Number.isFinite(value)) return DEFAULT_FONT_SIZE;
1282
+ return Math.round(Math.min(MAX_FONT_SIZE, Math.max(MIN_FONT_SIZE, value)));
1283
+ }
1284
+
1216
1285
  /**
1217
1286
  * Read the per-file language overrides.
1218
1287
  *
@@ -2165,6 +2234,10 @@ window.__ModuleLoader__.load({
2165
2234
  total: 0,
2166
2235
  hasMore: false,
2167
2236
  });
2237
+ // The working tree's pending changes belong on screen immediately;
2238
+ // load them alongside the first page of history so the section does
2239
+ // not stay on "loading" waiting for an explicit click.
2240
+ loadChanges();
2168
2241
  // Returned so a caller that awaits loadRepos also observes the first
2169
2242
  // page of history, which is what makes the panel testable end to end.
2170
2243
  return loadCommits(store.get().branch, 0);
@@ -2211,6 +2284,7 @@ window.__ModuleLoader__.load({
2211
2284
  detailStatus: "idle",
2212
2285
  notice: "",
2213
2286
  });
2287
+ loadChanges();
2214
2288
  return loadCommits(store.get().branch, 0);
2215
2289
  };
2216
2290
 
@@ -3629,15 +3703,50 @@ window.__ModuleLoader__.load({
3629
3703
  : null);
3630
3704
  }
3631
3705
 
3706
+ function isAbsolutePath(p) {
3707
+ if (typeof p !== "string" || p === "") return false;
3708
+ return /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("/") || p.startsWith("\\\\");
3709
+ }
3710
+
3632
3711
  function resolveAbsPath(rootPath, relPath) {
3633
3712
  if (!rootPath) return relPath || "";
3634
- const cleanRel = (relPath || "").replace(/^[\\/]+/, "");
3713
+ const cleanRel = (relPath || "").replace(/^\.[\\/]+/, "").replace(/^[\\/]+/, "");
3714
+ if (isAbsolutePath(cleanRel)) return cleanRel;
3635
3715
  if (rootPath.includes("\\")) {
3636
3716
  return rootPath.replace(/[\\/]+$/, "") + "\\" + cleanRel.replace(/\//g, "\\");
3637
3717
  }
3638
3718
  return rootPath.replace(/[\\/]+$/, "") + "/" + cleanRel.replace(/\\/g, "/");
3639
3719
  }
3640
3720
 
3721
+ function extractRelativeAndAbsolutePaths(item, rootPath) {
3722
+ if (!item) return { relPath: "", absPath: "" };
3723
+ let rel = item.path || "";
3724
+ if (isAbsolutePath(rel)) {
3725
+ // 如果 path 意外传了绝对路径,且位于 rootPath 之下,提取相对路径
3726
+ const normRoot = rootPath ? rootPath.replace(/\\/g, "/").replace(/\/+$/, "") : "";
3727
+ const normRel = rel.replace(/\\/g, "/");
3728
+ if (normRoot && (normRel.toLowerCase().startsWith(normRoot.toLowerCase() + "/") || normRel.startsWith(normRoot + "/"))) {
3729
+ rel = normRel.slice(normRoot.length + 1);
3730
+ }
3731
+ }
3732
+ if (!rel) {
3733
+ rel = item.name || "";
3734
+ }
3735
+ const cleanRel = String(rel).replace(/^\.[\\/]+/, "").replace(/^[\\/]+/, "");
3736
+
3737
+ // 绝对路径:优先考虑真实宿主路径 displayPath,或由 rootPath 与相对路径拼接
3738
+ let abs = "";
3739
+ if (isAbsolutePath(item.displayPath)) {
3740
+ abs = item.displayPath;
3741
+ } else if (isAbsolutePath(item.path)) {
3742
+ abs = item.path;
3743
+ } else {
3744
+ abs = resolveAbsPath(rootPath, cleanRel);
3745
+ }
3746
+
3747
+ return { relPath: cleanRel, absPath: abs };
3748
+ }
3749
+
3641
3750
  /** Path copy dropdown menu on viewer toolbar. */
3642
3751
  function PathCopyDropdown({ meta, root, onToast }) {
3643
3752
  const [open, setOpen] = react.useState(false);
@@ -3661,10 +3770,9 @@ window.__ModuleLoader__.load({
3661
3770
  };
3662
3771
  }, [open]);
3663
3772
 
3664
- if (!meta || (!meta.path && !meta.displayPath)) return null;
3773
+ if (!meta || (!meta.path && !meta.displayPath && !meta.name)) return null;
3665
3774
 
3666
- const relPath = meta.displayPath || meta.path || "";
3667
- const absPath = resolveAbsPath(root, relPath);
3775
+ const { relPath, absPath } = extractRelativeAndAbsolutePaths(meta, root);
3668
3776
 
3669
3777
  const handleCopy = (e, text, label) => {
3670
3778
  e.preventDefault();
@@ -3725,6 +3833,52 @@ window.__ModuleLoader__.load({
3725
3833
  : null);
3726
3834
  }
3727
3835
 
3836
+ /**
3837
+ * Reading-size stepper for the preview pane.
3838
+ *
3839
+ * A file's own text is what the reader spends their time on, so its size is
3840
+ * adjustable on its own and remembered per browser. The control is a
3841
+ * minus/value/plus group rather than a slider: the useful range is small and
3842
+ * discrete, and a slider at this width would be impossible to hit.
3843
+ *
3844
+ * @param {{value: number, onChange: Function, disabled?: boolean}} props
3845
+ */
3846
+ function FontSizeControl({ value, onChange, disabled }) {
3847
+ const size = clampFontSize(value);
3848
+ const atMin = size <= MIN_FONT_SIZE;
3849
+ const atMax = size >= MAX_FONT_SIZE;
3850
+ const step = (delta) => {
3851
+ const next = clampFontSize(size + delta);
3852
+ if (next !== size) onChange(next);
3853
+ };
3854
+ return react.createElement("div", {
3855
+ className: "fv-font-size",
3856
+ role: "group",
3857
+ "aria-label": "文件内字号",
3858
+ },
3859
+ react.createElement("button", {
3860
+ type: "button",
3861
+ className: "fv-font-size-btn",
3862
+ "aria-label": "减小字号",
3863
+ title: "减小字号",
3864
+ disabled: disabled === true || atMin,
3865
+ onClick: (e) => { e.stopPropagation(); step(-FONT_SIZE_STEP); },
3866
+ }, "A−"),
3867
+ react.createElement("span", {
3868
+ className: "fv-font-size-value",
3869
+ title: "当前字号",
3870
+ "aria-live": "polite",
3871
+ }, size + "px"),
3872
+ react.createElement("button", {
3873
+ type: "button",
3874
+ className: "fv-font-size-btn",
3875
+ "aria-label": "增大字号",
3876
+ title: "增大字号",
3877
+ disabled: disabled === true || atMax,
3878
+ onClick: (e) => { e.stopPropagation(); step(FONT_SIZE_STEP); },
3879
+ }, "A+"));
3880
+ }
3881
+
3728
3882
  function apply(ctx) {
3729
3883
  ensureStyles(typeof document === "undefined" ? null : document);
3730
3884
  // `null` is closed; an object is open. Carrying the requesting session lets
@@ -4220,7 +4374,7 @@ window.__ModuleLoader__.load({
4220
4374
 
4221
4375
  /** Line-numbered diff viewer with dual line number gutters. */
4222
4376
  /** Text, Markdown and JSON: one windowed read, three renderings. */
4223
- function TextView({ meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast }) {
4377
+ function TextView({ meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast, fontSize, onFontSizeChange }) {
4224
4378
  // Callers mount this with a per-file key, so paging state starts fresh
4225
4379
  // for every file instead of leaking the previous file's window into the
4226
4380
  // first request.
@@ -4510,17 +4664,23 @@ window.__ModuleLoader__.load({
4510
4664
  ? react.createElement(PathCopyDropdown, { meta, root, onToast })
4511
4665
  : null;
4512
4666
 
4513
- const tabs = showTabs || Boolean(backControl || langControl || sizeControl || copyControl)
4667
+ // Reading size belongs with the content it resizes, not in the drawer
4668
+ // header: it applies to this pane, and the stepper is next to the text.
4669
+ const fontControl = react.createElement(FontSizeControl, {
4670
+ value: fontSize,
4671
+ onChange: onFontSizeChange,
4672
+ });
4673
+
4674
+ const tabs = showTabs || Boolean(backControl || langControl || sizeControl || copyControl || fontControl)
4514
4675
  ? react.createElement("div", { className: "fv-sheet-tabs", role: "tablist" },
4515
4676
  react.createElement("div", { className: "fv-sheet-tabs-start" },
4516
4677
  backControl,
4517
4678
  ...tabButtons,
4518
4679
  copyControl),
4519
- (sizeControl || langControl)
4520
- ? react.createElement("div", { className: "fv-sheet-tabs-end" },
4521
- sizeControl,
4522
- langControl)
4523
- : null)
4680
+ react.createElement("div", { className: "fv-sheet-tabs-end" },
4681
+ fontControl,
4682
+ sizeControl,
4683
+ langControl))
4524
4684
  : null;
4525
4685
 
4526
4686
 
@@ -4546,7 +4706,13 @@ window.__ModuleLoader__.load({
4546
4706
 
4547
4707
  return react.createElement(react.Fragment, null,
4548
4708
  tabs,
4549
- react.createElement("div", { className: "fv-content-wrap" },
4709
+ react.createElement("div", {
4710
+ className: "fv-content-wrap",
4711
+ // The size reaches the rendered text through one inherited custom
4712
+ // property, so Markdown, JSON and plain source all follow it without
4713
+ // each view having to know about the setting.
4714
+ style: { "--fv-font-size": fontSize + "px" },
4715
+ },
4550
4716
  react.createElement("div", {
4551
4717
  className: "fv-content",
4552
4718
  "data-wrap": wrapping ? "on" : "off",
@@ -4587,10 +4753,18 @@ window.__ModuleLoader__.load({
4587
4753
  }
4588
4754
 
4589
4755
  /** Pick the viewer for a file and keep unsupported formats useful. */
4590
- function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo, loading, backControl, sizeControl, langControl, onToast }) {
4756
+ function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo, loading, backControl, sizeControl, langControl, onToast, fontSize, onFontSizeChange }) {
4591
4757
  const copyControl = meta !== null
4592
4758
  ? react.createElement(PathCopyDropdown, { meta, root, onToast })
4593
4759
  : null;
4760
+ // Only views that render text have a size to adjust; an image or a PDF
4761
+ // has its own zoom, and a sheet's cells are not prose.
4762
+ const textish = meta !== null
4763
+ && !meta.tooLarge
4764
+ && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json");
4765
+ const fontControl = textish
4766
+ ? react.createElement(FontSizeControl, { value: fontSize, onChange: onFontSizeChange })
4767
+ : null;
4594
4768
 
4595
4769
  if (meta === null) {
4596
4770
  // A tab exists but its metadata is still in flight. Saying "pick a
@@ -4638,10 +4812,10 @@ window.__ModuleLoader__.load({
4638
4812
  react.createElement("span", { className: "fv-history-hint" }, "这是历史版本,不是工作区当前内容。"))
4639
4813
  : null;
4640
4814
 
4641
- const headBar = (backControl || sizeControl || copyControl)
4815
+ const headBar = (backControl || sizeControl || copyControl || fontControl)
4642
4816
  ? react.createElement("div", { className: "fv-sheet-tabs", role: "toolbar" },
4643
4817
  react.createElement("div", { className: "fv-sheet-tabs-start" }, backControl, copyControl),
4644
- sizeControl ? react.createElement("div", { className: "fv-sheet-tabs-end" }, sizeControl) : null)
4818
+ react.createElement("div", { className: "fv-sheet-tabs-end" }, fontControl, sizeControl))
4645
4819
  : null;
4646
4820
 
4647
4821
  if (fromHistory && meta.kind !== "text" && meta.kind !== "markdown" && meta.kind !== "json") {
@@ -4658,7 +4832,7 @@ window.__ModuleLoader__.load({
4658
4832
  return react.createElement("div", { className: "fv-content" }, headBar, react.createElement(DocView, { key, meta, root }));
4659
4833
  }
4660
4834
  return react.createElement(react.Fragment, null, banner,
4661
- react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast }));
4835
+ react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo, backControl, sizeControl, langControl, onToast, fontSize, onFontSizeChange }));
4662
4836
  }
4663
4837
 
4664
4838
 
@@ -4693,8 +4867,7 @@ window.__ModuleLoader__.load({
4693
4867
 
4694
4868
  if (!menu) return null;
4695
4869
  const { x, y, entry } = menu;
4696
- const relPath = entry.displayPath || entry.path;
4697
- const absPath = resolveAbsPath(rootPath, relPath);
4870
+ const { relPath, absPath } = extractRelativeAndAbsolutePaths(entry, rootPath);
4698
4871
 
4699
4872
  const handleCopyRel = (e) => {
4700
4873
  e.stopPropagation();
@@ -5100,9 +5273,22 @@ window.__ModuleLoader__.load({
5100
5273
  setShellWidth(clamped);
5101
5274
  writeShellWidth(clamped);
5102
5275
  };
5103
- // Soft wrap for source views. Off by default: code is written with
5104
- // meaningful line breaks and wrapping obscures them.
5105
- const [wrap, setWrap] = react.useState(false);
5276
+ // Soft wrap is owned entirely by the host ReadBlock banner, which
5277
+ // renders its own toggle beside the copy button and reports the state
5278
+ // through `labels.wrapLabel` / `labels.unwrapLabel`. The viewer used to
5279
+ // keep a second, parallel switch in its header; two controls for one
5280
+ // flag could disagree, so the header's is gone and this flag is no
5281
+ // longer state — there is nothing left to toggle from here.
5282
+ const wrap = false;
5283
+ // Reading size for the preview, remembered per browser. Kept here rather
5284
+ // than inside TextView because the stepper sits in the pane's toolbar
5285
+ // while the text it sizes lives one level down.
5286
+ const [fontSize, setFontSize] = react.useState(readFontSize);
5287
+ const changeFontSize = (next) => {
5288
+ const clamped = clampFontSize(next);
5289
+ setFontSize(clamped);
5290
+ writeFontSize(clamped);
5291
+ };
5106
5292
  // The language override now lives on the tab it applies to (see
5107
5293
  // `localeTagOfActive`), so switching tabs switches the pin with it.
5108
5294
  const [fullscreen, setFullscreen] = react.useState(false);
@@ -5217,10 +5403,9 @@ window.__ModuleLoader__.load({
5217
5403
  // tab, and discovery spawns a git process per candidate repository.
5218
5404
  react.useEffect(() => {
5219
5405
  if (!open || side !== "git" || root === "") return;
5220
- git.ensure();
5221
- // The working-tree list is part of the panel's first paint, not a
5222
- // second click: "what is uncommitted" is the first thing to look at.
5223
- git.loadChanges();
5406
+ git.ensure().then(() => {
5407
+ git.loadChanges();
5408
+ }).catch(() => {});
5224
5409
  }, [open, side, root]);
5225
5410
  const [toast, setToast] = react.useState(null);
5226
5411
  const toastTimer = react.useRef(null);
@@ -5740,30 +5925,12 @@ window.__ModuleLoader__.load({
5740
5925
  }).catch(() => {});
5741
5926
  };
5742
5927
 
5743
- /**
5744
- * The top toolbar's refresh: the tree, the Git panel, and the tab on
5745
- * screen.
5746
- *
5747
- * The tab is refreshed too because that is what the reader is looking
5748
- * at — refreshing the tree around a stale preview would leave the file
5749
- * they care about unchanged.
5750
- */
5751
- const refresh = () => {
5752
- reloadWorkingTree();
5753
- if (side === "git") {
5754
- const state = git.store.get();
5755
- // The commit to come back to belongs to the tab on screen, not to
5756
- // whichever detail the controller happens to hold.
5757
- const keepSha = activeTab !== null && activeTab.kind === "commit"
5758
- ? activeTab.id.slice("commit:".length)
5759
- : "";
5760
- if (state.root !== root) git.reset(root);
5761
- git.loadRepos().then(() => {
5762
- if (keepSha !== "") git.openCommit(keepSha);
5763
- });
5764
- }
5765
- if (activeTabId !== "") refreshTab(activeTabId);
5766
- };
5928
+ // Refreshing is offered per subject now: the tab's own context menu
5929
+ // re-reads the file/commit on screen, the Git panel has its own
5930
+ // "refresh working tree" control, and the sidebar re-lists a directory
5931
+ // when it is reopened. The old header button duplicated all three at
5932
+ // once, which made a single click re-read things the reader was not
5933
+ // looking at.
5767
5934
 
5768
5935
  /** Open one commit in its own tab, or activate the tab already showing it. */
5769
5936
  const openCommit = (commit) => {
@@ -6172,18 +6339,10 @@ window.__ModuleLoader__.load({
6172
6339
  // file's path instead of navigable breadcrumbs.
6173
6340
  react.createElement("div", { className: "fv-crumbs fv-file-path", title: showCommit ? "" : (meta === null ? "" : meta.path) },
6174
6341
  showCommit ? "" : (meta === null ? "" : meta.path)),
6175
- // Only offered where it changes anything: images, PDFs, sheets and
6176
- // documents do not scroll sideways the way source lines do.
6177
- !showCommit && meta !== null && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
6178
- ? react.createElement("button", {
6179
- type: "button",
6180
- className: "fv-icon-button",
6181
- "aria-pressed": wrap ? "true" : "false",
6182
- "aria-label": wrap ? "取消自动换行" : "自动换行",
6183
- title: wrap ? "取消自动换行" : "自动换行",
6184
- onClick: () => setWrap((current) => !current),
6185
- }, react.createElement(SvgIconWrap, { size: 15 }))
6186
- : null,
6342
+ // Soft wrap is offered by the code banner itself (next to its copy
6343
+ // button), so the header no longer carries a second toggle for the
6344
+ // same setting — two switches for one flag only invited them to
6345
+ // drift apart. Refresh lives in the tab's context menu likewise.
6187
6346
  !showCommit && meta !== null && meta.kind !== "directory"
6188
6347
  ? react.createElement("button", {
6189
6348
  type: "button",
@@ -6193,9 +6352,6 @@ window.__ModuleLoader__.load({
6193
6352
  onClick: () => handleDownloadItem(meta),
6194
6353
  }, react.createElement(SvgIconDownload, { size: 15 }))
6195
6354
  : null,
6196
- react.createElement("button", {
6197
- type: "button", className: "fv-icon-button", "aria-label": "刷新", title: "刷新", onClick: refresh,
6198
- }, react.createElement(SvgIconRefresh, { size: 15 })),
6199
6355
  react.createElement("button", {
6200
6356
  type: "button",
6201
6357
  className: "fv-icon-button",
@@ -6379,6 +6535,8 @@ window.__ModuleLoader__.load({
6379
6535
  sizeControl,
6380
6536
  langControl,
6381
6537
  onToast: showToast,
6538
+ fontSize,
6539
+ onFontSizeChange: changeFontSize,
6382
6540
  })
6383
6541
  : react.createElement(FileView, {
6384
6542
  meta,
@@ -6398,6 +6556,8 @@ window.__ModuleLoader__.load({
6398
6556
  sizeControl,
6399
6557
  langControl,
6400
6558
  onToast: showToast,
6559
+ fontSize,
6560
+ onFontSizeChange: changeFontSize,
6401
6561
  })))),
6402
6562
  contextMenu !== null
6403
6563
  ? react.createElement(ContextMenu, {
@@ -6736,7 +6896,8 @@ window.__ModuleLoader__.load({
6736
6896
  langOverrideKey, isKnownLanguageId, setLanguageCatalog, getLanguageCatalog: () => languageCatalog, LanguagePicker,
6737
6897
  MARKDOWN_LABELS, MARKDOWN_CODE_LABELS, READ_BLOCK_LABELS, JSON_TREE_LABELS, DIFF_BLOCK_LABELS, ErrorBoundary,
6738
6898
  openViewerForPath, wrapWorkspaceOpenPath, wrapConnectionRpc, wrapChangesOpenFetch, wrapSidebarRight, parseFileResource, setupGlobalFileClickInterceptor, extractClickedPath, normalizeClickedPath, looksLikeFilePath, isFileOpenControl, isFileMentionCode, renderRootOptions,
6739
- triggerDownload, performDownload, isDesktopShell, SvgIconDownload, SvgIconCopy, PathCopyDropdown, resolveAbsPath, SvgIconBranch, SvgIconCommit,
6899
+ triggerDownload, performDownload, isDesktopShell, SvgIconDownload, SvgIconCopy, PathCopyDropdown, resolveAbsPath, extractRelativeAndAbsolutePaths, isAbsolutePath, SvgIconBranch, SvgIconCommit,
6900
+ FONT_SIZE_KEY, DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, FONT_SIZE_STEP, readFontSize, writeFontSize, clampFontSize, FontSizeControl,
6740
6901
  // Git panel: controller, views and the small formatters they share.
6741
6902
  GIT_PAGE_SIZE, createGitController, GitSidebar, CommitDetail, WorkspaceChangeDetail, DiffView, kindOfPath,
6742
6903
  formatCommitDate, formatCommitTimestamp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weniatall/dsh-file-viewer",
3
- "version": "0.3.13",
3
+ "version": "0.3.17",
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",