@weniatall/dsh-file-viewer 0.2.12 → 0.3.2

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