@weniatall/dsh-file-viewer 0.2.11 → 0.3.1

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
@@ -113,6 +113,32 @@ window.__ModuleLoader__.load({
113
113
  );
114
114
  }
115
115
 
116
+ /** Source-control entry marker: a branch with two commits. */
117
+ function SvgIconBranch({ size = 15 }) {
118
+ return react.createElement("svg", {
119
+ width: size, height: size, viewBox: "0 0 16 16", fill: "none",
120
+ stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round"
121
+ },
122
+ react.createElement("circle", { cx: "4.5", cy: "3.5", r: "1.6" }),
123
+ react.createElement("circle", { cx: "4.5", cy: "12.5", r: "1.6" }),
124
+ react.createElement("circle", { cx: "11.5", cy: "6.5", r: "1.6" }),
125
+ react.createElement("path", { d: "M4.5 5.1v5.8" }),
126
+ react.createElement("path", { d: "M11.5 8.1c0 2-2.4 2.6-4.6 3.2" })
127
+ );
128
+ }
129
+
130
+ /** One commit in the history list. */
131
+ function SvgIconCommit({ size = 15 }) {
132
+ return react.createElement("svg", {
133
+ width: size, height: size, viewBox: "0 0 16 16", fill: "none",
134
+ stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round"
135
+ },
136
+ react.createElement("circle", { cx: "8", cy: "8", r: "2.4" }),
137
+ react.createElement("path", { d: "M1.5 8h4.1" }),
138
+ react.createElement("path", { d: "M10.4 8h4.1" })
139
+ );
140
+ }
141
+
116
142
  function SvgIconDownload({ size = 15 }) {
117
143
  return react.createElement("svg", {
118
144
  width: size, height: size, viewBox: "0 0 16 16", fill: "none",
@@ -283,6 +309,11 @@ window.__ModuleLoader__.load({
283
309
  // settings; the key follows the `<plugin>.<setting>` form used elsewhere.
284
310
  const ENTRY_POSITION_KEY = "dsh-file-viewer.entry-position";
285
311
  const TREE_WIDTH_KEY = "dsh-file-viewer.tree-width";
312
+ // The drawer's own width, set by dragging its left edge. Stored separately
313
+ // from the tree width: they answer different questions ("how wide is the
314
+ // sidebar" vs "how wide is the whole panel"), and dragging one must not
315
+ // move the other.
316
+ const SHELL_WIDTH_KEY = "dsh-file-viewer.shell-width";
286
317
  // Per-file language overrides, keyed by the file's path within its root.
287
318
  // Per-file rather than per-extension because the user chose this file, and
288
319
  // a whole suffix could be shared by files that genuinely differ.
@@ -292,6 +323,14 @@ window.__ModuleLoader__.load({
292
323
  const DEFAULT_TREE_WIDTH = 280;
293
324
  const MIN_TREE_WIDTH = 160;
294
325
  const MAX_TREE_WIDTH = 700;
326
+ /**
327
+ * Smallest width the drawer may be dragged to.
328
+ *
329
+ * It has to hold the sidebar at its own minimum (160) plus enough of the
330
+ * preview to be worth reading, otherwise a drag can squeeze the content
331
+ * pane into a sliver that cannot be widened again by its own handle.
332
+ */
333
+ const MIN_SHELL_WIDTH = 420;
295
334
  // Below this much travel a press is a tap. Large enough to absorb the slip of
296
335
  // a finger on a touch target, small enough that a deliberate drag registers.
297
336
  const DRAG_SLOP = 8;
@@ -302,6 +341,23 @@ window.__ModuleLoader__.load({
302
341
  .fv-scrim{position:absolute;inset:0;z-index:40;display:flex;align-items:stretch;justify-content:flex-end;background:transparent;pointer-events:auto;animation:fv-fade .16s ease-out}
303
342
  .fv-shell{display:flex;flex-direction:column;width:min(64vw,1100px);min-width:0;min-height:0;overflow:hidden;border-left:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary);animation:fv-slide .2s cubic-bezier(.2,.8,.2,1);transition:width .2s cubic-bezier(.2,.8,.2,1)}
304
343
  .fv-shell[data-fullscreen="true"]{width:100vw;border-left:none}
344
+ /* The leading edge is the drawer's own resize handle. It is positioned
345
+ over the border rather than beside it so the panel does not shift when
346
+ it appears, and it overhangs slightly on both sides to be easy to grab
347
+ with a pointer or a finger. */
348
+ .fv-shell{position:relative}
349
+ .fv-edge{position:absolute;top:0;bottom:0;left:-4px;z-index:6;width:9px;cursor:col-resize;touch-action:none;background:transparent;transition:background-color .15s ease}
350
+ .fv-edge::after{content:"";position:absolute;top:0;bottom:0;left:4px;width:1px;background:transparent;transition:background-color .15s ease}
351
+ .fv-edge:hover,.fv-edge:focus-visible{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 18%,transparent)}
352
+ .fv-edge:hover::after,.fv-edge:focus-visible::after{background:var(--dsw-alias-brand-primary,#4d6bfe)}
353
+ .fv-edge[data-dragging="true"]{background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 26%,transparent)}
354
+ .fv-edge[data-dragging="true"]::after{background:var(--dsw-alias-brand-primary,#4d6bfe)}
355
+ .fv-edge:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4d6bfe);outline-offset:-2px}
356
+ /* While dragging, the width transition would lag the pointer by its own
357
+ duration, so it is dropped for the duration of the gesture. */
358
+ .fv-shell[data-edge-resizing="true"]{transition:none}
359
+ .fv-shell[data-edge-resizing="true"] *{pointer-events:none!important;user-select:none}
360
+ .fv-shell[data-edge-resizing="true"] .fv-edge{pointer-events:auto!important}
305
361
  /* Windows desktop paints the native caption buttons (minimize, maximize,
306
362
  close) in a strip across the top of the window, above every page layer.
307
363
  The shell's overlay seat spans the whole frame — padding box included —
@@ -424,6 +480,75 @@ window.__ModuleLoader__.load({
424
480
  .fv-badge{display:inline-block;padding:0 4px;font-size:10px;font-weight:600;line-height:14px;border-radius:4px;margin-left:4px;flex:none}
425
481
  .fv-badge-mod{background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 18%,transparent);color:var(--dsw-alias-state-warning-primary,#d97706)}
426
482
  .fv-badge-add{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#10b981) 18%,transparent);color:var(--dsw-alias-state-success-primary,#10b981)}
483
+ /* --- Git panel -------------------------------------------------------
484
+ Modelled on VSCode's source-control view: a two-tab sidebar (files /
485
+ Git), then repository, branch, paged history, and — in the main pane —
486
+ the files of the selected commit. The sidebar keeps the tree's own
487
+ container class so every existing mobile rule (full width, pane swap,
488
+ no resizer) applies to it unchanged. */
489
+ .fv-side-tabs{position:sticky;top:0;z-index:2;display:flex;flex:none;gap:2px;padding:2px;margin:-6px -6px 4px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff))}
490
+ .fv-side-tab{display:inline-flex;flex:1 1 0;align-items:center;justify-content:center;gap:5px;min-width:0;height:28px;padding:0 8px;border:none;border-radius:6px;background:none;color:var(--dsw-alias-label-secondary);font:var(--dsw-font-s-14);text-decoration:none;white-space:nowrap;cursor:pointer}
491
+ .fv-side-tab:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
492
+ .fv-side-tab[aria-selected="true"]{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary);font-weight:600}
493
+ .fv-side-tab svg{display:block;flex:none}
494
+ .fv-git-panel{overflow:hidden!important}
495
+ .fv-git{display:flex;flex:1 1 auto;flex-direction:column;min-height:0;gap:2px;padding-bottom:6px}
496
+ .fv-git-toolbar{display:flex;flex:none;flex-direction:column;gap:4px;padding:2px 2px 6px;border-bottom:1px solid var(--dsw-alias-border-l1)}
497
+ .fv-git-row{display:flex;min-width:0;align-items:center;gap:6px}
498
+ .fv-git-label{flex:none;font-size:11px;color:var(--dsw-alias-label-caption)}
499
+ .fv-select{box-sizing:border-box;flex:1 1 auto;min-width:0;max-width:100%;height:26px;padding:0 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:24px;cursor:pointer}
500
+ .fv-select:hover{border-color:var(--dsw-alias-border-l3,var(--dsw-alias-border-l2))}
501
+ .fv-select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:1px}
502
+ .fv-git-checkout{flex:none;height:26px;padding:0 10px;border:1px solid var(--dsw-alias-brand-primary,#4d6bfe);border-radius:6px;background:none;color:var(--dsw-alias-brand-primary,#4d6bfe);font:inherit;font-size:12px;white-space:nowrap;cursor:pointer}
503
+ .fv-git-checkout:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 12%,transparent)}
504
+ .fv-git-checkout:disabled{opacity:.45;cursor:default}
505
+ .fv-git-current{display:inline-flex;align-items:center;gap:4px;max-width:100%;overflow:hidden;font-size:11px;color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis}
506
+ .fv-git-section{display:flex;flex:none;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px 2px;color:var(--dsw-alias-label-caption);font-size:11px;font-weight:600}
507
+ .fv-git-count{font-weight:400;font-variant-numeric:tabular-nums}
508
+ .fv-git-list{display:flex;flex:1 1 auto;flex-direction:column;gap:1px;min-height:0;overflow-y:auto;overscroll-behavior:contain}
509
+ .fv-git-commit{display:flex;flex-direction:column;gap:2px;width:100%;min-width:0;padding:6px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary);font:inherit;text-align:left;cursor:pointer}
510
+ .fv-git-commit:hover{background:var(--dsw-alias-interactive-bg-hover)}
511
+ .fv-git-commit[aria-current="true"]{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover))}
512
+ .fv-git-subject{display:-webkit-box;overflow:hidden;font-size:13px;line-height:1.35;word-break:break-word;-webkit-box-orient:vertical;-webkit-line-clamp:2}
513
+ .fv-git-meta{display:flex;min-width:0;align-items:center;gap:6px;color:var(--dsw-alias-label-caption);font-size:11px;font-variant-numeric:tabular-nums}
514
+ .fv-git-sha{flex:none;font-family:var(--dsw-font-code,monospace);color:var(--dsw-alias-label-tertiary)}
515
+ .fv-git-author{flex:1 1 auto;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
516
+ .fv-git-date{flex:none}
517
+ .fv-git-more{flex:none;height:30px;margin:4px 2px 0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer}
518
+ .fv-git-more:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
519
+ .fv-git-more:disabled{opacity:.5;cursor:default}
520
+ .fv-git-hint{flex:none;padding:2px 8px;color:var(--dsw-alias-label-caption);font-size:11px;line-height:18px}
521
+ .fv-git-note{display:flex;flex:none;flex-direction:column;align-items:flex-start;gap:8px;padding:10px 8px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
522
+ .fv-commit{display:flex;flex:1 1 auto;flex-direction:column;min-height:0;overflow:hidden}
523
+ .fv-commit-head{display:flex;flex:none;flex-direction:column;gap:4px;padding:12px;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1,transparent)}
524
+ .fv-commit-subject{font-size:14px;font-weight:600;line-height:1.4;word-break:break-word}
525
+ .fv-commit-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;color:var(--dsw-alias-label-caption);font-size:11px}
526
+ .fv-commit-sha{font-family:var(--dsw-font-code,monospace);color:var(--dsw-alias-label-tertiary)}
527
+ .fv-commit-body{max-height:220px;overflow:auto;padding:10px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-code,monospace);font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-word}
528
+ .fv-commit-files{display:flex;flex:1 1 auto;flex-direction:column;gap:1px;min-height:0;padding:6px;overflow-y:auto;overscroll-behavior:contain}
529
+ .fv-commit-file{display:flex;align-items:center;gap:8px;width:100%;min-width:0;min-height:30px;padding:4px 8px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary);font:inherit;font-size:13px;text-align:left;cursor:pointer}
530
+ .fv-commit-file:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
531
+ .fv-commit-file:disabled{opacity:.55;cursor:default}
532
+ .fv-commit-file[aria-current="true"]{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover))}
533
+ .fv-commit-status{flex:none;width:18px;text-align:center;font-family:var(--dsw-font-code,monospace);font-size:11px;font-weight:700}
534
+ .fv-commit-status[data-tone="add"]{color:var(--dsw-alias-state-success-primary,#10b981)}
535
+ .fv-commit-status[data-tone="del"]{color:var(--dsw-alias-state-error-primary,#ef4444)}
536
+ .fv-commit-status[data-tone="mod"]{color:var(--dsw-alias-state-warning-primary,#d97706)}
537
+ .fv-commit-path{flex:1 1 auto;min-width:0;font-family:var(--dsw-font-code,monospace);font-size:12px;line-height:1.45;word-break:break-all}
538
+ .fv-commit-tag{flex:none;padding:1px 6px;border-radius:6px;background:var(--dsw-alias-bg-layer-3,rgba(0,0,0,.05));color:var(--dsw-alias-label-caption);font-family:var(--dsw-font-family);font-size:10px}
539
+ /* Working-tree changes and a commit's patch share the commit-row visuals
540
+ so the two panels read as one surface. */
541
+ .fv-git-changes{display:flex;flex:none;flex-direction:column;gap:1px;max-height:38%;overflow-y:auto;overscroll-behavior:contain;border-bottom:1px solid var(--dsw-alias-border-l1);padding-bottom:4px}
542
+ .fv-git-change-group{display:flex;flex-direction:column;gap:1px}
543
+ .fv-git-refresh{width:22px;height:22px;margin-left:auto}
544
+ .fv-commit-diff{display:flex;flex:1 1 auto;flex-direction:column;gap:2px;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:4px 6px}
545
+ .fv-commit-diff-file{display:flex;flex:none;flex-direction:column;gap:2px;margin-bottom:8px}
546
+ .fv-commit-diff-file .fv-git-section{padding:2px 2px 0}
547
+ .fv-commit-diff-file .fv-diff-view{margin:2px 0}
548
+ .fv-git-warn{display:flex;align-items:flex-start;gap:6px;padding:6px 8px;border:1px solid color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 45%,transparent);border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-state-warning-primary,#d97706) 10%,transparent);color:var(--dsw-alias-label-secondary);font-size:11px;line-height:1.5}
549
+ .fv-git-warn strong{color:var(--dsw-alias-state-warning-primary,#d97706)}
550
+ .fv-history-banner{display:flex;flex:none;flex-direction:column;gap:2px;margin:6px 6px 0;padding:7px 10px;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 40%,transparent);border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4d6bfe) 10%,transparent);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
551
+ .fv-history-hint{color:var(--dsw-alias-label-caption);font-size:11px}
427
552
  .fv-modal-scrim{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:16px;background:color-mix(in srgb,#000 45%,transparent);animation:fv-fade .15s ease-out}
428
553
  .fv-modal{width:min(94vw,560px);max-height:86vh;display:flex;flex-direction:column;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);overflow:hidden;font-family:var(--dsw-font-family)}
429
554
  .fv-modal-head{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--dsw-alias-border-l1)}
@@ -485,6 +610,8 @@ window.__ModuleLoader__.load({
485
610
  .fv-shell[data-mobile="true"]{width:100%!important;border-left:none!important}
486
611
  .fv-shell[data-mobile="true"] .fv-btn-fullscreen{display:none!important}
487
612
  .fv-shell[data-mobile="true"] .fv-resizer{display:none!important}
613
+ .fv-shell[data-mobile="true"] .fv-edge{display:none!important}
614
+ .fv-shell[data-fullscreen="true"] .fv-edge{display:none!important}
488
615
  .fv-shell[data-mobile="true"] .fv-tree{flex:1 1 auto!important;width:100%!important;min-width:0!important;max-width:none!important;border-right:none!important;padding:6px 6px!important}
489
616
  .fv-shell[data-mobile="true"] .fv-row-seat{width:100%!important;max-width:none!important}
490
617
  .fv-shell[data-mobile="true"] .fv-row{flex:1 1 auto!important;min-height:38px;padding:6px 6px;align-items:flex-start;padding-left:calc(4px + var(--fv-depth,0) * 8px)!important}
@@ -558,6 +685,20 @@ window.__ModuleLoader__.load({
558
685
  .fv-safe-item{padding:8px 8px!important}
559
686
  .fv-safe-item-path{font-size:11.5px!important;line-height:1.4!important}
560
687
  .fv-btn-del{height:32px!important;min-width:44px!important;font-size:13px!important;padding:4px 8px!important}
688
+ /* Git panel on a phone: the selects and the commit rows are the two
689
+ places a finger has to land precisely, so both get a full touch
690
+ target while the secondary text stays compact. */
691
+ .fv-side-tabs{margin:-6px -6px 4px!important;padding:2px!important}
692
+ .fv-side-tab{height:32px!important;font-size:13px!important}
693
+ .fv-select{height:32px!important;font-size:13px!important;line-height:30px!important}
694
+ .fv-git-checkout{height:32px!important;padding:0 12px!important;font-size:13px!important}
695
+ .fv-git-commit{min-height:44px!important;padding:8px!important}
696
+ .fv-git-subject{font-size:13.5px!important;-webkit-line-clamp:3!important}
697
+ .fv-git-more{height:38px!important;font-size:13px!important}
698
+ .fv-commit-file{min-height:42px!important;font-size:13.5px!important}
699
+ .fv-commit-path{font-size:12px!important}
700
+ .fv-git-changes{max-height:none!important}
701
+ .fv-commit-diff-file .fv-diff-table{font-size:12px!important}
561
702
  }
562
703
  `;
563
704
 
@@ -735,6 +876,53 @@ window.__ModuleLoader__.load({
735
876
  return DEFAULT_TREE_WIDTH;
736
877
  }
737
878
 
879
+ /**
880
+ * The drawer width to open with.
881
+ *
882
+ * Zero means "use the stylesheet default", which is what an untouched
883
+ * drawer should do: the default is a fraction of the viewport, and caching
884
+ * a resolved pixel value would pin it to whatever window happened to be
885
+ * open at the time.
886
+ * @returns {number} the stored width, or 0 when unset.
887
+ */
888
+ function readShellWidth() {
889
+ try {
890
+ const raw = globalThis.localStorage?.getItem(SHELL_WIDTH_KEY);
891
+ const parsed = raw === null || raw === undefined ? 0 : Number(raw);
892
+ if (Number.isFinite(parsed) && parsed >= MIN_SHELL_WIDTH) return Math.round(parsed);
893
+ } catch (_) {}
894
+ return 0;
895
+ }
896
+
897
+ /**
898
+ * Remember the drawer width, or forget it when it equals the default.
899
+ * @param {number} width - the width to store; 0 clears the entry.
900
+ */
901
+ function writeShellWidth(width) {
902
+ try {
903
+ if (!(width > 0)) globalThis.localStorage?.removeItem(SHELL_WIDTH_KEY);
904
+ else globalThis.localStorage?.setItem(SHELL_WIDTH_KEY, String(Math.round(width)));
905
+ } catch (_) {}
906
+ }
907
+
908
+ /**
909
+ * Clamp a requested drawer width onto what this viewport can honour.
910
+ *
911
+ * The upper bound is the frame itself: a panel wider than the window would
912
+ * push its own header out of reach, so the drag stops at the edge instead
913
+ * of letting the reader lose the close button.
914
+ * @param {number} width - requested width in pixels.
915
+ * @param {number} viewportWidth - available width in pixels.
916
+ * @returns {number} the width to apply.
917
+ */
918
+ function clampShellWidth(width, viewportWidth) {
919
+ const ceiling = Number.isFinite(viewportWidth) && viewportWidth > 0
920
+ ? Math.max(MIN_SHELL_WIDTH, viewportWidth)
921
+ : 1200;
922
+ const floor = Math.min(MIN_SHELL_WIDTH, ceiling);
923
+ return Math.round(Math.min(ceiling, Math.max(floor, Number(width) || 0)));
924
+ }
925
+
738
926
  function writeTreeWidth(width) {
739
927
  try {
740
928
  if (typeof width === "number" && Number.isFinite(width)) {
@@ -1414,6 +1602,17 @@ window.__ModuleLoader__.load({
1414
1602
  "read-failed": "读取失败,可能没有访问权限。",
1415
1603
  "not-text": "该文件不是 UTF-8 文本(可能是其他编码或二进制内容),查看器无法显示。",
1416
1604
  "unsupported": "宿主端缺少解析该格式所需的依赖。",
1605
+ // Git panel. These mirror the host's codes exactly; an unknown code falls
1606
+ // back to the host's own message, which is written for an operator and is
1607
+ // therefore less specific than these.
1608
+ "not-a-git-repo": "当前根目录及其子目录都不是 Git 仓库。",
1609
+ "unknown-repository": "请求的仓库不在当前根目录内。",
1610
+ "invalid-branch": "分支名不合法。",
1611
+ "unknown-branch": "该分支不存在。",
1612
+ "invalid-commit": "提交标识不合法。",
1613
+ "unknown-commit": "找不到该提交。",
1614
+ "git-dirty": "工作区有未提交的改动,无法切换分支。",
1615
+ "git-failed": "Git 命令执行失败。",
1417
1616
  };
1418
1617
 
1419
1618
  function messageOf(error) {
@@ -1436,7 +1635,889 @@ window.__ModuleLoader__.load({
1436
1635
  };
1437
1636
  }
1438
1637
 
1439
- /** Decode base64 into a Blob without a data: URL round-trip. */
1638
+ /** Commits per page; the host clamps anything larger to its own ceiling. */
1639
+ const GIT_PAGE_SIZE = 30;
1640
+
1641
+ /** Date part of an ISO timestamp, which is all a history row needs. */
1642
+ function formatCommitDate(value) {
1643
+ const text = typeof value === "string" ? value : "";
1644
+ return text.length >= 10 ? text.slice(0, 10) : text;
1645
+ }
1646
+
1647
+ /** Date and minute of an ISO timestamp, for the commit detail header. */
1648
+ function formatCommitTimestamp(value) {
1649
+ const text = typeof value === "string" ? value : "";
1650
+ return text.length >= 16 ? text.slice(0, 16).replace("T", " ") : text;
1651
+ }
1652
+
1653
+ /**
1654
+ * State and requests for the Git panel.
1655
+ *
1656
+ * Deliberately one controller rather than state spread across the drawer:
1657
+ * the sidebar (repository, branch, paged history) and the main pane (one
1658
+ * commit's changed files) read the same snapshot, and a checkout has to
1659
+ * invalidate both at once. A generation counter makes the newest request
1660
+ * win, so a slow answer can never overwrite a newer branch's history.
1661
+ */
1662
+ function createGitController(request) {
1663
+ const store = createStore({
1664
+ root: "",
1665
+ repos: [], reposStatus: "idle", reposError: null, truncated: false,
1666
+ repoId: "",
1667
+ branches: [], current: "", detached: false, scope: "root",
1668
+ branch: "",
1669
+ commits: [], commitsStatus: "idle", commitsError: null,
1670
+ total: 0, hasMore: false,
1671
+ detail: null, detailStatus: "idle", detailError: null,
1672
+ // One commit's complete patch, keyed by the commit it belongs to.
1673
+ commitDiff: null, commitDiffStatus: "idle", commitDiffError: null,
1674
+ // The working tree's pending changes, grouped like a source-control view.
1675
+ changes: null, changesStatus: "idle", changesError: null,
1676
+ busy: false, notice: "",
1677
+ });
1678
+
1679
+ // Two counters, because the list and the detail are independent
1680
+ // requests: sharing one made a detail read cancel an in-flight page,
1681
+ // which left the pager stuck on "loading more" with its button disabled.
1682
+ let listGeneration = 0;
1683
+ let detailGeneration = 0;
1684
+ // The commit diff and the workspace change list are separate reads again:
1685
+ // opening the diff must not cancel a page load, and refreshing the working
1686
+ // tree must not cancel the diff being read.
1687
+ let diffGeneration = 0;
1688
+ let changesGeneration = 0;
1689
+
1690
+ const patch = (next) => {
1691
+ store.set({ ...store.get(), ...next });
1692
+ };
1693
+
1694
+ const activeRepo = () => {
1695
+ const state = store.get();
1696
+ return state.repos.find((repo) => repo.id === state.repoId) || state.repos[0] || null;
1697
+ };
1698
+
1699
+ /** Drop every cached answer; profile changes make all of them stale. */
1700
+ const reset = (root) => {
1701
+ listGeneration += 1;
1702
+ detailGeneration += 1;
1703
+ store.set({
1704
+ root: typeof root === "string" ? root : "",
1705
+ repos: [], reposStatus: "idle", reposError: null, truncated: false, repoId: "",
1706
+ branches: [], current: "", detached: false, scope: "root", branch: "",
1707
+ commits: [], commitsStatus: "idle", commitsError: null, total: 0, hasMore: false,
1708
+ detail: null, detailStatus: "idle", detailError: null,
1709
+ commitDiff: null, commitDiffStatus: "idle", commitDiffError: null,
1710
+ changes: null, changesStatus: "idle", changesError: null,
1711
+ busy: false, notice: "",
1712
+ });
1713
+ };
1714
+
1715
+ /**
1716
+ * Read one page of history and append or replace the list.
1717
+ * @param {string} branch - branch to page, or "" for the checked-out HEAD.
1718
+ * @param {number} offset - commits to skip.
1719
+ */
1720
+ const loadCommits = (branch, offset) => {
1721
+ const repo = activeRepo();
1722
+ if (repo === null) return Promise.resolve();
1723
+ const root = store.get().root;
1724
+ const ticket = ++listGeneration;
1725
+ patch({ commitsStatus: offset > 0 ? "more" : "loading", commitsError: null });
1726
+ return request("gitCommits", {
1727
+ root,
1728
+ repo: repo.id,
1729
+ ...(branch === "" ? {} : { branch }),
1730
+ offset,
1731
+ limit: GIT_PAGE_SIZE,
1732
+ }).then((value) => {
1733
+ if (ticket !== listGeneration) return;
1734
+ const page = Array.isArray(value?.commits) ? value.commits : [];
1735
+ const previousRows = offset > 0 ? store.get().commits : [];
1736
+ patch({
1737
+ commits: previousRows.concat(page),
1738
+ commitsStatus: "ready",
1739
+ commitsError: null,
1740
+ total: Number.isFinite(Number(value?.total)) ? Number(value.total) : previousRows.length + page.length,
1741
+ hasMore: Boolean(value?.hasMore),
1742
+ branch: typeof value?.branch === "string" ? value.branch : branch,
1743
+ });
1744
+ }).catch((problem) => {
1745
+ if (ticket !== listGeneration) return;
1746
+ patch({ commitsStatus: "error", commitsError: problem });
1747
+ });
1748
+ };
1749
+
1750
+ /**
1751
+ * Re-discover the repositories under the current root and page history.
1752
+ *
1753
+ * `keepBranch` names the branch to come back to. A refresh passes the one
1754
+ * being browsed, so it does not silently jump to the checked-out branch;
1755
+ * a checkout passes the branch it just switched to, so the panel lands
1756
+ * where the reader asked to go.
1757
+ * @param {string} [keepBranch] - branch to select after the reload.
1758
+ */
1759
+ const loadRepos = (keepBranch) => {
1760
+ const root = store.get().root;
1761
+ const before = store.get();
1762
+ const keptBranch = typeof keepBranch === "string" && keepBranch !== ""
1763
+ ? keepBranch
1764
+ : (typeof before.branch === "string" && before.branch !== "" ? before.branch : "");
1765
+ const ticket = ++listGeneration;
1766
+ patch({ reposStatus: "loading", reposError: null, notice: "", detail: null, detailStatus: "idle" });
1767
+ return request("gitRepos", { root }).then((value) => {
1768
+ if (ticket !== listGeneration) return;
1769
+ const repos = Array.isArray(value?.repos) ? value.repos : [];
1770
+ const truncated = value?.truncated === true;
1771
+ if (repos.length === 0) {
1772
+ patch({
1773
+ repos: [], reposStatus: "empty", reposError: null, truncated, repoId: "",
1774
+ branches: [], current: "", detached: false, scope: "root", branch: "",
1775
+ commits: [], commitsStatus: "idle", total: 0, hasMore: false,
1776
+ });
1777
+ return;
1778
+ }
1779
+ // Keep the repository the reader already selected when it is still
1780
+ // present, so a refresh does not jump back to the first checkout.
1781
+ const preferred = store.get().repoId;
1782
+ const repo = repos.find((row) => row.id === preferred) || repos[0];
1783
+ const branches = Array.isArray(repo.branches) ? repo.branches : [];
1784
+ // The checked-out branch is a *short* name from git, while the list
1785
+ // is keyed by full ref; resolve it so the select has a real value.
1786
+ const checkedOut = typeof repo.current === "string" && repo.current !== ""
1787
+ ? (branches.find((row) => row.kind === "local" && row.shortName === repo.current) || null)
1788
+ : null;
1789
+ const current = checkedOut === null ? "" : checkedOut.name;
1790
+ // The kept branch only survives if it still exists here; otherwise
1791
+ // fall back to the checked-out one so the select always has a value.
1792
+ const branch = keptBranch !== "" && branches.some((row) => row.name === keptBranch)
1793
+ ? keptBranch
1794
+ : current;
1795
+ patch({
1796
+ repos,
1797
+ reposStatus: "ready",
1798
+ reposError: null,
1799
+ truncated,
1800
+ remotes: Array.isArray(repo.remotes) ? repo.remotes : [],
1801
+ repoId: repo.id,
1802
+ branches,
1803
+ // Full ref of the checked-out branch, plus what the reader sees.
1804
+ current,
1805
+ currentShort: typeof repo.current === "string" ? repo.current : "",
1806
+ detached: Boolean(repo.detached),
1807
+ scope: repo.scope === "parent" ? "parent" : "root",
1808
+ branch,
1809
+ commits: [],
1810
+ commitsStatus: "idle",
1811
+ commitsError: null,
1812
+ total: 0,
1813
+ hasMore: false,
1814
+ });
1815
+ // Returned so a caller that awaits loadRepos also observes the first
1816
+ // page of history, which is what makes the panel testable end to end.
1817
+ return loadCommits(store.get().branch, 0);
1818
+ }).catch((problem) => {
1819
+ if (ticket !== listGeneration) return;
1820
+ patch({ repos: [], reposStatus: "error", reposError: problem, truncated: false });
1821
+ });
1822
+ };
1823
+
1824
+ /** Load on first use, and retry after a failed load. */
1825
+ const ensure = () => {
1826
+ const status = store.get().reposStatus;
1827
+ if (status === "idle" || status === "error") return loadRepos();
1828
+ return Promise.resolve();
1829
+ };
1830
+
1831
+ /** Switch which repository the panel describes (kept across reloads). */
1832
+ const pickRepo = (repoId) => {
1833
+ const repo = store.get().repos.find((row) => row.id === repoId);
1834
+ if (repo === undefined) return Promise.resolve();
1835
+ detailGeneration += 1;
1836
+ diffGeneration += 1;
1837
+ changesGeneration += 1;
1838
+ const repoBranches = Array.isArray(repo.branches) ? repo.branches : [];
1839
+ const checkedOut = typeof repo.current === "string" && repo.current !== ""
1840
+ ? (repoBranches.find((row) => row.kind === "local" && row.shortName === repo.current) || null)
1841
+ : null;
1842
+ patch({
1843
+ repoId: repo.id,
1844
+ remotes: Array.isArray(repo.remotes) ? repo.remotes : [],
1845
+ branches: repoBranches,
1846
+ current: checkedOut === null ? "" : checkedOut.name,
1847
+ currentShort: typeof repo.current === "string" ? repo.current : "",
1848
+ detached: Boolean(repo.detached),
1849
+ scope: repo.scope === "parent" ? "parent" : "root",
1850
+ branch: checkedOut === null ? "" : checkedOut.name,
1851
+ changes: null, changesStatus: "idle",
1852
+ commitDiff: null, commitDiffStatus: "idle",
1853
+ commits: [],
1854
+ commitsStatus: "idle",
1855
+ total: 0,
1856
+ hasMore: false,
1857
+ detail: null,
1858
+ detailStatus: "idle",
1859
+ notice: "",
1860
+ });
1861
+ return loadCommits(store.get().branch, 0);
1862
+ };
1863
+
1864
+ /** Read another branch's history without touching the working tree. */
1865
+ const pickBranch = (branch) => {
1866
+ if (typeof branch !== "string" || branch === "") return Promise.resolve();
1867
+ // The commit pane belongs to the branch that was on screen, so it is
1868
+ // dropped rather than left describing history the reader just left.
1869
+ detailGeneration += 1;
1870
+ diffGeneration += 1;
1871
+ patch({
1872
+ branch,
1873
+ commits: [], commitsStatus: "idle", total: 0, hasMore: false,
1874
+ detail: null, detailStatus: "idle",
1875
+ commitDiff: null, commitDiffStatus: "idle",
1876
+ notice: "",
1877
+ });
1878
+ return loadCommits(branch, 0);
1879
+ };
1880
+
1881
+ /**
1882
+ * Check the selected branch out, then re-read the whole panel.
1883
+ *
1884
+ * A checkout whose repository lives above the root is sent only with
1885
+ * explicit consent: `allowOutsideRoot` is what the confirmation the
1886
+ * reader just accepted means on the wire.
1887
+ */
1888
+ const checkout = (branch, allowOutsideRoot) => {
1889
+ const repo = activeRepo();
1890
+ const state = store.get();
1891
+ if (repo === null || state.busy || typeof branch !== "string" || branch === "") return Promise.resolve();
1892
+ patch({ busy: true, notice: "" });
1893
+ const payload = { root: state.root, repo: repo.id, branch };
1894
+ if (allowOutsideRoot === true) payload.allowOutsideRoot = true;
1895
+ return request("gitCheckout", payload).then((value) => {
1896
+ const notice = value && value.changed === false ? "已在该分支上" : "已切换到 " + branch;
1897
+ // Set after the reload: loadRepos clears the notice on purpose, since
1898
+ // a stale message about a previous action would outlive it.
1899
+ // Land on the branch that was just checked out: `loadRepos` would
1900
+ // otherwise restore the branch the reader had been browsing.
1901
+ return loadRepos(branch).then(() => patch({ busy: false, notice }));
1902
+ }).catch((problem) => {
1903
+ const code = problem && problem.code;
1904
+ patch({
1905
+ busy: false,
1906
+ notice: code === "git-dirty"
1907
+ ? "工作区有未提交的改动,无法切换分支"
1908
+ : code === "checkout-outside-root"
1909
+ ? "该仓库位于根目录的上级,检出会影响根目录以外的文件"
1910
+ : (problem && problem.message) || "切换分支失败",
1911
+ });
1912
+ });
1913
+ };
1914
+
1915
+ /** Read one commit's metadata and changed files. */
1916
+ const openCommit = (sha) => {
1917
+ const repo = activeRepo();
1918
+ const state = store.get();
1919
+ if (repo === null || typeof sha !== "string" || sha === "") return Promise.resolve();
1920
+ // A detail read is independent of the list: bumping the shared counter
1921
+ // here used to cancel an in-flight page and strand the pager.
1922
+ const ticket = ++detailGeneration;
1923
+ // The diff belongs to the commit that was on screen; a new selection
1924
+ // invalidates it before it can be shown under the wrong header.
1925
+ diffGeneration += 1;
1926
+ patch({ detailStatus: "loading", detail: null, detailError: null, commitDiff: null, commitDiffStatus: "idle" });
1927
+ return request("gitCommitFiles", { root: state.root, repo: repo.id, sha }).then((value) => {
1928
+ if (ticket !== detailGeneration) return;
1929
+ patch({ detailStatus: "ready", detail: value, detailError: null });
1930
+ }).catch((problem) => {
1931
+ if (ticket !== detailGeneration) return;
1932
+ patch({ detailStatus: "error", detail: null, detailError: problem });
1933
+ });
1934
+ };
1935
+
1936
+ /**
1937
+ * Read the working tree's pending changes.
1938
+ *
1939
+ * Called when the Git tab opens and after a checkout, because both are
1940
+ * moments where "what is uncommitted" can have changed underneath the
1941
+ * reader.
1942
+ */
1943
+ const loadChanges = () => {
1944
+ const repo = activeRepo();
1945
+ if (repo === null) return Promise.resolve();
1946
+ const root = store.get().root;
1947
+ const ticket = ++changesGeneration;
1948
+ patch({ changesStatus: "loading", changesError: null });
1949
+ return request("gitStatusChanges", { root, repo: repo.id }).then((value) => {
1950
+ if (ticket !== changesGeneration) return;
1951
+ patch({ changes: value, changesStatus: "ready", changesError: null });
1952
+ }).catch((problem) => {
1953
+ if (ticket !== changesGeneration) return;
1954
+ // A repository that cannot report status is not an empty one; the
1955
+ // message explains rather than showing "no changes".
1956
+ patch({ changes: null, changesStatus: "error", changesError: problem });
1957
+ });
1958
+ };
1959
+
1960
+ /**
1961
+ * Read one commit's complete patch.
1962
+ * @param {string} sha - the commit to read.
1963
+ */
1964
+ const loadCommitDiff = (sha) => {
1965
+ const repo = activeRepo();
1966
+ if (repo === null || typeof sha !== "string" || sha === "") return Promise.resolve();
1967
+ const root = store.get().root;
1968
+ const ticket = ++diffGeneration;
1969
+ patch({ commitDiffStatus: "loading", commitDiff: null, commitDiffError: null });
1970
+ return request("gitCommitDiff", { root, repo: repo.id, sha }).then((value) => {
1971
+ if (ticket !== diffGeneration) return;
1972
+ patch({ commitDiffStatus: "ready", commitDiff: value, commitDiffError: null });
1973
+ }).catch((problem) => {
1974
+ if (ticket !== diffGeneration) return;
1975
+ patch({ commitDiffStatus: "error", commitDiff: null, commitDiffError: problem });
1976
+ });
1977
+ };
1978
+
1979
+ /** Append the next page of history. */
1980
+ const loadMore = () => {
1981
+ const state = store.get();
1982
+ if (!state.hasMore) return Promise.resolve();
1983
+ if (state.commitsStatus === "loading" || state.commitsStatus === "more") return Promise.resolve();
1984
+ return loadCommits(state.branch, state.commits.length);
1985
+ };
1986
+
1987
+ return {
1988
+ store, reset, ensure, loadRepos, pickRepo, pickBranch, checkout,
1989
+ openCommit, loadMore, loadChanges, loadCommitDiff, activeRepo,
1990
+ };
1991
+ }
1992
+
1993
+ /**
1994
+ * The Git sidebar: repository picker, branch picker and paged history.
1995
+ *
1996
+ * The branch select only chooses which history is shown; checking a
1997
+ * branch out rewrites the working tree, so it stays a separate press.
1998
+ * That split is the difference between browsing and mutating, and it is
1999
+ * also what makes it safe to switch branches in a hurry on a phone.
2000
+ */
2001
+ function GitSidebar({ state, width, header, onPickRepo, onPickBranch, onCheckout, onSelectCommit, onLoadMore, onRetry, selectedSha, onSelectChange, onRefreshChanges }) {
2002
+ // Every field is read defensively: this is a pure view over a snapshot,
2003
+ // and a partially populated one (first paint, a failed load) must render
2004
+ // as an empty pane rather than throwing inside the drawer.
2005
+ const repos = Array.isArray(state.repos) ? state.repos : [];
2006
+ const repo = repos.find((row) => row.id === state.repoId) || repos[0] || null;
2007
+ const branches = Array.isArray(state.branches) ? state.branches : [];
2008
+ const commits = Array.isArray(state.commits) ? state.commits : [];
2009
+ const current = typeof state.current === "string" ? state.current : "";
2010
+ const selectedBranch = typeof state.branch === "string" ? state.branch : "";
2011
+ // A detached checkout has no branch to preselect; the history shown is
2012
+ // HEAD's, which the select describes with its own entry.
2013
+ const viewingBranch = selectedBranch !== ""
2014
+ ? selectedBranch
2015
+ : (state.detached === true ? "" : current);
2016
+ const localBranches = branches.filter((branch) => branch.kind !== "remote");
2017
+ const remoteBranches = branches.filter((branch) => branch.kind === "remote");
2018
+ const selectedEntry = branches.find((branch) => branch.name === viewingBranch) || null;
2019
+ // A remote branch is browse-only: checking it out would produce a detached
2020
+ // HEAD (or a new local branch) rather than the switch the reader expects.
2021
+ const checkoutable = selectedEntry === null || selectedEntry.kind !== "remote";
2022
+ const canCheckout = repo !== null && viewingBranch !== "" && viewingBranch !== current
2023
+ && checkoutable && state.busy !== true;
2024
+
2025
+ const wrap = (...children) => react.createElement("div", {
2026
+ className: "fv-tree fv-git-panel",
2027
+ role: "group",
2028
+ "aria-label": "Git 面板",
2029
+ style: typeof width === "number" ? { width: width + "px" } : undefined,
2030
+ }, header, ...children);
2031
+
2032
+ if (state.reposStatus === "loading" || state.reposStatus === "idle") {
2033
+ return wrap(react.createElement("div", { className: "fv-git-note" }, "正在读取仓库…"));
2034
+ }
2035
+ if (state.reposStatus === "error") {
2036
+ return wrap(react.createElement("div", { className: "fv-git-note" },
2037
+ react.createElement("span", { className: "fv-error" }, messageOf(state.reposError)),
2038
+ react.createElement("button", { type: "button", className: "fv-button", onClick: onRetry }, "重试")));
2039
+ }
2040
+ if (repo === null) {
2041
+ return wrap(react.createElement("div", { className: "fv-git-note" },
2042
+ react.createElement("span", null, "当前根目录下没有 Git 仓库。"),
2043
+ react.createElement("span", null, "如果仓库在子目录里,请在上方切换到包含它们的根目录。")));
2044
+ }
2045
+
2046
+ const toolbar = react.createElement("div", { className: "fv-git-toolbar" },
2047
+ repos.length > 1
2048
+ ? react.createElement("div", { className: "fv-git-row" },
2049
+ react.createElement("span", { className: "fv-git-label" }, "仓库"),
2050
+ react.createElement("select", {
2051
+ className: "fv-select",
2052
+ value: repo.id,
2053
+ "aria-label": "选择仓库",
2054
+ onChange: (event) => onPickRepo(event.target.value),
2055
+ }, ...repos.map((row) => react.createElement("option", { key: row.id, value: row.id }, row.name || row.id))))
2056
+ : react.createElement("div", { className: "fv-git-current", title: repo.name || repo.id },
2057
+ react.createElement(SvgIconBranch, { size: 13 }),
2058
+ react.createElement("span", null, repo.name || repo.id)),
2059
+ react.createElement("div", { className: "fv-git-row" },
2060
+ react.createElement("span", { className: "fv-git-label" }, "分支"),
2061
+ branches.length === 0
2062
+ ? react.createElement("span", { className: "fv-git-current" }, repo.empty ? "尚无提交" : current || "未知")
2063
+ : react.createElement("select", {
2064
+ className: "fv-select",
2065
+ value: viewingBranch,
2066
+ "aria-label": "查看分支",
2067
+ onChange: (event) => onPickBranch(event.target.value),
2068
+ },
2069
+ // A detached HEAD has no branch to preselect; offer it as its
2070
+ // own entry so the select can still describe what is shown.
2071
+ state.detached === true ? react.createElement("option", { value: "" }, "HEAD(游离)") : null,
2072
+ // Local and remote branches are grouped because only the local
2073
+ // ones can be checked out; mixing them would invite a click that
2074
+ // silently creates a detached HEAD.
2075
+ localBranches.length > 0
2076
+ ? react.createElement("optgroup", { label: "本地分支" },
2077
+ ...localBranches.map((branch) => react.createElement("option", {
2078
+ key: branch.name,
2079
+ value: branch.name,
2080
+ }, (branch.shortName || branch.name) + (branch.name === current ? " · 当前" : ""))))
2081
+ : null,
2082
+ remoteBranches.length > 0
2083
+ ? react.createElement("optgroup", { label: "远程分支" },
2084
+ ...remoteBranches.map((branch) => react.createElement("option", {
2085
+ key: branch.name,
2086
+ value: branch.name,
2087
+ }, (branch.shortName || branch.name) + " · 只读")))
2088
+ : null),
2089
+ repo !== null && viewingBranch !== "" && viewingBranch !== current
2090
+ ? react.createElement("button", {
2091
+ type: "button",
2092
+ className: "fv-git-checkout",
2093
+ disabled: !canCheckout,
2094
+ title: checkoutable ? "检出该分支到工作区" : "远程分支只能查看,不能直接检出",
2095
+ onClick: () => onCheckout(viewingBranch),
2096
+ }, state.busy === true ? "切换中…" : (checkoutable ? "检出" : "只读"))
2097
+ : null),
2098
+ typeof state.notice === "string" && state.notice !== ""
2099
+ ? react.createElement("span", { className: "fv-git-hint", role: "status" }, state.notice)
2100
+ : null,
2101
+ // Checking a branch out here rewrites the checkout that *contains* the
2102
+ // root, which is wider than the directory being browsed. Saying so is
2103
+ // the difference between an informed action and a surprise.
2104
+ state.scope === "parent"
2105
+ ? react.createElement("div", { className: "fv-git-warn" },
2106
+ react.createElement("span", null,
2107
+ react.createElement("strong", null, "检出范围提醒:"),
2108
+ "该仓库位于当前根目录的上级,检出分支会一并改写根目录以外的文件。"))
2109
+ : null,
2110
+ // The discovery budget ran out, so this list may be incomplete.
2111
+ state.truncated === true
2112
+ ? react.createElement("div", { className: "fv-git-warn" },
2113
+ react.createElement("span", null,
2114
+ react.createElement("strong", null, "列表可能不完整:"),
2115
+ "子目录探测已达到上限,未列出的目录里可能还有仓库。"))
2116
+ : null);
2117
+
2118
+ // --- Working-tree changes -------------------------------------------
2119
+ // Grouped the way a source-control view does: what a commit would capture,
2120
+ // what is not staged yet, and what git is not tracking at all.
2121
+ const changes = state.changes || null;
2122
+ const changeGroup = (title, rows, extra) => {
2123
+ if (!Array.isArray(rows) || rows.length === 0) return null;
2124
+ return react.createElement("div", { className: "fv-git-change-group" },
2125
+ react.createElement("div", { className: "fv-git-section" },
2126
+ react.createElement("span", null, title),
2127
+ react.createElement("span", { className: "fv-git-count" }, String(rows.length))),
2128
+ ...rows.map((row) => react.createElement("button", {
2129
+ key: title + "\u0000" + row.path,
2130
+ type: "button",
2131
+ className: "fv-commit-file",
2132
+ title: extra === undefined ? row.path : extra,
2133
+ onClick: () => onSelectChange(row),
2134
+ },
2135
+ react.createElement("span", {
2136
+ className: "fv-commit-status",
2137
+ "data-tone": row.tone || "mod",
2138
+ "aria-label": row.change || "变更",
2139
+ }, row.letter || "M"),
2140
+ react.createElement("span", { className: "fv-commit-path" }, row.path),
2141
+ // Staged and then edited again: the row says so rather than
2142
+ // pretending the file is in one state.
2143
+ row.partiallyStaged === true
2144
+ ? react.createElement("span", { className: "fv-commit-tag" }, "部分暂存")
2145
+ : null)));
2146
+ };
2147
+
2148
+ let changesBody;
2149
+ if (state.changesStatus === "loading" || state.changesStatus === "idle") {
2150
+ changesBody = react.createElement("div", { className: "fv-git-hint" }, "正在读取工作区改动…");
2151
+ } else if (state.changesStatus === "error") {
2152
+ changesBody = react.createElement("div", { className: "fv-git-hint fv-error" }, messageOf(state.changesError));
2153
+ } else if (changes === null || changes.total === 0) {
2154
+ changesBody = react.createElement("div", { className: "fv-git-hint" }, "工作区没有未提交的改动。");
2155
+ } else {
2156
+ changesBody = react.createElement(react.Fragment, null,
2157
+ changeGroup("冲突", changes.conflicted),
2158
+ changeGroup("已暂存", changes.staged),
2159
+ changeGroup("未暂存", changes.unstaged),
2160
+ changeGroup("未跟踪", changes.untracked),
2161
+ changes.outside > 0
2162
+ ? react.createElement("div", { className: "fv-git-hint" }, changes.outside + " 个改动在根目录之外")
2163
+ : null);
2164
+ }
2165
+
2166
+ const changesSection = react.createElement("div", { className: "fv-git-changes" },
2167
+ react.createElement("div", { className: "fv-git-section" },
2168
+ react.createElement("span", null, "工作区改动"),
2169
+ react.createElement("span", { className: "fv-git-count" },
2170
+ changes !== null && typeof changes.total === "number" ? String(changes.total) : ""),
2171
+ react.createElement("button", {
2172
+ type: "button",
2173
+ className: "fv-icon-button fv-git-refresh",
2174
+ "aria-label": "刷新工作区改动",
2175
+ title: "刷新工作区改动",
2176
+ onClick: onRefreshChanges,
2177
+ }, react.createElement(SvgIconRefresh, { size: 13 }))),
2178
+ changesBody);
2179
+
2180
+ const commitRows = commits.map((commit) => react.createElement("button", {
2181
+ key: commit.sha,
2182
+ type: "button",
2183
+ className: "fv-git-commit",
2184
+ "aria-current": selectedSha === commit.sha ? "true" : undefined,
2185
+ title: commit.subject,
2186
+ onClick: () => onSelectCommit(commit),
2187
+ },
2188
+ react.createElement("span", { className: "fv-git-subject" }, commit.subject || "(无提交信息)"),
2189
+ react.createElement("span", { className: "fv-git-meta" },
2190
+ react.createElement("span", { className: "fv-git-sha" }, commit.shortSha || commit.sha.slice(0, 7)),
2191
+ react.createElement("span", { className: "fv-git-author" }, commit.author || ""),
2192
+ react.createElement("span", { className: "fv-git-date" }, formatCommitDate(commit.committedAt || commit.authoredAt)))));
2193
+
2194
+ let listBody;
2195
+ if (state.commitsStatus === "loading" || state.commitsStatus === "idle") {
2196
+ listBody = react.createElement("div", { className: "fv-git-hint" }, "正在读取提交记录…");
2197
+ } else if (state.commitsStatus === "error") {
2198
+ listBody = react.createElement("div", { className: "fv-git-hint fv-error" }, messageOf(state.commitsError));
2199
+ } else if (commitRows.length === 0) {
2200
+ listBody = react.createElement("div", { className: "fv-git-hint" }, repo.empty ? "该仓库还没有提交。" : "该分支没有提交记录。");
2201
+ } else {
2202
+ listBody = commitRows;
2203
+ }
2204
+
2205
+ const shownCount = commits.length;
2206
+ // A counter with no number and a button that cannot appear would leave an
2207
+ // empty strip under a short history, so the footer exists only when it
2208
+ // has something to say.
2209
+ const footer = state.total > 0 || state.hasMore
2210
+ ? react.createElement("div", null,
2211
+ state.total > 0
2212
+ ? react.createElement("div", { className: "fv-git-hint" }, `已显示 ${shownCount} / ${state.total}`)
2213
+ : null,
2214
+ state.hasMore
2215
+ ? react.createElement("button", {
2216
+ type: "button",
2217
+ className: "fv-git-more",
2218
+ disabled: state.commitsStatus === "more",
2219
+ onClick: onLoadMore,
2220
+ }, state.commitsStatus === "more" ? "正在加载…" : "加载更多")
2221
+ : null)
2222
+ : null;
2223
+
2224
+ return wrap(toolbar,
2225
+ changesSection,
2226
+ react.createElement("div", { className: "fv-git-section" },
2227
+ react.createElement("span", null, "提交记录"),
2228
+ react.createElement("span", { className: "fv-git-count" }, state.total > 0 ? String(state.total) : "")),
2229
+ react.createElement("div", { className: "fv-git-list" }, listBody),
2230
+ footer);
2231
+ }
2232
+
2233
+ function DiffView({ diffData, wrap }) {
2234
+ const rows = react.useMemo(() => {
2235
+ if (Array.isArray(diffData?.rows) && diffData.rows.length > 0) {
2236
+ return diffData.rows;
2237
+ }
2238
+ if (Array.isArray(diffData?.diffs)) {
2239
+ const fallback = [];
2240
+ let nLine = 1;
2241
+ for (const hunk of diffData.diffs) {
2242
+ if (hunk.oldText) {
2243
+ for (const l of hunk.oldText.split("\n")) {
2244
+ fallback.push({ type: "del", oldLine: null, newLine: null, text: l });
2245
+ }
2246
+ }
2247
+ if (hunk.newText) {
2248
+ for (const l of hunk.newText.split("\n")) {
2249
+ fallback.push({ type: "add", oldLine: null, newLine: nLine++, text: l });
2250
+ }
2251
+ }
2252
+ }
2253
+ return fallback;
2254
+ }
2255
+ return [];
2256
+ }, [diffData]);
2257
+
2258
+ const [copied, setCopied] = react.useState(false);
2259
+
2260
+ const onCopy = () => {
2261
+ const rawText = rows.map((r) => {
2262
+ const prefix = r.type === "add" ? "+" : (r.type === "del" ? "-" : (r.type === "hunk" ? "" : " "));
2263
+ return prefix + r.text;
2264
+ }).join("\n");
2265
+ if (typeof writeClipboard === "function") {
2266
+ writeClipboard(rawText).then(() => {
2267
+ setCopied(true);
2268
+ setTimeout(() => setCopied(false), 1500);
2269
+ });
2270
+ }
2271
+ };
2272
+
2273
+ if (rows.length === 0) {
2274
+ if (Array.isArray(diffData?.diffs) && diffData.diffs.length > 0) {
2275
+ return react.createElement(DiffBlock, {
2276
+ diffs: diffData.diffs,
2277
+ labels: DIFF_BLOCK_LABELS,
2278
+ maxLines: Infinity,
2279
+ });
2280
+ }
2281
+ return react.createElement("div", { className: "fv-note" }, "暂无改动差异。");
2282
+ }
2283
+
2284
+ return react.createElement("div", { className: "fv-diff-view" },
2285
+ react.createElement("div", { className: "fv-diff-toolbar" },
2286
+ react.createElement("span", { className: "fv-diff-source-tag" },
2287
+ diffData?.source === "untracked" ? "未跟踪新文件" : (diffData?.source === "working-tree" ? "工作区未提交改动" : "最新提交改动")),
2288
+ react.createElement("div", { style: { flex: "1 1 auto" } }),
2289
+ react.createElement("button", {
2290
+ type: "button",
2291
+ className: "fv-button",
2292
+ style: { height: "26px", fontSize: "12px", padding: "0 10px" },
2293
+ onClick: onCopy,
2294
+ }, copied ? "已复制" : "复制 Diff")),
2295
+ react.createElement("div", {
2296
+ className: "fv-diff-table",
2297
+ "data-wrap": wrap ? "on" : "off",
2298
+ },
2299
+ rows.map((row, idx) => {
2300
+ if (row.type === "hunk") {
2301
+ return react.createElement("div", { key: idx, className: "fv-diff-line fv-diff-hunk" },
2302
+ react.createElement("span", { className: "fv-diff-num fv-diff-hunk-gutter" }, "⋯"),
2303
+ react.createElement("span", { className: "fv-diff-num fv-diff-hunk-gutter fv-diff-num-new" }, "⋯"),
2304
+ react.createElement("span", { className: "fv-diff-sign" }),
2305
+ react.createElement("span", { className: "fv-diff-text" }, row.text));
2306
+ }
2307
+ const isAdd = row.type === "add";
2308
+ const isDel = row.type === "del";
2309
+ const lineCls = "fv-diff-line " + (isAdd ? "fv-diff-add-line" : (isDel ? "fv-diff-del-line" : "fv-diff-ctx-line"));
2310
+ return react.createElement("div", { key: idx, className: lineCls },
2311
+ react.createElement("span", { className: "fv-diff-num fv-diff-num-old" }, row.oldLine !== null ? String(row.oldLine) : ""),
2312
+ react.createElement("span", { className: "fv-diff-num fv-diff-num-new" }, row.newLine !== null ? String(row.newLine) : ""),
2313
+ react.createElement("span", { className: "fv-diff-sign" }, isAdd ? "+" : (isDel ? "-" : " ")),
2314
+ react.createElement("span", { className: "fv-diff-text" }, row.text || " "));
2315
+ })));
2316
+ }
2317
+
2318
+ /**
2319
+ * The main pane for one working-tree change.
2320
+ *
2321
+ * Shows the patch rather than the file contents, because that is what the
2322
+ * row represents: a deletion has nothing to open, and an untracked file has
2323
+ * no before-state to compare against — both still render here.
2324
+ *
2325
+ * @param {{state: object, path: string}} props - panel snapshot and the file to diff.
2326
+ */
2327
+ function WorkspaceChangeDetail({ state, path: filePath, request, repo }) {
2328
+ const [diff, setDiff] = react.useState({ status: "idle", data: null, error: null });
2329
+ // A change can be staged, unstaged, or both. The reader picks which side
2330
+ // to look at instead of the pane guessing.
2331
+ const [side, setSide] = react.useState("worktree");
2332
+
2333
+ const changes = state.changes || null;
2334
+ const row = changes === null
2335
+ ? null
2336
+ : [...(changes.staged || []), ...(changes.unstaged || []), ...(changes.untracked || []), ...(changes.conflicted || [])]
2337
+ .find((candidate) => candidate.path === filePath) || null;
2338
+ // A file that is only staged has no working-tree diff and vice versa, so
2339
+ // the side follows what the row actually contains.
2340
+ const bothSides = row !== null && changes !== null
2341
+ && (changes.staged || []).some((candidate) => candidate.path === filePath)
2342
+ && (changes.unstaged || []).some((candidate) => candidate.path === filePath);
2343
+
2344
+ const effectiveSide = bothSides
2345
+ ? side
2346
+ : (changes !== null && (changes.staged || []).some((candidate) => candidate.path === filePath)
2347
+ ? "staged"
2348
+ : "worktree");
2349
+
2350
+ // The diff is fetched per file and per side: staged and unstaged are two
2351
+ // different patches for the same path, and neither is a superset.
2352
+ react.useEffect(() => {
2353
+ if (typeof filePath !== "string" || filePath === "" || typeof request !== "function") return undefined;
2354
+ let live = true;
2355
+ setDiff((current) => ({ status: "loading", data: current.data, error: null }));
2356
+ request("gitWorkspaceDiff", {
2357
+ root: state.root,
2358
+ repo,
2359
+ path: filePath,
2360
+ staged: effectiveSide === "staged",
2361
+ }).then((value) => {
2362
+ if (live) setDiff({ status: "ready", data: value, error: null });
2363
+ }).catch((problem) => {
2364
+ if (live) setDiff({ status: "error", data: null, error: problem });
2365
+ });
2366
+ return () => { live = false; };
2367
+ }, [filePath, effectiveSide, repo, request, state.root]);
2368
+
2369
+ const body = diff.status === "error"
2370
+ ? react.createElement("div", { className: "fv-note fv-error" }, messageOf(diff.error))
2371
+ : diff.status === "ready" && diff.data !== null
2372
+ ? (diff.data.hasDiff === true
2373
+ ? react.createElement(DiffView, { diffData: diff.data, wrap: false })
2374
+ : react.createElement("div", { className: "fv-note" }, "该文件在" + (effectiveSide === "staged" ? "暂存区" : "工作区") + "没有差异。"))
2375
+ : react.createElement("div", { className: "fv-note" }, "正在读取改动…");
2376
+
2377
+ return react.createElement("div", { className: "fv-commit" },
2378
+ react.createElement("div", { className: "fv-commit-head" },
2379
+ react.createElement("span", { className: "fv-commit-subject" }, filePath || "工作区改动"),
2380
+ react.createElement("span", { className: "fv-commit-meta" },
2381
+ row !== null
2382
+ ? react.createElement("span", null, (row.change || "变更") + (row.partiallyStaged === true ? " · 部分暂存" : ""))
2383
+ : null,
2384
+ react.createElement("span", null, "工作区,未提交"))),
2385
+ bothSides
2386
+ ? react.createElement("div", { className: "fv-sheet-tabs", role: "tablist" },
2387
+ react.createElement("button", {
2388
+ type: "button", className: "fv-tab", "aria-selected": effectiveSide === "staged" ? "true" : "false",
2389
+ onClick: () => setSide("staged"),
2390
+ }, "已暂存"),
2391
+ react.createElement("button", {
2392
+ type: "button", className: "fv-tab", "aria-selected": effectiveSide === "worktree" ? "true" : "false",
2393
+ onClick: () => setSide("worktree"),
2394
+ }, "未暂存"))
2395
+ : null,
2396
+ react.createElement("div", { className: "fv-commit-diff" }, body));
2397
+ }
2398
+
2399
+ /**
2400
+ * The main pane for one commit: header, message, and changed files.
2401
+ *
2402
+ * A file row opens the ordinary preview, so the panel never grows a
2403
+ * second viewer; a row that lands outside the current root stays visible
2404
+ * but disabled rather than silently disappearing.
2405
+ */
2406
+ function CommitDetail({ state, selectedPath, onSelectFile, view, onSwitchView, onLoadDiff }) {
2407
+ if (state.detailStatus === "loading" || state.detailStatus === "idle") {
2408
+ return react.createElement("div", { className: "fv-note" }, "正在读取提交详情…");
2409
+ }
2410
+ if (state.detailStatus === "error") {
2411
+ return react.createElement("div", { className: "fv-note fv-error" }, messageOf(state.detailError));
2412
+ }
2413
+ const detail = state.detail;
2414
+ if (detail === null || detail === undefined || detail.commit === undefined) {
2415
+ return react.createElement("div", { className: "fv-note" }, "未选择提交。");
2416
+ }
2417
+ const commit = detail.commit;
2418
+ const files = Array.isArray(detail.files) ? detail.files : [];
2419
+ const author = typeof commit.author === "string" ? commit.author : "";
2420
+ const authorEmail = typeof commit.authorEmail === "string" ? commit.authorEmail : "";
2421
+ const message = typeof commit.body === "string" ? commit.body : "";
2422
+ const extraMessage = message !== "" && message !== commit.subject ? message : "";
2423
+
2424
+ const fileRows = files.length === 0
2425
+ ? [react.createElement("div", { key: "__none", className: "fv-git-hint" }, "该提交没有文件改动。")]
2426
+ : files.map((file) => {
2427
+ const renamed = typeof file.oldPath === "string" && file.oldPath !== "" && file.oldPath !== file.path;
2428
+ const shown = renamed ? file.oldPath + " → " + file.path : file.path;
2429
+ return react.createElement("button", {
2430
+ key: (file.oldPath || "") + "\u0000" + file.path,
2431
+ type: "button",
2432
+ className: "fv-commit-file",
2433
+ "aria-current": selectedPath !== "" && selectedPath === file.path ? "true" : undefined,
2434
+ title: file.outsideRoot ? "该文件在当前根目录之外,无法在这里打开" : shown,
2435
+ disabled: file.outsideRoot === true,
2436
+ onClick: () => { if (file.outsideRoot !== true) onSelectFile(file); },
2437
+ },
2438
+ react.createElement("span", {
2439
+ className: "fv-commit-status",
2440
+ "data-tone": file.tone || "mod",
2441
+ "aria-label": file.change || "变更",
2442
+ }, file.letter || "?"),
2443
+ react.createElement("span", { className: "fv-commit-path" }, shown),
2444
+ file.outsideRoot === true
2445
+ ? react.createElement("span", { className: "fv-commit-tag" }, "根目录外")
2446
+ : null);
2447
+ });
2448
+
2449
+ // Two views of one commit: which files changed, and what changed inside
2450
+ // them. Both come from the same revision pair, so they cannot disagree.
2451
+ const tabs = react.createElement("div", { className: "fv-sheet-tabs", role: "tablist" },
2452
+ react.createElement("button", {
2453
+ type: "button", className: "fv-tab", "aria-selected": view !== "diff" ? "true" : "false",
2454
+ onClick: () => onSwitchView("files"),
2455
+ }, "变更文件", react.createElement("span", { className: "fv-git-count" }, String(files.length))),
2456
+ react.createElement("button", {
2457
+ type: "button", className: "fv-tab fv-tab-diff", "aria-selected": view === "diff" ? "true" : "false",
2458
+ onClick: () => {
2459
+ onSwitchView("diff");
2460
+ if (onLoadDiff !== undefined) onLoadDiff();
2461
+ },
2462
+ }, "改动内容",
2463
+ state.commitDiff !== null && state.commitDiff !== undefined && state.commitDiff.summary !== undefined
2464
+ ? react.createElement("span", { className: "fv-diff-stat" },
2465
+ state.commitDiff.summary.added > 0
2466
+ ? react.createElement("span", { className: "fv-diff-add" }, "+" + state.commitDiff.summary.added)
2467
+ : null,
2468
+ state.commitDiff.summary.added > 0 && state.commitDiff.summary.removed > 0 ? " " : "",
2469
+ state.commitDiff.summary.removed > 0
2470
+ ? react.createElement("span", { className: "fv-diff-del" }, "-" + state.commitDiff.summary.removed)
2471
+ : null)
2472
+ : null));
2473
+
2474
+ let patchBody;
2475
+ if (state.commitDiffStatus === "loading" || state.commitDiffStatus === "idle") {
2476
+ patchBody = react.createElement("div", { className: "fv-note" }, "正在读取改动内容…");
2477
+ } else if (state.commitDiffStatus === "error") {
2478
+ patchBody = react.createElement("div", { className: "fv-note fv-error" }, messageOf(state.commitDiffError));
2479
+ } else {
2480
+ const diff = state.commitDiff;
2481
+ const diffFiles = diff !== null && diff !== undefined && Array.isArray(diff.files) ? diff.files : [];
2482
+ if (diffFiles.length === 0) {
2483
+ patchBody = react.createElement("div", { className: "fv-note" }, "该提交没有可显示的文本改动。");
2484
+ } else {
2485
+ patchBody = react.createElement("div", { className: "fv-commit-diff" },
2486
+ ...diffFiles.map((file, index) => react.createElement("div", {
2487
+ key: file.path + "\u0000" + index,
2488
+ className: "fv-commit-diff-file",
2489
+ },
2490
+ react.createElement("div", { className: "fv-git-section" },
2491
+ react.createElement("span", { className: "fv-commit-status", "data-tone": file.tone || "mod" }, file.letter || "M"),
2492
+ react.createElement("span", { className: "fv-commit-path" },
2493
+ file.oldPath !== null && file.oldPath !== file.path
2494
+ ? file.oldPath + " → " + file.path
2495
+ : file.path),
2496
+ react.createElement("span", { className: "fv-git-count" },
2497
+ "+" + (file.added || 0) + " -" + (file.removed || 0))),
2498
+ // Reuses the existing diff renderer, so a commit's patch and a
2499
+ // working-tree diff look and behave the same way.
2500
+ react.createElement(DiffView, { diffData: { rows: file.rows, diffs: [] }, wrap: false }))));
2501
+ }
2502
+ }
2503
+
2504
+ return react.createElement("div", { className: "fv-commit" },
2505
+ react.createElement("div", { className: "fv-commit-head" },
2506
+ react.createElement("span", { className: "fv-commit-subject" }, commit.subject || "(无提交信息)"),
2507
+ react.createElement("span", { className: "fv-commit-meta" },
2508
+ react.createElement("span", { className: "fv-commit-sha" }, commit.sha || ""),
2509
+ author !== ""
2510
+ ? react.createElement("span", null, author + (authorEmail !== "" ? " <" + authorEmail + ">" : ""))
2511
+ : null,
2512
+ react.createElement("span", null, formatCommitTimestamp(commit.committedAt || commit.authoredAt)))),
2513
+ extraMessage === "" ? null : react.createElement("div", { className: "fv-commit-body" }, extraMessage),
2514
+ tabs,
2515
+ view === "diff"
2516
+ ? patchBody
2517
+ : react.createElement("div", { className: "fv-commit-files" }, ...fileRows));
2518
+ }
2519
+
2520
+ /** Decode base64 into a Blob without a data: URL round-trip. */
1440
2521
  function blobOf(base64, mediaType) {
1441
2522
  const binary = atob(base64);
1442
2523
  const bytes = new Uint8Array(binary.length);
@@ -1444,6 +2525,29 @@ window.__ModuleLoader__.load({
1444
2525
  return new Blob([bytes], mediaType ? { type: mediaType } : undefined);
1445
2526
  }
1446
2527
 
2528
+ /**
2529
+ * Classify a path the way the host's `kindOf` does.
2530
+ *
2531
+ * The bundle is one hand-written file and cannot import `lib/core.js`, and
2532
+ * a file opened from a commit has no on-disk metadata to ask about, so the
2533
+ * name alone has to decide which previewer to mount.
2534
+ * @param {string} path - a file path.
2535
+ * @returns {string} the kind the viewer switches on.
2536
+ */
2537
+ function kindOfPath(path) {
2538
+ const base = baseNameOf(path).toLowerCase();
2539
+ const dot = base.lastIndexOf(".");
2540
+ const ext = dot <= 0 ? "" : base.slice(dot + 1).toLowerCase();
2541
+ if (ext === "md" || ext === "markdown" || ext === "mdx") return "markdown";
2542
+ if (ext === "json" || ext === "jsonc" || ext === "json5") return "json";
2543
+ if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "gif" || ext === "webp"
2544
+ || ext === "avif" || ext === "bmp" || ext === "ico" || ext === "svg") return "image";
2545
+ if (ext === "pdf") return "pdf";
2546
+ if (ext === "xlsx" || ext === "xlsm" || ext === "xls") return "sheet";
2547
+ if (ext === "docx" || ext === "doc") return "doc";
2548
+ return "text";
2549
+ }
2550
+
1447
2551
  const MEDIA_TYPES = {
1448
2552
  png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif",
1449
2553
  webp: "image/webp", avif: "image/avif", bmp: "image/bmp", ico: "image/x-icon",
@@ -1753,6 +2857,9 @@ window.__ModuleLoader__.load({
1753
2857
  // input state through props.
1754
2858
  const composerStore = createStore(null);
1755
2859
  const request = createRequest(ctx.connection);
2860
+ // One controller for the whole drawer: the Git sidebar and the commit
2861
+ // pane read the same snapshot, and a checkout invalidates both.
2862
+ const git = createGitController(request);
1756
2863
 
1757
2864
  /**
1758
2865
  * The file tree. Directories expand in place rather than replacing the
@@ -1870,7 +2977,7 @@ window.__ModuleLoader__.load({
1870
2977
  }, react.createElement("span", { "aria-hidden": "true" }, "@")));
1871
2978
  }
1872
2979
 
1873
- function Tree({ rows, selected, onToggle, onSelect, onMention, onContextMenu, width, gitStatus }) {
2980
+ function Tree({ rows, selected, onToggle, onSelect, onMention, onContextMenu, width, gitStatus, header }) {
1874
2981
  const children = rows.map((row) => {
1875
2982
  const indent = { "--fv-depth": row.depth, paddingLeft: 8 + row.depth * 14 + "px" };
1876
2983
  if (row.kind === "status") {
@@ -1897,7 +3004,7 @@ window.__ModuleLoader__.load({
1897
3004
  return react.createElement("div", {
1898
3005
  className: "fv-tree", role: "tree", "aria-label": "文件树",
1899
3006
  style: typeof width === "number" ? { width: width + "px" } : undefined,
1900
- }, ...children);
3007
+ }, header === undefined ? null : header, ...children);
1901
3008
  }
1902
3009
 
1903
3010
  /** Dedicated Image Previewer: zoom, pan, rotate, dimensions, toolbar, and reset. */
@@ -2222,93 +3329,8 @@ window.__ModuleLoader__.load({
2222
3329
  }
2223
3330
 
2224
3331
  /** Line-numbered diff viewer with dual line number gutters. */
2225
- function DiffView({ diffData, wrap }) {
2226
- const rows = react.useMemo(() => {
2227
- if (Array.isArray(diffData?.rows) && diffData.rows.length > 0) {
2228
- return diffData.rows;
2229
- }
2230
- if (Array.isArray(diffData?.diffs)) {
2231
- const fallback = [];
2232
- let nLine = 1;
2233
- for (const hunk of diffData.diffs) {
2234
- if (hunk.oldText) {
2235
- for (const l of hunk.oldText.split("\n")) {
2236
- fallback.push({ type: "del", oldLine: null, newLine: null, text: l });
2237
- }
2238
- }
2239
- if (hunk.newText) {
2240
- for (const l of hunk.newText.split("\n")) {
2241
- fallback.push({ type: "add", oldLine: null, newLine: nLine++, text: l });
2242
- }
2243
- }
2244
- }
2245
- return fallback;
2246
- }
2247
- return [];
2248
- }, [diffData]);
2249
-
2250
- const [copied, setCopied] = react.useState(false);
2251
-
2252
- const onCopy = () => {
2253
- const rawText = rows.map((r) => {
2254
- const prefix = r.type === "add" ? "+" : (r.type === "del" ? "-" : (r.type === "hunk" ? "" : " "));
2255
- return prefix + r.text;
2256
- }).join("\n");
2257
- if (typeof writeClipboard === "function") {
2258
- writeClipboard(rawText).then(() => {
2259
- setCopied(true);
2260
- setTimeout(() => setCopied(false), 1500);
2261
- });
2262
- }
2263
- };
2264
-
2265
- if (rows.length === 0) {
2266
- if (Array.isArray(diffData?.diffs) && diffData.diffs.length > 0) {
2267
- return react.createElement(DiffBlock, {
2268
- diffs: diffData.diffs,
2269
- labels: DIFF_BLOCK_LABELS,
2270
- maxLines: Infinity,
2271
- });
2272
- }
2273
- return react.createElement("div", { className: "fv-note" }, "暂无改动差异。");
2274
- }
2275
-
2276
- return react.createElement("div", { className: "fv-diff-view" },
2277
- react.createElement("div", { className: "fv-diff-toolbar" },
2278
- react.createElement("span", { className: "fv-diff-source-tag" },
2279
- diffData?.source === "untracked" ? "未跟踪新文件" : (diffData?.source === "working-tree" ? "工作区未提交改动" : "最新提交改动")),
2280
- react.createElement("div", { style: { flex: "1 1 auto" } }),
2281
- react.createElement("button", {
2282
- type: "button",
2283
- className: "fv-button",
2284
- style: { height: "26px", fontSize: "12px", padding: "0 10px" },
2285
- onClick: onCopy,
2286
- }, copied ? "已复制" : "复制 Diff")),
2287
- react.createElement("div", {
2288
- className: "fv-diff-table",
2289
- "data-wrap": wrap ? "on" : "off",
2290
- },
2291
- rows.map((row, idx) => {
2292
- if (row.type === "hunk") {
2293
- return react.createElement("div", { key: idx, className: "fv-diff-line fv-diff-hunk" },
2294
- react.createElement("span", { className: "fv-diff-num fv-diff-hunk-gutter" }, "⋯"),
2295
- react.createElement("span", { className: "fv-diff-num fv-diff-hunk-gutter fv-diff-num-new" }, "⋯"),
2296
- react.createElement("span", { className: "fv-diff-sign" }),
2297
- react.createElement("span", { className: "fv-diff-text" }, row.text));
2298
- }
2299
- const isAdd = row.type === "add";
2300
- const isDel = row.type === "del";
2301
- const lineCls = "fv-diff-line " + (isAdd ? "fv-diff-add-line" : (isDel ? "fv-diff-del-line" : "fv-diff-ctx-line"));
2302
- return react.createElement("div", { key: idx, className: lineCls },
2303
- react.createElement("span", { className: "fv-diff-num fv-diff-num-old" }, row.oldLine !== null ? String(row.oldLine) : ""),
2304
- react.createElement("span", { className: "fv-diff-num fv-diff-num-new" }, row.newLine !== null ? String(row.newLine) : ""),
2305
- react.createElement("span", { className: "fv-diff-sign" }, isAdd ? "+" : (isDel ? "-" : " ")),
2306
- react.createElement("span", { className: "fv-diff-text" }, row.text || " "));
2307
- })));
2308
- }
2309
-
2310
3332
  /** Text, Markdown and JSON: one windowed read, three renderings. */
2311
- function TextView({ meta, root, wrap, lang }) {
3333
+ function TextView({ meta, root, wrap, lang, commit, repo }) {
2312
3334
  // Callers mount this with a per-file key, so paging state starts fresh
2313
3335
  // for every file instead of leaking the previous file's window into the
2314
3336
  // first request.
@@ -2317,6 +3339,10 @@ window.__ModuleLoader__.load({
2317
3339
  const [raw, setRaw] = react.useState(false);
2318
3340
  const [diffMode, setDiffMode] = react.useState(false);
2319
3341
  const [diffData, setDiffData] = react.useState(null);
3342
+ // A file opened from a commit is read from that commit's blob, never
3343
+ // from the working tree: the two differ exactly when the history is
3344
+ // interesting, and a deleted file has no working-tree copy at all.
3345
+ const fromCommit = typeof commit === "string" && commit !== "";
2320
3346
 
2321
3347
  react.useEffect(() => {
2322
3348
  let live = true;
@@ -2325,18 +3351,30 @@ window.__ModuleLoader__.load({
2325
3351
  // The override rides on the request, so the host resolves the
2326
3352
  // language and the client never highlights on its own authority.
2327
3353
  const langPayload = lang === undefined || lang === "auto" ? {} : { lang };
2328
- request("read", { root, path: meta.path, offset, limit: WINDOW_LINES, ...langPayload }).then((value) => {
3354
+ const readMethod = fromCommit ? "gitCommitFile" : "read";
3355
+ // `meta.commit` is the commit the bytes come from; for a deletion that
3356
+ // is the parent, and `meta.sha` names the commit being viewed.
3357
+ const readPayload = fromCommit
3358
+ ? { root, repo, sha: commit, path: meta.path, offset, limit: WINDOW_LINES, ...langPayload }
3359
+ : { root, path: meta.path, offset, limit: WINDOW_LINES, ...langPayload };
3360
+ request(readMethod, readPayload).then((value) => {
2329
3361
  if (live) setState({ status: "ready", data: value, error: null });
2330
3362
  }).catch((error) => {
2331
3363
  if (live) setState({ status: "error", data: null, error });
2332
3364
  });
3365
+ // The working-tree diff is meaningless for a historical blob: it
3366
+ // compares the file with the index, not with the parent commit.
3367
+ if (fromCommit) {
3368
+ setDiffData(null);
3369
+ return () => { live = false; };
3370
+ }
2333
3371
  request("diff", { root, path: meta.path }).then((val) => {
2334
3372
  if (live) setDiffData(val);
2335
3373
  }).catch(() => {
2336
3374
  if (live) setDiffData(null);
2337
3375
  });
2338
3376
  return () => { live = false; };
2339
- }, [root, meta.path, offset, lang]);
3377
+ }, [root, meta.path, offset, lang, fromCommit, commit, repo]);
2340
3378
 
2341
3379
  if (state.status === "error") {
2342
3380
  return react.createElement("div", { className: "fv-note fv-error" }, messageOf(state.error));
@@ -2483,7 +3521,7 @@ window.__ModuleLoader__.load({
2483
3521
  }
2484
3522
 
2485
3523
  /** Pick the viewer for a file and keep unsupported formats useful. */
2486
- function FileView({ meta, root, api, wrap, lang, onDownload }) {
3524
+ function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo }) {
2487
3525
  if (meta === null) {
2488
3526
  return react.createElement("div", { className: "fv-note" }, "从左侧选择一个文件。");
2489
3527
  }
@@ -2509,7 +3547,22 @@ window.__ModuleLoader__.load({
2509
3547
  }
2510
3548
  // The key remounts each viewer per file so no per-file state (paging,
2511
3549
  // active sheet, blob URL) survives a switch.
2512
- const key = root + "\u0000" + meta.path;
3550
+ const key = root + "\u0000" + meta.path + (commit ? "\u0000" + commit : "");
3551
+ // A blob read from history is not the file on disk. Say which commit it
3552
+ // came from, and call out a deletion, so the preview cannot be mistaken
3553
+ // for the current contents.
3554
+ const fromHistory = typeof commit === "string" && commit !== "";
3555
+ const banner = fromHistory
3556
+ ? react.createElement("div", { className: "fv-history-banner" },
3557
+ react.createElement("span", null,
3558
+ "以下内容来自提交 " + commit.slice(0, 7) + (meta.change ? ",该文件本次为「" + meta.change + "」" : "")),
3559
+ react.createElement("span", { className: "fv-history-hint" }, "这是历史版本,不是工作区当前内容。"))
3560
+ : null;
3561
+ if (fromHistory && meta.kind !== "text" && meta.kind !== "markdown" && meta.kind !== "json") {
3562
+ return react.createElement(react.Fragment, null, banner,
3563
+ react.createElement("div", { className: "fv-note" },
3564
+ "该提交中的文件是 " + meta.kind + " 格式,历史版本暂不支持预览。"));
3565
+ }
2513
3566
  if (meta.kind === "image") return react.createElement(ImageView, { key, meta, root });
2514
3567
  if (meta.kind === "pdf") return react.createElement(BinaryView, { key, meta, root });
2515
3568
  if (meta.kind === "sheet") {
@@ -2518,7 +3571,8 @@ window.__ModuleLoader__.load({
2518
3571
  if (meta.kind === "doc") {
2519
3572
  return react.createElement("div", { className: "fv-content" }, react.createElement(DocView, { key, meta, root }));
2520
3573
  }
2521
- return react.createElement(TextView, { key, meta, root, wrap, lang });
3574
+ return react.createElement(react.Fragment, null, banner,
3575
+ react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo }));
2522
3576
  }
2523
3577
 
2524
3578
  /** Right-click context menu for file and directory items. */
@@ -2866,6 +3920,74 @@ window.__ModuleLoader__.load({
2866
3920
  setTreeWidth(DEFAULT_TREE_WIDTH);
2867
3921
  writeTreeWidth(DEFAULT_TREE_WIDTH);
2868
3922
  };
3923
+
3924
+ // --- Resizing the whole drawer from its leading edge ---------------
3925
+ // 0 means "whatever the stylesheet picked"; a stored value is only kept
3926
+ // once the reader has actually dragged the edge.
3927
+ const [shellWidth, setShellWidth] = react.useState(readShellWidth);
3928
+ const [edgeResizing, setEdgeResizing] = react.useState(false);
3929
+ const edgeRef = react.useRef(null);
3930
+ const shellWidthRef = react.useRef(shellWidth);
3931
+ shellWidthRef.current = shellWidth;
3932
+
3933
+ const onEdgePointerDown = (event) => {
3934
+ if (event.button !== 0 && event.button !== undefined) return;
3935
+ event.preventDefault();
3936
+ event.stopPropagation();
3937
+ const shellEl = edgeRef.current ? edgeRef.current.parentElement : null;
3938
+ // Measure the rendered panel rather than trusting state: on a fresh
3939
+ // open the width still comes from the stylesheet and no drag has
3940
+ // happened yet.
3941
+ const startW = shellEl ? shellEl.getBoundingClientRect().width : (shellWidthRef.current || 0);
3942
+ edgeRef.current._drag = { startX: event.clientX, startW };
3943
+ setEdgeResizing(true);
3944
+ try { event.currentTarget.setPointerCapture(event.pointerId); } catch (_) {}
3945
+ };
3946
+
3947
+ const onEdgePointerMove = (event) => {
3948
+ const drag = edgeRef.current ? edgeRef.current._drag : null;
3949
+ if (!drag) return;
3950
+ // Dragging left widens: the panel is pinned to the right edge, so the
3951
+ // pointer moving away from that edge grows it.
3952
+ const delta = drag.startX - event.clientX;
3953
+ const available = typeof window === "undefined" ? 0 : (window.visualViewport?.width ?? window.innerWidth ?? 0);
3954
+ const next = clampShellWidth(drag.startW + delta, available);
3955
+ setShellWidth(next);
3956
+ };
3957
+
3958
+ const onEdgePointerUp = (event) => {
3959
+ const node = edgeRef.current;
3960
+ if (!node || !node._drag) return;
3961
+ node._drag = null;
3962
+ setEdgeResizing(false);
3963
+ try { event.currentTarget.releasePointerCapture(event.pointerId); } catch (_) {}
3964
+ writeShellWidth(shellWidthRef.current);
3965
+ };
3966
+
3967
+ const onEdgeDoubleClick = () => {
3968
+ // Double-click restores the stylesheet width, which is the same
3969
+ // "forget my preference" gesture the sidebar handle uses.
3970
+ setShellWidth(0);
3971
+ writeShellWidth(0);
3972
+ };
3973
+
3974
+ /** Keyboard resizing: a drag handle that only a pointer can use is a defect. */
3975
+ const onEdgeKeyDown = (event) => {
3976
+ const available = typeof window === "undefined" ? 0 : (window.visualViewport?.width ?? window.innerWidth ?? 0);
3977
+ const shellEl = edgeRef.current ? edgeRef.current.parentElement : null;
3978
+ const current = shellEl ? shellEl.getBoundingClientRect().width : (shellWidthRef.current || 0);
3979
+ const step = event.shiftKey ? 64 : 16;
3980
+ let next = current;
3981
+ if (event.key === "ArrowLeft") next = current + step;
3982
+ else if (event.key === "ArrowRight") next = current - step;
3983
+ else if (event.key === "Home") next = MIN_SHELL_WIDTH;
3984
+ else if (event.key === "End") next = available;
3985
+ else return;
3986
+ event.preventDefault();
3987
+ const clamped = clampShellWidth(next, available);
3988
+ setShellWidth(clamped);
3989
+ writeShellWidth(clamped);
3990
+ };
2869
3991
  // Soft wrap for source views. Off by default: code is written with
2870
3992
  // meaningful line breaks and wrapping obscures them.
2871
3993
  const [wrap, setWrap] = react.useState(false);
@@ -2901,6 +4023,16 @@ window.__ModuleLoader__.load({
2901
4023
 
2902
4024
  const [contextMenu, setContextMenu] = react.useState(null);
2903
4025
  const [gitStatus, setGitStatus] = react.useState({ modified: new Set(), untracked: new Set() });
4026
+ // Which sidebar pane is showing, and whether the main pane is a commit
4027
+ // rather than a file. Both live here because the drawer owns the split.
4028
+ const [side, setSide] = react.useState("files");
4029
+ const [showCommit, setShowCommit] = react.useState(false);
4030
+ // Which commit a previewed file is being read from. Empty means the
4031
+ // ordinary working-tree preview, which is the file tree's behaviour.
4032
+ const [previewCommit, setPreviewCommit] = react.useState("");
4033
+ // Which half of the commit pane is showing: the file list or the patch.
4034
+ const [commitView, setCommitView] = react.useState("files");
4035
+ const gitState = useStore(git.store);
2904
4036
 
2905
4037
  react.useEffect(() => {
2906
4038
  if (!open || !root) return undefined;
@@ -2914,6 +4046,25 @@ window.__ModuleLoader__.load({
2914
4046
  }).catch(() => {});
2915
4047
  return () => { live = false; };
2916
4048
  }, [open, root]);
4049
+
4050
+ // The Git panel describes one root; switching roots drops every cached
4051
+ // repository, branch and page rather than showing another project's
4052
+ // history under the new name.
4053
+ react.useEffect(() => {
4054
+ if (!open) return;
4055
+ git.reset(root);
4056
+ setShowCommit(false);
4057
+ }, [open, root]);
4058
+
4059
+ // Loaded on first use, never on open: most visits never touch the Git
4060
+ // tab, and discovery spawns a git process per candidate repository.
4061
+ react.useEffect(() => {
4062
+ if (!open || side !== "git" || root === "") return;
4063
+ git.ensure();
4064
+ // The working-tree list is part of the panel's first paint, not a
4065
+ // second click: "what is uncommitted" is the first thing to look at.
4066
+ git.loadChanges();
4067
+ }, [open, side, root]);
2917
4068
  const [toast, setToast] = react.useState(null);
2918
4069
  const toastTimer = react.useRef(null);
2919
4070
  const showToast = react.useCallback((msg) => {
@@ -3176,8 +4327,9 @@ window.__ModuleLoader__.load({
3176
4327
  };
3177
4328
 
3178
4329
  // Re-read every directory the reader currently has open, keeping the
3179
- // tree's shape.
3180
- const refresh = () => {
4330
+ // tree's shape, plus the status badges a checkout or external edit
4331
+ // invalidates.
4332
+ const reloadWorkingTree = () => {
3181
4333
  treeGeneration.current += 1;
3182
4334
  setNodes(new Map());
3183
4335
  for (const dirPath of ["", ...expanded]) loadDirectory(dirPath);
@@ -3189,10 +4341,118 @@ window.__ModuleLoader__.load({
3189
4341
  }).catch(() => {});
3190
4342
  };
3191
4343
 
4344
+ const refresh = () => {
4345
+ reloadWorkingTree();
4346
+ if (side === "git") {
4347
+ const state = git.store.get();
4348
+ const keepSha = showCommit && state.detail !== null && state.detail !== undefined && state.detail.commit !== undefined
4349
+ ? state.detail.commit.sha
4350
+ : "";
4351
+ if (state.root !== root) git.reset(root);
4352
+ git.loadRepos().then(() => {
4353
+ if (keepSha !== "") git.openCommit(keepSha);
4354
+ });
4355
+ }
4356
+ };
4357
+
4358
+ /** Open one commit in the main pane, replacing whatever was there. */
4359
+ const openCommit = (commit) => {
4360
+ if (commit === null || commit === undefined || typeof commit.sha !== "string") return;
4361
+ setShowCommit(true);
4362
+ setPane("content");
4363
+ setCommitView("files");
4364
+ // Re-opening the commit already on screen costs nothing: its detail
4365
+ // does not depend on the working tree, only on the commit itself.
4366
+ const cached = git.store.get();
4367
+ if (cached.detailStatus === "ready" && cached.detail !== null && cached.detail !== undefined
4368
+ && cached.detail.commit !== undefined && cached.detail.commit.sha === commit.sha) return;
4369
+ git.openCommit(commit.sha);
4370
+ };
4371
+
4372
+ /**
4373
+ * Open one file from a commit in the ordinary preview.
4374
+ *
4375
+ * The row's path is already root-relative (the host rebased it), so it
4376
+ * goes straight through the same `meta` request the tree uses; files
4377
+ * outside the root are disabled in the list and never reach here.
4378
+ */
4379
+ const openCommitFile = (file) => {
4380
+ if (file === null || file === undefined || file.outsideRoot === true) return;
4381
+ if (typeof file.path !== "string" || file.path === "") return;
4382
+ const sha = commitSha;
4383
+ // A deleted file has nothing on disk, and any other file may differ
4384
+ // from the version this commit recorded. The preview therefore reads
4385
+ // the commit's blob, which needs the file's metadata from the same
4386
+ // source rather than a `meta` lookup that would hit the disk.
4387
+ if (sha !== "") {
4388
+ const component = kindOfPath(file.path);
4389
+ // A deletion has no blob in this commit, and a rename's old path
4390
+ // does too. Both still have their previous contents in the parent,
4391
+ // which is the version the reader is asking to see.
4392
+ const letter = typeof file.letter === "string" ? file.letter : "";
4393
+ const detail = gitState.detail;
4394
+ const parentSha = detail && typeof detail.parentSha === "string" ? detail.parentSha : "";
4395
+ const fromParent = (letter === "D" || letter === "R") && parentSha !== "";
4396
+ // A rename shows the old path, because that is the side that still
4397
+ // has content in the parent commit.
4398
+ const blobPath = fromParent && typeof file.oldPath === "string" && file.oldPath !== ""
4399
+ ? file.oldPath
4400
+ : file.path;
4401
+ const readSha = fromParent ? parentSha : sha;
4402
+ setShowCommit(false);
4403
+ setPane("content");
4404
+ setPreviewCommit(readSha);
4405
+ setLangChoice("auto");
4406
+ const ticket = metaTicket.current + 1;
4407
+ metaTicket.current = ticket;
4408
+ const known = detail && Array.isArray(detail.files)
4409
+ ? detail.files.find((row) => row.path === file.path)
4410
+ : null;
4411
+ setMeta({
4412
+ root: root,
4413
+ // The blob path is what the host reads; the display path keeps the
4414
+ // row the reader clicked.
4415
+ path: blobPath,
4416
+ name: baseNameOf(blobPath),
4417
+ displayPath: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
4418
+ kind: component,
4419
+ commit: readSha,
4420
+ // The commit records whether the file was added, modified or
4421
+ // deleted; the preview says so instead of looking like a normal
4422
+ // working-tree file.
4423
+ change: known ? known.change : "",
4424
+ changeLetter: known ? known.letter : "",
4425
+ deleted: known ? known.letter === "D" : false,
4426
+ });
4427
+ setError(null);
4428
+ return;
4429
+ }
4430
+ setShowCommit(false);
4431
+ selectFile({ path: file.path, name: baseNameOf(file.path), type: "file" });
4432
+ };
4433
+
4434
+ /**
4435
+ * Open one working-tree change as a diff.
4436
+ *
4437
+ * The main pane shows the patch rather than the file: a deletion has no
4438
+ * file to open, and "what changed" is the question the row answers.
4439
+ */
4440
+ const openWorkspaceChange = (row) => {
4441
+ if (row === null || row === undefined || typeof row.path !== "string" || row.path === "") return;
4442
+ setShowCommit(true);
4443
+ setPane("content");
4444
+ setCommitView("changes");
4445
+ setPreviewCommit("");
4446
+ void row;
4447
+ };
4448
+
3192
4449
  // Clicking two files quickly must not let the slower answer win: only
3193
4450
  // the newest request may write state.
3194
4451
  const selectFile = (entry) => {
3195
4452
  setPane("content");
4453
+ // Selecting from the file tree is always the working tree, so any
4454
+ // commit a preview was read from is dropped here.
4455
+ setPreviewCommit("");
3196
4456
  // The remembered choice for this file takes effect immediately, so
3197
4457
  // the read that follows is already resolved with it.
3198
4458
  setLangChoice(readLangOverride(root, entry.path));
@@ -3224,6 +4484,50 @@ window.__ModuleLoader__.load({
3224
4484
  const activeRoot = roots.find((row) => row.id === root);
3225
4485
  const treeRows = flattenTree({ expanded, nodes });
3226
4486
 
4487
+ /**
4488
+ * The sidebar's two-pane switch, kept out of the tree component so the
4489
+ * Git panel and the file tree share exactly one control and one seat.
4490
+ */
4491
+ const sideTabs = react.createElement("div", {
4492
+ className: "fv-side-tabs",
4493
+ role: "tablist",
4494
+ "aria-label": "侧栏视图",
4495
+ },
4496
+ react.createElement("button", {
4497
+ type: "button",
4498
+ role: "tab",
4499
+ className: "fv-side-tab",
4500
+ "aria-selected": side === "files" ? "true" : "false",
4501
+ title: "浏览项目文件",
4502
+ onClick: () => {
4503
+ setSide("files");
4504
+ setShowCommit(false);
4505
+ },
4506
+ }, "文件"),
4507
+ react.createElement("button", {
4508
+ type: "button",
4509
+ role: "tab",
4510
+ className: "fv-side-tab",
4511
+ "aria-selected": side === "git" ? "true" : "false",
4512
+ title: "查看 Git 分支、提交与改动文件",
4513
+ onClick: () => {
4514
+ setSide("git");
4515
+ git.ensure();
4516
+ },
4517
+ }, react.createElement(SvgIconBranch, { size: 13 }), "Git"));
4518
+
4519
+ const commitSha = gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
4520
+ ? gitState.detail.commit.sha
4521
+ : "";
4522
+ // The main pane is showing something commit-shaped whenever it is not
4523
+ // the ordinary file preview: a commit, or one working-tree change.
4524
+ const detailPane = showCommit;
4525
+ const commitHeadline = commitView === "changes"
4526
+ ? "工作区改动"
4527
+ : (showCommit && gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
4528
+ ? "提交 " + (gitState.detail.commit.shortSha || String(gitState.detail.commit.sha || "").slice(0, 7))
4529
+ : "");
4530
+
3227
4531
  const handleDownloadItem = (entry) => {
3228
4532
  if (!entry) return;
3229
4533
  const targetPath = entry.path || entry.displayPath;
@@ -3242,9 +4546,43 @@ window.__ModuleLoader__.load({
3242
4546
  openStore.set(null);
3243
4547
  },
3244
4548
  },
3245
- react.createElement("div", { className: "fv-shell", "data-fullscreen": fullscreen ? "true" : "false", "data-mobile": isMobile ? "true" : "false" },
4549
+ react.createElement("div", {
4550
+ className: "fv-shell",
4551
+ "data-fullscreen": fullscreen ? "true" : "false",
4552
+ "data-mobile": isMobile ? "true" : "false",
4553
+ "data-edge-resizing": edgeResizing ? "true" : "false",
4554
+ // Only a dragged width overrides the stylesheet default; 0 leaves
4555
+ // the responsive `min(64vw, 1100px)` in charge.
4556
+ style: shellWidth > 0 && !fullscreen ? { width: shellWidth + "px" } : undefined,
4557
+ },
4558
+ // The drawer's own resize handle, seated on its leading edge. It is
4559
+ // skipped on a phone and in fullscreen, where the panel already
4560
+ // spans the frame and there is nothing to widen into.
4561
+ isMobile || fullscreen
4562
+ ? null
4563
+ : react.createElement("div", {
4564
+ ref: edgeRef,
4565
+ className: "fv-edge",
4566
+ role: "separator",
4567
+ tabIndex: 0,
4568
+ "aria-orientation": "vertical",
4569
+ "aria-label": "调整文件查看器宽度",
4570
+ "aria-valuenow": shellWidth > 0 ? shellWidth : undefined,
4571
+ "aria-valuemin": MIN_SHELL_WIDTH,
4572
+ "aria-valuemax": 4000,
4573
+ title: "拖动调整查看器宽度,双击恢复默认",
4574
+ "data-dragging": edgeResizing ? "true" : "false",
4575
+ onPointerDown: onEdgePointerDown,
4576
+ onPointerMove: onEdgePointerMove,
4577
+ onPointerUp: onEdgePointerUp,
4578
+ onPointerCancel: onEdgePointerUp,
4579
+ onDoubleClick: onEdgeDoubleClick,
4580
+ onKeyDown: onEdgeKeyDown,
4581
+ }),
3246
4582
  react.createElement("div", { className: "fv-head" },
3247
- react.createElement("span", { className: "fv-title" }, "文件"),
4583
+ // The title names the pane the sidebar is showing, so the drawer
4584
+ // never claims to be a file browser while listing Git history.
4585
+ react.createElement("span", { className: "fv-title" }, side === "git" ? "Git" : "文件"),
3248
4586
  roots.length > 1
3249
4587
  ? react.createElement("select", {
3250
4588
  className: "fv-root-select",
@@ -3255,11 +4593,11 @@ window.__ModuleLoader__.load({
3255
4593
  : react.createElement("span", { className: "fv-crumbs" }, activeRoot ? activeRoot.label : ""),
3256
4594
  // The tree shows structure, so the header carries the selected
3257
4595
  // file's path instead of navigable breadcrumbs.
3258
- react.createElement("div", { className: "fv-crumbs fv-file-path", title: meta === null ? "" : meta.path },
3259
- meta === null ? "" : meta.path),
4596
+ react.createElement("div", { className: "fv-crumbs fv-file-path", title: showCommit ? "" : (meta === null ? "" : meta.path) },
4597
+ showCommit ? "" : (meta === null ? "" : meta.path)),
3260
4598
  // Only offered where it changes anything: images, PDFs, sheets and
3261
4599
  // documents do not scroll sideways the way source lines do.
3262
- meta !== null && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
4600
+ !showCommit && meta !== null && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
3263
4601
  ? react.createElement("button", {
3264
4602
  type: "button",
3265
4603
  className: "fv-icon-button",
@@ -3269,7 +4607,7 @@ window.__ModuleLoader__.load({
3269
4607
  onClick: () => setWrap((current) => !current),
3270
4608
  }, react.createElement(SvgIconWrap, { size: 15 }))
3271
4609
  : null,
3272
- meta !== null && meta.kind !== "directory"
4610
+ !showCommit && meta !== null && meta.kind !== "directory"
3273
4611
  ? react.createElement("button", {
3274
4612
  type: "button",
3275
4613
  className: "fv-icon-button fv-btn-download",
@@ -3308,19 +4646,54 @@ window.__ModuleLoader__.load({
3308
4646
  type: "button", className: "fv-icon-button fv-btn-close", "aria-label": "关闭", title: "关闭文件查看器", onClick: () => openStore.set(null),
3309
4647
  }, react.createElement(SvgIconClose, { size: 15 }))),
3310
4648
  react.createElement("div", { className: "fv-body", "data-pane": pane, "data-resizing": isResizing ? "true" : "false", "data-mobile": isMobile ? "true" : "false" },
3311
- react.createElement(Tree, {
3312
- rows: treeRows,
3313
- selected: meta === null ? "" : meta.path,
3314
- onToggle: toggleDirectory,
3315
- onSelect: selectFile,
3316
- // Always offered: the DOM fallback covers hosts that leave the
3317
- // bridge without input props, where gating on the store made the
3318
- // button vanish with no way to reach it.
3319
- onMention: mention,
3320
- onContextMenu: (ctx) => setContextMenu(ctx),
3321
- width: isMobile ? undefined : treeWidth,
3322
- gitStatus,
3323
- }),
4649
+ side === "git"
4650
+ ? react.createElement(GitSidebar, {
4651
+ state: gitState,
4652
+ width: isMobile ? undefined : treeWidth,
4653
+ header: sideTabs,
4654
+ selectedSha: commitSha,
4655
+ onPickRepo: git.pickRepo,
4656
+ onPickBranch: git.pickBranch,
4657
+ onCheckout: (branch) => {
4658
+ // A checkout rewrites the working tree, so the tree's
4659
+ // modified/untracked badges have to be re-read too. The
4660
+ // commit being viewed survives: it is history, not the
4661
+ // working tree, and reloading the repository clears it.
4662
+ const keepSha = commitSha;
4663
+ const state = git.store.get();
4664
+ // The repository lives above this root, so the checkout
4665
+ // reaches outside it. The host refuses without an explicit
4666
+ // consent flag, which is exactly what this question is.
4667
+ const consent = state.scope === "parent"
4668
+ ? (typeof window === "undefined" || typeof window.confirm !== "function"
4669
+ || window.confirm("该分支所在的仓库位于当前根目录的上级,检出会一并改写根目录以外的文件。确定继续吗?"))
4670
+ : true;
4671
+ if (!consent) return;
4672
+ git.checkout(branch, state.scope === "parent").then(() => {
4673
+ reloadWorkingTree();
4674
+ if (keepSha !== "") git.openCommit(keepSha);
4675
+ });
4676
+ },
4677
+ onSelectCommit: openCommit,
4678
+ onSelectChange: openWorkspaceChange,
4679
+ onRefreshChanges: git.loadChanges,
4680
+ onLoadMore: git.loadMore,
4681
+ onRetry: git.loadRepos,
4682
+ })
4683
+ : react.createElement(Tree, {
4684
+ rows: treeRows,
4685
+ selected: meta === null ? "" : meta.path,
4686
+ onToggle: toggleDirectory,
4687
+ onSelect: selectFile,
4688
+ // Always offered: the DOM fallback covers hosts that leave the
4689
+ // bridge without input props, where gating on the store made the
4690
+ // button vanish with no way to reach it.
4691
+ onMention: mention,
4692
+ onContextMenu: (ctx) => setContextMenu(ctx),
4693
+ width: isMobile ? undefined : treeWidth,
4694
+ gitStatus,
4695
+ header: sideTabs,
4696
+ }),
3324
4697
  react.createElement("div", {
3325
4698
  className: "fv-resizer",
3326
4699
  role: "separator",
@@ -3342,14 +4715,17 @@ window.__ModuleLoader__.load({
3342
4715
  react.createElement("button", {
3343
4716
  type: "button", className: "fv-icon-button", "aria-label": "返回文件列表", onClick: () => setPane("tree"),
3344
4717
  }, react.createElement("span", { "aria-hidden": "true" }, "‹")),
3345
- react.createElement("span", { className: "fv-main-name" }, meta === null ? "未选择文件" : meta.name),
3346
- meta !== null && typeof meta.size === "number"
4718
+ react.createElement("span", { className: "fv-main-name" },
4719
+ commitHeadline !== ""
4720
+ ? commitHeadline
4721
+ : (meta === null ? "未选择文件" : meta.name)),
4722
+ meta !== null && !showCommit && typeof meta.size === "number"
3347
4723
  ? react.createElement("span", null, formatBytes(meta.size))
3348
4724
  : null,
3349
4725
  // The language picker sits with the file it applies to, and
3350
4726
  // is offered only for source views: an image, sheet or
3351
4727
  // document has no grammar to choose.
3352
- meta !== null && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
4728
+ meta !== null && !showCommit && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
3353
4729
  ? react.createElement(LanguagePicker, {
3354
4730
  choice: langChoice,
3355
4731
  catalog: languageCatalog,
@@ -3358,7 +4734,7 @@ window.__ModuleLoader__.load({
3358
4734
  onChange: chooseLang,
3359
4735
  })
3360
4736
  : null),
3361
- error !== null && meta === null
4737
+ error !== null && meta === null && !showCommit
3362
4738
  ? react.createElement("div", { className: "fv-note fv-error" },
3363
4739
  react.createElement("span", null, messageOf(error)),
3364
4740
  error?.code === "outside-root"
@@ -3373,9 +4749,41 @@ window.__ModuleLoader__.load({
3373
4749
  },
3374
4750
  }, "配置安全访问路径")
3375
4751
  : null)
3376
- : react.createElement(ErrorBoundary, {
3377
- resetKey: meta ? meta.path : "",
3378
- }, react.createElement(FileView, { meta, root, api: props.api, wrap, lang: langChoice, onDownload: handleDownloadItem })))),
4752
+ : showCommit
4753
+ ? react.createElement(ErrorBoundary, {
4754
+ resetKey: "commit\u0000" + commitSha,
4755
+ }, commitView === "changes"
4756
+ ? react.createElement(WorkspaceChangeDetail, {
4757
+ state: gitState,
4758
+ path: meta === null ? "" : meta.path,
4759
+ request,
4760
+ repo: gitState.repoId,
4761
+ })
4762
+ : react.createElement(CommitDetail, {
4763
+ state: gitState,
4764
+ selectedPath: meta === null ? "" : meta.path,
4765
+ onSelectFile: openCommitFile,
4766
+ view: commitView,
4767
+ onSwitchView: setCommitView,
4768
+ onLoadDiff: () => {
4769
+ const sha = commitSha;
4770
+ if (sha !== "") git.loadCommitDiff(sha);
4771
+ },
4772
+ }))
4773
+ : react.createElement(ErrorBoundary, {
4774
+ resetKey: meta ? meta.path : "",
4775
+ }, react.createElement(FileView, {
4776
+ meta,
4777
+ root,
4778
+ api: props.api,
4779
+ wrap,
4780
+ lang: langChoice,
4781
+ onDownload: handleDownloadItem,
4782
+ // Empty for a working-tree file; a hash for one opened
4783
+ // from history, which makes the read a blob lookup.
4784
+ commit: previewCommit,
4785
+ repo: gitState.repoId,
4786
+ })))),
3379
4787
  contextMenu !== null
3380
4788
  ? react.createElement(ContextMenu, {
3381
4789
  menu: contextMenu,
@@ -3696,11 +5104,15 @@ window.__ModuleLoader__.load({
3696
5104
  mediaTypeOf, messageOf, createStore, createRequest, blobOf, ensureStyles,
3697
5105
  ENTRY_POSITION_KEY, DRAG_SLOP, settleEntry, readEntryPosition, writeEntryPosition,
3698
5106
  TREE_WIDTH_KEY, DEFAULT_TREE_WIDTH, MIN_TREE_WIDTH, MAX_TREE_WIDTH, readTreeWidth, writeTreeWidth,
5107
+ SHELL_WIDTH_KEY, MIN_SHELL_WIDTH, readShellWidth, writeShellWidth, clampShellWidth,
3699
5108
  LANG_OVERRIDES_KEY, LANG_OVERRIDES_MAX, readLangOverrides, writeLangOverrides, readLangOverride, writeLangOverride,
3700
5109
  langOverrideKey, isKnownLanguageId, setLanguageCatalog, getLanguageCatalog: () => languageCatalog, LanguagePicker,
3701
5110
  MARKDOWN_LABELS, MARKDOWN_CODE_LABELS, READ_BLOCK_LABELS, JSON_TREE_LABELS, DIFF_BLOCK_LABELS, ErrorBoundary,
3702
5111
  openViewerForPath, wrapWorkspaceOpenPath, wrapConnectionRpc, wrapChangesOpenFetch, wrapSidebarRight, parseFileResource, setupGlobalFileClickInterceptor, extractClickedPath, normalizeClickedPath, looksLikeFilePath, isFileOpenControl, isFileMentionCode, renderRootOptions,
3703
- triggerDownload, performDownload, isDesktopShell, SvgIconDownload,
5112
+ triggerDownload, performDownload, isDesktopShell, SvgIconDownload, SvgIconBranch, SvgIconCommit,
5113
+ // Git panel: controller, views and the small formatters they share.
5114
+ GIT_PAGE_SIZE, createGitController, GitSidebar, CommitDetail, WorkspaceChangeDetail, DiffView, kindOfPath,
5115
+ formatCommitDate, formatCommitTimestamp,
3704
5116
  };
3705
5117
  return module.exports;
3706
5118
  }