@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.
- package/README.md +18 -0
- package/lib/client.js +1337 -193
- 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
|
-
|
|
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
|
-
|
|
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
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
:
|
|
3551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4044
|
-
//
|
|
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
|
-
*
|
|
4817
|
+
* The right-hand content area is a set of tabs.
|
|
4086
4818
|
*
|
|
4087
|
-
*
|
|
4088
|
-
*
|
|
4089
|
-
*
|
|
4090
|
-
*
|
|
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
|
|
4093
|
-
*
|
|
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 [
|
|
4096
|
-
//
|
|
4097
|
-
//
|
|
4098
|
-
|
|
4099
|
-
|
|
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
|
-
|
|
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:
|
|
4286
|
-
|
|
4287
|
-
|
|
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
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
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
|
-
|
|
4427
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4446
|
-
&& cached.detail.commit !== undefined && cached.detail.commit.sha ===
|
|
4447
|
-
|
|
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
|
|
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
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
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
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
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
|
-
//
|
|
4505
|
-
//
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
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
|
|
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
|
-
|
|
4535
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
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
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
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 (
|
|
4602
|
-
|
|
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
|
|
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
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
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
|
-
|
|
4655
|
-
|
|
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 &&
|
|
4663
|
-
? "提交 " + (
|
|
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
|
|
5942
|
+
const originKind = previewOrigin === null || previewOrigin === undefined ? null : previewOrigin.kind;
|
|
5943
|
+
const backTarget = originKind === "commit"
|
|
4855
5944
|
? "返回变更文件列表"
|
|
4856
|
-
: (
|
|
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
|
-
: (
|
|
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:
|
|
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
|
-
:
|
|
4907
|
-
? react.createElement(
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
4917
|
-
|
|
4918
|
-
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
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
|
}
|