@weniatall/dsh-file-viewer 0.3.3 → 0.3.5
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/LICENSE +21 -0
- package/README.md +18 -0
- package/lib/client.js +903 -188
- package/package.json +5 -5
package/lib/client.js
CHANGED
|
@@ -414,6 +414,20 @@ window.__ModuleLoader__.load({
|
|
|
414
414
|
.fv-glyph-open{transform:rotate(90deg)}
|
|
415
415
|
.fv-tree-status{padding:4px 8px;color:var(--dsw-alias-label-caption);font-size:12px;line-height:20px}
|
|
416
416
|
.fv-main{display:flex;flex:1 1 auto;flex-direction:column;min-width:0;min-height:0}
|
|
417
|
+
/* 主内容区的多标签栏:一行横向滚动,每个标签是一个带关闭按钮的按钮组。
|
|
418
|
+
标签本身不是按钮里套按钮(HTML 不允许),所以外面是 div[role=tab],
|
|
419
|
+
内部的关闭与标题各是一个 button。 */
|
|
420
|
+
.fv-tabs{display:flex;flex:none;align-items:stretch;gap:2px;padding:4px 6px 0;overflow-x:auto;overflow-y:hidden;border-bottom:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1,transparent);scrollbar-width:thin}
|
|
421
|
+
.fv-tabs::-webkit-scrollbar{height:4px}
|
|
422
|
+
.fv-tabs-empty{flex:1 1 auto;padding:4px 8px 6px;color:var(--dsw-alias-label-caption);font-size:12px;white-space:nowrap}
|
|
423
|
+
.fv-tab-cell{display:inline-flex;flex:none;align-items:center;gap:2px;max-width:220px;min-width:0;height:30px;padding:0 2px 0 10px;border:1px solid transparent;border-bottom:none;border-radius:8px 8px 0 0;background:none;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}
|
|
424
|
+
.fv-tab-cell:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
425
|
+
.fv-tab-cell[aria-selected="true"]{border-color:var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary);font-weight:500}
|
|
426
|
+
.fv-tab-cell[data-menu="true"]{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}
|
|
427
|
+
.fv-tab-label{flex:0 1 auto;min-width:0;max-width:170px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;background:none;border:none;padding:0;color:inherit;font:inherit;cursor:pointer;text-align:left}
|
|
428
|
+
.fv-tab-dir{flex:0 0 auto;color:var(--dsw-alias-label-caption);font-size:11px;max-width:96px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
429
|
+
.fv-tab-close{flex:none;display:inline-grid;place-items:center;width:18px;height:18px;padding:0;border:none;border-radius:4px;background:none;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:14px;line-height:1;cursor:pointer}
|
|
430
|
+
.fv-tab-close:hover{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}
|
|
417
431
|
.fv-main-head{display:flex;flex:none;align-items:center;gap:8px;padding:8px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-tertiary);font-size:12px}
|
|
418
432
|
.fv-main-name{flex:1 1 auto;min-width:0;overflow:hidden;color:var(--dsw-alias-label-primary);font-weight:500;white-space:nowrap;text-overflow:ellipsis}
|
|
419
433
|
.fv-lang{display:inline-flex;flex:none;align-items:center;gap:4px;min-width:0}
|
|
@@ -604,11 +618,14 @@ window.__ModuleLoader__.load({
|
|
|
604
618
|
@keyframes fv-toast-in{from{opacity:0;transform:translate(-50%,8px)}to{opacity:1;transform:translate(-50%,0)}}
|
|
605
619
|
/* Floating entry button is strictly disabled across both PC and mobile. */
|
|
606
620
|
.fv-float-entry{display:none!important}
|
|
607
|
-
/* Composer toolbar entry (PC): seated in conversation.input.left beside the + command button
|
|
608
|
-
|
|
621
|
+
/* Composer toolbar entry (PC): seated in conversation.input.left beside the + command button.
|
|
622
|
+
几何必须与宿主原生「+」(.uV2eYG_add) 对齐:28×28、radius-sm、specific-selector 底色、
|
|
623
|
+
label-primary 前景。此前用 999px 圆角 + label-secondary,夹在原生按钮中间显得是一枚
|
|
624
|
+
突兀的实心圆扣,尺寸与圆角都对不上。 */
|
|
625
|
+
.fv-composer-entry{box-sizing:border-box;display:inline-grid;place-items:center;width:28px;height:28px;padding:0;border:none;border-radius:var(--dsw-radius-sm);background:var(--dsw-specific-selector);color:var(--dsw-alias-label-primary);cursor:pointer;flex:none;line-height:1;corner-shape:round;transition:background-color .12s,color .12s,transform .1s}
|
|
609
626
|
.fv-composer-entry:hover{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}
|
|
610
627
|
.fv-composer-entry:active{transform:scale(.95)}
|
|
611
|
-
.fv-composer-entry svg{display:block;flex:none}
|
|
628
|
+
.fv-composer-entry svg{display:block;flex:none;width:16px;height:16px}
|
|
612
629
|
@media(max-width:768px){.fv-composer-entry{display:none!important}}
|
|
613
630
|
@media(max-width:1100px){.fv-shell{width:min(82vw,900px)}}
|
|
614
631
|
.fv-shell[data-mobile="true"]{width:100%!important;border-left:none!important}
|
|
@@ -677,6 +694,13 @@ window.__ModuleLoader__.load({
|
|
|
677
694
|
@keyframes fv-menu-slide-up{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:translateY(0)}}
|
|
678
695
|
.fv-context-item{height:46px!important;font-size:15px!important;padding:0 14px!important;border-radius:12px!important}
|
|
679
696
|
.fv-context-item-icon{width:22px!important;height:22px!important;font-size:16px!important}
|
|
697
|
+
/* 手机上标签栏同样要能滚动,关闭按钮放大到可点面积。 */
|
|
698
|
+
.fv-tabs{padding:4px 4px 0!important}
|
|
699
|
+
.fv-tab-cell{height:34px!important;max-width:190px!important}
|
|
700
|
+
.fv-tab-label{max-width:120px!important}
|
|
701
|
+
.fv-tab-dir{display:none!important}
|
|
702
|
+
.fv-tab-close{width:24px!important;height:24px!important;font-size:16px!important}
|
|
703
|
+
.fv-main-head{padding:6px 8px!important;gap:6px!important}
|
|
680
704
|
/* SafePathsModal 移动端自适应优化:输入框与添加按钮纵向铺满,防溢出 */
|
|
681
705
|
.fv-modal-scrim{padding:10px!important}
|
|
682
706
|
.fv-modal{width:min(96vw,560px)!important;max-height:min(90dvh,90vh,680px)!important;border-radius:16px!important}
|
|
@@ -1482,6 +1506,20 @@ window.__ModuleLoader__.load({
|
|
|
1482
1506
|
return cut < 0 ? trimmed : trimmed.slice(cut + 1);
|
|
1483
1507
|
}
|
|
1484
1508
|
|
|
1509
|
+
/**
|
|
1510
|
+
* The directory part of a root-relative path, or "" at the top level.
|
|
1511
|
+
*
|
|
1512
|
+
* Used as the secondary line on a tab: two open files with the same name
|
|
1513
|
+
* from different folders would otherwise be indistinguishable.
|
|
1514
|
+
* @param {string} path - root-relative path.
|
|
1515
|
+
* @returns {string} its directory, without a trailing separator.
|
|
1516
|
+
*/
|
|
1517
|
+
function directoryOf(path) {
|
|
1518
|
+
const trimmed = String(path || "").replace(/[\\/]+$/, "");
|
|
1519
|
+
const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
|
1520
|
+
return cut <= 0 ? "" : trimmed.slice(0, cut);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1485
1523
|
/**
|
|
1486
1524
|
* Append a file reference to a draft. Mirrors core.js's appendMention; this
|
|
1487
1525
|
* file is the browser bundle and cannot import from the host half.
|
|
@@ -2839,6 +2877,271 @@ window.__ModuleLoader__.load({
|
|
|
2839
2877
|
return groups;
|
|
2840
2878
|
}
|
|
2841
2879
|
|
|
2880
|
+
/**
|
|
2881
|
+
* The items a tab's context menu offers, in order.
|
|
2882
|
+
*
|
|
2883
|
+
* Exported (through `internals`) rather than inlined so the labels are
|
|
2884
|
+
* asserted by the tests instead of drifting from the request that named
|
|
2885
|
+
* them: 刷新当前 / 关闭当前 / 关闭其它 / 关闭所有.
|
|
2886
|
+
*
|
|
2887
|
+
* @type {Array<{key: string, icon: string, label: string}>}
|
|
2888
|
+
*/
|
|
2889
|
+
const TAB_MENU_ITEMS = [
|
|
2890
|
+
{ key: "refresh", icon: "↻", label: "刷新当前" },
|
|
2891
|
+
{ key: "close", icon: "×", label: "关闭当前" },
|
|
2892
|
+
{ key: "others", icon: "⇤", label: "关闭其它" },
|
|
2893
|
+
{ key: "all", icon: "⊗", label: "关闭所有" },
|
|
2894
|
+
];
|
|
2895
|
+
|
|
2896
|
+
/**
|
|
2897
|
+
* Apply closes to a tab list and say which tab is on screen afterwards.
|
|
2898
|
+
*
|
|
2899
|
+
* Pure so the rule is testable without a renderer, and so the three menu
|
|
2900
|
+
* items ("close current / others / all") are one implementation instead of
|
|
2901
|
+
* three: they differ only in the set of ids handed to this function.
|
|
2902
|
+
*
|
|
2903
|
+
* The landing tab is the neighbour on the right of the tab that was on
|
|
2904
|
+
* screen, then the one on the left, and finally the first survivor — the
|
|
2905
|
+
* order every tabbed editor uses. When nothing survives, the active id
|
|
2906
|
+
* becomes the empty string, which is what the pane reads as "no tab".
|
|
2907
|
+
*
|
|
2908
|
+
* Returns null when the closes would change nothing, so callers can skip a
|
|
2909
|
+
* re-render (and so closing an id that is not in the list is a no-op rather
|
|
2910
|
+
* than a resurrection).
|
|
2911
|
+
*
|
|
2912
|
+
* @param {Array<{id: string}>} tabs - the current list.
|
|
2913
|
+
* @param {string} activeId - the tab on screen now.
|
|
2914
|
+
* @param {Array<string>} ids - tabs to close.
|
|
2915
|
+
* @returns {{tabs: Array<object>, activeId: string} | null}
|
|
2916
|
+
*/
|
|
2917
|
+
function tabsAfterClose(tabs, activeId, ids) {
|
|
2918
|
+
const current = Array.isArray(tabs) ? tabs : [];
|
|
2919
|
+
const doomed = new Set(Array.isArray(ids) ? ids : []);
|
|
2920
|
+
if (doomed.size === 0) return null;
|
|
2921
|
+
const next = current.filter((tab) => !doomed.has(tab.id));
|
|
2922
|
+
if (next.length === current.length) return null;
|
|
2923
|
+
if (!doomed.has(activeId) && next.some((tab) => tab.id === activeId)) {
|
|
2924
|
+
return { tabs: next, activeId };
|
|
2925
|
+
}
|
|
2926
|
+
if (next.length === 0) return { tabs: next, activeId: "" };
|
|
2927
|
+
// Measured in the OLD list: closing a middle tab must land on its right
|
|
2928
|
+
// neighbour, not on whatever ends up at the same index afterwards.
|
|
2929
|
+
const index = current.findIndex((tab) => tab.id === activeId);
|
|
2930
|
+
const right = current.slice(index + 1).find((tab) => !doomed.has(tab.id));
|
|
2931
|
+
const left = current.slice(0, Math.max(0, index)).reverse().find((tab) => !doomed.has(tab.id));
|
|
2932
|
+
const landing = right || left || next[0];
|
|
2933
|
+
return { tabs: next, activeId: landing.id };
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
/**
|
|
2937
|
+
* The tab that should be on screen after `activate` is brought forward.
|
|
2938
|
+
*
|
|
2939
|
+
* Only meaningful for the "reopen an already open file" path: the reader
|
|
2940
|
+
* expects the tab to come forward, not to be replaced, and definitely not
|
|
2941
|
+
* to be duplicated.
|
|
2942
|
+
*
|
|
2943
|
+
* @param {Array<object>} tabs - the current list.
|
|
2944
|
+
* @param {string} activeId - the tab on screen now.
|
|
2945
|
+
* @param {object} draft - the tab to open or update (must carry an `id`).
|
|
2946
|
+
* @returns {{tabs: Array<object>, activeId: string}}
|
|
2947
|
+
*/
|
|
2948
|
+
function tabsAfterUpsert(tabs, activeId, draft) {
|
|
2949
|
+
const current = Array.isArray(tabs) ? tabs : [];
|
|
2950
|
+
const existing = current.find((tab) => tab.id === draft.id);
|
|
2951
|
+
if (existing === undefined) {
|
|
2952
|
+
return { tabs: current.concat([{ ...draft, origin: draft.origin === undefined ? null : draft.origin }]), activeId: draft.id };
|
|
2953
|
+
}
|
|
2954
|
+
// Updating in place keeps the tab where it already sits in the strip.
|
|
2955
|
+
return { tabs: current.map((tab) => (tab.id === draft.id ? { ...tab, ...draft } : tab)), activeId: draft.id };
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
/**
|
|
2959
|
+
* The content area's tab strip.
|
|
2960
|
+
*
|
|
2961
|
+
* One tab per opened file, commit or working-tree change. The strip is a
|
|
2962
|
+
* plain horizontal scroller: the drawer is narrow and a wrapping strip
|
|
2963
|
+
* would eat the space the preview needs, so overflow scrolls instead.
|
|
2964
|
+
*
|
|
2965
|
+
* Right-clicking any tab opens the four-item menu the reader asked for
|
|
2966
|
+
* (refresh / close / close others / close all); the same menu is reachable
|
|
2967
|
+
* from the keyboard via Shift+F10 or the ContextMenu key, because a
|
|
2968
|
+
* pointer-only affordance is a defect on a phone.
|
|
2969
|
+
*
|
|
2970
|
+
* @param {{tabs: Array<object>, activeId: string, onActivate: Function, onClose: Function, onContextMenu: Function, menuFor: string}} props
|
|
2971
|
+
*/
|
|
2972
|
+
function TabStrip({ tabs, activeId, onActivate, onClose, onContextMenu, menuFor }) {
|
|
2973
|
+
const rows = Array.isArray(tabs) ? tabs : [];
|
|
2974
|
+
if (rows.length === 0) {
|
|
2975
|
+
return react.createElement("div", { className: "fv-tabs", role: "tablist", "aria-label": "已打开的文件" },
|
|
2976
|
+
react.createElement("span", { className: "fv-tabs-empty" }, "未打开文件"));
|
|
2977
|
+
}
|
|
2978
|
+
return react.createElement("div", {
|
|
2979
|
+
className: "fv-tabs",
|
|
2980
|
+
role: "tablist",
|
|
2981
|
+
"aria-label": "已打开的文件",
|
|
2982
|
+
}, ...rows.map((tab) => {
|
|
2983
|
+
const selected = tab.id === activeId;
|
|
2984
|
+
// The directory part is a secondary hint: two files with the same
|
|
2985
|
+
// name from different folders would otherwise look identical.
|
|
2986
|
+
const directory = typeof tab.dir === "string" ? tab.dir : "";
|
|
2987
|
+
return react.createElement("div", {
|
|
2988
|
+
key: tab.id,
|
|
2989
|
+
className: "fv-tab-cell",
|
|
2990
|
+
role: "tab",
|
|
2991
|
+
"aria-selected": selected ? "true" : "false",
|
|
2992
|
+
"data-menu": menuFor === tab.id ? "true" : "false",
|
|
2993
|
+
title: tab.path !== "" ? tab.path : tab.title,
|
|
2994
|
+
onClick: () => onActivate(tab.id),
|
|
2995
|
+
onContextMenu: (event) => {
|
|
2996
|
+
event.preventDefault();
|
|
2997
|
+
event.stopPropagation();
|
|
2998
|
+
onContextMenu({ tabId: tab.id, x: event.clientX, y: event.clientY });
|
|
2999
|
+
},
|
|
3000
|
+
onKeyDown: (event) => {
|
|
3001
|
+
// Shift+F10 and the dedicated ContextMenu key are what a keyboard
|
|
3002
|
+
// has instead of a right click.
|
|
3003
|
+
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
|
3004
|
+
event.preventDefault();
|
|
3005
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
3006
|
+
onContextMenu({ tabId: tab.id, x: box.left + 12, y: box.bottom - 4 });
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
3010
|
+
event.preventDefault();
|
|
3011
|
+
onActivate(tab.id);
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
// Arrow keys walk the strip the way a tablist is expected to.
|
|
3015
|
+
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
|
3016
|
+
event.preventDefault();
|
|
3017
|
+
const index = rows.findIndex((row) => row.id === tab.id);
|
|
3018
|
+
const step = event.key === "ArrowRight" ? 1 : -1;
|
|
3019
|
+
const next = rows[(index + step + rows.length) % rows.length];
|
|
3020
|
+
if (next !== undefined) onActivate(next.id);
|
|
3021
|
+
}
|
|
3022
|
+
},
|
|
3023
|
+
tabIndex: selected ? 0 : -1,
|
|
3024
|
+
},
|
|
3025
|
+
react.createElement("button", {
|
|
3026
|
+
type: "button",
|
|
3027
|
+
className: "fv-tab-label",
|
|
3028
|
+
// The tab cell already handles activation; this button exists so
|
|
3029
|
+
// the label is a real control for screen readers and touch.
|
|
3030
|
+
onClick: (event) => { event.stopPropagation(); onActivate(tab.id); },
|
|
3031
|
+
title: tab.path !== "" ? tab.path : tab.title,
|
|
3032
|
+
}, tab.title),
|
|
3033
|
+
directory !== ""
|
|
3034
|
+
? react.createElement("span", { className: "fv-tab-dir" }, directory)
|
|
3035
|
+
: null,
|
|
3036
|
+
react.createElement("button", {
|
|
3037
|
+
type: "button",
|
|
3038
|
+
className: "fv-tab-close",
|
|
3039
|
+
"aria-label": "关闭 " + tab.title,
|
|
3040
|
+
title: "关闭",
|
|
3041
|
+
onClick: (event) => { event.stopPropagation(); onClose(tab.id); },
|
|
3042
|
+
}, "×"));
|
|
3043
|
+
}));
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
/**
|
|
3047
|
+
* The four-item menu a tab offers on right click.
|
|
3048
|
+
*
|
|
3049
|
+
* Deliberately its own component instead of a mode of the file-tree menu:
|
|
3050
|
+
* the subjects differ (a tab versus a path), and merging them produced
|
|
3051
|
+
* items that made no sense on one of the two.
|
|
3052
|
+
*
|
|
3053
|
+
* @param {{menu: object, tabs: Array<object>, onClose: Function, onAction: Function}} props
|
|
3054
|
+
*/
|
|
3055
|
+
function TabContextMenu({ menu, tabs, onClose, onAction }) {
|
|
3056
|
+
const ref = react.useRef(null);
|
|
3057
|
+
const mountTimeRef = react.useRef(Date.now());
|
|
3058
|
+
react.useEffect(() => {
|
|
3059
|
+
mountTimeRef.current = Date.now();
|
|
3060
|
+
const handlePointerDown = (event) => {
|
|
3061
|
+
if (Date.now() - mountTimeRef.current < 400) return;
|
|
3062
|
+
if (ref.current && !ref.current.contains(event.target)) onClose();
|
|
3063
|
+
};
|
|
3064
|
+
const handleKeyDown = (event) => {
|
|
3065
|
+
if (event.key === "Escape") onClose();
|
|
3066
|
+
};
|
|
3067
|
+
const handleScroll = (event) => {
|
|
3068
|
+
if (Date.now() - mountTimeRef.current < 400) return;
|
|
3069
|
+
if (event.target === window || event.target === document || event.target === document.body) onClose();
|
|
3070
|
+
};
|
|
3071
|
+
window.addEventListener("pointerdown", handlePointerDown);
|
|
3072
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
3073
|
+
window.addEventListener("scroll", handleScroll);
|
|
3074
|
+
window.addEventListener("resize", onClose);
|
|
3075
|
+
return () => {
|
|
3076
|
+
window.removeEventListener("pointerdown", handlePointerDown);
|
|
3077
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
3078
|
+
window.removeEventListener("scroll", handleScroll);
|
|
3079
|
+
window.removeEventListener("resize", onClose);
|
|
3080
|
+
};
|
|
3081
|
+
}, [onClose]);
|
|
3082
|
+
|
|
3083
|
+
if (menu === null || menu === undefined) return null;
|
|
3084
|
+
const rows = Array.isArray(tabs) ? tabs : [];
|
|
3085
|
+
const target = rows.find((tab) => tab.id === menu.tabId) || null;
|
|
3086
|
+
const winW = typeof window !== "undefined" ? window.innerWidth : 1000;
|
|
3087
|
+
const winH = typeof window !== "undefined" ? window.innerHeight : 800;
|
|
3088
|
+
const top = Math.max(8, Math.min(menu.y, winH - 240)) + "px";
|
|
3089
|
+
const left = Math.max(8, Math.min(menu.x, winW - 210)) + "px";
|
|
3090
|
+
|
|
3091
|
+
// Which items can do anything is decided from the list, not left to
|
|
3092
|
+
// the view: "close others" with one tab open is a no-op that would
|
|
3093
|
+
// look like a broken button.
|
|
3094
|
+
const disabled = {
|
|
3095
|
+
refresh: target === null,
|
|
3096
|
+
close: target === null,
|
|
3097
|
+
others: rows.length < 2,
|
|
3098
|
+
all: rows.length === 0,
|
|
3099
|
+
};
|
|
3100
|
+
|
|
3101
|
+
return react.createElement(react.Fragment, null,
|
|
3102
|
+
react.createElement("div", {
|
|
3103
|
+
className: "fv-context-backdrop",
|
|
3104
|
+
onClick: (event) => {
|
|
3105
|
+
event.stopPropagation();
|
|
3106
|
+
if (Date.now() - mountTimeRef.current < 400) return;
|
|
3107
|
+
onClose();
|
|
3108
|
+
},
|
|
3109
|
+
onContextMenu: (event) => {
|
|
3110
|
+
event.preventDefault();
|
|
3111
|
+
event.stopPropagation();
|
|
3112
|
+
if (Date.now() - mountTimeRef.current < 400) return;
|
|
3113
|
+
onClose();
|
|
3114
|
+
},
|
|
3115
|
+
}),
|
|
3116
|
+
react.createElement("div", {
|
|
3117
|
+
ref,
|
|
3118
|
+
className: "fv-context-menu",
|
|
3119
|
+
style: { top, left },
|
|
3120
|
+
role: "menu",
|
|
3121
|
+
"aria-label": "标签操作",
|
|
3122
|
+
onContextMenu: (event) => event.preventDefault(),
|
|
3123
|
+
},
|
|
3124
|
+
react.createElement("div", { className: "fv-context-head" },
|
|
3125
|
+
react.createElement("div", { className: "fv-context-title" }, target === null ? "标签" : target.title),
|
|
3126
|
+
target !== null && target.path !== "" && target.path !== target.title
|
|
3127
|
+
? react.createElement("div", { className: "fv-context-path" }, target.path)
|
|
3128
|
+
: null),
|
|
3129
|
+
...TAB_MENU_ITEMS.map((item) => react.createElement("button", {
|
|
3130
|
+
key: item.key,
|
|
3131
|
+
type: "button",
|
|
3132
|
+
className: "fv-context-item",
|
|
3133
|
+
role: "menuitem",
|
|
3134
|
+
disabled: disabled[item.key] === true,
|
|
3135
|
+
onClick: (event) => {
|
|
3136
|
+
event.stopPropagation();
|
|
3137
|
+
onClose();
|
|
3138
|
+
onAction(item.key, menu.tabId);
|
|
3139
|
+
},
|
|
3140
|
+
},
|
|
3141
|
+
react.createElement("span", { className: "fv-context-item-icon", "aria-hidden": "true" }, item.icon),
|
|
3142
|
+
react.createElement("span", { className: "fv-context-item-label" }, item.label)))));
|
|
3143
|
+
}
|
|
3144
|
+
|
|
2842
3145
|
function apply(ctx) {
|
|
2843
3146
|
ensureStyles(typeof document === "undefined" ? null : document);
|
|
2844
3147
|
// `null` is closed; an object is open. Carrying the requesting session lets
|
|
@@ -3567,9 +3870,12 @@ window.__ModuleLoader__.load({
|
|
|
3567
3870
|
}
|
|
3568
3871
|
|
|
3569
3872
|
/** Pick the viewer for a file and keep unsupported formats useful. */
|
|
3570
|
-
function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo }) {
|
|
3873
|
+
function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo, loading }) {
|
|
3571
3874
|
if (meta === null) {
|
|
3572
|
-
|
|
3875
|
+
// A tab exists but its metadata is still in flight. Saying "pick a
|
|
3876
|
+
// file" there would be a lie, and the reader would click again.
|
|
3877
|
+
return react.createElement("div", { className: "fv-note" },
|
|
3878
|
+
loading === true ? "正在读取文件…" : "从左侧选择一个文件。");
|
|
3573
3879
|
}
|
|
3574
3880
|
if (meta.tooLarge || meta.kind === "binary") {
|
|
3575
3881
|
return react.createElement("div", { className: "fv-note" },
|
|
@@ -3621,6 +3927,7 @@ window.__ModuleLoader__.load({
|
|
|
3621
3927
|
react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo }));
|
|
3622
3928
|
}
|
|
3623
3929
|
|
|
3930
|
+
|
|
3624
3931
|
/** Right-click context menu for file and directory items. */
|
|
3625
3932
|
function ContextMenu({ menu, rootPath, onClose, onMention, onToast, onDownload }) {
|
|
3626
3933
|
const ref = react.useRef(null);
|
|
@@ -3763,6 +4070,7 @@ window.__ModuleLoader__.load({
|
|
|
3763
4070
|
return react.createElement("div", { className: "fv-toast", role: "status" }, toast);
|
|
3764
4071
|
}
|
|
3765
4072
|
|
|
4073
|
+
|
|
3766
4074
|
/** Safe Access Paths Configuration Dialog */
|
|
3767
4075
|
function SafePathsModal({ open, onClose, onToast, onUpdated, prefillPath }) {
|
|
3768
4076
|
const [loading, setLoading] = react.useState(false);
|
|
@@ -3924,7 +4232,9 @@ window.__ModuleLoader__.load({
|
|
|
3924
4232
|
// collapsing and reopening costs nothing.
|
|
3925
4233
|
const [expanded, setExpanded] = react.useState(() => new Set());
|
|
3926
4234
|
const [nodes, setNodes] = react.useState(() => new Map());
|
|
3927
|
-
|
|
4235
|
+
// The preview's own metadata is no longer a standalone piece of state:
|
|
4236
|
+
// it belongs to the tab that opened it, so switching tabs switches the
|
|
4237
|
+
// file. `meta` is derived from the active tab further down.
|
|
3928
4238
|
const [hidden, setHidden] = react.useState(false);
|
|
3929
4239
|
const [safeModalOpen, setSafeModalOpen] = react.useState(false);
|
|
3930
4240
|
const [prefillPath, setPrefillPath] = react.useState("");
|
|
@@ -4037,9 +4347,8 @@ window.__ModuleLoader__.load({
|
|
|
4037
4347
|
// Soft wrap for source views. Off by default: code is written with
|
|
4038
4348
|
// meaningful line breaks and wrapping obscures them.
|
|
4039
4349
|
const [wrap, setWrap] = react.useState(false);
|
|
4040
|
-
// The language override
|
|
4041
|
-
//
|
|
4042
|
-
const [langChoice, setLangChoice] = react.useState("auto");
|
|
4350
|
+
// The language override now lives on the tab it applies to (see
|
|
4351
|
+
// `localeTagOfActive`), so switching tabs switches the pin with it.
|
|
4043
4352
|
const [fullscreen, setFullscreen] = react.useState(false);
|
|
4044
4353
|
const [isMobile, setIsMobile] = react.useState(() => {
|
|
4045
4354
|
if (typeof window === "undefined") return false;
|
|
@@ -4072,28 +4381,35 @@ window.__ModuleLoader__.load({
|
|
|
4072
4381
|
// Which sidebar pane is showing, and whether the main pane is a commit
|
|
4073
4382
|
// rather than a file. Both live here because the drawer owns the split.
|
|
4074
4383
|
const [side, setSide] = react.useState("files");
|
|
4075
|
-
const [showCommit, setShowCommit] = react.useState(false);
|
|
4076
|
-
// Which commit a previewed file is being read from. Empty means the
|
|
4077
|
-
// ordinary working-tree preview, which is the file tree's behaviour.
|
|
4078
|
-
const [previewCommit, setPreviewCommit] = react.useState("");
|
|
4079
|
-
// Which half of the commit pane is showing: the file list or the patch.
|
|
4080
|
-
const [commitView, setCommitView] = react.useState("files");
|
|
4081
4384
|
/**
|
|
4082
|
-
*
|
|
4385
|
+
* The right-hand content area is a set of tabs.
|
|
4386
|
+
*
|
|
4387
|
+
* Every tab owns the *complete* description of what the pane shows:
|
|
4388
|
+
* the file preview (`meta`, which commit it was read from, the language
|
|
4389
|
+
* pinned for it) as well as the commit-shaped views (`detail` /
|
|
4390
|
+
* `changes`). Keeping one object per tab is what lets an inactive tab
|
|
4391
|
+
* be restored exactly, instead of inheriting whatever the last opened
|
|
4392
|
+
* file happened to leave behind.
|
|
4083
4393
|
*
|
|
4084
|
-
*
|
|
4085
|
-
*
|
|
4086
|
-
* tree, which is a different place entirely — so the origin is recorded
|
|
4087
|
-
* when the file is opened rather than guessed when back is pressed.
|
|
4394
|
+
* `null` (never an empty array) means the content area has no tabs at
|
|
4395
|
+
* all, which is the state the drawer opens in.
|
|
4088
4396
|
*
|
|
4089
|
-
*
|
|
4090
|
-
* @type {"commit" | "changes" | null}
|
|
4397
|
+
* @type {null | Array<{id: string, kind: "file" | "commit" | "change", title: string, path: string, label: string, meta: object | null, commit: string, commitView: string, changePath: string, lang: string}>}
|
|
4091
4398
|
*/
|
|
4092
|
-
const [
|
|
4093
|
-
//
|
|
4094
|
-
//
|
|
4095
|
-
|
|
4096
|
-
|
|
4399
|
+
const [tabs, setTabs] = react.useState(null);
|
|
4400
|
+
// Which tab is on screen. Kept beside the list rather than inside it so
|
|
4401
|
+
// closing the active tab can pick a neighbour with the list in hand.
|
|
4402
|
+
const [activeTabId, setActiveTabId] = react.useState("");
|
|
4403
|
+
// The tab whose context menu is open, plus where the pointer asked for
|
|
4404
|
+
// it. Kept apart from the tree's `contextMenu`: the two menus describe
|
|
4405
|
+
// different subjects and must never be confused for one another.
|
|
4406
|
+
const [tabMenu, setTabMenu] = react.useState(null);
|
|
4407
|
+
const tabsRef = react.useRef(null);
|
|
4408
|
+
tabsRef.current = tabs;
|
|
4409
|
+
const activeTabIdRef = react.useRef("");
|
|
4410
|
+
activeTabIdRef.current = activeTabId;
|
|
4411
|
+
// The tab a language pin was last applied to, so a pin made on one tab
|
|
4412
|
+
// is not mistaken for one made on another after a switch.
|
|
4097
4413
|
const gitState = useStore(git.store);
|
|
4098
4414
|
|
|
4099
4415
|
react.useEffect(() => {
|
|
@@ -4109,13 +4425,27 @@ window.__ModuleLoader__.load({
|
|
|
4109
4425
|
return () => { live = false; };
|
|
4110
4426
|
}, [open, root]);
|
|
4111
4427
|
|
|
4428
|
+
/**
|
|
4429
|
+
* The root the Git controller was last initialised for.
|
|
4430
|
+
*
|
|
4431
|
+
* Reopening the drawer must not wipe the panel: a commit tab reopened
|
|
4432
|
+
* from the previous visit needs its detail, and the branch the reader
|
|
4433
|
+
* was browsing is part of what "the same visit" means. Only an actual
|
|
4434
|
+
* root change discards the cached repositories, branches and pages.
|
|
4435
|
+
*
|
|
4436
|
+
* Null means "never initialised", which is distinct from the empty root
|
|
4437
|
+
* the first render sees before the host has resolved anything.
|
|
4438
|
+
*/
|
|
4439
|
+
const gitRootRef = react.useRef(null);
|
|
4440
|
+
|
|
4112
4441
|
// The Git panel describes one root; switching roots drops every cached
|
|
4113
4442
|
// repository, branch and page rather than showing another project's
|
|
4114
4443
|
// history under the new name.
|
|
4115
4444
|
react.useEffect(() => {
|
|
4116
4445
|
if (!open) return;
|
|
4446
|
+
if (gitRootRef.current === root) return;
|
|
4447
|
+
gitRootRef.current = root;
|
|
4117
4448
|
git.reset(root);
|
|
4118
|
-
setShowCommit(false);
|
|
4119
4449
|
}, [open, root]);
|
|
4120
4450
|
|
|
4121
4451
|
// Loaded on first use, never on open: most visits never touch the Git
|
|
@@ -4241,28 +4571,36 @@ window.__ModuleLoader__.load({
|
|
|
4241
4571
|
// Directory to open on arrival, relative to the resolved root: the
|
|
4242
4572
|
// session's working directory when it sits below the workspace root.
|
|
4243
4573
|
const [reveal, setReveal] = react.useState("");
|
|
4244
|
-
|
|
4574
|
+
// One ticket per tab id, so a read that is slow for one file cannot
|
|
4575
|
+
// cancel a read that is fast for another. A single drawer-wide counter
|
|
4576
|
+
// would have done exactly that once tabs made concurrent reads normal.
|
|
4577
|
+
const metaTickets = react.useRef(new Map());
|
|
4245
4578
|
const treeGeneration = react.useRef(0);
|
|
4579
|
+
/**
|
|
4580
|
+
* Which root (and therefore which project) the current state describes.
|
|
4581
|
+
*
|
|
4582
|
+
* Closing the drawer keeps everything mounted, so reopening lands on
|
|
4583
|
+
* the previous visit's tabs, sidebar and expansion set — that is the
|
|
4584
|
+
* whole point. This ref is how "the same visit" is recognised: as long
|
|
4585
|
+
* as the host resolves the same root, nothing is thrown away.
|
|
4586
|
+
*/
|
|
4587
|
+
const visitContextRef = react.useRef("");
|
|
4246
4588
|
|
|
4247
4589
|
// Resolve the root set on every open. When the request came from a
|
|
4248
4590
|
// conversation header or a file click, the host also reports which root
|
|
4249
4591
|
// that session/file belongs to, the directory path to reveal, and any
|
|
4250
4592
|
// file to automatically select and preview.
|
|
4593
|
+
//
|
|
4594
|
+
// The drawer stays mounted while closed (`open` only gates rendering),
|
|
4595
|
+
// so everything is still here when it reopens — and that is the point:
|
|
4596
|
+
// closing the viewer is not "ending a session", it is hiding a panel,
|
|
4597
|
+
// and it must come back the way it was left. The one thing that CANNOT
|
|
4598
|
+
// survive is a tab opened under a different root or a different session,
|
|
4599
|
+
// because its paths mean something else there; that case is handled
|
|
4600
|
+
// below, against the root the host resolves.
|
|
4251
4601
|
react.useEffect(() => {
|
|
4252
4602
|
if (request_ === null) return undefined;
|
|
4253
4603
|
let live = true;
|
|
4254
|
-
// The drawer stays mounted while closed (`open` only gates rendering),
|
|
4255
|
-
// so every view state survives from the previous visit. Opening a
|
|
4256
|
-
// plain file after browsing Git used to inherit the old commit hash
|
|
4257
|
-
// and the Git sidebar, and the file rendered as "history preview"
|
|
4258
|
-
// instead of its content. A new open is a new session: clear it all.
|
|
4259
|
-
setSide("files");
|
|
4260
|
-
setShowCommit(false);
|
|
4261
|
-
setCommitView("files");
|
|
4262
|
-
setPreviewCommit("");
|
|
4263
|
-
setPreviewOrigin(null);
|
|
4264
|
-
setPreviewChangePath("");
|
|
4265
|
-
setMeta(null);
|
|
4266
4604
|
setError(null);
|
|
4267
4605
|
const payload = {
|
|
4268
4606
|
...(request_.sessionId !== undefined ? { sessionId: request_.sessionId } : {}),
|
|
@@ -4274,18 +4612,31 @@ window.__ModuleLoader__.load({
|
|
|
4274
4612
|
if (!live) return;
|
|
4275
4613
|
setRoots(value.roots || []);
|
|
4276
4614
|
const resolvedRoot = value.root || (value.roots || [])[0]?.id || "";
|
|
4615
|
+
// A reopen against the same root and the same session keeps every
|
|
4616
|
+
// tab, the active one included. Anything else is a different
|
|
4617
|
+
// project, and carrying the tabs over would point them at files
|
|
4618
|
+
// that merely share a name.
|
|
4619
|
+
const sameContext = resolvedRoot !== "" && resolvedRoot === visitContextRef.current;
|
|
4620
|
+
if (!sameContext) {
|
|
4621
|
+
tabsRef.current = null;
|
|
4622
|
+
setTabs(null);
|
|
4623
|
+
activeTabIdRef.current = "";
|
|
4624
|
+
setActiveTabId("");
|
|
4625
|
+
setTabMenu(null);
|
|
4626
|
+
setPane("tree");
|
|
4627
|
+
// The tree belongs to the previous root too; the listing effect
|
|
4628
|
+
// below re-reads it from scratch.
|
|
4629
|
+
setExpanded(new Set());
|
|
4630
|
+
}
|
|
4631
|
+
visitContextRef.current = resolvedRoot;
|
|
4277
4632
|
setRoot(resolvedRoot);
|
|
4278
4633
|
setReveal(typeof value.reveal === "string" ? value.reveal : "");
|
|
4279
4634
|
setError(null);
|
|
4280
4635
|
if (typeof value.selectFile === "string" && value.selectFile !== "") {
|
|
4281
4636
|
setPane("content");
|
|
4282
|
-
// Arriving on a file from a link:
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
request("meta", { root: resolvedRoot, path: value.selectFile }).then((metaVal) => {
|
|
4286
|
-
if (metaTicket.current !== ticket) return;
|
|
4287
|
-
setMeta(metaVal);
|
|
4288
|
-
}).catch(() => {});
|
|
4637
|
+
// Arriving on a file from a link: it opens in its own tab, the
|
|
4638
|
+
// same way clicking it in the tree would.
|
|
4639
|
+
openFileTab(resolvedRoot, value.selectFile, { activate: true });
|
|
4289
4640
|
}
|
|
4290
4641
|
}).catch((problem) => {
|
|
4291
4642
|
if (live) setError(problem);
|
|
@@ -4327,23 +4678,60 @@ window.__ModuleLoader__.load({
|
|
|
4327
4678
|
});
|
|
4328
4679
|
};
|
|
4329
4680
|
|
|
4681
|
+
/**
|
|
4682
|
+
* What the tree was last built for.
|
|
4683
|
+
*
|
|
4684
|
+
* Opening and closing the drawer must not rebuild the tree: the reader
|
|
4685
|
+
* expands a few directories, looks at a file, hides the panel and
|
|
4686
|
+
* expects the same shape when it comes back. Only a real context change
|
|
4687
|
+
* — a different root, a different dotfile filter, a different directory
|
|
4688
|
+
* to reveal — starts over.
|
|
4689
|
+
*/
|
|
4690
|
+
const treeBuiltForRef = react.useRef({ root: "", hidden: null, reveal: "" });
|
|
4691
|
+
|
|
4330
4692
|
// A new root or filter invalidates every cached listing; the bumped
|
|
4331
4693
|
// generation makes any in-flight response from the old tree stale.
|
|
4332
4694
|
// When the host named a directory to reveal, its whole ancestor chain
|
|
4333
4695
|
// opens at once so the session's project is visible on arrival.
|
|
4334
4696
|
react.useEffect(() => {
|
|
4335
4697
|
if (!open || root === "") return;
|
|
4698
|
+
const built = treeBuiltForRef.current;
|
|
4699
|
+
const rootChanged = built.root !== root;
|
|
4700
|
+
const filterChanged = built.hidden !== hidden;
|
|
4701
|
+
// The reveal chain is only worth re-opening when the host names a
|
|
4702
|
+
// different directory; the reader's own expansions stay put.
|
|
4703
|
+
const revealChanged = built.reveal !== reveal;
|
|
4704
|
+
if (!rootChanged && !filterChanged && !revealChanged) return;
|
|
4705
|
+
treeBuiltForRef.current = { root, hidden, reveal };
|
|
4336
4706
|
treeGeneration.current += 1;
|
|
4337
4707
|
setNodes(new Map());
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4708
|
+
if (rootChanged) {
|
|
4709
|
+
// Tabs describe paths inside the root that opened them; carrying
|
|
4710
|
+
// one across a root switch would point it at a different file with
|
|
4711
|
+
// the same name, so the whole set is dropped with the old tree.
|
|
4712
|
+
// (The open path already cleared them; this covers a root switched
|
|
4713
|
+
// from the picker while the drawer stays open.)
|
|
4714
|
+
tabsRef.current = null;
|
|
4715
|
+
setTabs(null);
|
|
4716
|
+
activeTabIdRef.current = "";
|
|
4717
|
+
setActiveTabId("");
|
|
4718
|
+
setTabMenu(null);
|
|
4719
|
+
setExpanded(new Set());
|
|
4720
|
+
setPane("tree");
|
|
4721
|
+
}
|
|
4722
|
+
const chain = revealChanged && reveal !== "" ? [...ancestorsOf(reveal), reveal] : [];
|
|
4723
|
+
if (chain.length > 0) {
|
|
4724
|
+
setExpanded((current) => {
|
|
4725
|
+
const next = new Set(current);
|
|
4726
|
+
for (const dirPath of chain) next.add(dirPath);
|
|
4727
|
+
return next;
|
|
4728
|
+
});
|
|
4729
|
+
}
|
|
4730
|
+
// Reload the root plus whatever the tree is actually showing, so a
|
|
4731
|
+
// reopen shows current contents rather than a stale listing.
|
|
4732
|
+
const visible = rootChanged ? chain : [...expanded];
|
|
4733
|
+
const listings = [...new Set(["", ...chain, ...visible])];
|
|
4734
|
+
for (const dirPath of listings) loadDirectory(dirPath);
|
|
4347
4735
|
}, [open, root, hidden, reveal]);
|
|
4348
4736
|
|
|
4349
4737
|
react.useEffect(() => {
|
|
@@ -4401,6 +4789,177 @@ window.__ModuleLoader__.load({
|
|
|
4401
4789
|
if (!isOpen && !nodes.has(dirPath)) loadDirectory(dirPath);
|
|
4402
4790
|
};
|
|
4403
4791
|
|
|
4792
|
+
// ------------------------------------------------------------------
|
|
4793
|
+
// Tabs
|
|
4794
|
+
//
|
|
4795
|
+
// The content area holds a list of tabs instead of one preview. Every
|
|
4796
|
+
// operation below works on that list, and `activeTab` (plus the values
|
|
4797
|
+
// derived from it) is what the render body and the previously existing
|
|
4798
|
+
// handlers read. Keeping the list as one piece of state is what makes
|
|
4799
|
+
// "close others" and "close all" correct by construction: they are list
|
|
4800
|
+
// operations, not four separate flags that could disagree.
|
|
4801
|
+
// ------------------------------------------------------------------
|
|
4802
|
+
|
|
4803
|
+
/**
|
|
4804
|
+
* The tab on screen, or null.
|
|
4805
|
+
*
|
|
4806
|
+
* Never compute this from `tabs[0]`: the active tab can be the last
|
|
4807
|
+
* one closed, and falling back to a neighbour silently would show a
|
|
4808
|
+
* file the reader just dismissed.
|
|
4809
|
+
*/
|
|
4810
|
+
const activeTab = (() => {
|
|
4811
|
+
if (tabs === null || tabs.length === 0) return null;
|
|
4812
|
+
const found = tabs.find((tab) => tab.id === activeTabId);
|
|
4813
|
+
return found === undefined ? null : found;
|
|
4814
|
+
})();
|
|
4815
|
+
const meta = activeTab === null ? null : (activeTab.meta === undefined ? null : activeTab.meta);
|
|
4816
|
+
const previewCommit = activeTab === null ? "" : (activeTab.commit || "");
|
|
4817
|
+
const previewChangePath = activeTab === null ? "" : (activeTab.changePath || "");
|
|
4818
|
+
const localeTagOfActive = activeTab === null ? "auto" : (activeTab.lang || "auto");
|
|
4819
|
+
const showCommit = activeTab !== null && (activeTab.kind === "commit" || activeTab.kind === "change");
|
|
4820
|
+
const commitView = activeTab === null ? "files" : (activeTab.commitView || "files");
|
|
4821
|
+
const previewOrigin = activeTab === null ? null : (activeTab.origin || null);
|
|
4822
|
+
|
|
4823
|
+
/**
|
|
4824
|
+
* Read the list, tolerating the "no tabs yet" state.
|
|
4825
|
+
*
|
|
4826
|
+
* The render body runs before any tab exists, and every list operation
|
|
4827
|
+
* must be safe there rather than special-casing null at each call site.
|
|
4828
|
+
*/
|
|
4829
|
+
const tabList = () => (tabsRef.current === null ? [] : tabsRef.current);
|
|
4830
|
+
|
|
4831
|
+
/**
|
|
4832
|
+
* Open a tab, or update the one with the same id and bring it forward.
|
|
4833
|
+
*
|
|
4834
|
+
* Ids are content-derived (`file:<path>`, `commit:<sha>`,
|
|
4835
|
+
* `change:<path>`), which is what makes re-clicking an already open
|
|
4836
|
+
* file focus its tab instead of stacking a duplicate.
|
|
4837
|
+
*/
|
|
4838
|
+
const upsertTab = (draft, options) => {
|
|
4839
|
+
const result = tabsAfterUpsert(tabList(), activeTabIdRef.current, draft);
|
|
4840
|
+
tabsRef.current = result.tabs;
|
|
4841
|
+
setTabs(result.tabs);
|
|
4842
|
+
// A caller that does not want to move the reader's focus (an arrival
|
|
4843
|
+
// path that resolves in the background) keeps the current tab.
|
|
4844
|
+
if (options === undefined || options.activate !== false) {
|
|
4845
|
+
activeTabIdRef.current = result.activeId;
|
|
4846
|
+
setActiveTabId(result.activeId);
|
|
4847
|
+
}
|
|
4848
|
+
setTabMenu(null);
|
|
4849
|
+
return result.activeId;
|
|
4850
|
+
};
|
|
4851
|
+
|
|
4852
|
+
/** Focus one existing tab; a missing one is ignored rather than resurrected. */
|
|
4853
|
+
const activateTab = (id) => {
|
|
4854
|
+
const current = tabList();
|
|
4855
|
+
const tab = current.find((row) => row.id === id);
|
|
4856
|
+
if (tab === undefined) return;
|
|
4857
|
+
activeTabIdRef.current = id;
|
|
4858
|
+
setActiveTabId(id);
|
|
4859
|
+
setTabMenu(null);
|
|
4860
|
+
// The Git controller holds one commit's detail at a time, so coming
|
|
4861
|
+
// back to a commit tab whose detail has since been replaced has to
|
|
4862
|
+
// re-read it — otherwise the pane would show a different commit
|
|
4863
|
+
// under this tab's title.
|
|
4864
|
+
if (tab.kind === "commit") {
|
|
4865
|
+
const sha = id.slice("commit:".length);
|
|
4866
|
+
const detail = git.store.get().detail;
|
|
4867
|
+
const loaded = detail !== null && detail !== undefined && detail.commit !== undefined
|
|
4868
|
+
&& detail.commit.sha === sha;
|
|
4869
|
+
if (!loaded) git.openCommit(sha);
|
|
4870
|
+
}
|
|
4871
|
+
};
|
|
4872
|
+
|
|
4873
|
+
/** Patch one tab by id. Used by asynchronous reads that outlive a switch. */
|
|
4874
|
+
const patchTab = (id, mutator) => {
|
|
4875
|
+
const current = tabList();
|
|
4876
|
+
if (!current.some((tab) => tab.id === id)) return;
|
|
4877
|
+
const next = current.map((tab) => (tab.id === id
|
|
4878
|
+
? (typeof mutator === "function" ? mutator(tab) : { ...tab, ...mutator })
|
|
4879
|
+
: tab));
|
|
4880
|
+
tabsRef.current = next;
|
|
4881
|
+
setTabs(next);
|
|
4882
|
+
};
|
|
4883
|
+
|
|
4884
|
+
const findTab = (id) => {
|
|
4885
|
+
const found = tabList().find((tab) => tab.id === id);
|
|
4886
|
+
return found === undefined ? null : found;
|
|
4887
|
+
};
|
|
4888
|
+
|
|
4889
|
+
/**
|
|
4890
|
+
* Close tabs and land on a sensible neighbour.
|
|
4891
|
+
*
|
|
4892
|
+
* The list maths lives in `tabsAfterClose` so it can be exercised
|
|
4893
|
+
* without a renderer: "which tab is active afterwards" is the one rule
|
|
4894
|
+
* a reader notices immediately when it is wrong.
|
|
4895
|
+
*/
|
|
4896
|
+
const closeTabs = (ids) => {
|
|
4897
|
+
const current = tabList();
|
|
4898
|
+
const result = tabsAfterClose(current, activeTabIdRef.current, Array.isArray(ids) ? ids : [ids]);
|
|
4899
|
+
if (result === null) return;
|
|
4900
|
+
tabsRef.current = result.tabs;
|
|
4901
|
+
setTabs(result.tabs);
|
|
4902
|
+
setTabMenu(null);
|
|
4903
|
+
activeTabIdRef.current = result.activeId;
|
|
4904
|
+
setActiveTabId(result.activeId);
|
|
4905
|
+
if (result.tabs.length === 0) setPane("tree");
|
|
4906
|
+
};
|
|
4907
|
+
|
|
4908
|
+
const closeTab = (id) => { closeTabs([id]); };
|
|
4909
|
+
|
|
4910
|
+
/**
|
|
4911
|
+
* Re-read what one tab shows, without disturbing the others.
|
|
4912
|
+
*
|
|
4913
|
+
* What "refresh" means depends on what the tab is: a working-tree file
|
|
4914
|
+
* re-reads its metadata and forces the viewer to remount, a commit or a
|
|
4915
|
+
* change re-runs the Git read that produced it.
|
|
4916
|
+
*/
|
|
4917
|
+
const refreshTab = (id) => {
|
|
4918
|
+
const tab = findTab(id);
|
|
4919
|
+
if (tab === null) return;
|
|
4920
|
+
if (tab.kind === "commit") {
|
|
4921
|
+
git.openCommit(tab.id.slice("commit:".length));
|
|
4922
|
+
return;
|
|
4923
|
+
}
|
|
4924
|
+
if (tab.kind === "change") {
|
|
4925
|
+
git.loadChanges();
|
|
4926
|
+
// The diff itself is fetched by the child's own effect, which is
|
|
4927
|
+
// keyed on the path; bumping the epoch remounts it so a re-read
|
|
4928
|
+
// happens even when the path did not change.
|
|
4929
|
+
patchTab(id, (current) => ({ ...current, epoch: (current.epoch || 0) + 1 }));
|
|
4930
|
+
return;
|
|
4931
|
+
}
|
|
4932
|
+
if (tab.commit !== "" && tab.commit !== undefined) {
|
|
4933
|
+
// A blob from history has no metadata to re-read; remounting the
|
|
4934
|
+
// viewer is what asks the host for the bytes again.
|
|
4935
|
+
patchTab(id, (current) => ({ ...current, epoch: (current.epoch || 0) + 1 }));
|
|
4936
|
+
return;
|
|
4937
|
+
}
|
|
4938
|
+
patchTab(id, (current) => ({ ...current, status: "loading", epoch: (current.epoch || 0) + 1 }));
|
|
4939
|
+
// A ticket per tab, not per drawer: refreshing B after A must not
|
|
4940
|
+
// cancel A's read, which is what a shared counter would do.
|
|
4941
|
+
const tickets = metaTickets.current;
|
|
4942
|
+
const ticket = (tickets.get(id) || 0) + 1;
|
|
4943
|
+
tickets.set(id, ticket);
|
|
4944
|
+
request("meta", { root, path: tab.path }).then((value) => {
|
|
4945
|
+
if (tickets.get(id) !== ticket) return;
|
|
4946
|
+
patchTab(id, (current) => ({ ...current, meta: value, status: "ready", error: null }));
|
|
4947
|
+
}).catch((problem) => {
|
|
4948
|
+
if (tickets.get(id) !== ticket) return;
|
|
4949
|
+
patchTab(id, (current) => ({ ...current, status: "error", error: problem }));
|
|
4950
|
+
});
|
|
4951
|
+
};
|
|
4952
|
+
|
|
4953
|
+
/** The four items a tab's context menu offers. */
|
|
4954
|
+
const onTabAction = (action, id) => {
|
|
4955
|
+
const current = tabList();
|
|
4956
|
+
if (action === "refresh") refreshTab(id);
|
|
4957
|
+
else if (action === "close") closeTab(id);
|
|
4958
|
+
else if (action === "others") closeTabs(current.filter((tab) => tab.id !== id).map((tab) => tab.id));
|
|
4959
|
+
else if (action === "all") closeTabs(current.map((tab) => tab.id));
|
|
4960
|
+
setTabMenu(null);
|
|
4961
|
+
};
|
|
4962
|
+
|
|
4404
4963
|
// Re-read every directory the reader currently has open, keeping the
|
|
4405
4964
|
// tree's shape, plus the status badges a checkout or external edit
|
|
4406
4965
|
// invalidates.
|
|
@@ -4416,36 +4975,63 @@ window.__ModuleLoader__.load({
|
|
|
4416
4975
|
}).catch(() => {});
|
|
4417
4976
|
};
|
|
4418
4977
|
|
|
4978
|
+
/**
|
|
4979
|
+
* The top toolbar's refresh: the tree, the Git panel, and the tab on
|
|
4980
|
+
* screen.
|
|
4981
|
+
*
|
|
4982
|
+
* The tab is refreshed too because that is what the reader is looking
|
|
4983
|
+
* at — refreshing the tree around a stale preview would leave the file
|
|
4984
|
+
* they care about unchanged.
|
|
4985
|
+
*/
|
|
4419
4986
|
const refresh = () => {
|
|
4420
4987
|
reloadWorkingTree();
|
|
4421
4988
|
if (side === "git") {
|
|
4422
4989
|
const state = git.store.get();
|
|
4423
|
-
|
|
4424
|
-
|
|
4990
|
+
// The commit to come back to belongs to the tab on screen, not to
|
|
4991
|
+
// whichever detail the controller happens to hold.
|
|
4992
|
+
const keepSha = activeTab !== null && activeTab.kind === "commit"
|
|
4993
|
+
? activeTab.id.slice("commit:".length)
|
|
4425
4994
|
: "";
|
|
4426
4995
|
if (state.root !== root) git.reset(root);
|
|
4427
4996
|
git.loadRepos().then(() => {
|
|
4428
4997
|
if (keepSha !== "") git.openCommit(keepSha);
|
|
4429
4998
|
});
|
|
4430
4999
|
}
|
|
5000
|
+
if (activeTabId !== "") refreshTab(activeTabId);
|
|
4431
5001
|
};
|
|
4432
5002
|
|
|
4433
|
-
/** Open one commit in
|
|
5003
|
+
/** Open one commit in its own tab, or activate the tab already showing it. */
|
|
4434
5004
|
const openCommit = (commit) => {
|
|
4435
5005
|
if (commit === null || commit === undefined || typeof commit.sha !== "string") return;
|
|
4436
|
-
|
|
5006
|
+
const sha = commit.sha;
|
|
5007
|
+
const title = commit.shortSha || sha.slice(0, 7);
|
|
5008
|
+
const id = "commit:" + sha;
|
|
4437
5009
|
setPane("content");
|
|
4438
|
-
setCommitView("files");
|
|
4439
5010
|
// Re-opening the commit already on screen costs nothing: its detail
|
|
4440
5011
|
// does not depend on the working tree, only on the commit itself.
|
|
4441
5012
|
const cached = git.store.get();
|
|
4442
|
-
|
|
4443
|
-
&& cached.detail.commit !== undefined && cached.detail.commit.sha ===
|
|
4444
|
-
|
|
5013
|
+
const alreadyLoaded = cached.detailStatus === "ready" && cached.detail !== null && cached.detail !== undefined
|
|
5014
|
+
&& cached.detail.commit !== undefined && cached.detail.commit.sha === sha;
|
|
5015
|
+
upsertTab({
|
|
5016
|
+
id,
|
|
5017
|
+
kind: "commit",
|
|
5018
|
+
path: "",
|
|
5019
|
+
title,
|
|
5020
|
+
// The subject is the useful second line; the hash is already the
|
|
5021
|
+
// title, so repeating it would say nothing.
|
|
5022
|
+
dir: typeof commit.subject === "string" ? commit.subject : "",
|
|
5023
|
+
meta: null,
|
|
5024
|
+
commitView: "files",
|
|
5025
|
+
commit: "",
|
|
5026
|
+
changePath: "",
|
|
5027
|
+
origin: null,
|
|
5028
|
+
lang: "auto",
|
|
5029
|
+
}, { activate: true });
|
|
5030
|
+
if (!alreadyLoaded) git.openCommit(sha);
|
|
4445
5031
|
};
|
|
4446
5032
|
|
|
4447
5033
|
/**
|
|
4448
|
-
* Open one file from a commit in
|
|
5034
|
+
* Open one file from a commit in its own tab.
|
|
4449
5035
|
*
|
|
4450
5036
|
* The row's path is already root-relative (the host rebased it), so it
|
|
4451
5037
|
* goes straight through the same `meta` request the tree uses; files
|
|
@@ -4474,53 +5060,58 @@ window.__ModuleLoader__.load({
|
|
|
4474
5060
|
? file.oldPath
|
|
4475
5061
|
: file.path;
|
|
4476
5062
|
const readSha = fromParent ? parentSha : sha;
|
|
4477
|
-
setShowCommit(false);
|
|
4478
5063
|
setPane("content");
|
|
4479
|
-
setPreviewCommit(readSha);
|
|
4480
|
-
// Back must return to the changed-file list this row came from,
|
|
4481
|
-
// not to the file tree, which is somewhere else entirely.
|
|
4482
|
-
setPreviewOrigin("commit");
|
|
4483
|
-
setLangChoice("auto");
|
|
4484
|
-
const ticket = metaTicket.current + 1;
|
|
4485
|
-
metaTicket.current = ticket;
|
|
4486
5064
|
const known = detail && Array.isArray(detail.files)
|
|
4487
5065
|
? detail.files.find((row) => row.path === file.path)
|
|
4488
5066
|
: null;
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
5067
|
+
const id = "file:" + blobPath + "\u0000" + readSha;
|
|
5068
|
+
upsertTab({
|
|
5069
|
+
id,
|
|
5070
|
+
kind: "file",
|
|
4493
5071
|
path: blobPath,
|
|
4494
|
-
// A rename is read from its old path but highlighted as the row
|
|
4495
|
-
// the reader actually clicked.
|
|
4496
5072
|
rowPath: file.path,
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
5073
|
+
title: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
|
|
5074
|
+
dir: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
|
|
5075
|
+
meta: {
|
|
5076
|
+
root: root,
|
|
5077
|
+
// The blob path is what the host reads; the display path keeps the
|
|
5078
|
+
// row the reader clicked.
|
|
5079
|
+
path: blobPath,
|
|
5080
|
+
// A rename is read from its old path but highlighted as the row
|
|
5081
|
+
// the reader actually clicked.
|
|
5082
|
+
rowPath: file.path,
|
|
5083
|
+
name: baseNameOf(blobPath),
|
|
5084
|
+
displayPath: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
|
|
5085
|
+
kind: component,
|
|
5086
|
+
commit: readSha,
|
|
5087
|
+
// Opening a changed file must offer both halves of the question:
|
|
5088
|
+
// what this commit changed in it, and what the file looks like.
|
|
5089
|
+
// `diffSha` is the commit being viewed (the blob may come from
|
|
5090
|
+
// its parent for a deletion), and `oldPath` is what lets the host
|
|
5091
|
+
// keep rename detection intact when filtering the patch.
|
|
5092
|
+
diffSha: sha,
|
|
5093
|
+
oldPath: typeof file.oldPath === "string" && file.oldPath !== "" ? file.oldPath : null,
|
|
5094
|
+
// The commit records whether the file was added, modified or
|
|
5095
|
+
// deleted; the preview says so instead of looking like a normal
|
|
5096
|
+
// working-tree file.
|
|
5097
|
+
change: known ? known.change : "",
|
|
5098
|
+
changeLetter: known ? known.letter : "",
|
|
5099
|
+
deleted: known ? known.letter === "D" : false,
|
|
5100
|
+
},
|
|
4500
5101
|
commit: readSha,
|
|
4501
|
-
//
|
|
4502
|
-
//
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
diffSha: sha,
|
|
4507
|
-
oldPath: typeof file.oldPath === "string" && file.oldPath !== "" ? file.oldPath : null,
|
|
4508
|
-
// The commit records whether the file was added, modified or
|
|
4509
|
-
// deleted; the preview says so instead of looking like a normal
|
|
4510
|
-
// working-tree file.
|
|
4511
|
-
change: known ? known.change : "",
|
|
4512
|
-
changeLetter: known ? known.letter : "",
|
|
4513
|
-
deleted: known ? known.letter === "D" : false,
|
|
4514
|
-
});
|
|
5102
|
+
// Back must return to the changed-file list this row came from,
|
|
5103
|
+
// not to the file tree, which is somewhere else entirely.
|
|
5104
|
+
origin: { kind: "commit", sha: sha },
|
|
5105
|
+
lang: "auto",
|
|
5106
|
+
}, { activate: true });
|
|
4515
5107
|
setError(null);
|
|
4516
5108
|
return;
|
|
4517
5109
|
}
|
|
4518
|
-
setShowCommit(false);
|
|
4519
5110
|
selectFile({ path: file.path, name: baseNameOf(file.path), type: "file" });
|
|
4520
5111
|
};
|
|
4521
5112
|
|
|
4522
5113
|
/**
|
|
4523
|
-
* Step back one place from the
|
|
5114
|
+
* Step back one place from the active tab.
|
|
4524
5115
|
*
|
|
4525
5116
|
* Three destinations, in the order the reader arrived from: a commit's
|
|
4526
5117
|
* changed-file list, the working-tree change list, or the file tree.
|
|
@@ -4528,89 +5119,133 @@ window.__ModuleLoader__.load({
|
|
|
4528
5119
|
* a stale hash or blob path cannot leak into the next preview.
|
|
4529
5120
|
*/
|
|
4530
5121
|
const goBack = () => {
|
|
4531
|
-
|
|
4532
|
-
|
|
5122
|
+
const current = activeTab;
|
|
5123
|
+
const origin = current === null || current === undefined ? null : current.origin;
|
|
5124
|
+
if (origin !== null && origin !== undefined && origin.kind === "commit") {
|
|
5125
|
+
// Re-open the changed-file view of the commit this file came from;
|
|
4533
5126
|
// its hash is what the sidebar is highlighting.
|
|
4534
|
-
setShowCommit(true);
|
|
4535
|
-
setCommitView("files");
|
|
4536
|
-
setPreviewCommit("");
|
|
4537
|
-
setPreviewOrigin(null);
|
|
4538
5127
|
setPane("content");
|
|
5128
|
+
patchTab(current.id, (tab) => ({ ...tab, commitView: "files", commit: "", origin: null }));
|
|
5129
|
+
openCommit({ sha: origin.sha });
|
|
4539
5130
|
return;
|
|
4540
5131
|
}
|
|
4541
|
-
if (
|
|
4542
|
-
setShowCommit(true);
|
|
4543
|
-
setCommitView("changes");
|
|
4544
|
-
setPreviewCommit("");
|
|
4545
|
-
setPreviewOrigin(null);
|
|
5132
|
+
if (origin !== null && origin !== undefined && origin.kind === "changes") {
|
|
4546
5133
|
setPane("content");
|
|
5134
|
+
patchTab(current.id, (tab) => ({ ...tab, commitView: "changes", commit: "", origin: null }));
|
|
4547
5135
|
return;
|
|
4548
5136
|
}
|
|
4549
5137
|
// Nothing to undo inside the pane: hand control back to the sidebar.
|
|
4550
5138
|
// On a phone that actually swaps panes; on desktop the tree is
|
|
4551
5139
|
// already alongside, so the selection is cleared instead of the
|
|
4552
5140
|
// button doing nothing at all.
|
|
4553
|
-
|
|
4554
|
-
setPreviewOrigin(null);
|
|
4555
|
-
setMeta(null);
|
|
5141
|
+
closeTab(activeTabId);
|
|
4556
5142
|
setError(null);
|
|
4557
5143
|
setPane("tree");
|
|
4558
5144
|
};
|
|
4559
5145
|
|
|
4560
5146
|
/**
|
|
4561
|
-
* Open one working-tree change as a diff.
|
|
5147
|
+
* Open one working-tree change as a diff, in its own tab.
|
|
4562
5148
|
*
|
|
4563
5149
|
* The main pane shows the patch rather than the file: a deletion has no
|
|
4564
5150
|
* file to open, and "what changed" is the question the row answers.
|
|
4565
5151
|
*/
|
|
4566
5152
|
const openWorkspaceChange = (row) => {
|
|
4567
5153
|
if (row === null || row === undefined || typeof row.path !== "string" || row.path === "") return;
|
|
4568
|
-
setShowCommit(true);
|
|
4569
5154
|
setPane("content");
|
|
4570
|
-
setCommitView("changes");
|
|
4571
|
-
setPreviewCommit("");
|
|
4572
|
-
setPreviewOrigin(null);
|
|
4573
5155
|
// The pane diffs exactly this path; ignoring the row left it reading
|
|
4574
5156
|
// whatever file happened to be selected before.
|
|
4575
|
-
|
|
5157
|
+
upsertTab({
|
|
5158
|
+
id: "change:" + row.path,
|
|
5159
|
+
kind: "change",
|
|
5160
|
+
path: row.path,
|
|
5161
|
+
title: row.path,
|
|
5162
|
+
dir: row.change || "工作区",
|
|
5163
|
+
meta: null,
|
|
5164
|
+
commitView: "changes",
|
|
5165
|
+
changePath: row.path,
|
|
5166
|
+
lang: "auto",
|
|
5167
|
+
}, { activate: true });
|
|
4576
5168
|
};
|
|
4577
5169
|
|
|
4578
|
-
|
|
4579
|
-
|
|
4580
|
-
|
|
4581
|
-
|
|
5170
|
+
/**
|
|
5171
|
+
* Open one working-tree file as a tab, reading its metadata.
|
|
5172
|
+
*
|
|
5173
|
+
* Split out from `selectFile` because the drawer's arrival path (a
|
|
5174
|
+
* conversation link that names a file) has to open a tab against the
|
|
5175
|
+
* root it just resolved, before that root has reached `root` state.
|
|
5176
|
+
*
|
|
5177
|
+
* @param {string} rootPath - root the path belongs to.
|
|
5178
|
+
* @param {string} path - root-relative file path.
|
|
5179
|
+
* @param {{activate?: boolean, name?: string}} [options]
|
|
5180
|
+
*/
|
|
5181
|
+
const openFileTab = (rootPath, path, options) => {
|
|
5182
|
+
if (typeof path !== "string" || path === "") return;
|
|
5183
|
+
const chosenRoot = typeof rootPath === "string" && rootPath !== "" ? rootPath : root;
|
|
5184
|
+
const name = options && typeof options.name === "string" ? options.name : baseNameOf(path);
|
|
5185
|
+
const id = "file:" + path;
|
|
5186
|
+
const existing = findTab(id);
|
|
5187
|
+
// A tab already open for this path is re-activated rather than
|
|
5188
|
+
// re-read: its contents are on screen already, and the refresh item
|
|
5189
|
+
// in its context menu is how the reader asks for a re-read.
|
|
5190
|
+
if (existing !== null) {
|
|
5191
|
+
activateTab(id);
|
|
5192
|
+
return;
|
|
5193
|
+
}
|
|
4582
5194
|
// Selecting from the file tree is always the working tree, so any
|
|
4583
5195
|
// commit a preview was read from is dropped here — and there is no
|
|
4584
5196
|
// intermediate pane to return to.
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
5197
|
+
upsertTab({
|
|
5198
|
+
id,
|
|
5199
|
+
kind: "file",
|
|
5200
|
+
path,
|
|
5201
|
+
title: name,
|
|
5202
|
+
dir: directoryOf(path),
|
|
5203
|
+
meta: null,
|
|
5204
|
+
// A brand-new tab has nothing to show yet; `status` is what the
|
|
5205
|
+
// pane reads to tell "loading" from "the file is empty".
|
|
5206
|
+
status: "loading",
|
|
5207
|
+
commit: "",
|
|
5208
|
+
commitView: "files",
|
|
5209
|
+
changePath: "",
|
|
5210
|
+
origin: null,
|
|
5211
|
+
// The remembered choice for this file takes effect immediately, so
|
|
5212
|
+
// the read that follows is already resolved with it.
|
|
5213
|
+
lang: readLangOverride(chosenRoot, path),
|
|
5214
|
+
}, { activate: options === undefined || options.activate !== false });
|
|
5215
|
+
// Clicking two files quickly must not let the slower answer win: only
|
|
5216
|
+
// the newest request for THIS tab may write its state. Tabs are read
|
|
5217
|
+
// in parallel now, so the ticket is kept per id.
|
|
5218
|
+
const tickets = metaTickets.current;
|
|
5219
|
+
const ticket = (tickets.get(id) || 0) + 1;
|
|
5220
|
+
tickets.set(id, ticket);
|
|
5221
|
+
request("meta", { root: chosenRoot, path }).then((value) => {
|
|
5222
|
+
if (tickets.get(id) !== ticket) return;
|
|
5223
|
+
patchTab(id, (tab) => ({ ...tab, meta: value, status: "ready", error: null }));
|
|
4596
5224
|
setError(null);
|
|
4597
5225
|
}).catch((problem) => {
|
|
4598
|
-
if (
|
|
4599
|
-
|
|
5226
|
+
if (tickets.get(id) !== ticket) return;
|
|
5227
|
+
patchTab(id, (tab) => ({ ...tab, meta: null, status: "error", error: problem }));
|
|
4600
5228
|
setError(problem);
|
|
4601
5229
|
});
|
|
4602
5230
|
};
|
|
4603
5231
|
|
|
5232
|
+
const selectFile = (entry) => {
|
|
5233
|
+
if (entry === null || entry === undefined || typeof entry.path !== "string") return;
|
|
5234
|
+
setPane("content");
|
|
5235
|
+
openFileTab(root, entry.path, { name: entry.name });
|
|
5236
|
+
};
|
|
5237
|
+
|
|
4604
5238
|
/**
|
|
4605
|
-
* Pin, or unpin, the language for the
|
|
5239
|
+
* Pin, or unpin, the language for the active tab's file.
|
|
4606
5240
|
*
|
|
4607
5241
|
* The choice is remembered per file and sent with every read, so the
|
|
4608
5242
|
* host stays the only thing that decides what grammar applies.
|
|
4609
5243
|
*/
|
|
4610
5244
|
const chooseLang = (next) => {
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
5245
|
+
const current = activeTab;
|
|
5246
|
+
if (current === null || current.meta === null || current.meta === undefined) return;
|
|
5247
|
+
patchTab(current.id, (tab) => ({ ...tab, lang: next }));
|
|
5248
|
+
writeLangOverride(root, current.meta.path, next);
|
|
4614
5249
|
};
|
|
4615
5250
|
|
|
4616
5251
|
const activeRoot = roots.find((row) => row.id === root);
|
|
@@ -4632,8 +5267,10 @@ window.__ModuleLoader__.load({
|
|
|
4632
5267
|
"aria-selected": side === "files" ? "true" : "false",
|
|
4633
5268
|
title: "浏览项目文件",
|
|
4634
5269
|
onClick: () => {
|
|
5270
|
+
// Switching the sidebar back to the tree does not touch the
|
|
5271
|
+
// content area: the open tabs stay open, which is the whole point
|
|
5272
|
+
// of having them.
|
|
4635
5273
|
setSide("files");
|
|
4636
|
-
setShowCommit(false);
|
|
4637
5274
|
},
|
|
4638
5275
|
}, "文件"),
|
|
4639
5276
|
react.createElement("button", {
|
|
@@ -4648,16 +5285,27 @@ window.__ModuleLoader__.load({
|
|
|
4648
5285
|
},
|
|
4649
5286
|
}, react.createElement(SvgIconBranch, { size: 13 }), "Git"));
|
|
4650
5287
|
|
|
4651
|
-
|
|
4652
|
-
|
|
5288
|
+
/**
|
|
5289
|
+
* The hash the pane is describing.
|
|
5290
|
+
*
|
|
5291
|
+
* A commit tab names its own sha, which is what the header and the diff
|
|
5292
|
+
* read: the Git controller holds one detail at a time, so trusting it
|
|
5293
|
+
* alone would label this tab with whichever commit was opened last.
|
|
5294
|
+
*/
|
|
5295
|
+
const activeCommitSha = activeTab !== null && activeTab.kind === "commit"
|
|
5296
|
+
? activeTab.id.slice("commit:".length)
|
|
4653
5297
|
: "";
|
|
5298
|
+
const commitSha = activeCommitSha !== ""
|
|
5299
|
+
? activeCommitSha
|
|
5300
|
+
: (gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
|
|
5301
|
+
? gitState.detail.commit.sha
|
|
5302
|
+
: "");
|
|
4654
5303
|
// The main pane is showing something commit-shaped whenever it is not
|
|
4655
5304
|
// the ordinary file preview: a commit, or one working-tree change.
|
|
4656
|
-
const detailPane = showCommit;
|
|
4657
5305
|
const commitHeadline = commitView === "changes"
|
|
4658
5306
|
? "工作区改动"
|
|
4659
|
-
: (showCommit &&
|
|
4660
|
-
? "提交 " + (
|
|
5307
|
+
: (showCommit && activeTab !== null && activeTab.kind === "commit"
|
|
5308
|
+
? "提交 " + (commitSha === "" ? "" : commitSha.slice(0, 7))
|
|
4661
5309
|
: "");
|
|
4662
5310
|
|
|
4663
5311
|
const handleDownloadItem = (entry) => {
|
|
@@ -4843,14 +5491,26 @@ window.__ModuleLoader__.load({
|
|
|
4843
5491
|
onDoubleClick: onResizerDoubleClick,
|
|
4844
5492
|
}),
|
|
4845
5493
|
react.createElement("div", { className: "fv-main" },
|
|
5494
|
+
// The tab strip sits above the head so the head keeps naming
|
|
5495
|
+
// the active tab alone; the two together read as "these are
|
|
5496
|
+
// open, this one is on screen".
|
|
5497
|
+
react.createElement(TabStrip, {
|
|
5498
|
+
tabs,
|
|
5499
|
+
activeId: activeTabId,
|
|
5500
|
+
onActivate: activateTab,
|
|
5501
|
+
onClose: closeTab,
|
|
5502
|
+
onContextMenu: (menu) => setTabMenu(menu),
|
|
5503
|
+
menuFor: tabMenu === null || tabMenu === undefined ? "" : tabMenu.tabId,
|
|
5504
|
+
}),
|
|
4846
5505
|
react.createElement("div", { className: "fv-main-head" },
|
|
4847
5506
|
(() => {
|
|
4848
5507
|
// The control returns one step, so it names that step and
|
|
4849
5508
|
// is disabled when there is nowhere to go — a button that
|
|
4850
5509
|
// silently does nothing reads as broken.
|
|
4851
|
-
const
|
|
5510
|
+
const originKind = previewOrigin === null || previewOrigin === undefined ? null : previewOrigin.kind;
|
|
5511
|
+
const backTarget = originKind === "commit"
|
|
4852
5512
|
? "返回变更文件列表"
|
|
4853
|
-
: (
|
|
5513
|
+
: (originKind === "changes" ? "返回工作区改动列表" : "返回文件列表");
|
|
4854
5514
|
// On a phone the panes are exclusive, so leaving the content
|
|
4855
5515
|
// pane is always meaningful; on desktop the sidebar is
|
|
4856
5516
|
// already visible and only an origin is worth returning to.
|
|
@@ -4869,7 +5529,9 @@ window.__ModuleLoader__.load({
|
|
|
4869
5529
|
react.createElement("span", { className: "fv-main-name" },
|
|
4870
5530
|
commitHeadline !== ""
|
|
4871
5531
|
? commitHeadline
|
|
4872
|
-
: (
|
|
5532
|
+
: (activeTab === null
|
|
5533
|
+
? "未选择文件"
|
|
5534
|
+
: (meta === null ? activeTab.title : meta.name))),
|
|
4873
5535
|
meta !== null && !showCommit && typeof meta.size === "number"
|
|
4874
5536
|
? react.createElement("span", null, formatBytes(meta.size))
|
|
4875
5537
|
: null,
|
|
@@ -4878,14 +5540,14 @@ window.__ModuleLoader__.load({
|
|
|
4878
5540
|
// document has no grammar to choose.
|
|
4879
5541
|
meta !== null && !showCommit && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
|
|
4880
5542
|
? react.createElement(LanguagePicker, {
|
|
4881
|
-
choice:
|
|
5543
|
+
choice: localeTagOfActive,
|
|
4882
5544
|
catalog: languageCatalog,
|
|
4883
5545
|
source: meta.langSource,
|
|
4884
5546
|
resolved: meta.lang,
|
|
4885
5547
|
onChange: chooseLang,
|
|
4886
5548
|
})
|
|
4887
5549
|
: null),
|
|
4888
|
-
error !== null && meta === null && !showCommit
|
|
5550
|
+
error !== null && meta === null && !showCommit && activeTab === null
|
|
4889
5551
|
? react.createElement("div", { className: "fv-note fv-error" },
|
|
4890
5552
|
react.createElement("span", null, messageOf(error)),
|
|
4891
5553
|
error?.code === "outside-root"
|
|
@@ -4900,43 +5562,81 @@ window.__ModuleLoader__.load({
|
|
|
4900
5562
|
},
|
|
4901
5563
|
}, "配置安全访问路径")
|
|
4902
5564
|
: null)
|
|
4903
|
-
:
|
|
4904
|
-
? react.createElement(
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
5565
|
+
: activeTab !== null && activeTab.status === "error" && activeTab.error !== null && activeTab.error !== undefined
|
|
5566
|
+
? react.createElement("div", { className: "fv-note fv-error" },
|
|
5567
|
+
react.createElement("span", null, messageOf(activeTab.error)),
|
|
5568
|
+
activeTab.error?.code === "outside-root"
|
|
5569
|
+
? react.createElement("button", {
|
|
5570
|
+
type: "button",
|
|
5571
|
+
className: "fv-button",
|
|
5572
|
+
style: { marginTop: "6px" },
|
|
5573
|
+
onClick: () => {
|
|
5574
|
+
const reqPath = activeTab.error?.details?.realPath || activeTab.path || "";
|
|
5575
|
+
setPrefillPath(reqPath ? parentOf(reqPath) : "");
|
|
5576
|
+
setSafeModalOpen(true);
|
|
5577
|
+
},
|
|
5578
|
+
}, "配置安全访问路径")
|
|
5579
|
+
: null)
|
|
5580
|
+
: showCommit
|
|
5581
|
+
? react.createElement(ErrorBoundary, {
|
|
5582
|
+
// The epoch is part of the key so "刷新当前" remounts the
|
|
5583
|
+
// pane instead of showing the same cached answer.
|
|
5584
|
+
resetKey: "commit\u0000" + commitSha + "\u0000" + (activeTab === null ? 0 : (activeTab.epoch || 0)),
|
|
5585
|
+
}, commitView === "changes"
|
|
5586
|
+
? react.createElement(WorkspaceChangeDetail, {
|
|
5587
|
+
key: (activeTab === null ? "" : activeTab.id) + "\u0000" + (activeTab === null ? 0 : (activeTab.epoch || 0)),
|
|
5588
|
+
state: gitState,
|
|
5589
|
+
path: previewChangePath,
|
|
5590
|
+
request,
|
|
5591
|
+
repo: gitState.repoId,
|
|
5592
|
+
})
|
|
5593
|
+
: react.createElement(CommitDetail, {
|
|
5594
|
+
state: gitState,
|
|
5595
|
+
// A rename is read from its old path but highlighted as
|
|
5596
|
+
// the row that was clicked, so the two stay in step.
|
|
5597
|
+
selectedPath: meta === null ? "" : (meta.rowPath || meta.path),
|
|
5598
|
+
onSelectFile: openCommitFile,
|
|
5599
|
+
view: commitView,
|
|
5600
|
+
onSwitchView: (next) => {
|
|
5601
|
+
if (activeTab !== null) patchTab(activeTab.id, (tab) => ({ ...tab, commitView: next }));
|
|
5602
|
+
},
|
|
5603
|
+
onLoadDiff: () => {
|
|
5604
|
+
const sha = commitSha;
|
|
5605
|
+
if (sha !== "") git.loadCommitDiff(sha);
|
|
5606
|
+
},
|
|
5607
|
+
}))
|
|
5608
|
+
: react.createElement(ErrorBoundary, {
|
|
5609
|
+
// One viewer per tab (and per refresh epoch), so no
|
|
5610
|
+
// paging or blob state survives a tab switch.
|
|
5611
|
+
resetKey: activeTab === null
|
|
5612
|
+
? ""
|
|
5613
|
+
: activeTab.id + "\u0000" + (activeTab.epoch || 0),
|
|
5614
|
+
}, activeTab === null
|
|
5615
|
+
? react.createElement(FileView, {
|
|
5616
|
+
meta: null,
|
|
5617
|
+
root,
|
|
5618
|
+
api: props.api,
|
|
5619
|
+
wrap,
|
|
5620
|
+
lang: "auto",
|
|
5621
|
+
onDownload: handleDownloadItem,
|
|
5622
|
+
commit: "",
|
|
5623
|
+
repo: gitState.repoId,
|
|
5624
|
+
})
|
|
5625
|
+
: react.createElement(FileView, {
|
|
5626
|
+
meta,
|
|
5627
|
+
root,
|
|
5628
|
+
api: props.api,
|
|
5629
|
+
wrap,
|
|
5630
|
+
lang: localeTagOfActive,
|
|
5631
|
+
onDownload: handleDownloadItem,
|
|
5632
|
+
// Empty for a working-tree file; a hash for one opened
|
|
5633
|
+
// from history, which makes the read a blob lookup.
|
|
5634
|
+
commit: previewCommit,
|
|
5635
|
+
repo: gitState.repoId,
|
|
5636
|
+
// A tab is created before its metadata arrives, so the
|
|
5637
|
+
// pane has to say "reading" instead of "pick a file".
|
|
5638
|
+
loading: activeTab.status === "loading",
|
|
5639
|
+
})))),
|
|
4940
5640
|
contextMenu !== null
|
|
4941
5641
|
? react.createElement(ContextMenu, {
|
|
4942
5642
|
menu: contextMenu,
|
|
@@ -4947,6 +5647,17 @@ window.__ModuleLoader__.load({
|
|
|
4947
5647
|
onDownload: handleDownloadItem,
|
|
4948
5648
|
})
|
|
4949
5649
|
: null,
|
|
5650
|
+
// The tab menu is a separate component with a separate subject; it
|
|
5651
|
+
// is rendered last so it stacks above the tree's menu if both were
|
|
5652
|
+
// somehow open, which cannot happen but must not paint twice.
|
|
5653
|
+
tabMenu !== null && tabMenu !== undefined
|
|
5654
|
+
? react.createElement(TabContextMenu, {
|
|
5655
|
+
menu: tabMenu,
|
|
5656
|
+
tabs,
|
|
5657
|
+
onClose: () => setTabMenu(null),
|
|
5658
|
+
onAction: onTabAction,
|
|
5659
|
+
})
|
|
5660
|
+
: null,
|
|
4950
5661
|
safeModalOpen
|
|
4951
5662
|
? react.createElement(SafePathsModal, {
|
|
4952
5663
|
open: safeModalOpen,
|
|
@@ -5266,6 +5977,10 @@ window.__ModuleLoader__.load({
|
|
|
5266
5977
|
// Git panel: controller, views and the small formatters they share.
|
|
5267
5978
|
GIT_PAGE_SIZE, createGitController, GitSidebar, CommitDetail, WorkspaceChangeDetail, DiffView, kindOfPath,
|
|
5268
5979
|
formatCommitDate, formatCommitTimestamp,
|
|
5980
|
+
// Content-area tabs: the strip, its four-item menu and the path helper
|
|
5981
|
+
// that gives a tab its secondary directory line.
|
|
5982
|
+
directoryOf, TabStrip, TabContextMenu,
|
|
5983
|
+
TAB_MENU_ITEMS, tabsAfterClose, tabsAfterUpsert,
|
|
5269
5984
|
};
|
|
5270
5985
|
return module.exports;
|
|
5271
5986
|
}
|