@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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/lib/client.js +903 -188
  4. 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
- .fv-composer-entry{box-sizing:border-box;display:inline-grid;place-items:center;width:28px;height:28px;padding:0;border:none;border-radius:999px;background:var(--dsw-specific-selector,transparent);color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;line-height:1;transition:color .12s,background-color .12s,transform .1s}
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
- return react.createElement("div", { className: "fv-note" }, "从左侧选择一个文件。");
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
- const [meta, setMeta] = react.useState(null);
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 for the selected file: "auto" follows the
4041
- // host's name-then-content resolution, any other id pins a grammar.
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
- * What the main pane was showing before the current file preview.
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
- * The back control has to undo exactly one step. Opening a file from a
4085
- * commit's changed-file list must return to that list — not to the file
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
- * `null` means there is nowhere to go back to.
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 [previewOrigin, setPreviewOrigin] = react.useState(null);
4093
- // The working-tree change shown in the main pane. It cannot ride on the
4094
- // commit detail, which is keyed by hash, and `meta` belongs to the file
4095
- // preview — so the row the reader clicked is kept here.
4096
- const [previewChangePath, setPreviewChangePath] = react.useState("");
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
- const metaTicket = react.useRef(0);
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: honour its remembered choice.
4283
- setLangChoice(readLangOverride(resolvedRoot, value.selectFile));
4284
- const ticket = ++metaTicket.current;
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
- setMeta(null);
4339
- // The override belonged to a file in the previous root; carrying it
4340
- // over would silently apply one file's grammar to the next file
4341
- // opened, and the picker is not even rendered while no file is
4342
- // selected. The per-file choice is restored in selectFile.
4343
- setLangChoice("auto");
4344
- const chain = reveal === "" ? [] : [...ancestorsOf(reveal), reveal];
4345
- setExpanded(new Set(chain));
4346
- for (const dirPath of ["", ...chain]) loadDirectory(dirPath);
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
- const keepSha = showCommit && state.detail !== null && state.detail !== undefined && state.detail.commit !== undefined
4424
- ? state.detail.commit.sha
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 the main pane, replacing whatever was there. */
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
- setShowCommit(true);
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
- if (cached.detailStatus === "ready" && cached.detail !== null && cached.detail !== undefined
4443
- && cached.detail.commit !== undefined && cached.detail.commit.sha === commit.sha) return;
4444
- git.openCommit(commit.sha);
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 the ordinary preview.
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
- setMeta({
4490
- root: root,
4491
- // The blob path is what the host reads; the display path keeps the
4492
- // row the reader clicked.
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
- name: baseNameOf(blobPath),
4498
- displayPath: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
4499
- kind: component,
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
- // Opening a changed file must offer both halves of the question:
4502
- // what this commit changed in it, and what the file looks like.
4503
- // `diffSha` is the commit being viewed (the blob may come from
4504
- // its parent for a deletion), and `oldPath` is what lets the host
4505
- // keep rename detection intact when filtering the patch.
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 main pane.
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
- if (previewOrigin === "commit") {
4532
- // Re-open the changed-file view of the commit still in `detail`;
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 (previewOrigin === "changes") {
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
- setPreviewCommit("");
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
- setPreviewChangePath(row.path);
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
- // Clicking two files quickly must not let the slower answer win: only
4579
- // the newest request may write state.
4580
- const selectFile = (entry) => {
4581
- setPane("content");
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
- setPreviewCommit("");
4586
- setPreviewOrigin(null);
4587
- setPreviewChangePath("");
4588
- // The remembered choice for this file takes effect immediately, so
4589
- // the read that follows is already resolved with it.
4590
- setLangChoice(readLangOverride(root, entry.path));
4591
- const ticket = metaTicket.current + 1;
4592
- metaTicket.current = ticket;
4593
- request("meta", { root, path: entry.path }).then((value) => {
4594
- if (metaTicket.current !== ticket) return;
4595
- setMeta(value);
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 (metaTicket.current !== ticket) return;
4599
- setMeta(null);
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 selected file.
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
- if (meta === null) return;
4612
- setLangChoice(next);
4613
- writeLangOverride(root, meta.path, next);
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
- const commitSha = gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
4652
- ? gitState.detail.commit.sha
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 && gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
4660
- ? "提交 " + (gitState.detail.commit.shortSha || String(gitState.detail.commit.sha || "").slice(0, 7))
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 backTarget = previewOrigin === "commit"
5510
+ const originKind = previewOrigin === null || previewOrigin === undefined ? null : previewOrigin.kind;
5511
+ const backTarget = originKind === "commit"
4852
5512
  ? "返回变更文件列表"
4853
- : (previewOrigin === "changes" ? "返回工作区改动列表" : "返回文件列表");
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
- : (meta === null ? "未选择文件" : meta.name)),
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: langChoice,
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
- : showCommit
4904
- ? react.createElement(ErrorBoundary, {
4905
- resetKey: "commit\u0000" + commitSha,
4906
- }, commitView === "changes"
4907
- ? react.createElement(WorkspaceChangeDetail, {
4908
- state: gitState,
4909
- path: previewChangePath,
4910
- request,
4911
- repo: gitState.repoId,
4912
- })
4913
- : react.createElement(CommitDetail, {
4914
- state: gitState,
4915
- // A rename is read from its old path but highlighted as
4916
- // the row that was clicked, so the two stay in step.
4917
- selectedPath: meta === null ? "" : (meta.rowPath || meta.path),
4918
- onSelectFile: openCommitFile,
4919
- view: commitView,
4920
- onSwitchView: setCommitView,
4921
- onLoadDiff: () => {
4922
- const sha = commitSha;
4923
- if (sha !== "") git.loadCommitDiff(sha);
4924
- },
4925
- }))
4926
- : react.createElement(ErrorBoundary, {
4927
- resetKey: meta ? meta.path : "",
4928
- }, react.createElement(FileView, {
4929
- meta,
4930
- root,
4931
- api: props.api,
4932
- wrap,
4933
- lang: langChoice,
4934
- onDownload: handleDownloadItem,
4935
- // Empty for a working-tree file; a hash for one opened
4936
- // from history, which makes the read a blob lookup.
4937
- commit: previewCommit,
4938
- repo: gitState.repoId,
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
  }