@weniatall/dsh-file-viewer 0.3.18 → 0.3.21
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 +50 -2
- package/lib/client.js +215 -5
- package/lib/core.js +54 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,19 @@ dsh plugin --profile web add <release-tgz-url>
|
|
|
59
59
|
- **关闭抽屉再打开是「隐藏」而不是「重来」**:已打开的标签、当前选中的标签、左侧停在文件还是 Git 面板、树里展开的目录,都原样保留;重新打开时还会把树里正在显示的目录重读一遍,所以内容是新的、形状是你上次留下的。`Esc`、点遮罩、手机返回手势都只是隐藏。
|
|
60
60
|
- **只有真正换了项目才会重置**:切到另一个工作区(根目录)时,标签、展开集、内容区一起清空——标签里记的路径只对打开它的那个根目录有意义,留着会指到同名的另一个文件。
|
|
61
61
|
|
|
62
|
+
### 标签页与文件树联动
|
|
63
|
+
|
|
64
|
+
左侧目录树会**始终高亮右侧当前标签打开的那个文件**,两半永远描述同一个文件:
|
|
65
|
+
|
|
66
|
+
- **高亮跟随标签,不跟随点击**:换一个标签、用 `←` / `→` 在标签栏里移动、关掉当前标签落到邻居,左侧的高亮都立刻跟过去。它不是"记住最后点过的那一行",所以从 Git 面板、会话里的文件链接、`@` 引用等任何入口打开的标签都会同步。
|
|
67
|
+
- **打开即高亮,不等文件读完**:高亮取自标签自己记录的路径,而不是文件读取完成后才有的元数据。刚点开、内容还在加载时那一行就已经亮了,不会出现"标签开了、树没反应"的空窗。
|
|
68
|
+
- **自动展开到目标文件**:标签指向的文件可能藏在收起的目录里,甚至那个目录还从没被列过。树会自动展开沿途每一级目录(并请求缺失的列表),让这一行真正出现;它只**加**展开项,不会关掉你已经打开的目录。
|
|
69
|
+
- **自动滚动到可见位置**:目标行不在可视区时,树会把它滚到中间;已经在可视区内就不动,避免和手动滚动打架。遵循系统的"减少动态效果"设置(此时直接跳转、不做平滑动画)。
|
|
70
|
+
- **只高亮工作区里的真实文件**:Git 提交标签、工作区改动的 diff 标签、以及从提交历史打开的某个版本(其路径在磁盘上可能已不存在)都不在目录树里,因此左侧不标记任何行,不会假装那是一个正在查看的工作区文件。
|
|
71
|
+
- **没有打开的标签就没有高亮**:关掉最后一个标签后,左侧不再留一行亮着——高亮被当作"这个文件正开着"的陈述,不能比事实多停留。
|
|
72
|
+
|
|
73
|
+
高亮本身由三部分构成,保证在浅色与深色主题下都读得出来:整行底色、左侧 2px 品牌色竖条、行内文字加粗。键盘聚焦时的焦点框与"当前文件"分开,鼠标点完不会留下和选中态抢眼的方框。
|
|
74
|
+
|
|
62
75
|
标签上右键(键盘为 `Shift+F10` 或菜单键)打开四项菜单:
|
|
63
76
|
|
|
64
77
|
| 菜单项 | 作用 |
|
|
@@ -86,15 +99,50 @@ dsh plugin --profile web add <release-tgz-url>
|
|
|
86
99
|
|
|
87
100
|
两个宽度分别持久化(`dsh-file-viewer.shell-width` 与 `dsh-file-viewer.tree-width`),所以调宽面板不会改变侧栏比例。
|
|
88
101
|
|
|
102
|
+
### 调整阅读字号
|
|
103
|
+
|
|
104
|
+
内容区工具栏右侧有一个字号步进器(`A-` / `A+`,中间显示当前值),用于把正在读的内容放大或缩小。范围 11–24px,步长 1px,取值记住在本地(`dsh-file-viewer.font-size`)。
|
|
105
|
+
|
|
106
|
+
**只对渲染文本的视图出现**:纯文本/代码、Markdown、JSON。图片、PDF、Excel、Word、二进制文件没有这个按钮——它们各自有自己的缩放,或者本来就不是可调排版的正文。
|
|
107
|
+
|
|
108
|
+
三处文本视图**都会跟随**这个设置:
|
|
109
|
+
|
|
110
|
+
| 视图 | 字号生效方式 |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| 源码 / 代码(宿主 `ReadBlock`) | 重声明代码卡的 `font:` 简写 |
|
|
113
|
+
| Markdown 预览 | 重声明预览根节点的 `font:` 简写,标题按 1.35em、代码块按 0.95em 等比跟随 |
|
|
114
|
+
| JSON 预览 | 重声明 JSON 树根节点的 `font:` 简写 |
|
|
115
|
+
|
|
116
|
+
宿主的这几处根节点都用 `font:` 简写把字号写死,而简写会一次性重置字号、字重与行高——外层容器上的 `font-size` **继承不进去**。这正是 v0.3.19 之前"预览里点字号按钮毫无反应"的原因:规则都在、看着都对,但被简写压掉了。现在每个根节点各自重新声明字号,并连带声明行高,否则放大后行会互相重叠。
|
|
117
|
+
|
|
118
|
+
这类"规则存在但赢不了层叠"的问题无法靠断言规则文本来防住,因此 `test/cascade.test.mjs` 用真实 Chrome 计算最终字号:把宿主样式表放在插件样式表**之前**(对插件最不利的顺序),再读取 `getComputedStyle` 的实际结果。没有 Chrome 的环境会跳过这三条而不是假装通过。
|
|
119
|
+
|
|
89
120
|
左边缘手柄也支持键盘:聚焦后 `←` / `→` 每次 ±16px(按住 Shift 为 ±64px),`Home` 到最小宽度,`End` 到窗口宽度。
|
|
90
121
|
|
|
91
122
|
手机端(宽度 ≤ 960px)与全屏模式下抽屉已经铺满可视区域,左边缘手柄自动隐藏。
|
|
92
123
|
|
|
93
124
|
手机端抽屉占满全屏,文件列表与内容单列切换(左上角 `‹` 返回列表)。
|
|
94
125
|
|
|
95
|
-
|
|
126
|
+
### 桌面版标题栏避让
|
|
127
|
+
|
|
128
|
+
两个桌面平台的窗口都有系统绘制的按钮区,且永远盖在页面之上,所以抽屉顶部必须从它们下方开始。两端用**不同的宿主标记**,不要用 UA 或窗口尺寸去猜平台:
|
|
129
|
+
|
|
130
|
+
| 平台 | 宿主标记 | 避让高度 | 行为 |
|
|
131
|
+
| --- | --- | --- | --- |
|
|
132
|
+
| Windows 桌面版 | `html[data-windows-titlebar]` | `--dsh-windows-titlebar-height`(默认 40px) | 抽屉从右上角最小化/最大化/关闭按钮下方开始 |
|
|
133
|
+
| macOS 桌面版 | `html[data-platform="darwin"]` | `--dsh-frame-overlay-top` → 回落 `--dsh-frame-top-clearance`(48px) | 抽屉从左上角交通灯按钮下方开始 |
|
|
134
|
+
| 全屏(任一平台) | `html[data-fullscreen]` | 偏移撤销 | 条带/交通灯隐藏,抽屉贴到顶边,不留死区 |
|
|
135
|
+
| Web 浏览器 | 无上述标记 | 无偏移 | 没有系统按钮要让位 |
|
|
136
|
+
|
|
137
|
+
Windows 端读宿主变量而不是写死 40px:125% / 150% 缩放下条带会变成 50 / 60px,写死就会错位(已按 40 / 50 / 60 / 80 四档验证)。
|
|
138
|
+
|
|
139
|
+
macOS 端按规范**优先消费宿主算好的 `--dsh-frame-overlay-top`**——宿主已经把平台与全屏状态折进这个值,插件再自己拼一套判断只会多出一个需要同步的口径。变量缺失时回落到原始的 `--dsh-frame-top-clearance`,两者都没有则不加任何偏移(凭空造一条偏移会变成点不到的死区)。
|
|
140
|
+
|
|
141
|
+
两条偏移都带 `!important`:抽屉为退出宿主"居中卡片化"而声明的 `inset:0!important` 会展开出同样重要的 `top:0`,普通声明赢不过它——这正是该问题在 v0.3.6 之后复发的原因。
|
|
142
|
+
|
|
143
|
+
**验证方式**:`test/package.test.mjs` 锁住规则文本、`!important` 与选择器特异性,并把"新出现的、会设置抽屉垂直几何的 `!important` 规则"列为必须人工复核的白名单;`test/cascade.test.mjs` 进一步在真实 Chrome 里测量抽屉头部的最终坐标——四种平台/全屏组合逐一断言,`elementFromPoint` 确认工具栏按钮在最上层可点击。规范见仓库根目录 `AGENTS.md` 的「桌面版兼容」一节。
|
|
96
144
|
|
|
97
|
-
|
|
145
|
+
手机端不需要这条偏移:抽屉在手机上占满全屏,而 `dsh-mobile-adapter` 会把含 scrim 的 overlay 层提到 `z-index:1100`(高于它自己 900 的顶栏),所以抽屉工具栏不会被顶栏压住。这两点由插件测试之外的因素保证,改动移动端层级时需一并复查。
|
|
98
146
|
|
|
99
147
|
## Git 面板
|
|
100
148
|
|
package/lib/client.js
CHANGED
|
@@ -459,6 +459,21 @@ window.__ModuleLoader__.load({
|
|
|
459
459
|
rule it has to beat, so the outcome is decided by the important flag and
|
|
460
460
|
the attribute count rather than by sheet order. */
|
|
461
461
|
html[data-windows-titlebar]:not([data-fullscreen]) .fv-scrim[role="dialog"]{top:var(--dsh-windows-titlebar-height,40px)!important}
|
|
462
|
+
/* macOS 桌面版是另一套几何:没有那排 caption 按钮,但窗口左上角有交通灯
|
|
463
|
+
(关闭/最小化/缩放),宿主用 html[data-platform="darwin"] 标记,并把需要
|
|
464
|
+
让位的高度发布为 --dsh-frame-top-clearance(48px)。宿主另外提供一个算好
|
|
465
|
+
的 --dsh-frame-overlay-top(按平台与全屏状态取 max(默认, clearance+20)),
|
|
466
|
+
规范要求浮层优先消费它,而不是自己拼一套平台判断。
|
|
467
|
+
|
|
468
|
+
变量缺失时回落到 clearance,再回落到 0:var() 的第二个参数可以再套一层
|
|
469
|
+
var(),因此这条规则在只有 clearance、两者都缺失、以及变量都齐全的三种
|
|
470
|
+
环境下都给出正确结果,不需要额外的媒体查询或 JS 判断。
|
|
471
|
+
|
|
472
|
+
同样带 !important,理由与上面那条 Windows 规则完全一致:抽屉自己的
|
|
473
|
+
.fv-scrim[role="dialog"]{inset:0!important} 展开出的 top 也是重要的,
|
|
474
|
+
普通声明赢不过它。全屏时宿主会撤销条带(clearance 归 0、overlay-top
|
|
475
|
+
归默认值),:not([data-fullscreen]) 直接把偏移撤掉,不留死区。 */
|
|
476
|
+
html[data-platform="darwin"]:not([data-fullscreen]) .fv-scrim[role="dialog"]{top:var(--dsh-frame-overlay-top,var(--dsh-frame-top-clearance,0px))!important}
|
|
462
477
|
@keyframes fv-fade{from{opacity:0}to{opacity:1}}
|
|
463
478
|
@keyframes fv-slide{from{transform:translateX(100%)}to{transform:translateX(0)}}
|
|
464
479
|
@media(prefers-reduced-motion:reduce){.fv-scrim,.fv-shell{animation:none}}
|
|
@@ -499,7 +514,16 @@ window.__ModuleLoader__.load({
|
|
|
499
514
|
.fv-mention:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);opacity:1}
|
|
500
515
|
@media(hover:none),(pointer:coarse){.fv-mention{opacity:1}}
|
|
501
516
|
.fv-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
502
|
-
|
|
517
|
+
/* 选中高亮挂在行座(.fv-row-seat)上,因为一行由「行按钮 + @ 引用按钮」
|
|
518
|
+
两段组成,只有整行一起变色才读得懂"这个文件正开着"。曾经的选择器写的是
|
|
519
|
+
.fv-row[aria-current="true"],而属性在父节点上,规则从未命中——右侧标签页
|
|
520
|
+
换了,左侧却没有任何一行跟着亮。两种写法都保留,祖先/自身任一命中即可。 */
|
|
521
|
+
.fv-row-seat[aria-current="true"]>.fv-row,.fv-row[aria-current="true"]{background:var(--dsw-alias-interactive-bg-hover-solid,var(--dsw-alias-interactive-bg-hover))}
|
|
522
|
+
.fv-row-seat[aria-current="true"]>.fv-row{box-shadow:inset 2px 0 0 0 var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary);font-weight:600}
|
|
523
|
+
.fv-row-seat[aria-current="true"]>.fv-row .fv-row-name{color:var(--dsw-alias-label-primary)}
|
|
524
|
+
/* 键盘聚焦与"当前文件"是两件事:焦点框只在键盘操作时出现,避免鼠标点完
|
|
525
|
+
还留一个和选中态抢眼的方框。 */
|
|
526
|
+
.fv-row-seat[aria-current="true"]>.fv-row:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4d6bfe);outline-offset:-2px}
|
|
503
527
|
.fv-row-name{flex:1 1 auto;min-width:0;line-height:1.35;word-break:break-all;overflow-wrap:anywhere;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
504
528
|
.fv-row-size{flex:none;color:var(--dsw-alias-label-caption);font-size:11px;font-variant-numeric:tabular-nums}
|
|
505
529
|
.fv-glyph{flex:none;width:16px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:11px;transition:transform .12s}
|
|
@@ -611,6 +635,24 @@ window.__ModuleLoader__.load({
|
|
|
611
635
|
.fv-content div[class*="_card_"],.fv-content div[class*="_body_"]{font-size:var(--fv-font-size,13px);line-height:1.65}
|
|
612
636
|
.fv-content div[class*="_line_"]{font-size:var(--fv-font-size,13px);line-height:1.65}
|
|
613
637
|
.fv-content div[class*="_line_"] span[class*="_content_"]{font-size:inherit;line-height:inherit}
|
|
638
|
+
/* 预览(Markdown / JSON)的根节点各自用 font 简写把字号写死:
|
|
639
|
+
Markdown 根是 var(--dsw-font-markdown-base),JSON 根是
|
|
640
|
+
12px/16px var(--ds-font-family-code)。简写会一次性重置字号、
|
|
641
|
+
字重与行高,因此单靠外层 .fv-content 的 font-size 根本传不进去——
|
|
642
|
+
这就是「预览里点字号按钮没有任何反应」的原因。
|
|
643
|
+
两个根节点都必须显式重新声明字号,并连带行高,否则放大后行会重叠。
|
|
644
|
+
类名带构建哈希(_markdown_xxx / _root_xxx),只能按属性子串匹配,
|
|
645
|
+
这也是本插件匹配宿主类名的既有做法;选择器带 .fv-content 前缀与一个
|
|
646
|
+
属性,特异性高于宿主那条单类规则,与样式表顺序无关。 */
|
|
647
|
+
.fv-content div[class*="_markdown_"]{font-size:var(--fv-font-size,13px);line-height:1.65}
|
|
648
|
+
/* JSON 树根同时是横向滚动容器,行高沿用宿主的紧凑节奏,只放大字号。 */
|
|
649
|
+
.fv-content div[class*="_root_"]{font-size:var(--fv-font-size,12px)}
|
|
650
|
+
/* 预览里的标题自带 font 简写,按比例跟随正文字号,否则正文放大了而标题不动。 */
|
|
651
|
+
.fv-content div[class*="_markdown_"] :where(h1,h2,h3,h4,h5,h6){font-size:1.35em;line-height:1.35}
|
|
652
|
+
.fv-content div[class*="_markdown_"] :where(h1,h2,h3,h4,h5,h6) *{font-size:inherit}
|
|
653
|
+
/* 预览里的代码块与行内代码同样是简写,跟随字号但不跟着标题放大。 */
|
|
654
|
+
.fv-content div[class*="_markdown_"] :where(pre,code){font-size:0.95em}
|
|
655
|
+
.fv-content div[class*="_markdown_"] table{font-size:inherit}
|
|
614
656
|
.fv-content .fv-sheet-tabs{position:sticky;top:0;margin:-12px -12px 10px;padding:12px 12px 8px}
|
|
615
657
|
.fv-tab{height:28px;padding:0 10px;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:none;color:var(--dsw-alias-label-secondary);font-size:12px;cursor:pointer}
|
|
616
658
|
.fv-tab[aria-selected="true"]{border-color:transparent;background:var(--dsw-alias-brand-primary,#4d6bfe);color:var(--dsw-alias-label-primary-foreground,#fff)}
|
|
@@ -3458,6 +3500,69 @@ window.__ModuleLoader__.load({
|
|
|
3458
3500
|
return { tabs: current.map((tab) => (tab.id === draft.id ? { ...tab, ...draft } : tab)), activeId: draft.id };
|
|
3459
3501
|
}
|
|
3460
3502
|
|
|
3503
|
+
/**
|
|
3504
|
+
* The working-tree path the file tree should highlight, for a given tab set.
|
|
3505
|
+
*
|
|
3506
|
+
* This is the whole of the tab ↔ tree link: the highlight is a function of
|
|
3507
|
+
* which tab is on screen, not of which row was clicked last. Reading it off
|
|
3508
|
+
* the tab rather than off the tab's loaded metadata is what makes the
|
|
3509
|
+
* highlight appear immediately — a freshly opened tab knows its path before
|
|
3510
|
+
* the file read returns, and going through `meta` left the tree dark for
|
|
3511
|
+
* that window and again on every tab switch.
|
|
3512
|
+
*
|
|
3513
|
+
* Three kinds of tab describe something the tree cannot point at:
|
|
3514
|
+
* - a `commit` tab is a blob inside history, which no tree row shows;
|
|
3515
|
+
* - a `change` tab is a diff of a working-tree path, so the pane is showing
|
|
3516
|
+
* a patch rather than that file. Highlighting it would misreport what is
|
|
3517
|
+
* on screen;
|
|
3518
|
+
* - a `file` tab read from a commit (`commit` is a non-empty hash) is a
|
|
3519
|
+
* historical version, addressed by a path that may not even exist on disk
|
|
3520
|
+
* any more. The tree lists the working tree, so it stays unmarked.
|
|
3521
|
+
*
|
|
3522
|
+
* @param {null | Array<{id: string, kind: string, path?: string, commit?: string}>} tabs - the tab list.
|
|
3523
|
+
* @param {string} activeId - the tab on screen.
|
|
3524
|
+
* @returns {string} root-relative path to highlight, or '' for no highlight.
|
|
3525
|
+
*/
|
|
3526
|
+
function pathOfActiveTab(tabs, activeId) {
|
|
3527
|
+
if (!Array.isArray(tabs) || tabs.length === 0) return "";
|
|
3528
|
+
if (typeof activeId !== "string" || activeId === "") return "";
|
|
3529
|
+
const tab = tabs.find((row) => row !== null && row !== undefined && row.id === activeId);
|
|
3530
|
+
if (tab === undefined || tab.kind !== "file") return "";
|
|
3531
|
+
// A history preview is a different document that happens to share a path.
|
|
3532
|
+
if (typeof tab.commit === "string" && tab.commit !== "") return "";
|
|
3533
|
+
return typeof tab.path === "string" ? tab.path : "";
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
/**
|
|
3537
|
+
* How far the tree has to scroll to bring one row into view.
|
|
3538
|
+
*
|
|
3539
|
+
* Split out as a pure function because the interesting part — the geometry —
|
|
3540
|
+
* cannot be observed in a test environment that does no layout: jsdom
|
|
3541
|
+
* reports every rectangle as zero. With the decision here, the rule "reveal
|
|
3542
|
+
* a row that is off screen, leave one that is not, never scroll past the
|
|
3543
|
+
* start" is exercised directly instead of only being read in the source.
|
|
3544
|
+
*
|
|
3545
|
+
* @param {{rowTop: number, rowHeight: number, hostTop: number, hostHeight: number, scrollTop: number}} box
|
|
3546
|
+
* Rects relative to the viewport, as `getBoundingClientRect` reports them.
|
|
3547
|
+
* @returns {number | null} the scrollTop to set, or null when nothing is
|
|
3548
|
+
* needed — the row is already fully visible.
|
|
3549
|
+
*/
|
|
3550
|
+
function scrollTargetForRow({ rowTop, rowHeight, hostTop, hostHeight, scrollTop }) {
|
|
3551
|
+
const height = Number.isFinite(rowHeight) ? rowHeight : 0;
|
|
3552
|
+
const visibleTop = Number.isFinite(hostTop) ? hostTop : 0;
|
|
3553
|
+
const visibleHeight = Number.isFinite(hostHeight) ? hostHeight : 0;
|
|
3554
|
+
const fromTop = (Number.isFinite(rowTop) ? rowTop : 0) - visibleTop;
|
|
3555
|
+
const fromBottom = fromTop + height;
|
|
3556
|
+
// Already inside the viewport: scrolling would be motion with no
|
|
3557
|
+
// information in it, and it would fight a reader who scrolled on purpose.
|
|
3558
|
+
if (fromTop >= 0 && fromBottom <= visibleHeight) return null;
|
|
3559
|
+
const base = Number.isFinite(scrollTop) ? scrollTop : 0;
|
|
3560
|
+
// Centre the row, but never above the first row: a negative scrollTop is
|
|
3561
|
+
// clamped by the browser anyway, and doing it here keeps the value honest.
|
|
3562
|
+
const centred = base + fromTop - Math.max(0, (visibleHeight - height) / 2);
|
|
3563
|
+
return Math.max(0, Math.round(centred));
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3461
3566
|
/**
|
|
3462
3567
|
* The content area's tab strip.
|
|
3463
3568
|
*
|
|
@@ -3933,6 +4038,11 @@ window.__ModuleLoader__.load({
|
|
|
3933
4038
|
const longPressedRef = react.useRef(false);
|
|
3934
4039
|
const isModified = !isDirectory && Boolean(gitStatus?.modified?.has(entry.path));
|
|
3935
4040
|
const isUntracked = !isDirectory && Boolean(gitStatus?.untracked?.has(entry.path));
|
|
4041
|
+
// `selected` is the path the content area is showing, derived from the
|
|
4042
|
+
// active tab rather than from the last click. That is what keeps the
|
|
4043
|
+
// tree in step when the reader switches or closes a tab: the highlight
|
|
4044
|
+
// follows the tab, it does not remember the last row that was pressed.
|
|
4045
|
+
const isCurrent = !isDirectory && selected !== "" && entry.path === selected;
|
|
3936
4046
|
|
|
3937
4047
|
const clearLongPress = () => {
|
|
3938
4048
|
if (timerRef.current !== null) {
|
|
@@ -3988,7 +4098,12 @@ window.__ModuleLoader__.load({
|
|
|
3988
4098
|
role: "treeitem",
|
|
3989
4099
|
"aria-level": row.depth + 1,
|
|
3990
4100
|
...(isDirectory ? { "aria-expanded": row.expanded ? "true" : "false" } : {}),
|
|
3991
|
-
|
|
4101
|
+
// `aria-current` is the accessible name for "this is the file on
|
|
4102
|
+
// screen"; `data-active` is the same fact for the scroll-into-view
|
|
4103
|
+
// effect, which must not depend on a translated ARIA value.
|
|
4104
|
+
"aria-current": isCurrent ? "true" : undefined,
|
|
4105
|
+
"data-active": isCurrent ? "true" : undefined,
|
|
4106
|
+
"data-path": entry.path,
|
|
3992
4107
|
onContextMenu: handleContextMenu,
|
|
3993
4108
|
},
|
|
3994
4109
|
react.createElement("button", {
|
|
@@ -4035,7 +4150,7 @@ window.__ModuleLoader__.load({
|
|
|
4035
4150
|
}, react.createElement("span", { "aria-hidden": "true" }, "@")));
|
|
4036
4151
|
}
|
|
4037
4152
|
|
|
4038
|
-
function Tree({ rows, selected, onToggle, onSelect, onMention, onContextMenu, width, gitStatus, header }) {
|
|
4153
|
+
function Tree({ rows, selected, onToggle, onSelect, onMention, onContextMenu, width, gitStatus, header, treeRef }) {
|
|
4039
4154
|
const children = rows.map((row) => {
|
|
4040
4155
|
const indent = { "--fv-depth": row.depth, paddingLeft: 8 + row.depth * 14 + "px" };
|
|
4041
4156
|
if (row.kind === "status") {
|
|
@@ -4061,6 +4176,7 @@ window.__ModuleLoader__.load({
|
|
|
4061
4176
|
});
|
|
4062
4177
|
return react.createElement("div", {
|
|
4063
4178
|
className: "fv-tree", role: "tree", "aria-label": "文件树",
|
|
4179
|
+
ref: treeRef,
|
|
4064
4180
|
style: typeof width === "number" ? { width: width + "px" } : undefined,
|
|
4065
4181
|
}, header === undefined ? null : header, ...children);
|
|
4066
4182
|
}
|
|
@@ -5377,6 +5493,17 @@ window.__ModuleLoader__.load({
|
|
|
5377
5493
|
// is not mistaken for one made on another after a switch.
|
|
5378
5494
|
const gitState = useStore(git.store);
|
|
5379
5495
|
|
|
5496
|
+
// ------------------------------------------------------------------
|
|
5497
|
+
// Tabs drive the tree
|
|
5498
|
+
//
|
|
5499
|
+
// The highlight follows the tab on screen, so both halves of the drawer
|
|
5500
|
+
// always describe the same file. Derived here — above the early return,
|
|
5501
|
+
// hence stable in hook order — because the effects below need it too: a
|
|
5502
|
+
// file can only be highlighted if its row exists, which means every
|
|
5503
|
+
// directory on the way to it must be open.
|
|
5504
|
+
// ------------------------------------------------------------------
|
|
5505
|
+
const activeFilePath = pathOfActiveTab(tabs, activeTabId);
|
|
5506
|
+
|
|
5380
5507
|
react.useEffect(() => {
|
|
5381
5508
|
if (!open || !root) return undefined;
|
|
5382
5509
|
let live = true;
|
|
@@ -5696,6 +5823,85 @@ window.__ModuleLoader__.load({
|
|
|
5696
5823
|
for (const dirPath of listings) loadDirectory(dirPath);
|
|
5697
5824
|
}, [open, root, hidden, reveal]);
|
|
5698
5825
|
|
|
5826
|
+
// ------------------------------------------------------------------
|
|
5827
|
+
// Revealing the file the content area is showing
|
|
5828
|
+
//
|
|
5829
|
+
// A tab can name a file the tree cannot show yet: its directory is
|
|
5830
|
+
// collapsed, or has never been listed. Highlighting a row that does not
|
|
5831
|
+
// exist is invisible, so the tree opens the path to the file first and
|
|
5832
|
+
// only then scrolls it into view. This is additive — it never closes a
|
|
5833
|
+
// directory the reader opened, which is what makes it safe to run on
|
|
5834
|
+
// every tab switch.
|
|
5835
|
+
// ------------------------------------------------------------------
|
|
5836
|
+
const treeScrollRef = react.useRef(null);
|
|
5837
|
+
// Which path the tree was last scrolled to. Kept so the scroll is a
|
|
5838
|
+
// one-off gesture per file instead of something that re-runs whenever
|
|
5839
|
+
// an unrelated listing lands and yanks the reader back mid-scroll.
|
|
5840
|
+
const treeRevealedForRef = react.useRef("");
|
|
5841
|
+
|
|
5842
|
+
react.useEffect(() => {
|
|
5843
|
+
if (!open || root === "" || activeFilePath === "") return;
|
|
5844
|
+
const chain = ancestorsOf(activeFilePath);
|
|
5845
|
+
// Open only the directories that are actually closed. Returning the
|
|
5846
|
+
// same Set when nothing is missing keeps this from re-rendering (and
|
|
5847
|
+
// from re-running itself) on every pass.
|
|
5848
|
+
setExpanded((current) => {
|
|
5849
|
+
let next = current;
|
|
5850
|
+
for (const dirPath of chain) {
|
|
5851
|
+
if (next.has(dirPath)) continue;
|
|
5852
|
+
if (next === current) next = new Set(current);
|
|
5853
|
+
next.add(dirPath);
|
|
5854
|
+
}
|
|
5855
|
+
return next;
|
|
5856
|
+
});
|
|
5857
|
+
// A directory that has never been listed has no row to open into. The
|
|
5858
|
+
// root is left to the effect above, which owns the initial listing;
|
|
5859
|
+
// only the ancestors are fetched here, and only once (`undefined`),
|
|
5860
|
+
// never again after a failure — an erroring directory would otherwise
|
|
5861
|
+
// retry on every render.
|
|
5862
|
+
for (const dirPath of chain) {
|
|
5863
|
+
if (nodes.get(dirPath) !== undefined) continue;
|
|
5864
|
+
loadDirectory(dirPath);
|
|
5865
|
+
}
|
|
5866
|
+
}, [open, root, hidden, activeFilePath, expanded, nodes]);
|
|
5867
|
+
|
|
5868
|
+
react.useEffect(() => {
|
|
5869
|
+
if (!open) {
|
|
5870
|
+
// Reopening is a fresh look at the same tree, so reveal again.
|
|
5871
|
+
treeRevealedForRef.current = "";
|
|
5872
|
+
return;
|
|
5873
|
+
}
|
|
5874
|
+
if (activeFilePath === "" || treeRevealedForRef.current === activeFilePath) return;
|
|
5875
|
+
const host = treeScrollRef.current;
|
|
5876
|
+
if (host === null || host === undefined) return;
|
|
5877
|
+
const row = host.querySelector('[data-active="true"]');
|
|
5878
|
+
// Still waiting for an ancestor listing; the row will appear later and
|
|
5879
|
+
// this effect runs again once it does.
|
|
5880
|
+
if (row === null) return;
|
|
5881
|
+
treeRevealedForRef.current = activeFilePath;
|
|
5882
|
+
// Already on screen: scrolling would be motion with no information in
|
|
5883
|
+
// it, and it would fight a reader who has scrolled deliberately.
|
|
5884
|
+
const target = scrollTargetForRow({
|
|
5885
|
+
rowTop: row.getBoundingClientRect().top,
|
|
5886
|
+
rowHeight: row.getBoundingClientRect().height,
|
|
5887
|
+
hostTop: host.getBoundingClientRect().top,
|
|
5888
|
+
hostHeight: host.clientHeight,
|
|
5889
|
+
scrollTop: host.scrollTop,
|
|
5890
|
+
});
|
|
5891
|
+
if (target === null) return;
|
|
5892
|
+
const reduce = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
5893
|
+
// Drive the tree's own scroller rather than scrollIntoView, which
|
|
5894
|
+
// would also scroll the page and the content pane behind the drawer.
|
|
5895
|
+
// `scrollTo` is absent in non-visual environments (jsdom, some webview
|
|
5896
|
+
// shims); falling back to the assignment keeps the reveal working
|
|
5897
|
+
// without animation there instead of throwing inside an effect.
|
|
5898
|
+
if (typeof host.scrollTo === "function") {
|
|
5899
|
+
host.scrollTo({ top: target, behavior: reduce ? "auto" : "smooth" });
|
|
5900
|
+
} else {
|
|
5901
|
+
host.scrollTop = target;
|
|
5902
|
+
}
|
|
5903
|
+
}, [open, activeFilePath, expanded, nodes]);
|
|
5904
|
+
|
|
5699
5905
|
react.useEffect(() => {
|
|
5700
5906
|
if (!open) return undefined;
|
|
5701
5907
|
const onKeyDown = (event) => {
|
|
@@ -6456,7 +6662,10 @@ window.__ModuleLoader__.load({
|
|
|
6456
6662
|
})
|
|
6457
6663
|
: react.createElement(Tree, {
|
|
6458
6664
|
rows: treeRows,
|
|
6459
|
-
|
|
6665
|
+
// Derived from the active tab, so switching or closing a tab
|
|
6666
|
+
// moves the highlight with it instead of leaving the last
|
|
6667
|
+
// clicked row lit.
|
|
6668
|
+
selected: activeFilePath,
|
|
6460
6669
|
onToggle: toggleDirectory,
|
|
6461
6670
|
onSelect: selectFile,
|
|
6462
6671
|
// Always offered: the DOM fallback covers hosts that leave the
|
|
@@ -6467,6 +6676,7 @@ window.__ModuleLoader__.load({
|
|
|
6467
6676
|
width: isMobile ? undefined : treeWidth,
|
|
6468
6677
|
gitStatus,
|
|
6469
6678
|
header: sideTabs,
|
|
6679
|
+
treeRef: treeScrollRef,
|
|
6470
6680
|
}),
|
|
6471
6681
|
react.createElement("div", {
|
|
6472
6682
|
className: "fv-resizer",
|
|
@@ -6945,7 +7155,7 @@ window.__ModuleLoader__.load({
|
|
|
6945
7155
|
// Content-area tabs: the strip, its four-item menu and the path helper
|
|
6946
7156
|
// that gives a tab its secondary directory line.
|
|
6947
7157
|
directoryOf, TabStrip, TabContextMenu, parseHeadings, MarkdownOutline, SvgIconOutline,
|
|
6948
|
-
TAB_MENU_ITEMS, tabsAfterClose, tabsAfterUpsert,
|
|
7158
|
+
TAB_MENU_ITEMS, tabsAfterClose, tabsAfterUpsert, pathOfActiveTab, scrollTargetForRow,
|
|
6949
7159
|
};
|
|
6950
7160
|
return module.exports;
|
|
6951
7161
|
}
|
package/lib/core.js
CHANGED
|
@@ -756,6 +756,60 @@ export function ancestorsOf(path) {
|
|
|
756
756
|
return rows
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
/**
|
|
760
|
+
* The working-tree path a file tree should highlight, for a given tab set.
|
|
761
|
+
*
|
|
762
|
+
* The tab strip and the tree are two views of one fact — "this file is open" —
|
|
763
|
+
* so the highlight is derived from the active tab instead of from the last row
|
|
764
|
+
* that was clicked. Deriving it from the tab's own `path` (not from its loaded
|
|
765
|
+
* metadata) is what makes the row light up the instant the tab opens; a tab
|
|
766
|
+
* knows where it points before its file has been read.
|
|
767
|
+
*
|
|
768
|
+
* Only tabs that describe a working-tree file qualify. A `commit` tab names a
|
|
769
|
+
* blob inside history, which no tree row shows; a `change` tab names a path
|
|
770
|
+
* whose diff is on screen; and a `file` tab carrying a commit hash is a
|
|
771
|
+
* historical version whose path may not exist on disk any more. Highlighting
|
|
772
|
+
* any of them would claim the tree lists something it does not.
|
|
773
|
+
*
|
|
774
|
+
* @param {null | Array<{id: string, kind: string, path?: string, commit?: string}>} tabs - the tab list.
|
|
775
|
+
* @param {string} activeId - the tab on screen.
|
|
776
|
+
* @returns {string} root-relative path to highlight, or '' for no highlight.
|
|
777
|
+
*/
|
|
778
|
+
export function pathOfActiveTab(tabs, activeId) {
|
|
779
|
+
if (!Array.isArray(tabs) || tabs.length === 0) return ''
|
|
780
|
+
if (typeof activeId !== 'string' || activeId === '') return ''
|
|
781
|
+
const tab = tabs.find((row) => row !== null && row !== undefined && row.id === activeId)
|
|
782
|
+
if (tab === undefined || tab.kind !== 'file') return ''
|
|
783
|
+
if (typeof tab.commit === 'string' && tab.commit !== '') return ''
|
|
784
|
+
return typeof tab.path === 'string' ? tab.path : ''
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* How far a scrolling tree has to scroll to bring one row into view.
|
|
789
|
+
*
|
|
790
|
+
* Kept pure and host-side testable because the geometry cannot be observed in a
|
|
791
|
+
* test environment that performs no layout: jsdom reports every rectangle as
|
|
792
|
+
* zero. The three rules worth locking down are "reveal an off-screen row",
|
|
793
|
+
* "leave a visible one alone" (so a reader who scrolled deliberately is not
|
|
794
|
+
* yanked back) and "never produce a negative offset".
|
|
795
|
+
*
|
|
796
|
+
* @param {{rowTop: number, rowHeight: number, hostTop: number, hostHeight: number, scrollTop: number}} box
|
|
797
|
+
* Viewport-relative rectangles, exactly as `getBoundingClientRect` reports them.
|
|
798
|
+
* @returns {number | null} the scrollTop to apply, or null when the row is
|
|
799
|
+
* already fully visible and nothing should move.
|
|
800
|
+
*/
|
|
801
|
+
export function scrollTargetForRow({ rowTop, rowHeight, hostTop, hostHeight, scrollTop } = {}) {
|
|
802
|
+
const height = Number.isFinite(rowHeight) ? rowHeight : 0
|
|
803
|
+
const visibleTop = Number.isFinite(hostTop) ? hostTop : 0
|
|
804
|
+
const visibleHeight = Number.isFinite(hostHeight) ? hostHeight : 0
|
|
805
|
+
const fromTop = (Number.isFinite(rowTop) ? rowTop : 0) - visibleTop
|
|
806
|
+
const fromBottom = fromTop + height
|
|
807
|
+
if (fromTop >= 0 && fromBottom <= visibleHeight) return null
|
|
808
|
+
const base = Number.isFinite(scrollTop) ? scrollTop : 0
|
|
809
|
+
const centred = base + fromTop - Math.max(0, (visibleHeight - height) / 2)
|
|
810
|
+
return Math.max(0, Math.round(centred))
|
|
811
|
+
}
|
|
812
|
+
|
|
759
813
|
/**
|
|
760
814
|
* Flatten a lazily-loaded directory tree into render rows.
|
|
761
815
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weniatall/dsh-file-viewer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.21",
|
|
4
4
|
"description": "Workspace file viewer and Git panel for DeepSeek Harness: file tree, syntax-highlighted code, images, PDF, Excel and Word, plus local/remote branches, paged commit history, commit diffs, working-tree changes and changed-file navigation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|