@weniatall/dsh-file-viewer 0.3.4 → 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 (3) hide show
  1. package/README.md +18 -0
  2. package/lib/client.js +897 -185
  3. package/package.json +1 -1
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}
@@ -680,6 +694,13 @@ window.__ModuleLoader__.load({
680
694
  @keyframes fv-menu-slide-up{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:translateY(0)}}
681
695
  .fv-context-item{height:46px!important;font-size:15px!important;padding:0 14px!important;border-radius:12px!important}
682
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}
683
704
  /* SafePathsModal 移动端自适应优化:输入框与添加按钮纵向铺满,防溢出 */
684
705
  .fv-modal-scrim{padding:10px!important}
685
706
  .fv-modal{width:min(96vw,560px)!important;max-height:min(90dvh,90vh,680px)!important;border-radius:16px!important}
@@ -1485,6 +1506,20 @@ window.__ModuleLoader__.load({
1485
1506
  return cut < 0 ? trimmed : trimmed.slice(cut + 1);
1486
1507
  }
1487
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
+
1488
1523
  /**
1489
1524
  * Append a file reference to a draft. Mirrors core.js's appendMention; this
1490
1525
  * file is the browser bundle and cannot import from the host half.
@@ -2842,6 +2877,271 @@ window.__ModuleLoader__.load({
2842
2877
  return groups;
2843
2878
  }
2844
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
+
2845
3145
  function apply(ctx) {
2846
3146
  ensureStyles(typeof document === "undefined" ? null : document);
2847
3147
  // `null` is closed; an object is open. Carrying the requesting session lets
@@ -3570,9 +3870,12 @@ window.__ModuleLoader__.load({
3570
3870
  }
3571
3871
 
3572
3872
  /** Pick the viewer for a file and keep unsupported formats useful. */
3573
- function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo }) {
3873
+ function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo, loading }) {
3574
3874
  if (meta === null) {
3575
- 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 ? "正在读取文件…" : "从左侧选择一个文件。");
3576
3879
  }
3577
3880
  if (meta.tooLarge || meta.kind === "binary") {
3578
3881
  return react.createElement("div", { className: "fv-note" },
@@ -3624,6 +3927,7 @@ window.__ModuleLoader__.load({
3624
3927
  react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo }));
3625
3928
  }
3626
3929
 
3930
+
3627
3931
  /** Right-click context menu for file and directory items. */
3628
3932
  function ContextMenu({ menu, rootPath, onClose, onMention, onToast, onDownload }) {
3629
3933
  const ref = react.useRef(null);
@@ -3766,6 +4070,7 @@ window.__ModuleLoader__.load({
3766
4070
  return react.createElement("div", { className: "fv-toast", role: "status" }, toast);
3767
4071
  }
3768
4072
 
4073
+
3769
4074
  /** Safe Access Paths Configuration Dialog */
3770
4075
  function SafePathsModal({ open, onClose, onToast, onUpdated, prefillPath }) {
3771
4076
  const [loading, setLoading] = react.useState(false);
@@ -3927,7 +4232,9 @@ window.__ModuleLoader__.load({
3927
4232
  // collapsing and reopening costs nothing.
3928
4233
  const [expanded, setExpanded] = react.useState(() => new Set());
3929
4234
  const [nodes, setNodes] = react.useState(() => new Map());
3930
- 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.
3931
4238
  const [hidden, setHidden] = react.useState(false);
3932
4239
  const [safeModalOpen, setSafeModalOpen] = react.useState(false);
3933
4240
  const [prefillPath, setPrefillPath] = react.useState("");
@@ -4040,9 +4347,8 @@ window.__ModuleLoader__.load({
4040
4347
  // Soft wrap for source views. Off by default: code is written with
4041
4348
  // meaningful line breaks and wrapping obscures them.
4042
4349
  const [wrap, setWrap] = react.useState(false);
4043
- // The language override for the selected file: "auto" follows the
4044
- // host's name-then-content resolution, any other id pins a grammar.
4045
- 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.
4046
4352
  const [fullscreen, setFullscreen] = react.useState(false);
4047
4353
  const [isMobile, setIsMobile] = react.useState(() => {
4048
4354
  if (typeof window === "undefined") return false;
@@ -4075,28 +4381,35 @@ window.__ModuleLoader__.load({
4075
4381
  // Which sidebar pane is showing, and whether the main pane is a commit
4076
4382
  // rather than a file. Both live here because the drawer owns the split.
4077
4383
  const [side, setSide] = react.useState("files");
4078
- const [showCommit, setShowCommit] = react.useState(false);
4079
- // Which commit a previewed file is being read from. Empty means the
4080
- // ordinary working-tree preview, which is the file tree's behaviour.
4081
- const [previewCommit, setPreviewCommit] = react.useState("");
4082
- // Which half of the commit pane is showing: the file list or the patch.
4083
- const [commitView, setCommitView] = react.useState("files");
4084
4384
  /**
4085
- * 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.
4086
4393
  *
4087
- * The back control has to undo exactly one step. Opening a file from a
4088
- * commit's changed-file list must return to that list — not to the file
4089
- * tree, which is a different place entirely — so the origin is recorded
4090
- * 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.
4091
4396
  *
4092
- * `null` means there is nowhere to go back to.
4093
- * @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}>}
4094
4398
  */
4095
- const [previewOrigin, setPreviewOrigin] = react.useState(null);
4096
- // The working-tree change shown in the main pane. It cannot ride on the
4097
- // commit detail, which is keyed by hash, and `meta` belongs to the file
4098
- // preview — so the row the reader clicked is kept here.
4099
- 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.
4100
4413
  const gitState = useStore(git.store);
4101
4414
 
4102
4415
  react.useEffect(() => {
@@ -4112,13 +4425,27 @@ window.__ModuleLoader__.load({
4112
4425
  return () => { live = false; };
4113
4426
  }, [open, root]);
4114
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
+
4115
4441
  // The Git panel describes one root; switching roots drops every cached
4116
4442
  // repository, branch and page rather than showing another project's
4117
4443
  // history under the new name.
4118
4444
  react.useEffect(() => {
4119
4445
  if (!open) return;
4446
+ if (gitRootRef.current === root) return;
4447
+ gitRootRef.current = root;
4120
4448
  git.reset(root);
4121
- setShowCommit(false);
4122
4449
  }, [open, root]);
4123
4450
 
4124
4451
  // Loaded on first use, never on open: most visits never touch the Git
@@ -4244,28 +4571,36 @@ window.__ModuleLoader__.load({
4244
4571
  // Directory to open on arrival, relative to the resolved root: the
4245
4572
  // session's working directory when it sits below the workspace root.
4246
4573
  const [reveal, setReveal] = react.useState("");
4247
- 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());
4248
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("");
4249
4588
 
4250
4589
  // Resolve the root set on every open. When the request came from a
4251
4590
  // conversation header or a file click, the host also reports which root
4252
4591
  // that session/file belongs to, the directory path to reveal, and any
4253
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.
4254
4601
  react.useEffect(() => {
4255
4602
  if (request_ === null) return undefined;
4256
4603
  let live = true;
4257
- // The drawer stays mounted while closed (`open` only gates rendering),
4258
- // so every view state survives from the previous visit. Opening a
4259
- // plain file after browsing Git used to inherit the old commit hash
4260
- // and the Git sidebar, and the file rendered as "history preview"
4261
- // instead of its content. A new open is a new session: clear it all.
4262
- setSide("files");
4263
- setShowCommit(false);
4264
- setCommitView("files");
4265
- setPreviewCommit("");
4266
- setPreviewOrigin(null);
4267
- setPreviewChangePath("");
4268
- setMeta(null);
4269
4604
  setError(null);
4270
4605
  const payload = {
4271
4606
  ...(request_.sessionId !== undefined ? { sessionId: request_.sessionId } : {}),
@@ -4277,18 +4612,31 @@ window.__ModuleLoader__.load({
4277
4612
  if (!live) return;
4278
4613
  setRoots(value.roots || []);
4279
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;
4280
4632
  setRoot(resolvedRoot);
4281
4633
  setReveal(typeof value.reveal === "string" ? value.reveal : "");
4282
4634
  setError(null);
4283
4635
  if (typeof value.selectFile === "string" && value.selectFile !== "") {
4284
4636
  setPane("content");
4285
- // Arriving on a file from a link: honour its remembered choice.
4286
- setLangChoice(readLangOverride(resolvedRoot, value.selectFile));
4287
- const ticket = ++metaTicket.current;
4288
- request("meta", { root: resolvedRoot, path: value.selectFile }).then((metaVal) => {
4289
- if (metaTicket.current !== ticket) return;
4290
- setMeta(metaVal);
4291
- }).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 });
4292
4640
  }
4293
4641
  }).catch((problem) => {
4294
4642
  if (live) setError(problem);
@@ -4330,23 +4678,60 @@ window.__ModuleLoader__.load({
4330
4678
  });
4331
4679
  };
4332
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
+
4333
4692
  // A new root or filter invalidates every cached listing; the bumped
4334
4693
  // generation makes any in-flight response from the old tree stale.
4335
4694
  // When the host named a directory to reveal, its whole ancestor chain
4336
4695
  // opens at once so the session's project is visible on arrival.
4337
4696
  react.useEffect(() => {
4338
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 };
4339
4706
  treeGeneration.current += 1;
4340
4707
  setNodes(new Map());
4341
- setMeta(null);
4342
- // The override belonged to a file in the previous root; carrying it
4343
- // over would silently apply one file's grammar to the next file
4344
- // opened, and the picker is not even rendered while no file is
4345
- // selected. The per-file choice is restored in selectFile.
4346
- setLangChoice("auto");
4347
- const chain = reveal === "" ? [] : [...ancestorsOf(reveal), reveal];
4348
- setExpanded(new Set(chain));
4349
- 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);
4350
4735
  }, [open, root, hidden, reveal]);
4351
4736
 
4352
4737
  react.useEffect(() => {
@@ -4404,6 +4789,177 @@ window.__ModuleLoader__.load({
4404
4789
  if (!isOpen && !nodes.has(dirPath)) loadDirectory(dirPath);
4405
4790
  };
4406
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
+
4407
4963
  // Re-read every directory the reader currently has open, keeping the
4408
4964
  // tree's shape, plus the status badges a checkout or external edit
4409
4965
  // invalidates.
@@ -4419,36 +4975,63 @@ window.__ModuleLoader__.load({
4419
4975
  }).catch(() => {});
4420
4976
  };
4421
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
+ */
4422
4986
  const refresh = () => {
4423
4987
  reloadWorkingTree();
4424
4988
  if (side === "git") {
4425
4989
  const state = git.store.get();
4426
- const keepSha = showCommit && state.detail !== null && state.detail !== undefined && state.detail.commit !== undefined
4427
- ? 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)
4428
4994
  : "";
4429
4995
  if (state.root !== root) git.reset(root);
4430
4996
  git.loadRepos().then(() => {
4431
4997
  if (keepSha !== "") git.openCommit(keepSha);
4432
4998
  });
4433
4999
  }
5000
+ if (activeTabId !== "") refreshTab(activeTabId);
4434
5001
  };
4435
5002
 
4436
- /** 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. */
4437
5004
  const openCommit = (commit) => {
4438
5005
  if (commit === null || commit === undefined || typeof commit.sha !== "string") return;
4439
- setShowCommit(true);
5006
+ const sha = commit.sha;
5007
+ const title = commit.shortSha || sha.slice(0, 7);
5008
+ const id = "commit:" + sha;
4440
5009
  setPane("content");
4441
- setCommitView("files");
4442
5010
  // Re-opening the commit already on screen costs nothing: its detail
4443
5011
  // does not depend on the working tree, only on the commit itself.
4444
5012
  const cached = git.store.get();
4445
- if (cached.detailStatus === "ready" && cached.detail !== null && cached.detail !== undefined
4446
- && cached.detail.commit !== undefined && cached.detail.commit.sha === commit.sha) return;
4447
- 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);
4448
5031
  };
4449
5032
 
4450
5033
  /**
4451
- * Open one file from a commit in the ordinary preview.
5034
+ * Open one file from a commit in its own tab.
4452
5035
  *
4453
5036
  * The row's path is already root-relative (the host rebased it), so it
4454
5037
  * goes straight through the same `meta` request the tree uses; files
@@ -4477,53 +5060,58 @@ window.__ModuleLoader__.load({
4477
5060
  ? file.oldPath
4478
5061
  : file.path;
4479
5062
  const readSha = fromParent ? parentSha : sha;
4480
- setShowCommit(false);
4481
5063
  setPane("content");
4482
- setPreviewCommit(readSha);
4483
- // Back must return to the changed-file list this row came from,
4484
- // not to the file tree, which is somewhere else entirely.
4485
- setPreviewOrigin("commit");
4486
- setLangChoice("auto");
4487
- const ticket = metaTicket.current + 1;
4488
- metaTicket.current = ticket;
4489
5064
  const known = detail && Array.isArray(detail.files)
4490
5065
  ? detail.files.find((row) => row.path === file.path)
4491
5066
  : null;
4492
- setMeta({
4493
- root: root,
4494
- // The blob path is what the host reads; the display path keeps the
4495
- // row the reader clicked.
5067
+ const id = "file:" + blobPath + "\u0000" + readSha;
5068
+ upsertTab({
5069
+ id,
5070
+ kind: "file",
4496
5071
  path: blobPath,
4497
- // A rename is read from its old path but highlighted as the row
4498
- // the reader actually clicked.
4499
5072
  rowPath: file.path,
4500
- name: baseNameOf(blobPath),
4501
- displayPath: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
4502
- 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
+ },
4503
5101
  commit: readSha,
4504
- // Opening a changed file must offer both halves of the question:
4505
- // what this commit changed in it, and what the file looks like.
4506
- // `diffSha` is the commit being viewed (the blob may come from
4507
- // its parent for a deletion), and `oldPath` is what lets the host
4508
- // keep rename detection intact when filtering the patch.
4509
- diffSha: sha,
4510
- oldPath: typeof file.oldPath === "string" && file.oldPath !== "" ? file.oldPath : null,
4511
- // The commit records whether the file was added, modified or
4512
- // deleted; the preview says so instead of looking like a normal
4513
- // working-tree file.
4514
- change: known ? known.change : "",
4515
- changeLetter: known ? known.letter : "",
4516
- deleted: known ? known.letter === "D" : false,
4517
- });
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 });
4518
5107
  setError(null);
4519
5108
  return;
4520
5109
  }
4521
- setShowCommit(false);
4522
5110
  selectFile({ path: file.path, name: baseNameOf(file.path), type: "file" });
4523
5111
  };
4524
5112
 
4525
5113
  /**
4526
- * Step back one place from the main pane.
5114
+ * Step back one place from the active tab.
4527
5115
  *
4528
5116
  * Three destinations, in the order the reader arrived from: a commit's
4529
5117
  * changed-file list, the working-tree change list, or the file tree.
@@ -4531,89 +5119,133 @@ window.__ModuleLoader__.load({
4531
5119
  * a stale hash or blob path cannot leak into the next preview.
4532
5120
  */
4533
5121
  const goBack = () => {
4534
- if (previewOrigin === "commit") {
4535
- // 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;
4536
5126
  // its hash is what the sidebar is highlighting.
4537
- setShowCommit(true);
4538
- setCommitView("files");
4539
- setPreviewCommit("");
4540
- setPreviewOrigin(null);
4541
5127
  setPane("content");
5128
+ patchTab(current.id, (tab) => ({ ...tab, commitView: "files", commit: "", origin: null }));
5129
+ openCommit({ sha: origin.sha });
4542
5130
  return;
4543
5131
  }
4544
- if (previewOrigin === "changes") {
4545
- setShowCommit(true);
4546
- setCommitView("changes");
4547
- setPreviewCommit("");
4548
- setPreviewOrigin(null);
5132
+ if (origin !== null && origin !== undefined && origin.kind === "changes") {
4549
5133
  setPane("content");
5134
+ patchTab(current.id, (tab) => ({ ...tab, commitView: "changes", commit: "", origin: null }));
4550
5135
  return;
4551
5136
  }
4552
5137
  // Nothing to undo inside the pane: hand control back to the sidebar.
4553
5138
  // On a phone that actually swaps panes; on desktop the tree is
4554
5139
  // already alongside, so the selection is cleared instead of the
4555
5140
  // button doing nothing at all.
4556
- setPreviewCommit("");
4557
- setPreviewOrigin(null);
4558
- setMeta(null);
5141
+ closeTab(activeTabId);
4559
5142
  setError(null);
4560
5143
  setPane("tree");
4561
5144
  };
4562
5145
 
4563
5146
  /**
4564
- * Open one working-tree change as a diff.
5147
+ * Open one working-tree change as a diff, in its own tab.
4565
5148
  *
4566
5149
  * The main pane shows the patch rather than the file: a deletion has no
4567
5150
  * file to open, and "what changed" is the question the row answers.
4568
5151
  */
4569
5152
  const openWorkspaceChange = (row) => {
4570
5153
  if (row === null || row === undefined || typeof row.path !== "string" || row.path === "") return;
4571
- setShowCommit(true);
4572
5154
  setPane("content");
4573
- setCommitView("changes");
4574
- setPreviewCommit("");
4575
- setPreviewOrigin(null);
4576
5155
  // The pane diffs exactly this path; ignoring the row left it reading
4577
5156
  // whatever file happened to be selected before.
4578
- 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 });
4579
5168
  };
4580
5169
 
4581
- // Clicking two files quickly must not let the slower answer win: only
4582
- // the newest request may write state.
4583
- const selectFile = (entry) => {
4584
- 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
+ }
4585
5194
  // Selecting from the file tree is always the working tree, so any
4586
5195
  // commit a preview was read from is dropped here — and there is no
4587
5196
  // intermediate pane to return to.
4588
- setPreviewCommit("");
4589
- setPreviewOrigin(null);
4590
- setPreviewChangePath("");
4591
- // The remembered choice for this file takes effect immediately, so
4592
- // the read that follows is already resolved with it.
4593
- setLangChoice(readLangOverride(root, entry.path));
4594
- const ticket = metaTicket.current + 1;
4595
- metaTicket.current = ticket;
4596
- request("meta", { root, path: entry.path }).then((value) => {
4597
- if (metaTicket.current !== ticket) return;
4598
- 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 }));
4599
5224
  setError(null);
4600
5225
  }).catch((problem) => {
4601
- if (metaTicket.current !== ticket) return;
4602
- setMeta(null);
5226
+ if (tickets.get(id) !== ticket) return;
5227
+ patchTab(id, (tab) => ({ ...tab, meta: null, status: "error", error: problem }));
4603
5228
  setError(problem);
4604
5229
  });
4605
5230
  };
4606
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
+
4607
5238
  /**
4608
- * Pin, or unpin, the language for the selected file.
5239
+ * Pin, or unpin, the language for the active tab's file.
4609
5240
  *
4610
5241
  * The choice is remembered per file and sent with every read, so the
4611
5242
  * host stays the only thing that decides what grammar applies.
4612
5243
  */
4613
5244
  const chooseLang = (next) => {
4614
- if (meta === null) return;
4615
- setLangChoice(next);
4616
- 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);
4617
5249
  };
4618
5250
 
4619
5251
  const activeRoot = roots.find((row) => row.id === root);
@@ -4635,8 +5267,10 @@ window.__ModuleLoader__.load({
4635
5267
  "aria-selected": side === "files" ? "true" : "false",
4636
5268
  title: "浏览项目文件",
4637
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.
4638
5273
  setSide("files");
4639
- setShowCommit(false);
4640
5274
  },
4641
5275
  }, "文件"),
4642
5276
  react.createElement("button", {
@@ -4651,16 +5285,27 @@ window.__ModuleLoader__.load({
4651
5285
  },
4652
5286
  }, react.createElement(SvgIconBranch, { size: 13 }), "Git"));
4653
5287
 
4654
- const commitSha = gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
4655
- ? 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)
4656
5297
  : "";
5298
+ const commitSha = activeCommitSha !== ""
5299
+ ? activeCommitSha
5300
+ : (gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
5301
+ ? gitState.detail.commit.sha
5302
+ : "");
4657
5303
  // The main pane is showing something commit-shaped whenever it is not
4658
5304
  // the ordinary file preview: a commit, or one working-tree change.
4659
- const detailPane = showCommit;
4660
5305
  const commitHeadline = commitView === "changes"
4661
5306
  ? "工作区改动"
4662
- : (showCommit && gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
4663
- ? "提交 " + (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))
4664
5309
  : "");
4665
5310
 
4666
5311
  const handleDownloadItem = (entry) => {
@@ -4846,14 +5491,26 @@ window.__ModuleLoader__.load({
4846
5491
  onDoubleClick: onResizerDoubleClick,
4847
5492
  }),
4848
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
+ }),
4849
5505
  react.createElement("div", { className: "fv-main-head" },
4850
5506
  (() => {
4851
5507
  // The control returns one step, so it names that step and
4852
5508
  // is disabled when there is nowhere to go — a button that
4853
5509
  // silently does nothing reads as broken.
4854
- const backTarget = previewOrigin === "commit"
5510
+ const originKind = previewOrigin === null || previewOrigin === undefined ? null : previewOrigin.kind;
5511
+ const backTarget = originKind === "commit"
4855
5512
  ? "返回变更文件列表"
4856
- : (previewOrigin === "changes" ? "返回工作区改动列表" : "返回文件列表");
5513
+ : (originKind === "changes" ? "返回工作区改动列表" : "返回文件列表");
4857
5514
  // On a phone the panes are exclusive, so leaving the content
4858
5515
  // pane is always meaningful; on desktop the sidebar is
4859
5516
  // already visible and only an origin is worth returning to.
@@ -4872,7 +5529,9 @@ window.__ModuleLoader__.load({
4872
5529
  react.createElement("span", { className: "fv-main-name" },
4873
5530
  commitHeadline !== ""
4874
5531
  ? commitHeadline
4875
- : (meta === null ? "未选择文件" : meta.name)),
5532
+ : (activeTab === null
5533
+ ? "未选择文件"
5534
+ : (meta === null ? activeTab.title : meta.name))),
4876
5535
  meta !== null && !showCommit && typeof meta.size === "number"
4877
5536
  ? react.createElement("span", null, formatBytes(meta.size))
4878
5537
  : null,
@@ -4881,14 +5540,14 @@ window.__ModuleLoader__.load({
4881
5540
  // document has no grammar to choose.
4882
5541
  meta !== null && !showCommit && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
4883
5542
  ? react.createElement(LanguagePicker, {
4884
- choice: langChoice,
5543
+ choice: localeTagOfActive,
4885
5544
  catalog: languageCatalog,
4886
5545
  source: meta.langSource,
4887
5546
  resolved: meta.lang,
4888
5547
  onChange: chooseLang,
4889
5548
  })
4890
5549
  : null),
4891
- error !== null && meta === null && !showCommit
5550
+ error !== null && meta === null && !showCommit && activeTab === null
4892
5551
  ? react.createElement("div", { className: "fv-note fv-error" },
4893
5552
  react.createElement("span", null, messageOf(error)),
4894
5553
  error?.code === "outside-root"
@@ -4903,43 +5562,81 @@ window.__ModuleLoader__.load({
4903
5562
  },
4904
5563
  }, "配置安全访问路径")
4905
5564
  : null)
4906
- : showCommit
4907
- ? react.createElement(ErrorBoundary, {
4908
- resetKey: "commit\u0000" + commitSha,
4909
- }, commitView === "changes"
4910
- ? react.createElement(WorkspaceChangeDetail, {
4911
- state: gitState,
4912
- path: previewChangePath,
4913
- request,
4914
- repo: gitState.repoId,
4915
- })
4916
- : react.createElement(CommitDetail, {
4917
- state: gitState,
4918
- // A rename is read from its old path but highlighted as
4919
- // the row that was clicked, so the two stay in step.
4920
- selectedPath: meta === null ? "" : (meta.rowPath || meta.path),
4921
- onSelectFile: openCommitFile,
4922
- view: commitView,
4923
- onSwitchView: setCommitView,
4924
- onLoadDiff: () => {
4925
- const sha = commitSha;
4926
- if (sha !== "") git.loadCommitDiff(sha);
4927
- },
4928
- }))
4929
- : react.createElement(ErrorBoundary, {
4930
- resetKey: meta ? meta.path : "",
4931
- }, react.createElement(FileView, {
4932
- meta,
4933
- root,
4934
- api: props.api,
4935
- wrap,
4936
- lang: langChoice,
4937
- onDownload: handleDownloadItem,
4938
- // Empty for a working-tree file; a hash for one opened
4939
- // from history, which makes the read a blob lookup.
4940
- commit: previewCommit,
4941
- repo: gitState.repoId,
4942
- })))),
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
+ })))),
4943
5640
  contextMenu !== null
4944
5641
  ? react.createElement(ContextMenu, {
4945
5642
  menu: contextMenu,
@@ -4950,6 +5647,17 @@ window.__ModuleLoader__.load({
4950
5647
  onDownload: handleDownloadItem,
4951
5648
  })
4952
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,
4953
5661
  safeModalOpen
4954
5662
  ? react.createElement(SafePathsModal, {
4955
5663
  open: safeModalOpen,
@@ -5269,6 +5977,10 @@ window.__ModuleLoader__.load({
5269
5977
  // Git panel: controller, views and the small formatters they share.
5270
5978
  GIT_PAGE_SIZE, createGitController, GitSidebar, CommitDetail, WorkspaceChangeDetail, DiffView, kindOfPath,
5271
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,
5272
5984
  };
5273
5985
  return module.exports;
5274
5986
  }