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