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