@weniatall/dsh-file-viewer 0.3.4 → 0.3.6

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 +1337 -193
  3. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -52,6 +52,19 @@ window.__ModuleLoader__.load({
52
52
  );
53
53
  }
54
54
 
55
+ /** Outline / table-of-contents glyph: short bars stepping in by level. */
56
+ function SvgIconOutline({ size = 15 }) {
57
+ return react.createElement("svg", {
58
+ width: size, height: size, viewBox: "0 0 16 16", fill: "none",
59
+ stroke: "currentColor", strokeWidth: "1.4", strokeLinecap: "round", strokeLinejoin: "round"
60
+ },
61
+ react.createElement("path", { d: "M2.5 3.5h11" }),
62
+ react.createElement("path", { d: "M2.5 7h7.5" }),
63
+ react.createElement("path", { d: "M2.5 10.5h9" }),
64
+ react.createElement("path", { d: "M2.5 14h5.5" })
65
+ );
66
+ }
67
+
55
68
  function SvgIconRefresh({ size = 15 }) {
56
69
  return react.createElement("svg", {
57
70
  width: size, height: size, viewBox: "0 0 16 16", fill: "none",
@@ -305,6 +318,18 @@ window.__ModuleLoader__.load({
305
318
  // nothing itself.
306
319
  const COMPOSER_SLOT = "conversation.input.left";
307
320
  const STYLE_ID = "dsh-file-viewer-styles";
321
+ /**
322
+ * The id both context menus carry.
323
+ *
324
+ * It exists purely so the stylesheet can win against a theme's glass rule.
325
+ * The glass theme paints `[role="menu"]` with a translucent background and
326
+ * a large backdrop blur; its selector is long enough (a :is() chain plus
327
+ * :not()) that no class-based override beats it, and it loads after this
328
+ * bundle. An id selector outranks any combination of classes and
329
+ * attributes, so the fix does not depend on sheet order. Only one menu is
330
+ * mounted at a time, so sharing the id is safe.
331
+ */
332
+ const CONTEXT_MENU_ID = "dsh-fv-context-menu";
308
333
  // Per-browser UI state, so it lives in localStorage rather than plugin
309
334
  // settings; the key follows the `<plugin>.<setting>` form used elsewhere.
310
335
  const ENTRY_POSITION_KEY = "dsh-file-viewer.entry-position";
@@ -338,6 +363,29 @@ window.__ModuleLoader__.load({
338
363
  const inject = ["slots", "connection", "workspaces"];
339
364
 
340
365
  const css = `
366
+ /* ---------------------------------------------------------------------
367
+ Opt out of the "every modal becomes a centred card" treatment.
368
+
369
+ The host mounts overlay seats inside a plain div[role="presentation"],
370
+ and dsh-mobile-adapter turns that container into a full-screen
371
+ rgba(0,0,0,.5) + blur(4px) scrim whenever it CONTAINS a
372
+ [role="dialog"][aria-modal="true"] — and shrinks the dialog itself to a
373
+ 420px centred card. That is right for a small confirm dialog and wrong
374
+ for this drawer: the viewer is a full-height panel pinned to the right
375
+ edge, and being treated as a card blacked out the app behind it (read
376
+ by the user as "the right-click menu has an opaque mask") and fought
377
+ the drawer's own width.
378
+
379
+ The adapter keys on containment (:has(...)), not on being the parent,
380
+ so these overrides use the descendant form too: wherever the host nests
381
+ this viewer, the same container is neutralised. Every rule is scoped to
382
+ a container that actually holds .fv-scrim, so no other dialog on the
383
+ page is affected. !important is required because the adapter's own
384
+ declarations use it.
385
+ --------------------------------------------------------------------- */
386
+ body>div[role="presentation"]:has(.fv-scrim){background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;padding:0!important;display:block!important;align-items:stretch!important;justify-content:flex-start!important}
387
+ body>div[role="presentation"]:has(.fv-scrim)>[aria-hidden="true"]{display:none!important}
388
+ .fv-scrim[role="dialog"]{position:absolute!important;inset:0!important;width:auto!important;max-width:none!important;max-height:none!important;min-height:0!important;margin:0!important;border:none!important;border-radius:0!important;box-shadow:none!important;background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;overflow:visible!important}
341
389
  .fv-scrim{position:absolute;inset:0;z-index:40;display:flex;align-items:stretch;justify-content:flex-end;background:transparent;pointer-events:auto;animation:fv-fade .16s ease-out}
342
390
  .fv-shell{display:flex;flex-direction:column;width:min(64vw,1100px);min-width:0;min-height:0;overflow:hidden;border-left:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary);animation:fv-slide .2s cubic-bezier(.2,.8,.2,1);transition:width .2s cubic-bezier(.2,.8,.2,1)}
343
391
  .fv-shell[data-fullscreen="true"]{width:100vw;border-left:none}
@@ -414,6 +462,20 @@ window.__ModuleLoader__.load({
414
462
  .fv-glyph-open{transform:rotate(90deg)}
415
463
  .fv-tree-status{padding:4px 8px;color:var(--dsw-alias-label-caption);font-size:12px;line-height:20px}
416
464
  .fv-main{display:flex;flex:1 1 auto;flex-direction:column;min-width:0;min-height:0}
465
+ /* 主内容区的多标签栏:一行横向滚动,每个标签是一个带关闭按钮的按钮组。
466
+ 标签本身不是按钮里套按钮(HTML 不允许),所以外面是 div[role=tab],
467
+ 内部的关闭与标题各是一个 button。 */
468
+ .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}
469
+ .fv-tabs::-webkit-scrollbar{height:4px}
470
+ .fv-tabs-empty{flex:1 1 auto;padding:4px 8px 6px;color:var(--dsw-alias-label-caption);font-size:12px;white-space:nowrap}
471
+ .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}
472
+ .fv-tab-cell:hover{background:var(--dsw-alias-interactive-bg-hover)}
473
+ .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}
474
+ .fv-tab-cell[data-menu="true"]{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}
475
+ .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}
476
+ .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}
477
+ .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}
478
+ .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
479
  .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
480
  .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
481
  .fv-lang{display:inline-flex;flex:none;align-items:center;gap:4px;min-width:0}
@@ -423,6 +485,33 @@ window.__ModuleLoader__.load({
423
485
  .fv-lang-select:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:1px}
424
486
  .fv-content{flex:1 1 auto;min-height:0;overflow:auto;padding:12px;overscroll-behavior:contain;-webkit-overflow-scrolling:touch}
425
487
  .fv-content>*{max-width:100%}
488
+ /* The Markdown outline: a rail pinned to the reading area's trailing edge.
489
+ The wrapper is the positioning context, so the rail stays put while the
490
+ text inside .fv-content scrolls. */
491
+ .fv-content-wrap{position:relative;flex:1 1 auto;min-height:0;display:flex;flex-direction:column}
492
+ .fv-content-wrap>.fv-content{flex:1 1 auto}
493
+ .fv-outline{position:absolute;top:8px;right:8px;z-index:5;display:flex;flex-direction:column;align-items:flex-end;gap:6px;max-height:calc(100% - 16px);pointer-events:none}
494
+ .fv-outline>*{pointer-events:auto}
495
+ .fv-outline-toggle{display:inline-grid;place-items:center;width:28px;height:28px;padding:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv2,0 2px 8px rgba(0,0,0,.08));color:var(--dsw-alias-label-secondary);cursor:pointer;flex:none;transition:color .12s,background-color .12s}
496
+ .fv-outline-toggle:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover))}
497
+ .fv-outline[data-open="true"] .fv-outline-toggle{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary)}
498
+ .fv-outline-toggle:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:1px}
499
+ /* The panel scrolls on its own: a long document can have fifty headings,
500
+ and the rail must never push past the reading area. */
501
+ .fv-outline-panel{display:flex;flex-direction:column;min-height:0;max-height:min(60vh,420px);width:min(280px,52vw);padding:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);animation:fv-menu-in .12s cubic-bezier(.2,.8,.2,1)}
502
+ .fv-outline-head{flex:none;padding:4px 8px 6px;border-bottom:1px solid var(--dsw-alias-border-l1);margin-bottom:4px;color:var(--dsw-alias-label-caption);font-size:11px;font-weight:600;letter-spacing:.02em}
503
+ .fv-outline-list{display:flex;flex-direction:column;gap:1px;min-height:0;overflow-y:auto;overscroll-behavior:contain}
504
+ .fv-outline-item{display:block;width:100%;padding:5px 8px;border:none;border-radius:6px;background:none;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;line-height:1.4;text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
505
+ .fv-outline-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
506
+ .fv-outline-item[aria-current="true"]{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover));color:var(--dsw-alias-label-primary);font-weight:600}
507
+ .fv-outline-item:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}
508
+ /* Indentation comes from the DOM order and the level, so a document that
509
+ starts at level two is not silently pushed one level in. */
510
+ .fv-outline-item[data-level="1"]{padding-left:20px}
511
+ .fv-outline-item[data-level="2"]{padding-left:32px}
512
+ .fv-outline-item[data-level="3"]{padding-left:44px}
513
+ .fv-outline-item[data-level="4"]{padding-left:56px}
514
+ .fv-outline-item[data-level="5"]{padding-left:68px}
426
515
  .fv-doc-article{max-width:880px;margin:0 auto;padding:12px 16px;line-height:1.8;font-size:14px;word-break:break-word}
427
516
  .fv-doc-article h1{font-size:22px;font-weight:700;margin:28px 0 16px;line-height:1.35;border-bottom:1px solid var(--dsw-alias-border-l1);padding-bottom:8px}
428
517
  .fv-doc-article h2{font-size:18px;font-weight:600;margin:24px 0 12px;line-height:1.4;border-bottom:1px dashed var(--dsw-alias-border-l1);padding-bottom:6px}
@@ -589,7 +678,29 @@ window.__ModuleLoader__.load({
589
678
  .fv-content[data-wrap="on"] span[class*="_gutter_"]{flex:none;white-space:pre}
590
679
  .fv-content[data-wrap="on"] span[class*="_content_"]{min-width:0;flex:1 1 auto;white-space:pre-wrap;overflow-wrap:anywhere}
591
680
  .fv-context-backdrop{position:fixed;inset:0;z-index:99;background:transparent;touch-action:none}
592
- .fv-context-menu{position:fixed;z-index:100;min-width:190px;padding:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base)));box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:13px;display:flex;flex-direction:column;gap:3px;animation:fv-menu-in .12s cubic-bezier(.2,.8,.2,1);touch-action:manipulation}
681
+ /* 菜单是实底卡片,不吃主题的毛玻璃。
682
+ 玻璃主题(glass.css)里有一条规则,给 [role="menu"] 套上
683
+ background:var(--color-menu) 和 backdrop-filter:blur(...);我们的菜单正好
684
+ 带 role="menu",于是被套上半透明底 + 几十 px 的模糊。backdrop-filter 会把
685
+ **它背后的一切**糊掉,而背后正是铺满全屏的遮罩层,所以整屏发虚、只剩菜单
686
+ 轮廓;系统默认主题没有这条规则,因此只有玻璃主题下才看得到。
687
+ 这里把三样都钉死:清掉模糊、底色用宿主菜单底(不透明),确保菜单里的文字
688
+ 永远落在实色上、背后也不会被糊。 */
689
+ .fv-context-menu{position:fixed;z-index:100;min-width:190px;padding:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff));box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);font-size:13px;display:flex;flex-direction:column;gap:3px;animation:fv-menu-in .12s cubic-bezier(.2,.8,.2,1);touch-action:manipulation;-webkit-backdrop-filter:none!important;backdrop-filter:none!important;isolation:isolate}
690
+ /* 底色单独一条,且带 !important:玻璃主题用 background:var(--color-menu)
691
+ !important 把菜单底改成 rgba(248,249,255,.72),不压住它菜单会透出背后的
692
+ 正文,读起来像半透明脏块。这条把菜单底钉回宿主的实色值,主题换色也不影响
693
+ 可读性。 */
694
+ .fv-context-menu{background-color:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff))!important}
695
+ /* 上面两条还不够。玻璃主题的样式表在插件之后注入,两条 !important 规则
696
+ 特异性相同时「后加载者胜」,实测菜单仍拿到 blur(41.6px) 与
697
+ rgba(248,249,255,.72)。这条把选择器抬到 html + 重复类 + 属性同时命中,
698
+ 特异性稳稳高过主题的 :is(html[data-...]) [role="menu"],与加载顺序无关。 */
699
+ /* 用 id 选择器(特异性 1-0-0)压过主题:玻璃主题的选择器是
700
+ :is(html[data-theme-studio=...]) [role="menu"]:not(...),叠加后仍属类/
701
+ 属性级别,且它的样式表在本插件之后注入;实测类级 !important 会输给它,
702
+ id 级则与加载顺序无关地胜出。 */
703
+ #dsh-fv-context-menu{-webkit-backdrop-filter:none!important;backdrop-filter:none!important;background:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff))!important;background-color:var(--dsw-alias-bg-layer-2,var(--dsw-alias-bg-base,#fff))!important;isolation:isolate!important}
593
704
  @keyframes fv-menu-in{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}
594
705
  .fv-context-item{display:flex;align-items:center;gap:10px;width:100%;height:34px;padding:0 10px;border:none;border-radius:8px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;user-select:none;-webkit-tap-highlight-color:transparent}
595
706
  .fv-context-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
@@ -672,7 +783,11 @@ window.__ModuleLoader__.load({
672
783
  .fv-body[data-pane="content"] .fv-tree,.fv-body[data-pane="tree"] .fv-main{display:none!important}
673
784
  .fv-crumbs{font-size:11px}
674
785
  .fv-file-path{display:none!important}
675
- .fv-context-backdrop{background:rgba(0,0,0,.45);-webkit-backdrop-filter:blur(3px);backdrop-filter:blur(3px);animation:fv-fade .16s ease-out}
786
+ /* 遮罩只用来接住「点菜单外面关闭」,不该遮住内容。
787
+ 它曾经在这里被涂成 rgba(0,0,0,.45) 并加了模糊,等于把整个抽屉
788
+ 盖黑。手机上菜单本身已经是贴底的独立面板,靠自身圆角与阴影就能
789
+ 立住,不需要再压一层遮罩。 */
790
+ .fv-context-backdrop{background:transparent!important;-webkit-backdrop-filter:none!important;backdrop-filter:none!important}
676
791
  .fv-context-menu{position:fixed!important;bottom:max(16px,var(--dsh-sab,0px))!important;left:max(16px,var(--dsh-sal,0px))!important;right:max(16px,var(--dsh-sar,0px))!important;top:auto!important;width:auto!important;max-width:none!important;border-radius:18px!important;padding:8px!important;box-shadow:var(--dsw-shadow-lv3)!important;animation:fv-menu-slide-up .2s cubic-bezier(.2,.8,.2,1)!important}
677
792
  .fv-context-head{padding:4px 8px 8px;border-bottom:1px solid var(--dsw-alias-border-l1);display:flex;flex-direction:column;gap:2px;margin-bottom:4px}
678
793
  .fv-context-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);word-break:break-all;line-height:1.3}
@@ -680,6 +795,24 @@ window.__ModuleLoader__.load({
680
795
  @keyframes fv-menu-slide-up{from{opacity:0;transform:translateY(28px)}to{opacity:1;transform:translateY(0)}}
681
796
  .fv-context-item{height:46px!important;font-size:15px!important;padding:0 14px!important;border-radius:12px!important}
682
797
  .fv-context-item-icon{width:22px!important;height:22px!important;font-size:16px!important}
798
+ /* 手机上标签栏同样要能滚动,关闭按钮放大到可点面积。 */
799
+ .fv-tabs{padding:4px 4px 0!important}
800
+ .fv-tab-cell{height:34px!important;max-width:190px!important}
801
+ .fv-tab-label{max-width:120px!important}
802
+ .fv-tab-dir{display:none!important}
803
+ .fv-tab-close{width:24px!important;height:24px!important;font-size:16px!important}
804
+ .fv-main-head{padding:6px 8px!important;gap:6px!important}
805
+ /* 手机上的目录:按钮放大到可点面积,面板几乎占满宽度,
806
+ 并让正文在目录展开时仍能读到。 */
807
+ .fv-outline{top:6px!important;right:6px!important;max-height:calc(100% - 12px)!important}
808
+ .fv-outline-toggle{width:34px!important;height:34px!important;border-radius:10px!important}
809
+ .fv-outline-panel{width:min(78vw,320px)!important;max-height:min(56vh,380px)!important;border-radius:12px!important}
810
+ .fv-outline-item{padding:9px 10px!important;font-size:13px!important;border-radius:8px!important}
811
+ .fv-outline-item[data-level="1"]{padding-left:22px!important}
812
+ .fv-outline-item[data-level="2"]{padding-left:34px!important}
813
+ .fv-outline-item[data-level="3"]{padding-left:46px!important}
814
+ .fv-outline-item[data-level="4"]{padding-left:58px!important}
815
+ .fv-outline-item[data-level="5"]{padding-left:70px!important}
683
816
  /* SafePathsModal 移动端自适应优化:输入框与添加按钮纵向铺满,防溢出 */
684
817
  .fv-modal-scrim{padding:10px!important}
685
818
  .fv-modal{width:min(96vw,560px)!important;max-height:min(90dvh,90vh,680px)!important;border-radius:16px!important}
@@ -709,6 +842,79 @@ window.__ModuleLoader__.load({
709
842
  }
710
843
  `;
711
844
 
845
+ /**
846
+ * Strip any full-screen blur / darkening that other layers put over the app
847
+ * while this drawer is open.
848
+ *
849
+ * The drawer is a right-hand panel, not a modal dialog: content behind it
850
+ * stays visible and interactive. Other layers disagree — dsh-mobile-adapter
851
+ * turns the host's role="presentation" wrapper into a blurred
852
+ * rgba(0,0,0,.5) scrim, and glass themes paint backdrop-filter on anything
853
+ * they consider a floating surface. Both are keyed on selectors this plugin
854
+ * does not own (role attributes, sibling scrims), so overriding them from
855
+ * CSS depends on winning a specificity fight against third-party sheets that
856
+ * load after ours.
857
+ *
858
+ * Walking up from the drawer and neutralising those properties in place is
859
+ * source-agnostic: it removes whatever a theme or adapter applied, no matter
860
+ * which rule did it, and it is re-applied on every open because those layers
861
+ * can re-render. The original inline values are restored on close so no
862
+ * other feature inherits our override.
863
+ *
864
+ * @param {Element | null} scrim - this drawer's root element.
865
+ * @param {boolean} active - true while the drawer is open.
866
+ * @returns {() => void} a function that restores what was changed.
867
+ */
868
+ function suppressBackdropEffects(scrim, active) {
869
+ if (!scrim || typeof window === "undefined") return () => {};
870
+ const view = scrim.ownerDocument?.defaultView || window;
871
+ const touched = [];
872
+ const clear = (element) => {
873
+ if (!element || element.nodeType !== 1) return;
874
+ const style = view.getComputedStyle(element);
875
+ const backdrop = style.backdropFilter || style.webkitBackdropFilter || "";
876
+ const background = style.backgroundColor || "";
877
+ // Only touch what actually has an effect: a transparent background and a
878
+ // `none` filter mean there is nothing to undo, and writing to them would
879
+ // burn a style recalculation for no visible reason.
880
+ const hasBlur = backdrop !== "" && backdrop !== "none";
881
+ const isDark = /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/.test(background)
882
+ && !/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0\s*\)/.test(background)
883
+ && parseFloat(/rgba?\([^)]*?([\d.]+)\)$/.exec(background)?.[1] ?? "1") > 0.05;
884
+ if (!hasBlur && !isDark) return;
885
+ touched.push({
886
+ element,
887
+ backdrop: element.style.backdropFilter,
888
+ webkitBackdrop: element.style.webkitBackdropFilter,
889
+ background: element.style.backgroundColor,
890
+ });
891
+ if (hasBlur) {
892
+ element.style.setProperty("backdrop-filter", "none", "important");
893
+ element.style.setProperty("-webkit-backdrop-filter", "none", "important");
894
+ }
895
+ if (isDark) element.style.setProperty("background-color", "transparent", "important");
896
+ };
897
+ if (active) {
898
+ // The wrapper the host mounts this seat into, plus its own sibling
899
+ // scrims, are the two places a modal treatment shows up.
900
+ let node = scrim.parentElement;
901
+ for (let depth = 0; node && depth < 4; depth += 1) {
902
+ clear(node);
903
+ for (const sibling of node.children) {
904
+ if (sibling !== scrim && sibling.getAttribute("aria-hidden") === "true") clear(sibling);
905
+ }
906
+ node = node.parentElement;
907
+ }
908
+ }
909
+ return () => {
910
+ for (const entry of touched) {
911
+ entry.element.style.backdropFilter = entry.backdrop;
912
+ entry.element.style.webkitBackdropFilter = entry.webkitBackdrop;
913
+ entry.element.style.backgroundColor = entry.background;
914
+ }
915
+ };
916
+ }
917
+
712
918
  /**
713
919
  * Inject the stylesheet, refreshing it when the content has changed.
714
920
  *
@@ -1485,6 +1691,77 @@ window.__ModuleLoader__.load({
1485
1691
  return cut < 0 ? trimmed : trimmed.slice(cut + 1);
1486
1692
  }
1487
1693
 
1694
+ /**
1695
+ * The directory part of a root-relative path, or "" at the top level.
1696
+ *
1697
+ * Used as the secondary line on a tab: two open files with the same name
1698
+ * from different folders would otherwise be indistinguishable.
1699
+ * @param {string} path - root-relative path.
1700
+ * @returns {string} its directory, without a trailing separator.
1701
+ */
1702
+ function directoryOf(path) {
1703
+ const trimmed = String(path || "").replace(/[\\/]+$/, "");
1704
+ const cut = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
1705
+ return cut <= 0 ? "" : trimmed.slice(0, cut);
1706
+ }
1707
+
1708
+ /**
1709
+ * The headings a Markdown document declares, in document order.
1710
+ *
1711
+ * Read from the SOURCE rather than from the rendered DOM: the renderer's
1712
+ * node shapes are the host's business and can change, while the ATX heading
1713
+ * syntax is fixed by CommonMark. The outline therefore stays correct even if
1714
+ * the primitives package swaps its renderer.
1715
+ *
1716
+ * ATX headings only (`# Title`). Setext headings (`Title` underlined with
1717
+ * `===` / `---`) are not recognised, which mirrors what the outline can
1718
+ * usefully show — a document using them mixes styles and the extra rules
1719
+ * would risk mis-reading a table separator as a heading.
1720
+ *
1721
+ * Fenced code blocks are skipped: a `# comment` inside a shell snippet is
1722
+ * not a section, and listing it would scroll the reader into a code block.
1723
+ *
1724
+ * @param {string} text - the Markdown source.
1725
+ * @returns {Array<{level: number, text: string, line: number, key: string}>}
1726
+ * headings from `level` 1..6, in order, with their 0-based source line.
1727
+ */
1728
+ function parseHeadings(text) {
1729
+ const source = typeof text === "string" ? text : "";
1730
+ if (source === "") return [];
1731
+ const headings = [];
1732
+ const lines = source.split(/\r?\n/);
1733
+ // Fence tracking: ``` or ~~~ toggles; a closing fence must use the same
1734
+ // character and be at least as long as the opener (CommonMark).
1735
+ let fence = null;
1736
+ for (let index = 0; index < lines.length; index += 1) {
1737
+ const line = lines[index];
1738
+ const fenceMatch = /^\s{0,3}(`{3,}|~{3,})/.exec(line);
1739
+ if (fenceMatch !== null) {
1740
+ const marker = fenceMatch[1];
1741
+ if (fence === null) fence = marker;
1742
+ else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null;
1743
+ continue;
1744
+ }
1745
+ if (fence !== null) continue;
1746
+ // A heading is at most three leading spaces, then 1..6 #, then a space
1747
+ // or end of line. `#nospace` is not a heading.
1748
+ const match = /^\s{0,3}(#{1,6})(?:\s+|$)(.*)$/.exec(line);
1749
+ if (match === null) continue;
1750
+ const level = match[1].length;
1751
+ // Strip the optional closing sequence (`## Title ##`) and inline
1752
+ // markers that add nothing to a navigation label.
1753
+ let label = match[2].replace(/\s+#+\s*$/, "").trim();
1754
+ label = label
1755
+ .replace(/`([^`]*)`/g, "$1")
1756
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
1757
+ .replace(/[*_~]{1,3}([^*_~]+)[*_~]{1,3}/g, "$1")
1758
+ .trim();
1759
+ if (label === "") continue;
1760
+ headings.push({ level, text: label, line: index, key: index + "\u0000" + label });
1761
+ }
1762
+ return headings;
1763
+ }
1764
+
1488
1765
  /**
1489
1766
  * Append a file reference to a draft. Mirrors core.js's appendMention; this
1490
1767
  * file is the browser bundle and cannot import from the host half.
@@ -2842,6 +3119,330 @@ window.__ModuleLoader__.load({
2842
3119
  return groups;
2843
3120
  }
2844
3121
 
3122
+ /**
3123
+ * The items a tab's context menu offers, in order.
3124
+ *
3125
+ * Exported (through `internals`) rather than inlined so the labels are
3126
+ * asserted by the tests instead of drifting from the request that named
3127
+ * them: 刷新当前 / 关闭当前 / 关闭其它 / 关闭所有.
3128
+ *
3129
+ * @type {Array<{key: string, icon: string, label: string}>}
3130
+ */
3131
+ const TAB_MENU_ITEMS = [
3132
+ { key: "refresh", icon: "↻", label: "刷新当前" },
3133
+ { key: "close", icon: "×", label: "关闭当前" },
3134
+ { key: "others", icon: "⇤", label: "关闭其它" },
3135
+ { key: "all", icon: "⊗", label: "关闭所有" },
3136
+ ];
3137
+
3138
+ /**
3139
+ * Apply closes to a tab list and say which tab is on screen afterwards.
3140
+ *
3141
+ * Pure so the rule is testable without a renderer, and so the three menu
3142
+ * items ("close current / others / all") are one implementation instead of
3143
+ * three: they differ only in the set of ids handed to this function.
3144
+ *
3145
+ * The landing tab is the neighbour on the right of the tab that was on
3146
+ * screen, then the one on the left, and finally the first survivor — the
3147
+ * order every tabbed editor uses. When nothing survives, the active id
3148
+ * becomes the empty string, which is what the pane reads as "no tab".
3149
+ *
3150
+ * Returns null when the closes would change nothing, so callers can skip a
3151
+ * re-render (and so closing an id that is not in the list is a no-op rather
3152
+ * than a resurrection).
3153
+ *
3154
+ * @param {Array<{id: string}>} tabs - the current list.
3155
+ * @param {string} activeId - the tab on screen now.
3156
+ * @param {Array<string>} ids - tabs to close.
3157
+ * @returns {{tabs: Array<object>, activeId: string} | null}
3158
+ */
3159
+ function tabsAfterClose(tabs, activeId, ids) {
3160
+ const current = Array.isArray(tabs) ? tabs : [];
3161
+ const doomed = new Set(Array.isArray(ids) ? ids : []);
3162
+ if (doomed.size === 0) return null;
3163
+ const next = current.filter((tab) => !doomed.has(tab.id));
3164
+ if (next.length === current.length) return null;
3165
+ if (!doomed.has(activeId) && next.some((tab) => tab.id === activeId)) {
3166
+ return { tabs: next, activeId };
3167
+ }
3168
+ if (next.length === 0) return { tabs: next, activeId: "" };
3169
+ // Measured in the OLD list: closing a middle tab must land on its right
3170
+ // neighbour, not on whatever ends up at the same index afterwards.
3171
+ const index = current.findIndex((tab) => tab.id === activeId);
3172
+ const right = current.slice(index + 1).find((tab) => !doomed.has(tab.id));
3173
+ const left = current.slice(0, Math.max(0, index)).reverse().find((tab) => !doomed.has(tab.id));
3174
+ const landing = right || left || next[0];
3175
+ return { tabs: next, activeId: landing.id };
3176
+ }
3177
+
3178
+ /**
3179
+ * The tab that should be on screen after `activate` is brought forward.
3180
+ *
3181
+ * Only meaningful for the "reopen an already open file" path: the reader
3182
+ * expects the tab to come forward, not to be replaced, and definitely not
3183
+ * to be duplicated.
3184
+ *
3185
+ * @param {Array<object>} tabs - the current list.
3186
+ * @param {string} activeId - the tab on screen now.
3187
+ * @param {object} draft - the tab to open or update (must carry an `id`).
3188
+ * @returns {{tabs: Array<object>, activeId: string}}
3189
+ */
3190
+ function tabsAfterUpsert(tabs, activeId, draft) {
3191
+ const current = Array.isArray(tabs) ? tabs : [];
3192
+ const existing = current.find((tab) => tab.id === draft.id);
3193
+ if (existing === undefined) {
3194
+ return { tabs: current.concat([{ ...draft, origin: draft.origin === undefined ? null : draft.origin }]), activeId: draft.id };
3195
+ }
3196
+ // Updating in place keeps the tab where it already sits in the strip.
3197
+ return { tabs: current.map((tab) => (tab.id === draft.id ? { ...tab, ...draft } : tab)), activeId: draft.id };
3198
+ }
3199
+
3200
+ /**
3201
+ * The content area's tab strip.
3202
+ *
3203
+ * One tab per opened file, commit or working-tree change. The strip is a
3204
+ * plain horizontal scroller: the drawer is narrow and a wrapping strip
3205
+ * would eat the space the preview needs, so overflow scrolls instead.
3206
+ *
3207
+ * Right-clicking any tab opens the four-item menu the reader asked for
3208
+ * (refresh / close / close others / close all); the same menu is reachable
3209
+ * from the keyboard via Shift+F10 or the ContextMenu key, because a
3210
+ * pointer-only affordance is a defect on a phone.
3211
+ *
3212
+ * @param {{tabs: Array<object>, activeId: string, onActivate: Function, onClose: Function, onContextMenu: Function, menuFor: string}} props
3213
+ */
3214
+ function TabStrip({ tabs, activeId, onActivate, onClose, onContextMenu, menuFor }) {
3215
+ const rows = Array.isArray(tabs) ? tabs : [];
3216
+ if (rows.length === 0) {
3217
+ return react.createElement("div", { className: "fv-tabs", role: "tablist", "aria-label": "已打开的文件" },
3218
+ react.createElement("span", { className: "fv-tabs-empty" }, "未打开文件"));
3219
+ }
3220
+ return react.createElement("div", {
3221
+ className: "fv-tabs",
3222
+ role: "tablist",
3223
+ "aria-label": "已打开的文件",
3224
+ }, ...rows.map((tab) => {
3225
+ const selected = tab.id === activeId;
3226
+ // The directory part is a secondary hint: two files with the same
3227
+ // name from different folders would otherwise look identical.
3228
+ const directory = typeof tab.dir === "string" ? tab.dir : "";
3229
+ return react.createElement("div", {
3230
+ key: tab.id,
3231
+ className: "fv-tab-cell",
3232
+ role: "tab",
3233
+ "aria-selected": selected ? "true" : "false",
3234
+ "data-menu": menuFor === tab.id ? "true" : "false",
3235
+ title: tab.path !== "" ? tab.path : tab.title,
3236
+ onClick: () => onActivate(tab.id),
3237
+ onContextMenu: (event) => {
3238
+ event.preventDefault();
3239
+ event.stopPropagation();
3240
+ onContextMenu({ tabId: tab.id, x: event.clientX, y: event.clientY });
3241
+ },
3242
+ onKeyDown: (event) => {
3243
+ // Shift+F10 and the dedicated ContextMenu key are what a keyboard
3244
+ // has instead of a right click.
3245
+ if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
3246
+ event.preventDefault();
3247
+ const box = event.currentTarget.getBoundingClientRect();
3248
+ onContextMenu({ tabId: tab.id, x: box.left + 12, y: box.bottom - 4 });
3249
+ return;
3250
+ }
3251
+ if (event.key === "Enter" || event.key === " ") {
3252
+ event.preventDefault();
3253
+ onActivate(tab.id);
3254
+ return;
3255
+ }
3256
+ // Arrow keys walk the strip the way a tablist is expected to.
3257
+ if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
3258
+ event.preventDefault();
3259
+ const index = rows.findIndex((row) => row.id === tab.id);
3260
+ const step = event.key === "ArrowRight" ? 1 : -1;
3261
+ const next = rows[(index + step + rows.length) % rows.length];
3262
+ if (next !== undefined) onActivate(next.id);
3263
+ }
3264
+ },
3265
+ tabIndex: selected ? 0 : -1,
3266
+ },
3267
+ react.createElement("button", {
3268
+ type: "button",
3269
+ className: "fv-tab-label",
3270
+ // The tab cell already handles activation; this button exists so
3271
+ // the label is a real control for screen readers and touch.
3272
+ onClick: (event) => { event.stopPropagation(); onActivate(tab.id); },
3273
+ title: tab.path !== "" ? tab.path : tab.title,
3274
+ }, tab.title),
3275
+ directory !== ""
3276
+ ? react.createElement("span", { className: "fv-tab-dir" }, directory)
3277
+ : null,
3278
+ react.createElement("button", {
3279
+ type: "button",
3280
+ className: "fv-tab-close",
3281
+ "aria-label": "关闭 " + tab.title,
3282
+ title: "关闭",
3283
+ onClick: (event) => { event.stopPropagation(); onClose(tab.id); },
3284
+ }, "×"));
3285
+ }));
3286
+ }
3287
+
3288
+ /**
3289
+ * The four-item menu a tab offers on right click.
3290
+ *
3291
+ * Deliberately its own component instead of a mode of the file-tree menu:
3292
+ * the subjects differ (a tab versus a path), and merging them produced
3293
+ * items that made no sense on one of the two.
3294
+ *
3295
+ * @param {{menu: object, tabs: Array<object>, onClose: Function, onAction: Function}} props
3296
+ */
3297
+ function TabContextMenu({ menu, tabs, onClose, onAction }) {
3298
+ const ref = react.useRef(null);
3299
+ const mountTimeRef = react.useRef(Date.now());
3300
+ react.useEffect(() => {
3301
+ mountTimeRef.current = Date.now();
3302
+ const handlePointerDown = (event) => {
3303
+ if (Date.now() - mountTimeRef.current < 400) return;
3304
+ if (ref.current && !ref.current.contains(event.target)) onClose();
3305
+ };
3306
+ const handleKeyDown = (event) => {
3307
+ if (event.key === "Escape") onClose();
3308
+ };
3309
+ const handleScroll = (event) => {
3310
+ if (Date.now() - mountTimeRef.current < 400) return;
3311
+ if (event.target === window || event.target === document || event.target === document.body) onClose();
3312
+ };
3313
+ window.addEventListener("pointerdown", handlePointerDown);
3314
+ window.addEventListener("keydown", handleKeyDown);
3315
+ window.addEventListener("scroll", handleScroll);
3316
+ window.addEventListener("resize", onClose);
3317
+ return () => {
3318
+ window.removeEventListener("pointerdown", handlePointerDown);
3319
+ window.removeEventListener("keydown", handleKeyDown);
3320
+ window.removeEventListener("scroll", handleScroll);
3321
+ window.removeEventListener("resize", onClose);
3322
+ };
3323
+ }, [onClose]);
3324
+
3325
+ if (menu === null || menu === undefined) return null;
3326
+ const rows = Array.isArray(tabs) ? tabs : [];
3327
+ const target = rows.find((tab) => tab.id === menu.tabId) || null;
3328
+ const winW = typeof window !== "undefined" ? window.innerWidth : 1000;
3329
+ const winH = typeof window !== "undefined" ? window.innerHeight : 800;
3330
+ const top = Math.max(8, Math.min(menu.y, winH - 240)) + "px";
3331
+ const left = Math.max(8, Math.min(menu.x, winW - 210)) + "px";
3332
+
3333
+ // Which items can do anything is decided from the list, not left to
3334
+ // the view: "close others" with one tab open is a no-op that would
3335
+ // look like a broken button.
3336
+ const disabled = {
3337
+ refresh: target === null,
3338
+ close: target === null,
3339
+ others: rows.length < 2,
3340
+ all: rows.length === 0,
3341
+ };
3342
+
3343
+ return react.createElement(react.Fragment, null,
3344
+ react.createElement("div", {
3345
+ className: "fv-context-backdrop",
3346
+ onClick: (event) => {
3347
+ event.stopPropagation();
3348
+ if (Date.now() - mountTimeRef.current < 400) return;
3349
+ onClose();
3350
+ },
3351
+ onContextMenu: (event) => {
3352
+ event.preventDefault();
3353
+ event.stopPropagation();
3354
+ if (Date.now() - mountTimeRef.current < 400) return;
3355
+ onClose();
3356
+ },
3357
+ }),
3358
+ react.createElement("div", {
3359
+ ref,
3360
+ // The id exists so the stylesheet can outrank a theme's glass rule
3361
+ // (an id beats any class/attribute selector, so it does not matter
3362
+ // which sheet loads last). Only one menu is ever mounted at a time.
3363
+ id: CONTEXT_MENU_ID,
3364
+ className: "fv-context-menu",
3365
+ style: { top, left },
3366
+ role: "menu",
3367
+ "aria-label": "标签操作",
3368
+ onContextMenu: (event) => event.preventDefault(),
3369
+ },
3370
+ react.createElement("div", { className: "fv-context-head" },
3371
+ react.createElement("div", { className: "fv-context-title" }, target === null ? "标签" : target.title),
3372
+ target !== null && target.path !== "" && target.path !== target.title
3373
+ ? react.createElement("div", { className: "fv-context-path" }, target.path)
3374
+ : null),
3375
+ ...TAB_MENU_ITEMS.map((item) => react.createElement("button", {
3376
+ key: item.key,
3377
+ type: "button",
3378
+ className: "fv-context-item",
3379
+ role: "menuitem",
3380
+ disabled: disabled[item.key] === true,
3381
+ onClick: (event) => {
3382
+ event.stopPropagation();
3383
+ onClose();
3384
+ onAction(item.key, menu.tabId);
3385
+ },
3386
+ },
3387
+ react.createElement("span", { className: "fv-context-item-icon", "aria-hidden": "true" }, item.icon),
3388
+ react.createElement("span", { className: "fv-context-item-label" }, item.label)))));
3389
+ }
3390
+
3391
+ /**
3392
+ * The outline of a rendered Markdown document.
3393
+ *
3394
+ * Modelled on the session's turn navigator: a compact rail on the right
3395
+ * edge that stays out of the reading column, expands into a labelled list
3396
+ * on demand, marks the section currently on screen, and jumps on click.
3397
+ *
3398
+ * The headings come from the source (see `parseHeadings`), while the
3399
+ * scroll target is the Nth rendered heading element. Matching by position
3400
+ * rather than by text keeps it working when the renderer rewrites labels
3401
+ * (smart quotes, inline code, links) — the two lists are built from the
3402
+ * same document in the same order, so index N in one is index N in the
3403
+ * other.
3404
+ *
3405
+ * @param {{headings: Array<object>, scrollerRef: object, activeKey: string,
3406
+ * open: boolean, onToggle: Function, onPick: Function}} props
3407
+ */
3408
+ function MarkdownOutline({ headings, open, activeKey, onToggle, onPick }) {
3409
+ const rows = Array.isArray(headings) ? headings : [];
3410
+ if (rows.length === 0) return null;
3411
+ // One indent step per nesting level, measured from the shallowest
3412
+ // heading present: a document starting at `##` should not indent every
3413
+ // row by one level of nothing.
3414
+ const baseLevel = rows.reduce((min, row) => Math.min(min, row.level), 6);
3415
+ return react.createElement("div", {
3416
+ className: "fv-outline",
3417
+ "data-open": open ? "true" : "false",
3418
+ },
3419
+ react.createElement("button", {
3420
+ type: "button",
3421
+ className: "fv-outline-toggle",
3422
+ "aria-expanded": open ? "true" : "false",
3423
+ "aria-label": open ? "收起目录" : "展开目录",
3424
+ title: open ? "收起目录" : "目录",
3425
+ onClick: onToggle,
3426
+ }, react.createElement(SvgIconOutline, { size: 15 })),
3427
+ open
3428
+ ? react.createElement("nav", {
3429
+ className: "fv-outline-panel",
3430
+ "aria-label": "文档目录",
3431
+ },
3432
+ react.createElement("div", { className: "fv-outline-head" }, "目录"),
3433
+ react.createElement("div", { className: "fv-outline-list" },
3434
+ ...rows.map((row) => react.createElement("button", {
3435
+ key: row.key,
3436
+ type: "button",
3437
+ className: "fv-outline-item",
3438
+ "data-level": row.level - baseLevel,
3439
+ "aria-current": activeKey === row.key ? "true" : undefined,
3440
+ title: row.text,
3441
+ onClick: () => onPick(row),
3442
+ }, row.text))))
3443
+ : null);
3444
+ }
3445
+
2845
3446
  function apply(ctx) {
2846
3447
  ensureStyles(typeof document === "undefined" ? null : document);
2847
3448
  // `null` is closed; an object is open. Carrying the requesting session lets
@@ -3346,6 +3947,13 @@ window.__ModuleLoader__.load({
3346
3947
  const [raw, setRaw] = react.useState(false);
3347
3948
  const [diffMode, setDiffMode] = react.useState(false);
3348
3949
  const [diffData, setDiffData] = react.useState(null);
3950
+ // The Markdown outline: whether it is expanded, which section is on
3951
+ // screen, and the scroll container it drives. The container is owned
3952
+ // here rather than by the outline so the outline never has to reach
3953
+ // across the tree to find it.
3954
+ const [outlineOpen, setOutlineOpen] = react.useState(false);
3955
+ const [activeHeading, setActiveHeading] = react.useState("");
3956
+ const contentRef = react.useRef(null);
3349
3957
  // A file opened from a commit is read from that commit's blob, never
3350
3958
  // from the working tree: the two differ exactly when the history is
3351
3959
  // interesting, and a deleted file has no working-tree copy at all.
@@ -3416,6 +4024,81 @@ window.__ModuleLoader__.load({
3416
4024
  return () => { live = false; };
3417
4025
  }, [root, meta.path, offset, lang, fromCommit, commit, repo, meta.diffSha, meta.rowPath, meta.oldPath]);
3418
4026
 
4027
+ // ------------------------------------------------------------------
4028
+ // Markdown outline
4029
+ //
4030
+ // These hooks sit ABOVE the early returns below. React matches hooks by
4031
+ // call order, so a hook that only runs once a document has loaded would
4032
+ // change the order between the loading render and the loaded one — the
4033
+ // exact shape of "Minified React error #310" (too many/few hooks).
4034
+ // Everything here therefore reads `state.data` defensively instead of
4035
+ // the post-return `data` binding.
4036
+ // ------------------------------------------------------------------
4037
+ const loaded = state.data;
4038
+ // The outline only exists for a rendered Markdown preview. In source
4039
+ // mode the text is a code block, and a JSON tree has no headings.
4040
+ const outlineVisible = loaded !== null && loaded !== undefined
4041
+ && loaded.kind === "markdown" && typeof loaded.text === "string"
4042
+ && !raw && !diffMode;
4043
+ const outlineText = outlineVisible ? loaded.text : "";
4044
+ const headings = react.useMemo(
4045
+ () => (outlineText === "" ? [] : parseHeadings(outlineText)),
4046
+ [outlineText],
4047
+ );
4048
+
4049
+ /**
4050
+ * The rendered heading elements, in document order.
4051
+ *
4052
+ * Kept as a plain lookup instead of state: the elements are owned by
4053
+ * the renderer and re-created on every render, so caching them would
4054
+ * hand out detached nodes.
4055
+ */
4056
+ const headingElements = () => {
4057
+ const host = contentRef.current;
4058
+ if (!host) return [];
4059
+ return [...host.querySelectorAll("h1,h2,h3,h4,h5,h6")];
4060
+ };
4061
+
4062
+ // Mark the section currently at the top of the reading area. Measured
4063
+ // on scroll rather than with an IntersectionObserver because the
4064
+ // "current" section is the last heading above the fold, which is a
4065
+ // comparison between two positions, not a visibility test.
4066
+ react.useEffect(() => {
4067
+ if (!outlineVisible || headings.length === 0) {
4068
+ setActiveHeading("");
4069
+ return undefined;
4070
+ }
4071
+ const host = contentRef.current;
4072
+ if (!host) return undefined;
4073
+ let frame = 0;
4074
+ const measure = () => {
4075
+ frame = 0;
4076
+ const elements = host.querySelectorAll("h1,h2,h3,h4,h5,h6");
4077
+ if (elements.length === 0) return;
4078
+ const top = host.getBoundingClientRect().top;
4079
+ let current = "";
4080
+ for (let index = 0; index < elements.length; index += 1) {
4081
+ const row = headings[index];
4082
+ if (row === undefined) continue;
4083
+ // 24px of slack keeps a heading that is exactly at the edge from
4084
+ // flickering between two rows while the reader scrolls past it.
4085
+ if (elements[index].getBoundingClientRect().top - top <= 24) current = row.key;
4086
+ else break;
4087
+ }
4088
+ setActiveHeading(current);
4089
+ };
4090
+ const onScroll = () => {
4091
+ if (frame !== 0) return;
4092
+ frame = requestAnimationFrame(measure);
4093
+ };
4094
+ measure();
4095
+ host.addEventListener("scroll", onScroll, { passive: true });
4096
+ return () => {
4097
+ if (frame !== 0) cancelAnimationFrame(frame);
4098
+ host.removeEventListener("scroll", onScroll);
4099
+ };
4100
+ }, [outlineVisible, headings]);
4101
+
3419
4102
  if (state.status === "error") {
3420
4103
  return react.createElement("div", { className: "fv-note fv-error" }, messageOf(state.error));
3421
4104
  }
@@ -3541,14 +4224,50 @@ window.__ModuleLoader__.load({
3541
4224
  ? [diffTab, contentTab, sourceTab]
3542
4225
  : [contentTab, sourceTab, diffTab]).filter((tab) => tab !== null))
3543
4226
  : null;
4227
+
4228
+
4229
+ /** Scroll the section for one outline row into view. */
4230
+ const pickHeading = (row) => {
4231
+ const index = headings.findIndex((candidate) => candidate.key === row.key);
4232
+ const element = headingElements()[index];
4233
+ if (!element) return;
4234
+ const host = contentRef.current;
4235
+ // Scroll the container, not the window: the drawer is its own
4236
+ // scroller and scrollIntoView would also move the page behind it.
4237
+ if (host) {
4238
+ const offset = element.getBoundingClientRect().top - host.getBoundingClientRect().top;
4239
+ const reduce = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
4240
+ host.scrollTo({ top: host.scrollTop + offset - 8, behavior: reduce ? "auto" : "smooth" });
4241
+ } else {
4242
+ element.scrollIntoView({ block: "start" });
4243
+ }
4244
+ setActiveHeading(row.key);
4245
+ // On a phone the panel would cover the text it just jumped to.
4246
+ if (typeof window !== "undefined" && window.innerWidth <= 768) setOutlineOpen(false);
4247
+ };
4248
+
3544
4249
  return react.createElement(react.Fragment, null,
3545
4250
  tabs,
3546
- react.createElement("div", { className: "fv-content", "data-wrap": wrapping ? "on" : "off" },
3547
- previewUnavailable
3548
- ? react.createElement("div", { className: "fv-note" },
3549
- data.kind === "markdown" ? "文档过大,仅显示源码。" : "文件过大,仅显示源码。")
3550
- : null,
3551
- body),
4251
+ react.createElement("div", { className: "fv-content-wrap" },
4252
+ react.createElement("div", {
4253
+ className: "fv-content",
4254
+ "data-wrap": wrapping ? "on" : "off",
4255
+ ref: contentRef,
4256
+ },
4257
+ previewUnavailable
4258
+ ? react.createElement("div", { className: "fv-note" },
4259
+ data.kind === "markdown" ? "文档过大,仅显示源码。" : "文件过大,仅显示源码。")
4260
+ : null,
4261
+ body),
4262
+ outlineVisible
4263
+ ? react.createElement(MarkdownOutline, {
4264
+ headings,
4265
+ open: outlineOpen,
4266
+ activeKey: activeHeading,
4267
+ onToggle: () => setOutlineOpen((current) => !current),
4268
+ onPick: pickHeading,
4269
+ })
4270
+ : null),
3552
4271
  pager);
3553
4272
  }
3554
4273
 
@@ -3570,9 +4289,12 @@ window.__ModuleLoader__.load({
3570
4289
  }
3571
4290
 
3572
4291
  /** Pick the viewer for a file and keep unsupported formats useful. */
3573
- function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo }) {
4292
+ function FileView({ meta, root, api, wrap, lang, onDownload, commit, repo, loading }) {
3574
4293
  if (meta === null) {
3575
- return react.createElement("div", { className: "fv-note" }, "从左侧选择一个文件。");
4294
+ // A tab exists but its metadata is still in flight. Saying "pick a
4295
+ // file" there would be a lie, and the reader would click again.
4296
+ return react.createElement("div", { className: "fv-note" },
4297
+ loading === true ? "正在读取文件…" : "从左侧选择一个文件。");
3576
4298
  }
3577
4299
  if (meta.tooLarge || meta.kind === "binary") {
3578
4300
  return react.createElement("div", { className: "fv-note" },
@@ -3624,6 +4346,7 @@ window.__ModuleLoader__.load({
3624
4346
  react.createElement(TextView, { key, meta, root, wrap, lang, commit, repo }));
3625
4347
  }
3626
4348
 
4349
+
3627
4350
  /** Right-click context menu for file and directory items. */
3628
4351
  function ContextMenu({ menu, rootPath, onClose, onMention, onToast, onDownload }) {
3629
4352
  const ref = react.useRef(null);
@@ -3712,6 +4435,7 @@ window.__ModuleLoader__.load({
3712
4435
  }),
3713
4436
  react.createElement("div", {
3714
4437
  ref,
4438
+ id: CONTEXT_MENU_ID,
3715
4439
  className: "fv-context-menu",
3716
4440
  style: { top, left },
3717
4441
  role: "menu",
@@ -3766,6 +4490,8 @@ window.__ModuleLoader__.load({
3766
4490
  return react.createElement("div", { className: "fv-toast", role: "status" }, toast);
3767
4491
  }
3768
4492
 
4493
+
4494
+
3769
4495
  /** Safe Access Paths Configuration Dialog */
3770
4496
  function SafePathsModal({ open, onClose, onToast, onUpdated, prefillPath }) {
3771
4497
  const [loading, setLoading] = react.useState(false);
@@ -3922,12 +4648,25 @@ window.__ModuleLoader__.load({
3922
4648
  }, [open]);
3923
4649
  const [roots, setRoots] = react.useState([]);
3924
4650
  const [root, setRoot] = react.useState("");
4651
+ // The drawer is a panel, not a modal: whatever layer wraps it must not
4652
+ // dim or blur the app behind it. The CSS in this bundle opts out of the
4653
+ // known cases, and this walks the real DOM for the rest — a theme or
4654
+ // adapter may paint those properties from a rule this plugin cannot name
4655
+ // in advance. Re-applied on every open because those layers re-render.
4656
+ react.useEffect(() => {
4657
+ if (!open) return undefined;
4658
+ const scrim = typeof document === "undefined" ? null : document.querySelector(".fv-scrim");
4659
+ const restore = suppressBackdropEffects(scrim, true);
4660
+ return () => { restore(); };
4661
+ }, [open]);
3925
4662
  // The tree is an expansion set plus a per-directory listing cache; a
3926
4663
  // directory is fetched the first time it opens and then remembered, so
3927
4664
  // collapsing and reopening costs nothing.
3928
4665
  const [expanded, setExpanded] = react.useState(() => new Set());
3929
4666
  const [nodes, setNodes] = react.useState(() => new Map());
3930
- const [meta, setMeta] = react.useState(null);
4667
+ // The preview's own metadata is no longer a standalone piece of state:
4668
+ // it belongs to the tab that opened it, so switching tabs switches the
4669
+ // file. `meta` is derived from the active tab further down.
3931
4670
  const [hidden, setHidden] = react.useState(false);
3932
4671
  const [safeModalOpen, setSafeModalOpen] = react.useState(false);
3933
4672
  const [prefillPath, setPrefillPath] = react.useState("");
@@ -4040,9 +4779,8 @@ window.__ModuleLoader__.load({
4040
4779
  // Soft wrap for source views. Off by default: code is written with
4041
4780
  // meaningful line breaks and wrapping obscures them.
4042
4781
  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");
4782
+ // The language override now lives on the tab it applies to (see
4783
+ // `localeTagOfActive`), so switching tabs switches the pin with it.
4046
4784
  const [fullscreen, setFullscreen] = react.useState(false);
4047
4785
  const [isMobile, setIsMobile] = react.useState(() => {
4048
4786
  if (typeof window === "undefined") return false;
@@ -4075,28 +4813,35 @@ window.__ModuleLoader__.load({
4075
4813
  // Which sidebar pane is showing, and whether the main pane is a commit
4076
4814
  // rather than a file. Both live here because the drawer owns the split.
4077
4815
  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
4816
  /**
4085
- * What the main pane was showing before the current file preview.
4817
+ * The right-hand content area is a set of tabs.
4086
4818
  *
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.
4819
+ * Every tab owns the *complete* description of what the pane shows:
4820
+ * the file preview (`meta`, which commit it was read from, the language
4821
+ * pinned for it) as well as the commit-shaped views (`detail` /
4822
+ * `changes`). Keeping one object per tab is what lets an inactive tab
4823
+ * be restored exactly, instead of inheriting whatever the last opened
4824
+ * file happened to leave behind.
4091
4825
  *
4092
- * `null` means there is nowhere to go back to.
4093
- * @type {"commit" | "changes" | null}
4826
+ * `null` (never an empty array) means the content area has no tabs at
4827
+ * all, which is the state the drawer opens in.
4828
+ *
4829
+ * @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
4830
  */
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("");
4831
+ const [tabs, setTabs] = react.useState(null);
4832
+ // Which tab is on screen. Kept beside the list rather than inside it so
4833
+ // closing the active tab can pick a neighbour with the list in hand.
4834
+ const [activeTabId, setActiveTabId] = react.useState("");
4835
+ // The tab whose context menu is open, plus where the pointer asked for
4836
+ // it. Kept apart from the tree's `contextMenu`: the two menus describe
4837
+ // different subjects and must never be confused for one another.
4838
+ const [tabMenu, setTabMenu] = react.useState(null);
4839
+ const tabsRef = react.useRef(null);
4840
+ tabsRef.current = tabs;
4841
+ const activeTabIdRef = react.useRef("");
4842
+ activeTabIdRef.current = activeTabId;
4843
+ // The tab a language pin was last applied to, so a pin made on one tab
4844
+ // is not mistaken for one made on another after a switch.
4100
4845
  const gitState = useStore(git.store);
4101
4846
 
4102
4847
  react.useEffect(() => {
@@ -4112,13 +4857,27 @@ window.__ModuleLoader__.load({
4112
4857
  return () => { live = false; };
4113
4858
  }, [open, root]);
4114
4859
 
4860
+ /**
4861
+ * The root the Git controller was last initialised for.
4862
+ *
4863
+ * Reopening the drawer must not wipe the panel: a commit tab reopened
4864
+ * from the previous visit needs its detail, and the branch the reader
4865
+ * was browsing is part of what "the same visit" means. Only an actual
4866
+ * root change discards the cached repositories, branches and pages.
4867
+ *
4868
+ * Null means "never initialised", which is distinct from the empty root
4869
+ * the first render sees before the host has resolved anything.
4870
+ */
4871
+ const gitRootRef = react.useRef(null);
4872
+
4115
4873
  // The Git panel describes one root; switching roots drops every cached
4116
4874
  // repository, branch and page rather than showing another project's
4117
4875
  // history under the new name.
4118
4876
  react.useEffect(() => {
4119
4877
  if (!open) return;
4878
+ if (gitRootRef.current === root) return;
4879
+ gitRootRef.current = root;
4120
4880
  git.reset(root);
4121
- setShowCommit(false);
4122
4881
  }, [open, root]);
4123
4882
 
4124
4883
  // Loaded on first use, never on open: most visits never touch the Git
@@ -4244,28 +5003,36 @@ window.__ModuleLoader__.load({
4244
5003
  // Directory to open on arrival, relative to the resolved root: the
4245
5004
  // session's working directory when it sits below the workspace root.
4246
5005
  const [reveal, setReveal] = react.useState("");
4247
- const metaTicket = react.useRef(0);
5006
+ // One ticket per tab id, so a read that is slow for one file cannot
5007
+ // cancel a read that is fast for another. A single drawer-wide counter
5008
+ // would have done exactly that once tabs made concurrent reads normal.
5009
+ const metaTickets = react.useRef(new Map());
4248
5010
  const treeGeneration = react.useRef(0);
5011
+ /**
5012
+ * Which root (and therefore which project) the current state describes.
5013
+ *
5014
+ * Closing the drawer keeps everything mounted, so reopening lands on
5015
+ * the previous visit's tabs, sidebar and expansion set — that is the
5016
+ * whole point. This ref is how "the same visit" is recognised: as long
5017
+ * as the host resolves the same root, nothing is thrown away.
5018
+ */
5019
+ const visitContextRef = react.useRef("");
4249
5020
 
4250
5021
  // Resolve the root set on every open. When the request came from a
4251
5022
  // conversation header or a file click, the host also reports which root
4252
5023
  // that session/file belongs to, the directory path to reveal, and any
4253
5024
  // file to automatically select and preview.
5025
+ //
5026
+ // The drawer stays mounted while closed (`open` only gates rendering),
5027
+ // so everything is still here when it reopens — and that is the point:
5028
+ // closing the viewer is not "ending a session", it is hiding a panel,
5029
+ // and it must come back the way it was left. The one thing that CANNOT
5030
+ // survive is a tab opened under a different root or a different session,
5031
+ // because its paths mean something else there; that case is handled
5032
+ // below, against the root the host resolves.
4254
5033
  react.useEffect(() => {
4255
5034
  if (request_ === null) return undefined;
4256
5035
  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
5036
  setError(null);
4270
5037
  const payload = {
4271
5038
  ...(request_.sessionId !== undefined ? { sessionId: request_.sessionId } : {}),
@@ -4277,18 +5044,31 @@ window.__ModuleLoader__.load({
4277
5044
  if (!live) return;
4278
5045
  setRoots(value.roots || []);
4279
5046
  const resolvedRoot = value.root || (value.roots || [])[0]?.id || "";
5047
+ // A reopen against the same root and the same session keeps every
5048
+ // tab, the active one included. Anything else is a different
5049
+ // project, and carrying the tabs over would point them at files
5050
+ // that merely share a name.
5051
+ const sameContext = resolvedRoot !== "" && resolvedRoot === visitContextRef.current;
5052
+ if (!sameContext) {
5053
+ tabsRef.current = null;
5054
+ setTabs(null);
5055
+ activeTabIdRef.current = "";
5056
+ setActiveTabId("");
5057
+ setTabMenu(null);
5058
+ setPane("tree");
5059
+ // The tree belongs to the previous root too; the listing effect
5060
+ // below re-reads it from scratch.
5061
+ setExpanded(new Set());
5062
+ }
5063
+ visitContextRef.current = resolvedRoot;
4280
5064
  setRoot(resolvedRoot);
4281
5065
  setReveal(typeof value.reveal === "string" ? value.reveal : "");
4282
5066
  setError(null);
4283
5067
  if (typeof value.selectFile === "string" && value.selectFile !== "") {
4284
5068
  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(() => {});
5069
+ // Arriving on a file from a link: it opens in its own tab, the
5070
+ // same way clicking it in the tree would.
5071
+ openFileTab(resolvedRoot, value.selectFile, { activate: true });
4292
5072
  }
4293
5073
  }).catch((problem) => {
4294
5074
  if (live) setError(problem);
@@ -4330,23 +5110,60 @@ window.__ModuleLoader__.load({
4330
5110
  });
4331
5111
  };
4332
5112
 
5113
+ /**
5114
+ * What the tree was last built for.
5115
+ *
5116
+ * Opening and closing the drawer must not rebuild the tree: the reader
5117
+ * expands a few directories, looks at a file, hides the panel and
5118
+ * expects the same shape when it comes back. Only a real context change
5119
+ * — a different root, a different dotfile filter, a different directory
5120
+ * to reveal — starts over.
5121
+ */
5122
+ const treeBuiltForRef = react.useRef({ root: "", hidden: null, reveal: "" });
5123
+
4333
5124
  // A new root or filter invalidates every cached listing; the bumped
4334
5125
  // generation makes any in-flight response from the old tree stale.
4335
5126
  // When the host named a directory to reveal, its whole ancestor chain
4336
5127
  // opens at once so the session's project is visible on arrival.
4337
5128
  react.useEffect(() => {
4338
5129
  if (!open || root === "") return;
5130
+ const built = treeBuiltForRef.current;
5131
+ const rootChanged = built.root !== root;
5132
+ const filterChanged = built.hidden !== hidden;
5133
+ // The reveal chain is only worth re-opening when the host names a
5134
+ // different directory; the reader's own expansions stay put.
5135
+ const revealChanged = built.reveal !== reveal;
5136
+ if (!rootChanged && !filterChanged && !revealChanged) return;
5137
+ treeBuiltForRef.current = { root, hidden, reveal };
4339
5138
  treeGeneration.current += 1;
4340
5139
  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);
5140
+ if (rootChanged) {
5141
+ // Tabs describe paths inside the root that opened them; carrying
5142
+ // one across a root switch would point it at a different file with
5143
+ // the same name, so the whole set is dropped with the old tree.
5144
+ // (The open path already cleared them; this covers a root switched
5145
+ // from the picker while the drawer stays open.)
5146
+ tabsRef.current = null;
5147
+ setTabs(null);
5148
+ activeTabIdRef.current = "";
5149
+ setActiveTabId("");
5150
+ setTabMenu(null);
5151
+ setExpanded(new Set());
5152
+ setPane("tree");
5153
+ }
5154
+ const chain = revealChanged && reveal !== "" ? [...ancestorsOf(reveal), reveal] : [];
5155
+ if (chain.length > 0) {
5156
+ setExpanded((current) => {
5157
+ const next = new Set(current);
5158
+ for (const dirPath of chain) next.add(dirPath);
5159
+ return next;
5160
+ });
5161
+ }
5162
+ // Reload the root plus whatever the tree is actually showing, so a
5163
+ // reopen shows current contents rather than a stale listing.
5164
+ const visible = rootChanged ? chain : [...expanded];
5165
+ const listings = [...new Set(["", ...chain, ...visible])];
5166
+ for (const dirPath of listings) loadDirectory(dirPath);
4350
5167
  }, [open, root, hidden, reveal]);
4351
5168
 
4352
5169
  react.useEffect(() => {
@@ -4404,6 +5221,177 @@ window.__ModuleLoader__.load({
4404
5221
  if (!isOpen && !nodes.has(dirPath)) loadDirectory(dirPath);
4405
5222
  };
4406
5223
 
5224
+ // ------------------------------------------------------------------
5225
+ // Tabs
5226
+ //
5227
+ // The content area holds a list of tabs instead of one preview. Every
5228
+ // operation below works on that list, and `activeTab` (plus the values
5229
+ // derived from it) is what the render body and the previously existing
5230
+ // handlers read. Keeping the list as one piece of state is what makes
5231
+ // "close others" and "close all" correct by construction: they are list
5232
+ // operations, not four separate flags that could disagree.
5233
+ // ------------------------------------------------------------------
5234
+
5235
+ /**
5236
+ * The tab on screen, or null.
5237
+ *
5238
+ * Never compute this from `tabs[0]`: the active tab can be the last
5239
+ * one closed, and falling back to a neighbour silently would show a
5240
+ * file the reader just dismissed.
5241
+ */
5242
+ const activeTab = (() => {
5243
+ if (tabs === null || tabs.length === 0) return null;
5244
+ const found = tabs.find((tab) => tab.id === activeTabId);
5245
+ return found === undefined ? null : found;
5246
+ })();
5247
+ const meta = activeTab === null ? null : (activeTab.meta === undefined ? null : activeTab.meta);
5248
+ const previewCommit = activeTab === null ? "" : (activeTab.commit || "");
5249
+ const previewChangePath = activeTab === null ? "" : (activeTab.changePath || "");
5250
+ const localeTagOfActive = activeTab === null ? "auto" : (activeTab.lang || "auto");
5251
+ const showCommit = activeTab !== null && (activeTab.kind === "commit" || activeTab.kind === "change");
5252
+ const commitView = activeTab === null ? "files" : (activeTab.commitView || "files");
5253
+ const previewOrigin = activeTab === null ? null : (activeTab.origin || null);
5254
+
5255
+ /**
5256
+ * Read the list, tolerating the "no tabs yet" state.
5257
+ *
5258
+ * The render body runs before any tab exists, and every list operation
5259
+ * must be safe there rather than special-casing null at each call site.
5260
+ */
5261
+ const tabList = () => (tabsRef.current === null ? [] : tabsRef.current);
5262
+
5263
+ /**
5264
+ * Open a tab, or update the one with the same id and bring it forward.
5265
+ *
5266
+ * Ids are content-derived (`file:<path>`, `commit:<sha>`,
5267
+ * `change:<path>`), which is what makes re-clicking an already open
5268
+ * file focus its tab instead of stacking a duplicate.
5269
+ */
5270
+ const upsertTab = (draft, options) => {
5271
+ const result = tabsAfterUpsert(tabList(), activeTabIdRef.current, draft);
5272
+ tabsRef.current = result.tabs;
5273
+ setTabs(result.tabs);
5274
+ // A caller that does not want to move the reader's focus (an arrival
5275
+ // path that resolves in the background) keeps the current tab.
5276
+ if (options === undefined || options.activate !== false) {
5277
+ activeTabIdRef.current = result.activeId;
5278
+ setActiveTabId(result.activeId);
5279
+ }
5280
+ setTabMenu(null);
5281
+ return result.activeId;
5282
+ };
5283
+
5284
+ /** Focus one existing tab; a missing one is ignored rather than resurrected. */
5285
+ const activateTab = (id) => {
5286
+ const current = tabList();
5287
+ const tab = current.find((row) => row.id === id);
5288
+ if (tab === undefined) return;
5289
+ activeTabIdRef.current = id;
5290
+ setActiveTabId(id);
5291
+ setTabMenu(null);
5292
+ // The Git controller holds one commit's detail at a time, so coming
5293
+ // back to a commit tab whose detail has since been replaced has to
5294
+ // re-read it — otherwise the pane would show a different commit
5295
+ // under this tab's title.
5296
+ if (tab.kind === "commit") {
5297
+ const sha = id.slice("commit:".length);
5298
+ const detail = git.store.get().detail;
5299
+ const loaded = detail !== null && detail !== undefined && detail.commit !== undefined
5300
+ && detail.commit.sha === sha;
5301
+ if (!loaded) git.openCommit(sha);
5302
+ }
5303
+ };
5304
+
5305
+ /** Patch one tab by id. Used by asynchronous reads that outlive a switch. */
5306
+ const patchTab = (id, mutator) => {
5307
+ const current = tabList();
5308
+ if (!current.some((tab) => tab.id === id)) return;
5309
+ const next = current.map((tab) => (tab.id === id
5310
+ ? (typeof mutator === "function" ? mutator(tab) : { ...tab, ...mutator })
5311
+ : tab));
5312
+ tabsRef.current = next;
5313
+ setTabs(next);
5314
+ };
5315
+
5316
+ const findTab = (id) => {
5317
+ const found = tabList().find((tab) => tab.id === id);
5318
+ return found === undefined ? null : found;
5319
+ };
5320
+
5321
+ /**
5322
+ * Close tabs and land on a sensible neighbour.
5323
+ *
5324
+ * The list maths lives in `tabsAfterClose` so it can be exercised
5325
+ * without a renderer: "which tab is active afterwards" is the one rule
5326
+ * a reader notices immediately when it is wrong.
5327
+ */
5328
+ const closeTabs = (ids) => {
5329
+ const current = tabList();
5330
+ const result = tabsAfterClose(current, activeTabIdRef.current, Array.isArray(ids) ? ids : [ids]);
5331
+ if (result === null) return;
5332
+ tabsRef.current = result.tabs;
5333
+ setTabs(result.tabs);
5334
+ setTabMenu(null);
5335
+ activeTabIdRef.current = result.activeId;
5336
+ setActiveTabId(result.activeId);
5337
+ if (result.tabs.length === 0) setPane("tree");
5338
+ };
5339
+
5340
+ const closeTab = (id) => { closeTabs([id]); };
5341
+
5342
+ /**
5343
+ * Re-read what one tab shows, without disturbing the others.
5344
+ *
5345
+ * What "refresh" means depends on what the tab is: a working-tree file
5346
+ * re-reads its metadata and forces the viewer to remount, a commit or a
5347
+ * change re-runs the Git read that produced it.
5348
+ */
5349
+ const refreshTab = (id) => {
5350
+ const tab = findTab(id);
5351
+ if (tab === null) return;
5352
+ if (tab.kind === "commit") {
5353
+ git.openCommit(tab.id.slice("commit:".length));
5354
+ return;
5355
+ }
5356
+ if (tab.kind === "change") {
5357
+ git.loadChanges();
5358
+ // The diff itself is fetched by the child's own effect, which is
5359
+ // keyed on the path; bumping the epoch remounts it so a re-read
5360
+ // happens even when the path did not change.
5361
+ patchTab(id, (current) => ({ ...current, epoch: (current.epoch || 0) + 1 }));
5362
+ return;
5363
+ }
5364
+ if (tab.commit !== "" && tab.commit !== undefined) {
5365
+ // A blob from history has no metadata to re-read; remounting the
5366
+ // viewer is what asks the host for the bytes again.
5367
+ patchTab(id, (current) => ({ ...current, epoch: (current.epoch || 0) + 1 }));
5368
+ return;
5369
+ }
5370
+ patchTab(id, (current) => ({ ...current, status: "loading", epoch: (current.epoch || 0) + 1 }));
5371
+ // A ticket per tab, not per drawer: refreshing B after A must not
5372
+ // cancel A's read, which is what a shared counter would do.
5373
+ const tickets = metaTickets.current;
5374
+ const ticket = (tickets.get(id) || 0) + 1;
5375
+ tickets.set(id, ticket);
5376
+ request("meta", { root, path: tab.path }).then((value) => {
5377
+ if (tickets.get(id) !== ticket) return;
5378
+ patchTab(id, (current) => ({ ...current, meta: value, status: "ready", error: null }));
5379
+ }).catch((problem) => {
5380
+ if (tickets.get(id) !== ticket) return;
5381
+ patchTab(id, (current) => ({ ...current, status: "error", error: problem }));
5382
+ });
5383
+ };
5384
+
5385
+ /** The four items a tab's context menu offers. */
5386
+ const onTabAction = (action, id) => {
5387
+ const current = tabList();
5388
+ if (action === "refresh") refreshTab(id);
5389
+ else if (action === "close") closeTab(id);
5390
+ else if (action === "others") closeTabs(current.filter((tab) => tab.id !== id).map((tab) => tab.id));
5391
+ else if (action === "all") closeTabs(current.map((tab) => tab.id));
5392
+ setTabMenu(null);
5393
+ };
5394
+
4407
5395
  // Re-read every directory the reader currently has open, keeping the
4408
5396
  // tree's shape, plus the status badges a checkout or external edit
4409
5397
  // invalidates.
@@ -4419,36 +5407,63 @@ window.__ModuleLoader__.load({
4419
5407
  }).catch(() => {});
4420
5408
  };
4421
5409
 
5410
+ /**
5411
+ * The top toolbar's refresh: the tree, the Git panel, and the tab on
5412
+ * screen.
5413
+ *
5414
+ * The tab is refreshed too because that is what the reader is looking
5415
+ * at — refreshing the tree around a stale preview would leave the file
5416
+ * they care about unchanged.
5417
+ */
4422
5418
  const refresh = () => {
4423
5419
  reloadWorkingTree();
4424
5420
  if (side === "git") {
4425
5421
  const state = git.store.get();
4426
- const keepSha = showCommit && state.detail !== null && state.detail !== undefined && state.detail.commit !== undefined
4427
- ? state.detail.commit.sha
5422
+ // The commit to come back to belongs to the tab on screen, not to
5423
+ // whichever detail the controller happens to hold.
5424
+ const keepSha = activeTab !== null && activeTab.kind === "commit"
5425
+ ? activeTab.id.slice("commit:".length)
4428
5426
  : "";
4429
5427
  if (state.root !== root) git.reset(root);
4430
5428
  git.loadRepos().then(() => {
4431
5429
  if (keepSha !== "") git.openCommit(keepSha);
4432
5430
  });
4433
5431
  }
5432
+ if (activeTabId !== "") refreshTab(activeTabId);
4434
5433
  };
4435
5434
 
4436
- /** Open one commit in the main pane, replacing whatever was there. */
5435
+ /** Open one commit in its own tab, or activate the tab already showing it. */
4437
5436
  const openCommit = (commit) => {
4438
5437
  if (commit === null || commit === undefined || typeof commit.sha !== "string") return;
4439
- setShowCommit(true);
5438
+ const sha = commit.sha;
5439
+ const title = commit.shortSha || sha.slice(0, 7);
5440
+ const id = "commit:" + sha;
4440
5441
  setPane("content");
4441
- setCommitView("files");
4442
5442
  // Re-opening the commit already on screen costs nothing: its detail
4443
5443
  // does not depend on the working tree, only on the commit itself.
4444
5444
  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);
5445
+ const alreadyLoaded = cached.detailStatus === "ready" && cached.detail !== null && cached.detail !== undefined
5446
+ && cached.detail.commit !== undefined && cached.detail.commit.sha === sha;
5447
+ upsertTab({
5448
+ id,
5449
+ kind: "commit",
5450
+ path: "",
5451
+ title,
5452
+ // The subject is the useful second line; the hash is already the
5453
+ // title, so repeating it would say nothing.
5454
+ dir: typeof commit.subject === "string" ? commit.subject : "",
5455
+ meta: null,
5456
+ commitView: "files",
5457
+ commit: "",
5458
+ changePath: "",
5459
+ origin: null,
5460
+ lang: "auto",
5461
+ }, { activate: true });
5462
+ if (!alreadyLoaded) git.openCommit(sha);
4448
5463
  };
4449
5464
 
4450
5465
  /**
4451
- * Open one file from a commit in the ordinary preview.
5466
+ * Open one file from a commit in its own tab.
4452
5467
  *
4453
5468
  * The row's path is already root-relative (the host rebased it), so it
4454
5469
  * goes straight through the same `meta` request the tree uses; files
@@ -4477,53 +5492,58 @@ window.__ModuleLoader__.load({
4477
5492
  ? file.oldPath
4478
5493
  : file.path;
4479
5494
  const readSha = fromParent ? parentSha : sha;
4480
- setShowCommit(false);
4481
5495
  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
5496
  const known = detail && Array.isArray(detail.files)
4490
5497
  ? detail.files.find((row) => row.path === file.path)
4491
5498
  : 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.
5499
+ const id = "file:" + blobPath + "\u0000" + readSha;
5500
+ upsertTab({
5501
+ id,
5502
+ kind: "file",
4496
5503
  path: blobPath,
4497
- // A rename is read from its old path but highlighted as the row
4498
- // the reader actually clicked.
4499
5504
  rowPath: file.path,
4500
- name: baseNameOf(blobPath),
4501
- displayPath: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
4502
- kind: component,
5505
+ title: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
5506
+ dir: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
5507
+ meta: {
5508
+ root: root,
5509
+ // The blob path is what the host reads; the display path keeps the
5510
+ // row the reader clicked.
5511
+ path: blobPath,
5512
+ // A rename is read from its old path but highlighted as the row
5513
+ // the reader actually clicked.
5514
+ rowPath: file.path,
5515
+ name: baseNameOf(blobPath),
5516
+ displayPath: baseNameOf(blobPath) + " @ " + readSha.slice(0, 7),
5517
+ kind: component,
5518
+ commit: readSha,
5519
+ // Opening a changed file must offer both halves of the question:
5520
+ // what this commit changed in it, and what the file looks like.
5521
+ // `diffSha` is the commit being viewed (the blob may come from
5522
+ // its parent for a deletion), and `oldPath` is what lets the host
5523
+ // keep rename detection intact when filtering the patch.
5524
+ diffSha: sha,
5525
+ oldPath: typeof file.oldPath === "string" && file.oldPath !== "" ? file.oldPath : null,
5526
+ // The commit records whether the file was added, modified or
5527
+ // deleted; the preview says so instead of looking like a normal
5528
+ // working-tree file.
5529
+ change: known ? known.change : "",
5530
+ changeLetter: known ? known.letter : "",
5531
+ deleted: known ? known.letter === "D" : false,
5532
+ },
4503
5533
  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
- });
5534
+ // Back must return to the changed-file list this row came from,
5535
+ // not to the file tree, which is somewhere else entirely.
5536
+ origin: { kind: "commit", sha: sha },
5537
+ lang: "auto",
5538
+ }, { activate: true });
4518
5539
  setError(null);
4519
5540
  return;
4520
5541
  }
4521
- setShowCommit(false);
4522
5542
  selectFile({ path: file.path, name: baseNameOf(file.path), type: "file" });
4523
5543
  };
4524
5544
 
4525
5545
  /**
4526
- * Step back one place from the main pane.
5546
+ * Step back one place from the active tab.
4527
5547
  *
4528
5548
  * Three destinations, in the order the reader arrived from: a commit's
4529
5549
  * changed-file list, the working-tree change list, or the file tree.
@@ -4531,89 +5551,133 @@ window.__ModuleLoader__.load({
4531
5551
  * a stale hash or blob path cannot leak into the next preview.
4532
5552
  */
4533
5553
  const goBack = () => {
4534
- if (previewOrigin === "commit") {
4535
- // Re-open the changed-file view of the commit still in `detail`;
5554
+ const current = activeTab;
5555
+ const origin = current === null || current === undefined ? null : current.origin;
5556
+ if (origin !== null && origin !== undefined && origin.kind === "commit") {
5557
+ // Re-open the changed-file view of the commit this file came from;
4536
5558
  // its hash is what the sidebar is highlighting.
4537
- setShowCommit(true);
4538
- setCommitView("files");
4539
- setPreviewCommit("");
4540
- setPreviewOrigin(null);
4541
5559
  setPane("content");
5560
+ patchTab(current.id, (tab) => ({ ...tab, commitView: "files", commit: "", origin: null }));
5561
+ openCommit({ sha: origin.sha });
4542
5562
  return;
4543
5563
  }
4544
- if (previewOrigin === "changes") {
4545
- setShowCommit(true);
4546
- setCommitView("changes");
4547
- setPreviewCommit("");
4548
- setPreviewOrigin(null);
5564
+ if (origin !== null && origin !== undefined && origin.kind === "changes") {
4549
5565
  setPane("content");
5566
+ patchTab(current.id, (tab) => ({ ...tab, commitView: "changes", commit: "", origin: null }));
4550
5567
  return;
4551
5568
  }
4552
5569
  // Nothing to undo inside the pane: hand control back to the sidebar.
4553
5570
  // On a phone that actually swaps panes; on desktop the tree is
4554
5571
  // already alongside, so the selection is cleared instead of the
4555
5572
  // button doing nothing at all.
4556
- setPreviewCommit("");
4557
- setPreviewOrigin(null);
4558
- setMeta(null);
5573
+ closeTab(activeTabId);
4559
5574
  setError(null);
4560
5575
  setPane("tree");
4561
5576
  };
4562
5577
 
4563
5578
  /**
4564
- * Open one working-tree change as a diff.
5579
+ * Open one working-tree change as a diff, in its own tab.
4565
5580
  *
4566
5581
  * The main pane shows the patch rather than the file: a deletion has no
4567
5582
  * file to open, and "what changed" is the question the row answers.
4568
5583
  */
4569
5584
  const openWorkspaceChange = (row) => {
4570
5585
  if (row === null || row === undefined || typeof row.path !== "string" || row.path === "") return;
4571
- setShowCommit(true);
4572
5586
  setPane("content");
4573
- setCommitView("changes");
4574
- setPreviewCommit("");
4575
- setPreviewOrigin(null);
4576
5587
  // The pane diffs exactly this path; ignoring the row left it reading
4577
5588
  // whatever file happened to be selected before.
4578
- setPreviewChangePath(row.path);
5589
+ upsertTab({
5590
+ id: "change:" + row.path,
5591
+ kind: "change",
5592
+ path: row.path,
5593
+ title: row.path,
5594
+ dir: row.change || "工作区",
5595
+ meta: null,
5596
+ commitView: "changes",
5597
+ changePath: row.path,
5598
+ lang: "auto",
5599
+ }, { activate: true });
4579
5600
  };
4580
5601
 
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");
5602
+ /**
5603
+ * Open one working-tree file as a tab, reading its metadata.
5604
+ *
5605
+ * Split out from `selectFile` because the drawer's arrival path (a
5606
+ * conversation link that names a file) has to open a tab against the
5607
+ * root it just resolved, before that root has reached `root` state.
5608
+ *
5609
+ * @param {string} rootPath - root the path belongs to.
5610
+ * @param {string} path - root-relative file path.
5611
+ * @param {{activate?: boolean, name?: string}} [options]
5612
+ */
5613
+ const openFileTab = (rootPath, path, options) => {
5614
+ if (typeof path !== "string" || path === "") return;
5615
+ const chosenRoot = typeof rootPath === "string" && rootPath !== "" ? rootPath : root;
5616
+ const name = options && typeof options.name === "string" ? options.name : baseNameOf(path);
5617
+ const id = "file:" + path;
5618
+ const existing = findTab(id);
5619
+ // A tab already open for this path is re-activated rather than
5620
+ // re-read: its contents are on screen already, and the refresh item
5621
+ // in its context menu is how the reader asks for a re-read.
5622
+ if (existing !== null) {
5623
+ activateTab(id);
5624
+ return;
5625
+ }
4585
5626
  // Selecting from the file tree is always the working tree, so any
4586
5627
  // commit a preview was read from is dropped here — and there is no
4587
5628
  // 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);
5629
+ upsertTab({
5630
+ id,
5631
+ kind: "file",
5632
+ path,
5633
+ title: name,
5634
+ dir: directoryOf(path),
5635
+ meta: null,
5636
+ // A brand-new tab has nothing to show yet; `status` is what the
5637
+ // pane reads to tell "loading" from "the file is empty".
5638
+ status: "loading",
5639
+ commit: "",
5640
+ commitView: "files",
5641
+ changePath: "",
5642
+ origin: null,
5643
+ // The remembered choice for this file takes effect immediately, so
5644
+ // the read that follows is already resolved with it.
5645
+ lang: readLangOverride(chosenRoot, path),
5646
+ }, { activate: options === undefined || options.activate !== false });
5647
+ // Clicking two files quickly must not let the slower answer win: only
5648
+ // the newest request for THIS tab may write its state. Tabs are read
5649
+ // in parallel now, so the ticket is kept per id.
5650
+ const tickets = metaTickets.current;
5651
+ const ticket = (tickets.get(id) || 0) + 1;
5652
+ tickets.set(id, ticket);
5653
+ request("meta", { root: chosenRoot, path }).then((value) => {
5654
+ if (tickets.get(id) !== ticket) return;
5655
+ patchTab(id, (tab) => ({ ...tab, meta: value, status: "ready", error: null }));
4599
5656
  setError(null);
4600
5657
  }).catch((problem) => {
4601
- if (metaTicket.current !== ticket) return;
4602
- setMeta(null);
5658
+ if (tickets.get(id) !== ticket) return;
5659
+ patchTab(id, (tab) => ({ ...tab, meta: null, status: "error", error: problem }));
4603
5660
  setError(problem);
4604
5661
  });
4605
5662
  };
4606
5663
 
5664
+ const selectFile = (entry) => {
5665
+ if (entry === null || entry === undefined || typeof entry.path !== "string") return;
5666
+ setPane("content");
5667
+ openFileTab(root, entry.path, { name: entry.name });
5668
+ };
5669
+
4607
5670
  /**
4608
- * Pin, or unpin, the language for the selected file.
5671
+ * Pin, or unpin, the language for the active tab's file.
4609
5672
  *
4610
5673
  * The choice is remembered per file and sent with every read, so the
4611
5674
  * host stays the only thing that decides what grammar applies.
4612
5675
  */
4613
5676
  const chooseLang = (next) => {
4614
- if (meta === null) return;
4615
- setLangChoice(next);
4616
- writeLangOverride(root, meta.path, next);
5677
+ const current = activeTab;
5678
+ if (current === null || current.meta === null || current.meta === undefined) return;
5679
+ patchTab(current.id, (tab) => ({ ...tab, lang: next }));
5680
+ writeLangOverride(root, current.meta.path, next);
4617
5681
  };
4618
5682
 
4619
5683
  const activeRoot = roots.find((row) => row.id === root);
@@ -4635,8 +5699,10 @@ window.__ModuleLoader__.load({
4635
5699
  "aria-selected": side === "files" ? "true" : "false",
4636
5700
  title: "浏览项目文件",
4637
5701
  onClick: () => {
5702
+ // Switching the sidebar back to the tree does not touch the
5703
+ // content area: the open tabs stay open, which is the whole point
5704
+ // of having them.
4638
5705
  setSide("files");
4639
- setShowCommit(false);
4640
5706
  },
4641
5707
  }, "文件"),
4642
5708
  react.createElement("button", {
@@ -4651,16 +5717,27 @@ window.__ModuleLoader__.load({
4651
5717
  },
4652
5718
  }, react.createElement(SvgIconBranch, { size: 13 }), "Git"));
4653
5719
 
4654
- const commitSha = gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
4655
- ? gitState.detail.commit.sha
5720
+ /**
5721
+ * The hash the pane is describing.
5722
+ *
5723
+ * A commit tab names its own sha, which is what the header and the diff
5724
+ * read: the Git controller holds one detail at a time, so trusting it
5725
+ * alone would label this tab with whichever commit was opened last.
5726
+ */
5727
+ const activeCommitSha = activeTab !== null && activeTab.kind === "commit"
5728
+ ? activeTab.id.slice("commit:".length)
4656
5729
  : "";
5730
+ const commitSha = activeCommitSha !== ""
5731
+ ? activeCommitSha
5732
+ : (gitState.detail !== null && gitState.detail !== undefined && gitState.detail.commit !== undefined
5733
+ ? gitState.detail.commit.sha
5734
+ : "");
4657
5735
  // The main pane is showing something commit-shaped whenever it is not
4658
5736
  // the ordinary file preview: a commit, or one working-tree change.
4659
- const detailPane = showCommit;
4660
5737
  const commitHeadline = commitView === "changes"
4661
5738
  ? "工作区改动"
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))
5739
+ : (showCommit && activeTab !== null && activeTab.kind === "commit"
5740
+ ? "提交 " + (commitSha === "" ? "" : commitSha.slice(0, 7))
4664
5741
  : "");
4665
5742
 
4666
5743
  const handleDownloadItem = (entry) => {
@@ -4846,14 +5923,26 @@ window.__ModuleLoader__.load({
4846
5923
  onDoubleClick: onResizerDoubleClick,
4847
5924
  }),
4848
5925
  react.createElement("div", { className: "fv-main" },
5926
+ // The tab strip sits above the head so the head keeps naming
5927
+ // the active tab alone; the two together read as "these are
5928
+ // open, this one is on screen".
5929
+ react.createElement(TabStrip, {
5930
+ tabs,
5931
+ activeId: activeTabId,
5932
+ onActivate: activateTab,
5933
+ onClose: closeTab,
5934
+ onContextMenu: (menu) => setTabMenu(menu),
5935
+ menuFor: tabMenu === null || tabMenu === undefined ? "" : tabMenu.tabId,
5936
+ }),
4849
5937
  react.createElement("div", { className: "fv-main-head" },
4850
5938
  (() => {
4851
5939
  // The control returns one step, so it names that step and
4852
5940
  // is disabled when there is nowhere to go — a button that
4853
5941
  // silently does nothing reads as broken.
4854
- const backTarget = previewOrigin === "commit"
5942
+ const originKind = previewOrigin === null || previewOrigin === undefined ? null : previewOrigin.kind;
5943
+ const backTarget = originKind === "commit"
4855
5944
  ? "返回变更文件列表"
4856
- : (previewOrigin === "changes" ? "返回工作区改动列表" : "返回文件列表");
5945
+ : (originKind === "changes" ? "返回工作区改动列表" : "返回文件列表");
4857
5946
  // On a phone the panes are exclusive, so leaving the content
4858
5947
  // pane is always meaningful; on desktop the sidebar is
4859
5948
  // already visible and only an origin is worth returning to.
@@ -4872,7 +5961,9 @@ window.__ModuleLoader__.load({
4872
5961
  react.createElement("span", { className: "fv-main-name" },
4873
5962
  commitHeadline !== ""
4874
5963
  ? commitHeadline
4875
- : (meta === null ? "未选择文件" : meta.name)),
5964
+ : (activeTab === null
5965
+ ? "未选择文件"
5966
+ : (meta === null ? activeTab.title : meta.name))),
4876
5967
  meta !== null && !showCommit && typeof meta.size === "number"
4877
5968
  ? react.createElement("span", null, formatBytes(meta.size))
4878
5969
  : null,
@@ -4881,14 +5972,14 @@ window.__ModuleLoader__.load({
4881
5972
  // document has no grammar to choose.
4882
5973
  meta !== null && !showCommit && !meta.tooLarge && (meta.kind === "text" || meta.kind === "markdown" || meta.kind === "json")
4883
5974
  ? react.createElement(LanguagePicker, {
4884
- choice: langChoice,
5975
+ choice: localeTagOfActive,
4885
5976
  catalog: languageCatalog,
4886
5977
  source: meta.langSource,
4887
5978
  resolved: meta.lang,
4888
5979
  onChange: chooseLang,
4889
5980
  })
4890
5981
  : null),
4891
- error !== null && meta === null && !showCommit
5982
+ error !== null && meta === null && !showCommit && activeTab === null
4892
5983
  ? react.createElement("div", { className: "fv-note fv-error" },
4893
5984
  react.createElement("span", null, messageOf(error)),
4894
5985
  error?.code === "outside-root"
@@ -4903,43 +5994,81 @@ window.__ModuleLoader__.load({
4903
5994
  },
4904
5995
  }, "配置安全访问路径")
4905
5996
  : 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
- })))),
5997
+ : activeTab !== null && activeTab.status === "error" && activeTab.error !== null && activeTab.error !== undefined
5998
+ ? react.createElement("div", { className: "fv-note fv-error" },
5999
+ react.createElement("span", null, messageOf(activeTab.error)),
6000
+ activeTab.error?.code === "outside-root"
6001
+ ? react.createElement("button", {
6002
+ type: "button",
6003
+ className: "fv-button",
6004
+ style: { marginTop: "6px" },
6005
+ onClick: () => {
6006
+ const reqPath = activeTab.error?.details?.realPath || activeTab.path || "";
6007
+ setPrefillPath(reqPath ? parentOf(reqPath) : "");
6008
+ setSafeModalOpen(true);
6009
+ },
6010
+ }, "配置安全访问路径")
6011
+ : null)
6012
+ : showCommit
6013
+ ? react.createElement(ErrorBoundary, {
6014
+ // The epoch is part of the key so "刷新当前" remounts the
6015
+ // pane instead of showing the same cached answer.
6016
+ resetKey: "commit\u0000" + commitSha + "\u0000" + (activeTab === null ? 0 : (activeTab.epoch || 0)),
6017
+ }, commitView === "changes"
6018
+ ? react.createElement(WorkspaceChangeDetail, {
6019
+ key: (activeTab === null ? "" : activeTab.id) + "\u0000" + (activeTab === null ? 0 : (activeTab.epoch || 0)),
6020
+ state: gitState,
6021
+ path: previewChangePath,
6022
+ request,
6023
+ repo: gitState.repoId,
6024
+ })
6025
+ : react.createElement(CommitDetail, {
6026
+ state: gitState,
6027
+ // A rename is read from its old path but highlighted as
6028
+ // the row that was clicked, so the two stay in step.
6029
+ selectedPath: meta === null ? "" : (meta.rowPath || meta.path),
6030
+ onSelectFile: openCommitFile,
6031
+ view: commitView,
6032
+ onSwitchView: (next) => {
6033
+ if (activeTab !== null) patchTab(activeTab.id, (tab) => ({ ...tab, commitView: next }));
6034
+ },
6035
+ onLoadDiff: () => {
6036
+ const sha = commitSha;
6037
+ if (sha !== "") git.loadCommitDiff(sha);
6038
+ },
6039
+ }))
6040
+ : react.createElement(ErrorBoundary, {
6041
+ // One viewer per tab (and per refresh epoch), so no
6042
+ // paging or blob state survives a tab switch.
6043
+ resetKey: activeTab === null
6044
+ ? ""
6045
+ : activeTab.id + "\u0000" + (activeTab.epoch || 0),
6046
+ }, activeTab === null
6047
+ ? react.createElement(FileView, {
6048
+ meta: null,
6049
+ root,
6050
+ api: props.api,
6051
+ wrap,
6052
+ lang: "auto",
6053
+ onDownload: handleDownloadItem,
6054
+ commit: "",
6055
+ repo: gitState.repoId,
6056
+ })
6057
+ : react.createElement(FileView, {
6058
+ meta,
6059
+ root,
6060
+ api: props.api,
6061
+ wrap,
6062
+ lang: localeTagOfActive,
6063
+ onDownload: handleDownloadItem,
6064
+ // Empty for a working-tree file; a hash for one opened
6065
+ // from history, which makes the read a blob lookup.
6066
+ commit: previewCommit,
6067
+ repo: gitState.repoId,
6068
+ // A tab is created before its metadata arrives, so the
6069
+ // pane has to say "reading" instead of "pick a file".
6070
+ loading: activeTab.status === "loading",
6071
+ })))),
4943
6072
  contextMenu !== null
4944
6073
  ? react.createElement(ContextMenu, {
4945
6074
  menu: contextMenu,
@@ -4950,6 +6079,17 @@ window.__ModuleLoader__.load({
4950
6079
  onDownload: handleDownloadItem,
4951
6080
  })
4952
6081
  : null,
6082
+ // The tab menu is a separate component with a separate subject; it
6083
+ // is rendered last so it stacks above the tree's menu if both were
6084
+ // somehow open, which cannot happen but must not paint twice.
6085
+ tabMenu !== null && tabMenu !== undefined
6086
+ ? react.createElement(TabContextMenu, {
6087
+ menu: tabMenu,
6088
+ tabs,
6089
+ onClose: () => setTabMenu(null),
6090
+ onAction: onTabAction,
6091
+ })
6092
+ : null,
4953
6093
  safeModalOpen
4954
6094
  ? react.createElement(SafePathsModal, {
4955
6095
  open: safeModalOpen,
@@ -5269,6 +6409,10 @@ window.__ModuleLoader__.load({
5269
6409
  // Git panel: controller, views and the small formatters they share.
5270
6410
  GIT_PAGE_SIZE, createGitController, GitSidebar, CommitDetail, WorkspaceChangeDetail, DiffView, kindOfPath,
5271
6411
  formatCommitDate, formatCommitTimestamp,
6412
+ // Content-area tabs: the strip, its four-item menu and the path helper
6413
+ // that gives a tab its secondary directory line.
6414
+ directoryOf, TabStrip, TabContextMenu, parseHeadings, MarkdownOutline, SvgIconOutline,
6415
+ TAB_MENU_ITEMS, tabsAfterClose, tabsAfterUpsert,
5272
6416
  };
5273
6417
  return module.exports;
5274
6418
  }