dsh-plugin-core 0.20.4 → 0.20.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/design/tokens-css.generated.d.ts +1 -1
  2. package/dist/design/tokens-css.generated.d.ts.map +1 -1
  3. package/dist/design/tokens-css.generated.js +1 -1
  4. package/dist/design/tokens-css.generated.js.map +1 -1
  5. package/dist/explorer/FileBrowser.d.ts +13 -6
  6. package/dist/explorer/FileBrowser.d.ts.map +1 -1
  7. package/dist/explorer/FileBrowser.js +190 -305
  8. package/dist/explorer/FileBrowser.js.map +1 -1
  9. package/dist/explorer/crumb-nav.d.ts +45 -0
  10. package/dist/explorer/crumb-nav.d.ts.map +1 -0
  11. package/dist/explorer/crumb-nav.js +75 -0
  12. package/dist/explorer/crumb-nav.js.map +1 -0
  13. package/dist/explorer/crumbs-menu.d.ts +37 -0
  14. package/dist/explorer/crumbs-menu.d.ts.map +1 -0
  15. package/dist/explorer/crumbs-menu.js +102 -0
  16. package/dist/explorer/crumbs-menu.js.map +1 -0
  17. package/dist/explorer/explorer-css.generated.d.ts +1 -1
  18. package/dist/explorer/explorer-css.generated.d.ts.map +1 -1
  19. package/dist/explorer/explorer-css.generated.js +1 -1
  20. package/dist/explorer/explorer-css.generated.js.map +1 -1
  21. package/dist/explorer/fs-face.d.ts +36 -0
  22. package/dist/explorer/fs-face.d.ts.map +1 -0
  23. package/dist/explorer/fs-face.js +74 -0
  24. package/dist/explorer/fs-face.js.map +1 -0
  25. package/dist/explorer/index.d.ts +2 -1
  26. package/dist/explorer/index.d.ts.map +1 -1
  27. package/dist/explorer/index.js +2 -1
  28. package/dist/explorer/index.js.map +1 -1
  29. package/dist/explorer/landing.d.ts +26 -0
  30. package/dist/explorer/landing.d.ts.map +1 -0
  31. package/dist/explorer/landing.js +43 -0
  32. package/dist/explorer/landing.js.map +1 -0
  33. package/dist/explorer/open.d.ts +35 -38
  34. package/dist/explorer/open.d.ts.map +1 -1
  35. package/dist/explorer/open.js +124 -156
  36. package/dist/explorer/open.js.map +1 -1
  37. package/dist/explorer/preview/BytesPreview.d.ts +2 -3
  38. package/dist/explorer/preview/BytesPreview.d.ts.map +1 -1
  39. package/dist/explorer/preview/BytesPreview.js +3 -3
  40. package/dist/explorer/preview/BytesPreview.js.map +1 -1
  41. package/dist/explorer/preview/HtmlPreview.d.ts +2 -3
  42. package/dist/explorer/preview/HtmlPreview.d.ts.map +1 -1
  43. package/dist/explorer/preview/HtmlPreview.js +4 -5
  44. package/dist/explorer/preview/HtmlPreview.js.map +1 -1
  45. package/dist/explorer/preview/OfficePreview.d.ts +2 -1
  46. package/dist/explorer/preview/OfficePreview.d.ts.map +1 -1
  47. package/dist/explorer/preview/OfficePreview.js +9 -2
  48. package/dist/explorer/preview/OfficePreview.js.map +1 -1
  49. package/dist/explorer/preview/TextPreview.d.ts +2 -3
  50. package/dist/explorer/preview/TextPreview.d.ts.map +1 -1
  51. package/dist/explorer/preview/TextPreview.js +3 -3
  52. package/dist/explorer/preview/TextPreview.js.map +1 -1
  53. package/dist/explorer/preview/index.d.ts +6 -3
  54. package/dist/explorer/preview/index.d.ts.map +1 -1
  55. package/dist/explorer/preview/index.js +3 -6
  56. package/dist/explorer/preview/index.js.map +1 -1
  57. package/dist/explorer/preview-title-bar.d.ts +26 -0
  58. package/dist/explorer/preview-title-bar.d.ts.map +1 -0
  59. package/dist/explorer/preview-title-bar.js +48 -0
  60. package/dist/explorer/preview-title-bar.js.map +1 -0
  61. package/dist/explorer/types.d.ts +25 -23
  62. package/dist/explorer/types.d.ts.map +1 -1
  63. package/dist/explorer/types.js +61 -120
  64. package/dist/explorer/types.js.map +1 -1
  65. package/dist/host/fs-route.d.ts +25 -0
  66. package/dist/host/fs-route.d.ts.map +1 -0
  67. package/dist/host/fs-route.js +243 -0
  68. package/dist/host/fs-route.js.map +1 -0
  69. package/dist/host/index.d.ts +2 -0
  70. package/dist/host/index.d.ts.map +1 -1
  71. package/dist/host/index.js +1 -0
  72. package/dist/host/index.js.map +1 -1
  73. package/dist/session/session-css.generated.d.ts +1 -1
  74. package/dist/session/session-css.generated.d.ts.map +1 -1
  75. package/dist/session/session-css.generated.js +1 -1
  76. package/dist/session/session-css.generated.js.map +1 -1
  77. package/dist/ui/DateTimePicker.d.ts.map +1 -1
  78. package/dist/ui/DateTimePicker.js +5 -1
  79. package/dist/ui/DateTimePicker.js.map +1 -1
  80. package/dist/ui/Modal.d.ts.map +1 -1
  81. package/dist/ui/Modal.js +7 -2
  82. package/dist/ui/Modal.js.map +1 -1
  83. package/dist/ui/Picker.d.ts.map +1 -1
  84. package/dist/ui/Picker.js +6 -3
  85. package/dist/ui/Picker.js.map +1 -1
  86. package/dist/ui/controls/datetime-css.generated.d.ts +1 -1
  87. package/dist/ui/controls/datetime-css.generated.d.ts.map +1 -1
  88. package/dist/ui/controls/datetime-css.generated.js +1 -1
  89. package/dist/ui/controls/datetime-css.generated.js.map +1 -1
  90. package/dist/ui/controls/field-css.generated.d.ts +1 -1
  91. package/dist/ui/controls/field-css.generated.d.ts.map +1 -1
  92. package/dist/ui/controls/field-css.generated.js +1 -1
  93. package/dist/ui/controls/field-css.generated.js.map +1 -1
  94. package/dist/ui/controls/loading-css.generated.d.ts +1 -1
  95. package/dist/ui/controls/loading-css.generated.d.ts.map +1 -1
  96. package/dist/ui/controls/loading-css.generated.js +1 -1
  97. package/dist/ui/controls/loading-css.generated.js.map +1 -1
  98. package/dist/ui/feedback-css.generated.d.ts +1 -1
  99. package/dist/ui/feedback-css.generated.d.ts.map +1 -1
  100. package/dist/ui/feedback-css.generated.js +1 -1
  101. package/dist/ui/feedback-css.generated.js.map +1 -1
  102. package/dist/ui/marquee-css.generated.d.ts +1 -1
  103. package/dist/ui/marquee-css.generated.d.ts.map +1 -1
  104. package/dist/ui/marquee-css.generated.js +1 -1
  105. package/dist/ui/marquee-css.generated.js.map +1 -1
  106. package/dist/ui/overlay-css.generated.d.ts +1 -1
  107. package/dist/ui/overlay-css.generated.d.ts.map +1 -1
  108. package/dist/ui/overlay-css.generated.js +1 -1
  109. package/dist/ui/overlay-css.generated.js.map +1 -1
  110. package/dist/ui/overlay.d.ts +8 -2
  111. package/dist/ui/overlay.d.ts.map +1 -1
  112. package/dist/ui/overlay.js +7 -6
  113. package/dist/ui/overlay.js.map +1 -1
  114. package/dist/ui/panel-css.d.ts +2 -0
  115. package/dist/ui/panel-css.d.ts.map +1 -0
  116. package/dist/ui/panel-css.generated.d.ts +2 -0
  117. package/dist/ui/panel-css.generated.d.ts.map +1 -0
  118. package/dist/ui/panel-css.generated.js +4 -0
  119. package/dist/ui/panel-css.generated.js.map +1 -0
  120. package/dist/ui/panel-css.js +2 -0
  121. package/dist/ui/panel-css.js.map +1 -0
  122. package/dist/ui/panel.d.ts +41 -0
  123. package/dist/ui/panel.d.ts.map +1 -0
  124. package/dist/ui/panel.js +37 -0
  125. package/dist/ui/panel.js.map +1 -0
  126. package/dist/ui/picker-css.generated.d.ts +1 -1
  127. package/dist/ui/picker-css.generated.d.ts.map +1 -1
  128. package/dist/ui/picker-css.generated.js +1 -1
  129. package/dist/ui/picker-css.generated.js.map +1 -1
  130. package/dist/utils/i18n.d.ts +1 -1
  131. package/dist/utils/i18n.d.ts.map +1 -1
  132. package/dist/utils/i18n.js +8 -2
  133. package/dist/utils/i18n.js.map +1 -1
  134. package/docs/design/architecture.md +9 -4
  135. package/docs/design/external/dsh-host.md +60 -0
  136. package/docs/design/style.md +26 -1
  137. package/docs/help/explorer.doc.ts +35 -30
  138. package/docs/help/explorer.md +23 -19
  139. package/docs/help/integration.doc.ts +15 -10
  140. package/docs/help/integration.md +15 -10
  141. package/docs/requirements.md +2 -1
  142. package/package.json +1 -1
@@ -8,104 +8,37 @@
8
8
  // 两个消费场景共用本组件(用户 2026-10-20 拍板:同一套内容,两种展现形式):
9
9
  // · 浏览(dock):由 **核心 PanelDock** 承载(openFilePreview),拖宽与关闭都由它负责,
10
10
  // 本组件不再自绘拖拽条 / 侧边栏外壳;
11
- // · 选择(picker):`picker` + `onPick(path, isDir)`(path 为**工作区相对**路径),点文件即回调。
11
+ // · 选择(picker):`picker` + `onPick(picked)`(`path` 是**绝对路径**,另带工作区归属),点文件即回调。
12
12
  //
13
13
  // 控件纪律:能用核心控件的一律核心(ghost 面包屑 / IconButton / Segmented / MarqueeText);
14
14
  // 核心没有的那一项 = ▾ 选层浮层,按兄弟项目 dsh-task-dispatch-table 的 `.dsh-tdt-sv-crumbs-*`
15
15
  // 逐像素照抄(间距 / 圆角 / 最大高度 / 空态),不自创度量。
16
16
  import { createElement as h, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
17
- import { IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCopyOutlineRegular, IconFolderCloseRegular, IconFolderOpenOutlineRegular, IconRefreshOutlineRegular, writeClipboard, FileTypeIcon, } from '../ui/official.js';
17
+ import { IconChevronRightOutlineRegular, writeClipboard, FileTypeIcon, } from '../ui/official.js';
18
18
  import { Button, IconButton } from '../ui/Button.js';
19
- import { MarqueeText } from '../ui/MarqueeText.js';
20
- import { Segmented } from '../ui/Segmented.js';
21
19
  import { workspaceList } from '../ui/host-data.js';
22
20
  import { t } from '../utils/i18n.js';
23
21
  import { FilePreview } from './preview/index.js';
24
- import { absolutizeDir, bareCode, baseNameOfPath, crumbsOfDir, createRequestGate, createRootCache, dirnameOf, errView, findAnyFileAbs, isAborted, isAbsoluteish, isFailed, joinPath, learnRoot, listingOf, listPathOf, matchesFilter, relativizeToRoot, sortEntries, workspaceAnchorForPath, } from './types.js';
22
+ import { bareCode, baseNameOfPath, crumbsOfDir, createRequestGate, dirnameOf, errView, isAborted, isAbsoluteish, isFailed, joinPath, listingOf, matchesFilter, relativizeToDir, sortEntries, normalizePath, workspaceOfPath, } from './types.js';
25
23
  import { ensureComponentStyles } from '../ui/css.js';
24
+ import { PANEL_CSS } from '../ui/panel-css.js';
26
25
  import { EXPLORER_CSS } from './explorer-css.js';
26
+ import { crumbsMenuEntries } from './crumbs-menu.js';
27
+ import { CrumbNav, PlainNav } from './crumb-nav.js';
28
+ import { LandingBody } from './landing.js';
29
+ import { PreviewTitleBar } from './preview-title-bar.js';
27
30
  /**
28
- * ▾ 下拉条目构造(**picker 与 dock 共用同一段代码**,差别只在入参——源实现用户 2026-09-29
29
- * 点名提炼,严禁两处各写一份):
30
- * - 当前工作区排第一(打开文件夹图标 + 加粗),**其下路径紧跟**(每层一个右箭头位缩进),
31
- * 再列其他工作区(关合图标 + 灰)——「打开哪个就跟在哪个下面」;点当前工作区 = 回到其根目录。
32
- * - 当前工作区未知(rootName 空或不在清单)时路径段置顶、全部工作区按「其他」罗列;
33
- * - workspaces 不传 = 不显示工作区段(根无箭头、其下逐层缩进)。
34
- *
35
- * 条目是**自写浮层**(核心没有「带缩进的选择层菜单」这类控件 ⇒ 按兄弟项目
36
- * `dsh-task-dispatch-table` 的 `.dsh-tdt-sv-crumbs-menu*` 逐条照抄间距):
37
- * backdrop + 菜单本体 + `-item`(4px 8px / 最大宽 280 / 超出滚动 / 空态)。
31
+ * 「根」在 POSIX 上的落点 `/`。Windows 没有这一个根 —— 根层是盘符清单,由宿主 `op:'roots'` 给出
32
+ * (见下面 `roots` 状态),本常量只在 POSIX / 探不到盘符时用。
38
33
  */
39
- function crumbsMenuEntries(params) {
40
- const { workspaces, rootName, rootFallbackLabel, crumbs, onPickWorkspace, onLoadDir } = params;
41
- const out = [];
42
- // 内联样式照抄兄弟项目(wsEntry 的 gap 6px 覆盖类的 2px:图标与文字的间距以它为准)。
43
- const wsEntry = (ws, current) => h('button', {
44
- key: `ws:${ws}`,
45
- type: 'button',
46
- role: 'menuitem',
47
- className: 'dshpc-fb__menu-item',
48
- style: { paddingLeft: 8, display: 'flex', alignItems: 'center', gap: '6px' },
49
- title: ws,
50
- // 点当前工作区 = 回到该工作区根目录(浏览深处时唯一的「回根」入口,兄弟 2026-10-10);
51
- // 点其他工作区才走 onPickWorkspace(换会话)。
52
- onClick: () => {
53
- if (current)
54
- onLoadDir?.('');
55
- else
56
- onPickWorkspace?.(ws);
57
- },
58
- }, h('span', { className: 'dshpc-fb__menu-icon', style: { color: current ? 'var(--dshpc-fg)' : 'var(--dshpc-fg-2)' } }, h(current ? IconFolderOpenOutlineRegular : IconFolderCloseRegular, { size: 13 })), h('span', { className: 'dshpc-fb__menu-label', style: { fontWeight: current ? 600 : 400 } }, ws));
59
- const pathEntry = (crumb, depth, withArrow = true) => h('button', {
60
- key: `dir:${crumb.path}`,
61
- type: 'button',
62
- role: 'menuitem',
63
- className: 'dshpc-fb__menu-item',
64
- style: { paddingLeft: 8 },
65
- title: crumb.path,
66
- onClick: () => {
67
- onLoadDir?.(crumb.path);
68
- },
69
- }, withArrow
70
- ? h(Fragment, null, Array.from({ length: depth }, (_, s) => h('span', { key: `s${s}`, className: 'dshpc-fb__menu-chev-slot', 'aria-hidden': true })), h('span', { className: 'dshpc-fb__menu-chev' }, h(IconChevronRightOutlineRegular, { size: 11 })))
71
- : null, h('span', { className: 'dshpc-fb__menu-label' }, crumb.label));
72
- if (workspaces === undefined) {
73
- if (crumbs.length === 0)
74
- return [h('div', { key: 'empty', className: 'dshpc-fb__menu-empty' }, rootFallbackLabel)];
75
- return crumbs.map((crumb, index) => pathEntry(crumb, index === 0 ? 0 : index - 1, index !== 0));
76
- }
77
- // 路径段:根 crumb(path='')已由工作区段表达时剔除,深度从根下一层起算。
78
- const segments = rootName !== '' && crumbs.length > 0 && crumbs[0]?.path === '' ? crumbs.slice(1) : crumbs;
79
- if (rootName !== '' && workspaces.includes(rootName)) {
80
- // 当前工作区排第一,其下路径紧跟,然后是其他工作区(保持原相对顺序)。
81
- out.push(wsEntry(rootName, true));
82
- segments.forEach((crumb, i) => {
83
- out.push(pathEntry(crumb, i));
84
- });
85
- for (const ws of workspaces) {
86
- if (ws !== rootName)
87
- out.push(wsEntry(ws, false));
88
- }
89
- }
90
- else {
91
- segments.forEach((crumb, i) => {
92
- out.push(pathEntry(crumb, i));
93
- });
94
- for (const ws of workspaces)
95
- out.push(wsEntry(ws, false));
96
- }
97
- return out;
98
- }
34
+ const FS_ROOT = '/';
99
35
  /** 目录浏览器面板。详见 {@link FileBrowserProps}。 */
100
36
  export function FileBrowser(props) {
101
37
  ensureComponentStyles('explorer', EXPLORER_CSS);
38
+ // ▾ 选层用浮层 menu 档(panel.css):跟弹窗同一套底色/描边/圆角/阴影。
39
+ ensureComponentStyles('panel', PANEL_CSS);
102
40
  const tt = props.translate ?? t;
103
41
  const picker = props.picker === true;
104
- // 根缓存:调用方可注入(多实例/清理策略归调用方);不传则内部自建、随组件废弃。
105
- const cacheRef = useRef(null);
106
- if (cacheRef.current === null)
107
- cacheRef.current = props.rootCache ?? createRootCache();
108
- const rootCache = props.rootCache ?? cacheRef.current;
109
42
  // mode:加载/目录树/文件预览/列举错误。viewing 非空 ⇒ 在 dir 树内预览文件。
110
43
  const [mode, setMode] = useState('loading');
111
44
  const [dir, setDir] = useState('');
@@ -125,11 +58,15 @@ export function FileBrowser(props) {
125
58
  // 内联展开:openDirs = 已展开目录路径集合;childCache = 各展开目录拉到的子项。
126
59
  const [openDirs, setOpenDirs] = useState(new Set());
127
60
  const [childCache, setChildCache] = useState({});
128
- // 工作区根补学完成信号(缓存非响应式,学成后 bump 触发面包屑重算)。
129
- const [rootNonce, setRootNonce] = useState(0);
130
61
  // 当前目录在会话工作区之外(官方 list 限工作区内,越界即报 outside-workspace)。
131
62
  // 依据 = list 的**真实错误码**,不靠缓存猜(根学不出时会误判成"在区内")。
132
63
  const [outside, setOutside] = useState(false);
64
+ // picker 且路径/会话都没给 ⇒ 落地页:主窗体先列「根目录 + 各工作区」让用户自己选(用户 2026-11-03 定)。
65
+ const [landing, setLanding] = useState(props.picker === true && props.path === '' && props.sessionId === undefined);
66
+ const landingRef = useRef(landing);
67
+ useLayoutEffect(() => {
68
+ landingRef.current = landing;
69
+ }, [landing]);
133
70
  const revision = props.revision ?? 0;
134
71
  // 请求闸门(latest-wins):顶层列举 / 补学根 各一口;内联展开每个目录一口。
135
72
  // begin() 先 abort 上一次,回调用 token.current() 判新旧 ⇒ 连续切换只认最后一次,
@@ -138,10 +75,6 @@ export function FileBrowser(props) {
138
75
  if (listGateRef.current === null)
139
76
  listGateRef.current = createRequestGate();
140
77
  const listGate = listGateRef.current;
141
- const rootGateRef = useRef(null);
142
- if (rootGateRef.current === null)
143
- rootGateRef.current = createRequestGate();
144
- const rootGate = rootGateRef.current;
145
78
  const childAbortsRef = useRef(new Map());
146
79
  const abortChildren = useCallback(() => {
147
80
  for (const controller of childAbortsRef.current.values())
@@ -155,18 +88,56 @@ export function FileBrowser(props) {
155
88
  dirRef.current = dir;
156
89
  listingRef.current = listing;
157
90
  }, [dir, listing]);
158
- // 路径 → 工作区匹配:props.path 是绝对路径时,找出它所属的工作区(最长前缀),
159
- // list / 学根 / 相对化**全部**换用该工作区的会话与根——官方 list 按 sessionId 解析工作区根,
160
- // 只换 list 的 sessionId 不换学根的键,面包屑会露出工作区之外的层级(2026-10-22 真机:
161
- // 匹配生效但 rootCache 仍按 props.sessionId 学根 ⇒ 相对化失败 ⇒ 显示 `C.x > workspace > …`)。
162
- // 匹配不到 ⇒ 整体回落 props.sessionId(旧行为)。
91
+ // 路径 → 工作区匹配:props.path 是绝对路径时按最长前缀找出所属工作区,面包屑根名与
92
+ // 相对化就用它(路径本来就在 FS 面里,不需要再「反推工作区根」)。
93
+ // 匹配不到 ⇒ 回落 props.rootName(调用方给的显示名)。
163
94
  // 工作区清单来源(优先级从高到低):① props.workspaceAnchors(调用方显式给,可覆盖);
164
95
  // ② 宿主 workspaces 快照(connectHost 注入的 workspaceList,官方 WorkspaceView 镜像)。
165
- // 快照异步到达时会重算 + 重跑初始 effect(listSessionId 在其依赖里)⇒ 自愈,无感。
96
+ // 快照异步到达时会重算 + 重跑初始 effect(rootBase 在其依赖里)⇒ 自愈,无感。
166
97
  const hostWorkspaces = useSyncExternalStore(workspaceList.subscribe, workspaceList.getSnapshot);
167
98
  const anchors = props.workspaceAnchors ?? hostWorkspaces;
168
- const anchorMatch = useMemo(() => workspaceAnchorForPath(props.path, anchors), [props.path, anchors]);
169
- const listSessionId = anchorMatch?.sessionId ?? props.sessionId;
99
+ // 根层清单:问宿主要(`op:'roots'`)。POSIX 回 `['/']`;Windows 回盘符 —— **Windows 没有「根目录」
100
+ // 这一个东西,根就是盘符清单**(用户 2026-11-03)。拿不到(文件面没这能力)⇒ 按 POSIX 单根。
101
+ const [roots, setRoots] = useState([FS_ROOT]);
102
+ useEffect(() => {
103
+ const ask = props.files.roots;
104
+ if (ask === undefined)
105
+ return;
106
+ let alive = true;
107
+ void Promise.resolve()
108
+ .then(() => ask.call(props.files))
109
+ .then((list) => {
110
+ if (!alive || list.length === 0)
111
+ return;
112
+ // 内容相同就不换引用:调用方每次渲染都给新的 files 对象时,否则会「effect → setState → 渲染 → effect」空转。
113
+ setRoots((prev) => (prev.length === list.length && prev.every((item, index) => item === list[index]) ? prev : list));
114
+ })
115
+ .catch(() => { });
116
+ return () => { alive = false; };
117
+ }, [props.files]);
118
+ const anchorMatch = useMemo(() => workspaceOfPath(props.path, anchors), [props.path, anchors]);
119
+ /** 相对路径的基准目录(工作区根):匹配出的工作区 > 调用方给的 > 无(只认绝对路径)。 */
120
+ const sessionAnchor = props.sessionId === undefined
121
+ ? null
122
+ : anchors.find((item) => item.sessionIds?.includes(props.sessionId) === true) ?? null;
123
+ const rootBase = anchorMatch?.path ?? props.rootPath ?? sessionAnchor?.path ?? anchors.find((item) => item.path !== undefined)?.path;
124
+ /** 任意入参 → 绝对路径(FS 面只认绝对路径)。 */
125
+ const absOf = useCallback((p) => {
126
+ if (isAbsoluteish(p))
127
+ return normalizePath(p);
128
+ // '' = 「根」:有工作区基准就是该工作区根,没有就是文件系统根(`/`;Windows 上是当前盘根)。
129
+ if (rootBase === undefined || rootBase === '')
130
+ return p === '' ? FS_ROOT : p;
131
+ return p === '' ? normalizePath(rootBase) : normalizePath(`${rootBase}/${p.replace(/^\/+/, '')}`);
132
+ }, [rootBase]);
133
+ /** 面板回传(绝对路径 + 工作区归属)。 */
134
+ const toPicked = useCallback((p, isDir) => {
135
+ const abs = absOf(p);
136
+ const owner = workspaceOfPath(abs, anchors);
137
+ return owner === null
138
+ ? { path: abs, isDir }
139
+ : { path: abs, isDir, workspaceRel: relativizeToDir(abs, owner.path ?? null), workspace: owner.title };
140
+ }, [absOf, anchors]);
170
141
  /** 记一次 list 结果的"是否越界"(outside-workspace / not-found 均视为不可浏览)。 */
171
142
  const noteOutside = useCallback((parsed) => {
172
143
  if (parsed === null || parsed === undefined)
@@ -183,7 +154,8 @@ export function FileBrowser(props) {
183
154
  const fetchDir = useCallback((targetDir) => {
184
155
  const token = listGate.begin();
185
156
  abortChildren();
186
- setDir(targetDir);
157
+ const absTarget = absOf(targetDir);
158
+ setDir(absTarget);
187
159
  setViewing(null);
188
160
  setListErr(null);
189
161
  setMenuOpen(false);
@@ -191,8 +163,8 @@ export function FileBrowser(props) {
191
163
  // 切换顶层目录:清空内联展开态(展开树只属于当前这一层)。
192
164
  setOpenDirs(new Set());
193
165
  setChildCache({});
194
- props.workspaceFiles
195
- .list(listSessionId, listPathOf(targetDir), token.signal)
166
+ props.files
167
+ .list(absTarget, token.signal)
196
168
  .then((result) => {
197
169
  if (!token.current())
198
170
  return;
@@ -218,21 +190,26 @@ export function FileBrowser(props) {
218
190
  setListErr(errView(error));
219
191
  setMode('error');
220
192
  });
221
- }, [abortChildren, listGate, listSessionId, noteOutside, props.workspaceFiles]);
193
+ }, [abortChildren, absOf, listGate, noteOutside, props.files]);
222
194
  /** 进入某目录;点到当前目录本身 = 重列本层(刷新)。 */
223
195
  const loadDir = useCallback((targetDir) => {
224
- if (targetDir === dir) {
225
- fetchDir(targetDir);
226
- return;
227
- }
196
+ // 落地页上点 ▾ 选根目录 / 工作区:必须先退出落地页,否则主体还是落地页那张表,
197
+ // 点了跟没点一样(用户 2026-11-03 真机:"选里面的任何一个都没有任何效果")。
198
+ if (landingRef.current)
199
+ setLanding(false);
228
200
  fetchDir(targetDir);
229
- }, [dir, fetchDir]);
201
+ }, [fetchDir]);
230
202
  // 初次进入(path 可能是文件或目录):
231
203
  // list(path) 成功 ⇒ 目录树(dir 尽量取宿主绝对路径 ⇒ 面包屑从工作区根往下列);
232
204
  // 报错 ⇒ 文件预览态(dir = 父目录,面包屑保留可返回)。
233
205
  // ⚠️ 切勿改用服务端 list 返回的 path 当面包屑:那是 workspacePathOf 的工作区**相对**形式,
234
206
  // 会丢掉根以下的前导段,面包屑只剩最近一层(源实现 2026-09-29 踩过 ⇒ 已回退为入参路径)。
235
207
  useEffect(() => {
208
+ // 落地页(还没选根目录/工作区):什么都不列,等用户点。
209
+ // 用 ref 读、**不进依赖**:否则从落地页点进目录那一刻 landing 变 false 会重跑本 effect,
210
+ // 又把目录拉回 absOf('') = 第一个工作区(真机 2026-11-03)。
211
+ if (landingRef.current)
212
+ return;
236
213
  // 同目录换文件(侧栏连点 / 重开同一文件):只换 viewing——目录树、展开态、滚动位置保留,
237
214
  // 也不再额外触发一次 list。旧目录列举已由上一轮 effect 的清理函数 abort。
238
215
  // 判据用 listingRef(不是 props.path 的类型):只有「当前已列出的这一层里确实有这个文件」
@@ -241,7 +218,7 @@ export function FileBrowser(props) {
241
218
  const name = baseNameOfPath(props.path);
242
219
  const knownFile = listingRef.current?.some((entry) => entry.name === name && entry.type === 'file') === true;
243
220
  if (props.path !== '' && dirRef.current !== '' && parent === dirRef.current && knownFile) {
244
- setViewing(props.path);
221
+ setViewing(absOf(props.path));
245
222
  setMode('file');
246
223
  setSourceView(false);
247
224
  // 同路径再点一次也要重读内容(对齐旧版「每次打开都重读」;不重挂只换 key)。
@@ -251,7 +228,6 @@ export function FileBrowser(props) {
251
228
  return;
252
229
  }
253
230
  const token = listGate.begin();
254
- const rootToken = rootGate.begin();
255
231
  abortChildren();
256
232
  setMode('loading');
257
233
  setListErr(null);
@@ -261,94 +237,28 @@ export function FileBrowser(props) {
261
237
  setMenuOpen(false);
262
238
  setOpenDirs(new Set());
263
239
  setChildCache({});
264
- /** 补学工作区根(面包屑根名显示 + picker 相对化要用):列目录 → 有文件就 stat 反推,没有就走有界 BFS。 */
265
- const ensureRootLearned = (currentDir) => {
266
- if (rootCache.get(listSessionId) !== undefined)
267
- return;
268
- void (async () => {
269
- try {
270
- const parsed = listingOf(await props.workspaceFiles.list(listSessionId, listPathOf(currentDir), rootToken.signal));
271
- if (!rootToken.current() || parsed === null || isFailed(parsed))
272
- return;
273
- const stat = props.workspaceFiles.stat;
274
- if (stat === undefined)
275
- return;
276
- const rel = parsed.path;
277
- const file = parsed.entries.find((entry) => entry.type === 'file');
278
- if (file !== undefined) {
279
- const sub = rel === '' ? file.name : `${rel}/${file.name}`;
280
- const abs = (await stat(listSessionId, sub, rootToken.signal).catch(() => null));
281
- if (!rootToken.current())
282
- return;
283
- const absPath = typeof abs?.absolutePath === 'string' ? abs.absolutePath : null;
284
- if (absPath !== null && learnRoot(rootCache, listSessionId, sub, absPath) !== null) {
285
- setRootNonce((n) => n + 1);
286
- return;
287
- }
288
- }
289
- const found = await findAnyFileAbs(props.workspaceFiles, listSessionId, rel, rootToken.signal);
290
- if (!rootToken.current())
291
- return;
292
- if (found !== null && learnRoot(rootCache, listSessionId, found.relativePath, found.absolutePath) !== null) {
293
- setRootNonce((n) => n + 1);
294
- }
295
- }
296
- catch {
297
- /* 学不出就维持退化显示 */
298
- }
299
- })();
300
- };
301
- props.workspaceFiles
302
- .list(listSessionId, listPathOf(props.path), token.signal)
303
- .then(async (result) => {
240
+ const entry = absOf(props.path);
241
+ props.files
242
+ .list(entry, token.signal)
243
+ .then((result) => {
304
244
  if (!token.current())
305
245
  return;
306
246
  const parsed = listingOf(result);
307
247
  if (!isFailed(parsed) && parsed !== null) {
308
248
  setOutside(false);
309
- // 入口可能是工作区相对名 ⇒ 用目录里任一文件子项的 stat 反推绝对路径;
310
- // 解析不出(空目录/只有子目录/stat 缺失)就退回入参,不阻塞浏览。
311
- const absDir = await absolutizeDir(rootCache, props.workspaceFiles, listSessionId, props.path, parsed.path, parsed.entries, token.signal);
312
- if (!token.current())
313
- return;
314
- setDir(absDir);
249
+ setDir(entry);
315
250
  setListing(parsed.entries);
316
251
  setTruncated(parsed.truncated);
317
252
  setMode('dir');
318
- ensureRootLearned(absDir);
319
253
  return;
320
254
  }
321
255
  // 不是目录 ⇒ 当作文件预览,dir 取父目录,尽量把父树也列出来。
322
- let parentDir = dirnameOf(props.path);
323
- if (!isAbsoluteish(props.path)) {
324
- const stat = props.workspaceFiles.stat;
325
- if (stat !== undefined) {
326
- try {
327
- const res = await stat(listSessionId, props.path, token.signal);
328
- if (!token.current())
329
- return;
330
- const abs = typeof res.absolutePath === 'string'
331
- ? res.absolutePath
332
- : null;
333
- if (abs !== null) {
334
- parentDir = dirnameOf(abs);
335
- // 记住工作区根 ⇒ 之后同会话里相对名也能解析
336
- learnRoot(rootCache, listSessionId, props.path, abs);
337
- }
338
- }
339
- catch {
340
- /* 解析不出就用入参父目录 */
341
- }
342
- }
343
- }
344
- if (!token.current())
345
- return;
256
+ const parentDir = dirnameOf(entry);
346
257
  setDir(parentDir);
347
- setViewing(props.path);
258
+ setViewing(entry);
348
259
  setMode('file');
349
- ensureRootLearned(parentDir);
350
- props.workspaceFiles
351
- .list(listSessionId, listPathOf(parentDir), token.signal)
260
+ props.files
261
+ .list(parentDir, token.signal)
352
262
  .then((pres) => {
353
263
  if (!token.current())
354
264
  return;
@@ -367,21 +277,28 @@ export function FileBrowser(props) {
367
277
  if (!token.current() || isAborted(token.signal, error))
368
278
  return;
369
279
  // 传输层失败:仍尝试按文件预览(预览体内部会给出错误态)。
370
- const parentDir = dirnameOf(props.path);
371
- setDir(parentDir);
372
- setViewing(props.path);
280
+ setDir(dirnameOf(entry));
281
+ setViewing(entry);
373
282
  setMode('file');
374
- ensureRootLearned(parentDir);
375
283
  });
376
284
  return () => {
377
285
  listGate.abort();
378
- rootGate.abort();
379
286
  };
380
287
  // ⚠️ 这里**不** abort 内联展开的子请求:同目录换文件时它们仍然有效(树也保留着)。
381
288
  // 子请求只在「换顶层目录」(fetchDir)与卸载时取消。
382
- }, [abortChildren, listGate, listSessionId, noteOutside, props.workspaceFiles, props.path, revision, rootCache, rootGate]);
289
+ }, [abortChildren, absOf, listGate, noteOutside, props.files, props.path, revision]);
383
290
  // 卸载时把内联展开的在途请求一起取消(依赖为空 ⇒ 只有卸载才跑清理)。
384
291
  useEffect(() => () => abortChildren(), [abortChildren]);
292
+ /** ▾ 里点别的工作区:调用方没接管就直接列该工作区根(不需要会话)。 */
293
+ const switchWorkspace = useCallback((name) => {
294
+ if (props.onSelectWorkspace !== undefined) {
295
+ props.onSelectWorkspace(name);
296
+ return;
297
+ }
298
+ const target = anchors.find((item) => item.title === name);
299
+ if (target?.path !== undefined)
300
+ loadDir(target.path);
301
+ }, [anchors, loadDir, props.onSelectWorkspace]);
385
302
  // 面包屑溢出测量:隐藏测量条渲染与可见态完全相同的 crumb 按钮(含 padding/max-width),
386
303
  // 使溢出判定与真实排版一致——不超长时必定还原完整路径。ResizeObserver 同时覆盖
387
304
  // dock 宽度变化 / 初次布局。
@@ -400,7 +317,7 @@ export function FileBrowser(props) {
400
317
  // 依赖要带上「根反推完成」「越界态变化」:这两样会让面包屑从超长绝对路径相对化成
401
318
  // 短路径,但若不重算,crumbsOverflow 会卡在首帧的 true(只显示末段),直到下次导航才复原
402
319
  // (2026-10-28 真机:打开工作区内的绝对路径,面包屑只剩末段,点一下目录才恢复)。
403
- }, [dir, viewing, mode, listing, rootNonce, outside]);
320
+ }, [dir, viewing, mode, listing, outside]);
404
321
  const reload = useCallback(() => {
405
322
  if (viewing !== null) {
406
323
  setReloadNonce((n) => n + 1);
@@ -413,7 +330,7 @@ export function FileBrowser(props) {
413
330
  const target = viewing ?? dir;
414
331
  if (target === '')
415
332
  return;
416
- const rel = relativizeToRoot(rootCache, listSessionId, target);
333
+ const rel = relativizeToDir(target, rootBase ?? null);
417
334
  writeClipboard(rel)
418
335
  .then((ok) => {
419
336
  if (ok) {
@@ -424,7 +341,7 @@ export function FileBrowser(props) {
424
341
  .catch(() => {
425
342
  /* 剪贴板不可用(非安全上下文等):静默,不弹错 */
426
343
  });
427
- }, [viewing, dir, rootCache, listSessionId]);
344
+ }, [viewing, dir, rootBase]);
428
345
  /** 内联展开/收起某目录(点 ▸):只切展开态,不导航;首次展开才拉子项。 */
429
346
  const toggleDir = useCallback((path) => {
430
347
  setOpenDirs((prev) => {
@@ -440,8 +357,8 @@ export function FileBrowser(props) {
440
357
  const controller = new AbortController();
441
358
  childAbortsRef.current.set(path, controller);
442
359
  setChildCache((prev) => ({ ...prev, [path]: { status: 'loading' } }));
443
- props.workspaceFiles
444
- .list(listSessionId, listPathOf(path), controller.signal)
360
+ props.files
361
+ .list(absOf(path), controller.signal)
445
362
  .then((result) => {
446
363
  if (controller.signal.aborted)
447
364
  return;
@@ -463,7 +380,7 @@ export function FileBrowser(props) {
463
380
  childAbortsRef.current.delete(path);
464
381
  });
465
382
  }
466
- }, [childCache, props.workspaceFiles, listSessionId]);
383
+ }, [absOf, childCache, props.files]);
467
384
  // 过滤点名了扩展名 ⇒ 要的是**文件**:目录只作导航(点行就地展开),不给「选择」、也回传不了 isDir。
468
385
  // (旧实现「目录永远保留且永远带选择钮」,于是「仅 JS/MD」下出现「文件夹旁边有选择、
469
386
  // MD 文件旁边反而没有」,选中还回传一个文件夹——与过滤语义直接打架。)
@@ -480,8 +397,8 @@ export function FileBrowser(props) {
480
397
  if (!isDir) {
481
398
  const pick = () => {
482
399
  if (picker && props.onPick !== undefined) {
483
- // 回传**工作区相对**路径(源实现真机 bug:绝对路径被宿主 zod 拒)。
484
- props.onPick(relativizeToRoot(rootCache, listSessionId, childPath), false);
400
+ // 回传绝对路径 + 工作区归属(用户 2026-11-03 定 `PickedPath`)。
401
+ props.onPick(toPicked(childPath, false));
485
402
  return;
486
403
  }
487
404
  // 逐层展开后选中文件:面包屑要跟随到该文件所在目录。
@@ -532,7 +449,7 @@ export function FileBrowser(props) {
532
449
  if (picker) {
533
450
  // 扩展名过滤下这里不该被触发(目录行根本不渲染这枚钮);兜住是为了语义不被绕过。
534
451
  if (!extsOnly && props.onPick !== undefined)
535
- props.onPick(relativizeToRoot(rootCache, listSessionId, childPath), true);
452
+ props.onPick(toPicked(childPath, true));
536
453
  return;
537
454
  }
538
455
  loadDir(childPath);
@@ -588,132 +505,100 @@ export function FileBrowser(props) {
588
505
  ? h('div', { className: 'dshpc-fb__row-err' }, tt(cached.error.key, cached.error.params))
589
506
  : h(Fragment, null, renderTree(cached.entries, childPath), cached.truncated ? h('div', { className: 'dshpc-fb__row-note' }, tt('explorerTruncated')) : null))
590
507
  : null);
591
- }), [childCache, openDirs, picker, dirPickable, extsOnly, props.filter, props.onPick, listSessionId, rootCache, dir, loadDir, tt]);
592
- // 面包屑 = 工作区根 + 其下相对层级。根名优先级:**匹配出的工作区名**(当前路径所属,
593
- // 不写死)> 调用方 rootName > 缓存根末段。学根/相对化一律按 listSessionId——
594
- // 用调用方原始 sessionId 学根会学出别的根 ⇒ 相对化失败 ⇒ 露出工作区外层级(真机 2026-10-22)。
595
- const rootAbs = rootCache.get(listSessionId);
596
- const rootLabel = anchorMatch?.title ?? props.rootName ?? (rootAbs !== undefined ? baseNameOfPath(rootAbs.replace(/\/+$/, '')) : '');
597
- const relDir = relativizeToRoot(rootCache, listSessionId, dir);
598
- // 根还没反推出来时 dir 仍是整条绝对路径:逐段铺开会露出「root/.local/share/…」十几段工作区之外的
599
- // 层级(点不动、也没信息量),根一学成又整条缩回 ⇒ 中间闪一下。这里先只给「根名 + 当前层」,
600
- // 根学成后由 rootNonce 触发重算,自动补全成完整层级(2026-10-28 真机:绝对路径打开时的闪段)。
601
- const rootPending = rootAbs === undefined && dir !== '' && isAbsoluteish(dir);
602
- const crumbs = [
603
- ...(rootLabel !== '' ? [{ label: rootLabel, path: '' }] : []),
604
- ...(rootPending ? [{ label: baseNameOfPath(dir), path: dir }] : crumbsOfDir(relDir)),
605
- ];
508
+ }), [childCache, openDirs, picker, dirPickable, extsOnly, props.filter, props.onPick, toPicked, dir, loadDir, tt]);
509
+ // 根层条目:有盘符(Windows)就一条一档;否则单条「根目录」(POSIX)。
510
+ // 两者都是**可选目标**(用户 2026-11-03:Linux 的 `/` 也可以选;Windows 至少能选盘)。
511
+ const drives = roots.filter((root) => root !== FS_ROOT);
512
+ const rootRows = drives.length > 0
513
+ ? drives.map((path) => ({ label: path.replace(/\/+$/, ''), path }))
514
+ : [{ label: tt('explorerRootDir'), path: FS_ROOT }];
515
+ // 面包屑(用户 2026-11-03 定):**按当前目录**去比所有工作区。
516
+ // 命中 ⇒ 第一段是工作区名,其下按相对层级;没命中 ⇒ 第一段是根(「根目录」或盘符),其下逐层。
517
+ // 判据只看 `dir`(不比 props.path):浏览途中切进别的工作区也要立刻跟着切根。
518
+ const owner = workspaceOfPath(dir, anchors);
519
+ const rootLabel = owner?.title ?? '';
520
+ const crumbs = owner === null
521
+ ? (/^[a-zA-Z]:/.test(normalizePath(dir))
522
+ // 盘符路径:根段由 crumbsOfDir 自己出(`C:`),再补一段「根目录」就重复了。
523
+ ? crumbsOfDir(dir)
524
+ : [{ label: rootRows[0]?.label ?? tt('explorerRootDir'), path: FS_ROOT }, ...crumbsOfDir(dir)])
525
+ : [{ label: rootLabel, path: '' }, ...crumbsOfDir(relativizeToDir(dir, owner.path ?? null))];
606
526
  // md / html 都出「渲染 ⇄ 源码」切换(默认渲染)。
607
527
  const viewingIsMd = viewing !== null && /\.(md|markdown)$/i.test(viewing);
608
528
  const viewingIsHtml = viewing !== null && /\.(html|htm)$/i.test(viewing);
609
529
  const switchable = viewingIsMd || viewingIsHtml;
530
+ // 工作区清单:调用方没传就用宿主快照里的(picker 与 dock 同一条逻辑 —— 用户 2026-11-03:
531
+ // 两处显示必须一致,不能 dock 有清单、picker 只有一个根目录)。
610
532
  const menuItems = crumbsMenuEntries({
611
- workspaces: props.workspaces,
533
+ workspaces: props.workspaces ?? anchors.map((item) => item.title),
534
+ roots: rootRows,
612
535
  rootName: rootLabel,
613
536
  rootFallbackLabel: tt('explorerRootName'),
614
537
  crumbs,
615
- onPickWorkspace: props.onSelectWorkspace,
538
+ onPickWorkspace: switchWorkspace,
616
539
  onLoadDir: loadDir,
617
540
  });
618
- /** 一段面包屑(核心 ghost 钮:无底无边;度量条与可见态必须逐字同构)。 */
619
- const crumbButton = (label, onClick, current = false) => h(Button, {
620
- variant: 'ghost',
621
- size: 'sm',
622
- className: `dshpc-fb__crumb${current ? ' dshpc-fb__crumb--current' : ''}`,
623
- disabled: onClick === undefined,
624
- tabIndex: onClick === undefined ? -1 : 0,
625
- onClick,
626
- }, h(MarqueeText, { text: label, className: 'dshpc-fb__crumb-mq' }));
627
- const crumbBar = h('nav', { className: 'dshpc-fb__bar', 'aria-label': tt('explorerCrumbsAria') }, h('div', { className: 'dshpc-fb__menu-wrap' }, h(IconButton, {
628
- variant: 'plain',
629
- size: 'md',
630
- icon: h(IconChevronDownOutlineRegular, { size: 14 }),
631
- label: tt('explorerLevels'),
632
- 'aria-expanded': menuOpen,
633
- onClick: () => setMenuOpen((v) => !v),
634
- }),
635
- // 选层下拉 = 自写浮层(抄兄弟 .dsh-tdt-sv-crumbs-*):透明遮罩点一下即收,
636
- // 遮罩层序高于 picker 面板(1100)⇒ 菜单开着时点面板也算「点外部」。
637
- menuOpen
638
- ? h(Fragment, null, h('div', {
639
- className: 'dshpc-fb__crumbs-backdrop',
640
- onClick: () => setMenuOpen(false),
641
- }), h('div', { className: 'dshpc-fb__crumbs-menu', role: 'menu' }, menuItems))
642
- : null), h('div', { ref: regionRef, className: 'dshpc-fb__crumbs' },
643
- // 隐藏测量条:与可见态完全同构的 crumb 按钮,确保溢出判定精确。
644
- h('span', { ref: measureRef, className: 'dshpc-fb__crumbs-measure', 'aria-hidden': true }, crumbs.length === 0
645
- ? crumbButton(tt('explorerRootName'))
646
- : crumbs.map((crumb, index) => h(Fragment, { key: crumb.path }, index > 0 ? h(IconChevronRightOutlineRegular, { className: 'dshpc-fb__crumb-sep', size: 12 }) : null, crumbButton(crumb.label)))),
647
- // 工作区根目录(crumbs 空)也要有可见的面包屑占位。
648
- crumbsOverflow
649
- ? crumbButton(crumbs.length > 0 ? crumbs[crumbs.length - 1]?.label ?? tt('explorerRootName') : tt('explorerRootName'), undefined, true)
650
- : crumbs.length === 0
651
- ? crumbButton(tt('explorerRootName'), undefined, true)
652
- : crumbs.map((crumb, index) => h(Fragment, { key: crumb.path }, index > 0 ? h(IconChevronRightOutlineRegular, { className: 'dshpc-fb__crumb-sep', size: 12 }) : null, crumbButton(crumb.label, () => loadDir(crumb.path))))), h('div', { className: 'dshpc-fb__actions' }, props.onClose === undefined
653
- ? null
654
- : h(IconButton, {
655
- variant: 'plain',
656
- size: 'md',
657
- icon: h(IconCloseOutlineRegular, { size: 14 }),
658
- label: tt('previewClose'),
659
- onClick: props.onClose,
660
- })));
661
- // 越界(outside-workspace):第一排整条换成只读完整路径,不给任何会失败的入口。
662
- const crumbBarPlain = h('nav', { className: 'dshpc-fb__bar', 'aria-label': tt('explorerCrumbsAria') }, h(MarqueeText, {
663
- text: dir,
664
- title: dir,
665
- className: 'dshpc-fb__bar-plain',
666
- style: { flex: '1 1 auto', minWidth: 0 },
667
- }), h('div', { className: 'dshpc-fb__actions' }, props.onClose === undefined
668
- ? null
669
- : h(IconButton, {
670
- variant: 'plain',
671
- size: 'md',
672
- icon: h(IconCloseOutlineRegular, { size: 14 }),
673
- label: tt('previewClose'),
674
- onClick: props.onClose,
675
- })));
676
- // 第二排(仅预览态显示):文件名 + 渲染⇄源码切换 / 复制路径 / 刷新。
677
- // 档位照兄弟项目调用点:Segmented `sm` + 图标钮 `md`(核心 Segmented 高度由 CSS 定,不随档变)。
541
+ // 选择器的「选当前目录」入口(用户 2026-11-03:进到某一层之后,行上没有它自己,
542
+ // 树里也点不到「当前这层」⇒ 在 ✕ 左边给一枚按钮)。落地页还没进任何目录、扩展名过滤下
543
+ // 目录不是合法结果 —— 这两种情况不出按钮。
544
+ const pickNow = picker && props.onPick !== undefined && !landing && dirPickable && dir !== ''
545
+ ? h(Button, {
546
+ // 描边(outline):ghost 在这一排**看不出来是按钮**——它无底无边,与面板同色,
547
+ // 用户 2026-11-03 实测「就像没有背景一样」,于是改回有 border 的一档(覆盖当天早些的 ghost 结论)。
548
+ variant: 'outline',
549
+ size: 'sm',
550
+ className: 'dshpc-fb__pick',
551
+ 'aria-label': tt('explorerPickDirAria'),
552
+ onClick: () => props.onPick?.(toPicked(dir, true)),
553
+ }, tt('explorerPickDir'))
554
+ : null;
555
+ const nav = outside
556
+ ? h(PlainNav, { translate: tt, path: dir, pickNow, onClose: props.onClose })
557
+ : h(CrumbNav, {
558
+ translate: tt,
559
+ landing,
560
+ crumbs,
561
+ overflow: crumbsOverflow,
562
+ menuItems,
563
+ menuOpen,
564
+ onToggleMenu: () => setMenuOpen((v) => !v),
565
+ onCloseMenu: () => setMenuOpen(false),
566
+ onLoadDir: loadDir,
567
+ pickNow,
568
+ onClose: props.onClose,
569
+ regionRef,
570
+ measureRef,
571
+ });
678
572
  const titleBar = viewing === null
679
573
  ? null
680
- : h('div', { className: 'dshpc-fb__title' }, h(MarqueeText, {
681
- text: baseNameOfPath(viewing),
682
- title: viewing,
683
- className: 'dshpc-fb__title-inner',
684
- style: { flex: '1 1 auto', minWidth: 0 },
685
- }), h('div', { className: 'dshpc-fb__actions' }, switchable
686
- ? h(Segmented, {
687
- size: 'sm',
688
- label: viewingIsMd ? tt('previewMdSwitchAria') : tt('previewHtmlSwitchAria'),
689
- value: sourceView ? 'source' : 'render',
690
- items: [
691
- { value: 'render', label: tt('previewRender') },
692
- { value: 'source', label: tt('previewSource') },
693
- ],
694
- onChange: (next) => setSourceView(next === 'source'),
695
- })
696
- : null, h(IconButton, {
697
- variant: 'plain',
698
- size: 'md',
699
- icon: copied ? h(IconCheckOutlineRegular, { size: 14 }) : h(IconCopyOutlineRegular, { size: 14 }),
700
- label: tt('previewCopyPath'),
701
- onClick: copyPath,
702
- }), h(IconButton, {
703
- variant: 'plain',
704
- size: 'md',
705
- icon: h(IconRefreshOutlineRegular, { size: 14 }),
706
- label: tt('previewRefresh'),
707
- onClick: reload,
708
- })));
574
+ : h(PreviewTitleBar, {
575
+ translate: tt,
576
+ path: viewing,
577
+ switchable,
578
+ isMarkdown: viewingIsMd,
579
+ sourceView,
580
+ onSourceViewChange: setSourceView,
581
+ copied,
582
+ onCopyPath: copyPath,
583
+ onReload: reload,
584
+ });
709
585
  // ── 主体 ──
710
586
  let body;
711
- if (viewing !== null) {
587
+ if (landing) {
588
+ body = h(LandingBody, {
589
+ translate: tt,
590
+ roots: rootRows,
591
+ anchors,
592
+ pickable: picker && props.onPick !== undefined && !extsOnly,
593
+ onOpen: loadDir,
594
+ onPickDir: (target) => props.onPick?.(toPicked(target, true)),
595
+ });
596
+ }
597
+ else if (viewing !== null) {
712
598
  body = h(FilePreview, {
713
599
  key: `${viewing}:${reloadNonce}`,
714
- workspaceFiles: props.workspaceFiles,
600
+ files: props.files,
715
601
  officeToPdf: props.officeToPdf ?? null,
716
- sessionId: listSessionId,
717
602
  path: viewing,
718
603
  sourceView,
719
604
  translate: tt,
@@ -737,6 +622,6 @@ export function FileBrowser(props) {
737
622
  return h('div', {
738
623
  className: 'dshpc-fb',
739
624
  style: props.style ?? undefined,
740
- }, outside ? crumbBarPlain : crumbBar, titleBar, body);
625
+ }, nav, titleBar, body);
741
626
  }
742
627
  //# sourceMappingURL=FileBrowser.js.map