dsh-ssh-workbench 1.7.0 → 1.7.9

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/lib/client.js CHANGED
@@ -83,6 +83,17 @@ window.__ModuleLoader__.load({
83
83
  'workbench.disabled': '已关闭',
84
84
  'workbench.sidebarLoadFailed': '读取侧边栏设置失败:{msg}',
85
85
  'workbench.sidebarSaveFailed': '保存失败:{msg}',
86
+ 'workbench.writeTitle': '允许远程写入',
87
+ 'workbench.writeHint': '开启后模型可以用 remote_write / remote_edit 修改远程文件。关闭时远程读取、remote_glob、remote_grep 仍然可用,写入会被明确拒绝。',
88
+ 'workbench.writeOn': '已允许',
89
+ 'workbench.writeOff': '已禁止',
90
+ 'workbench.writeLoadFailed': '读取远程写入设置失败:{msg}',
91
+ 'workbench.writeNeedRestart': '已保存,但当前运行的后端还不支持这个开关,重启 DSH 后生效。',
92
+ 'workbench.hierarchyTitle': '系统文件浏览使用层级模式',
93
+ 'workbench.hierarchyHint': '开启后右侧栏「工作区文件」改为带面包屑和向上导航的层级浏览器,只浏览当前会话目录;关闭时保持 DSH 原生文件页。',
94
+ 'workbench.hierarchyOn': '层级模式',
95
+ 'workbench.hierarchyOff': '原生模式',
96
+ 'workbench.hierarchyLoadFailed': '读取系统文件浏览设置失败:{msg}',
86
97
  'workbench.saving': '保存中…',
87
98
  'workbench.saved': '已保存',
88
99
  'workbench.split': '拖动调整终端与文件宽度',
@@ -157,6 +168,10 @@ window.__ModuleLoader__.load({
157
168
  'panel.keyRequired': '密钥文件路径不能为空',
158
169
  'source.section': '纳管资源',
159
170
  'files.title': '远程文件',
171
+ 'files.systemTitle': '工作区文件',
172
+ 'files.systemDescription': '层级浏览当前会话目录',
173
+ 'files.noSessionDir': '当前会话还没有工作目录',
174
+ 'files.outsideRoot': '只能在当前会话目录内浏览',
160
175
  'files.up': '上一级',
161
176
  'files.refresh': '刷新',
162
177
  'files.empty': '目录为空',
@@ -171,8 +186,23 @@ window.__ModuleLoader__.load({
171
186
  'files.enterPath': '输入路径,回车跳转',
172
187
  'files.created': '已创建文件夹 {name}',
173
188
  'files.more': '更多操作',
189
+ 'files.openFile': '打开',
190
+ 'files.actionFailed': '操作失败:{message}',
174
191
  'files.downloadFile': '下载',
175
192
  'files.copyFile': '复制文件',
193
+ 'files.openInApp': '在应用中打开',
194
+ 'files.openInAppWith': '用 {app} 打开',
195
+ 'files.openInAppMore': '选择其他应用打开',
196
+ 'files.openInAppDefault': '{app}(默认)',
197
+ 'files.pickDirectory': '选择目录',
198
+ 'files.search': '搜索文件',
199
+ 'files.searchPlaceholder': '搜索文件名或路径',
200
+ 'files.searchClear': '清空搜索',
201
+ 'files.searching': '正在搜索…',
202
+ 'files.searchFailed': '搜索失败,请稍后重试',
203
+ 'files.searchEmpty': '没有匹配的文件',
204
+ 'files.searchTruncated': '结果较多,只显示前面一部分',
205
+ 'files.searchIncomplete': '搜索未完成,结果可能不全',
176
206
  'files.copyPathEntry': '复制路径',
177
207
  'files.rename': '重命名',
178
208
  'files.delete': '删除',
@@ -233,6 +263,17 @@ window.__ModuleLoader__.load({
233
263
  'files.sourceMode': '源码',
234
264
  'files.viewerLoading': '读取文件…',
235
265
  'files.viewerBinary': '二进制文件,暂不支持在线阅读',
266
+ 'files.truncated': '已截断',
267
+ 'files.viewerTruncated': '文件较大,这里只显示开头一部分。',
268
+ 'files.viewerImageTruncated': '图片超过预览上限,无法完整显示;请下载后查看。',
269
+ 'files.tableMode': '表格',
270
+ 'files.imageMode': '图片',
271
+ 'files.tableRows': '{n} 行 · {columns} 列',
272
+ 'files.tableRowsPartial': '前 {n} 行 · {columns} 列',
273
+ 'files.linesPartial': '前 {n} 行',
274
+ 'files.tableTruncatedRows': '表格较大,只显示前 {rows} 行。',
275
+ 'files.tableTruncatedColumns': '表格较宽,只显示前 {columns} 列。',
276
+ 'files.tableTruncatedBoth': '表格较大,只显示前 {rows} 行、前 {columns} 列。',
236
277
  'files.viewerDirectory': '该条目是目录,无法预览',
237
278
  'files.viewerNoParams': '预览参数缺失,请从远程文件重新打开',
238
279
  'files.openUnavailable': '当前宿主不支持打开预览页签',
@@ -309,6 +350,17 @@ window.__ModuleLoader__.load({
309
350
  'workbench.disabled': 'Off',
310
351
  'workbench.sidebarLoadFailed': 'Failed to read sidebar settings: {msg}',
311
352
  'workbench.sidebarSaveFailed': 'Save failed: {msg}',
353
+ 'workbench.writeTitle': 'Allow remote writes',
354
+ 'workbench.writeHint': 'When on, the model can modify remote files with remote_write / remote_edit. When off, remote_read, remote_glob and remote_grep still work and writes are refused explicitly.',
355
+ 'workbench.writeOn': 'Allowed',
356
+ 'workbench.writeOff': 'Blocked',
357
+ 'workbench.writeLoadFailed': 'Failed to read the remote write setting: {msg}',
358
+ 'workbench.writeNeedRestart': 'Saved, but the running backend does not support this switch yet. Restart DSH to apply it.',
359
+ 'workbench.hierarchyTitle': 'Hierarchical system file browser',
360
+ 'workbench.hierarchyHint': 'Replaces the right-sidebar Workspace Files page with the breadcrumb-and-up hierarchy browser, limited to the current session directory. Off keeps the native DSH file page.',
361
+ 'workbench.hierarchyOn': 'Hierarchical',
362
+ 'workbench.hierarchyOff': 'Native',
363
+ 'workbench.hierarchyLoadFailed': 'Could not read the system file browser setting: {msg}',
312
364
  'workbench.saving': 'Saving…',
313
365
  'workbench.saved': 'Saved',
314
366
  'workbench.split': 'Drag to resize the terminal and file panes',
@@ -383,6 +435,10 @@ window.__ModuleLoader__.load({
383
435
  'panel.keyRequired': 'Key file path is required',
384
436
  'source.section': 'Managed resources',
385
437
  'files.title': 'Remote files',
438
+ 'files.systemTitle': 'Workspace files',
439
+ 'files.systemDescription': 'Browse the current session directory hierarchically',
440
+ 'files.noSessionDir': 'This session has no working directory yet',
441
+ 'files.outsideRoot': 'Only the current session directory can be browsed',
386
442
  'files.up': 'Up',
387
443
  'files.refresh': 'Refresh',
388
444
  'files.empty': 'Empty directory',
@@ -397,8 +453,23 @@ window.__ModuleLoader__.load({
397
453
  'files.enterPath': 'Enter a path and press Enter',
398
454
  'files.created': 'Folder created: {name}',
399
455
  'files.more': 'More actions',
456
+ 'files.openFile': 'Open',
457
+ 'files.actionFailed': 'Action failed: {message}',
400
458
  'files.downloadFile': 'Download',
401
459
  'files.copyFile': 'Copy file',
460
+ 'files.openInApp': 'Open in app',
461
+ 'files.openInAppWith': 'Open with {app}',
462
+ 'files.openInAppMore': 'Open with another app',
463
+ 'files.openInAppDefault': '{app} (default)',
464
+ 'files.pickDirectory': 'Choose folder',
465
+ 'files.search': 'Search files',
466
+ 'files.searchPlaceholder': 'Search by name or path',
467
+ 'files.searchClear': 'Clear search',
468
+ 'files.searching': 'Searching…',
469
+ 'files.searchFailed': 'Search failed, please try again',
470
+ 'files.searchEmpty': 'No matching files',
471
+ 'files.searchTruncated': 'Too many matches; only the first ones are shown',
472
+ 'files.searchIncomplete': 'Search incomplete; results may be partial',
402
473
  'files.copyPathEntry': 'Copy path',
403
474
  'files.rename': 'Rename',
404
475
  'files.delete': 'Delete',
@@ -457,6 +528,17 @@ window.__ModuleLoader__.load({
457
528
  'files.sourceMode': 'Source',
458
529
  'files.viewerLoading': 'Reading file…',
459
530
  'files.viewerBinary': 'Binary file; inline reading is not available',
531
+ 'files.truncated': 'Truncated',
532
+ 'files.viewerTruncated': 'This file is large; only the beginning is shown.',
533
+ 'files.viewerImageTruncated': 'This image is larger than the preview limit and cannot be shown in full; download it to view.',
534
+ 'files.tableMode': 'Table',
535
+ 'files.imageMode': 'Image',
536
+ 'files.tableRows': '{n} rows · {columns} columns',
537
+ 'files.tableRowsPartial': 'first {n} rows · {columns} columns',
538
+ 'files.linesPartial': 'first {n} lines',
539
+ 'files.tableTruncatedRows': 'This table is large; only the first {rows} rows are shown.',
540
+ 'files.tableTruncatedColumns': 'This table is wide; only the first {columns} columns are shown.',
541
+ 'files.tableTruncatedBoth': 'This table is large; only the first {rows} rows and {columns} columns are shown.',
460
542
  'files.viewerDirectory': 'This entry is a directory; it cannot be previewed',
461
543
  'files.viewerNoParams': 'Missing preview parameters; reopen from Remote files',
462
544
  'files.openUnavailable': 'This host cannot open preview tabs',
@@ -579,8 +661,16 @@ window.__ModuleLoader__.load({
579
661
  let composerBridge = null
580
662
  /** Client `sessions` service: gives the session-scoped ctx that owns composer events. */
581
663
  let sessionsService = null
664
+ /** Set while the sidebar tab service is available; toggled from the settings form. */
665
+ let systemFilesSync = null
582
666
  /** Owner client ctx, set in apply(); used by the composer target chip. */
583
667
  let clientCtx = null
668
+ /** Namespace service (`remote.session`) used to open Host-local paths in their app. */
669
+ let sessionRemote = null
670
+ /** Namespace service (`remote.workspaceFiles`) used by the local files search. */
671
+ let workspaceFilesRemote = null
672
+ /** Workspace navigation service, for the Host-native directory picker. */
673
+ let uiWorkspaceService = null
584
674
 
585
675
 
586
676
  /** The open conversation's session id, exposed on the chat region for a11y/debugging. */
@@ -917,6 +1007,17 @@ window.__ModuleLoader__.load({
917
1007
  '.dsh-ssh-workbench-files-split button:hover{background:var(--dsw-alias-interactive-bg-hover)}',
918
1008
  '.dsh-ssh-workbench-files-split button:focus-visible{outline:var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-1px}',
919
1009
  '.dsh-ssh-workbench-files-split svg{width:14px;height:14px}',
1010
+ '.dsh-ssh-workbench-files-search{box-sizing:border-box;height:42px;padding:8px 8px 4px;flex:none}',
1011
+ '.dsh-ssh-workbench-files-search-field{display:flex;gap:2px;align-items:center;height:100%;padding:0 2px;background:var(--dsw-alias-bg-layer-1);border:.5px solid var(--dsw-alias-border-l4);border-radius:var(--dsw-radius-md)}',
1012
+ '.dsh-ssh-workbench-files-search-field:focus-within{border-color:var(--dsw-alias-state-business-primary)}',
1013
+ '.dsh-ssh-workbench-files-search-glyph{display:inline-flex;flex:none;width:24px;height:24px;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary);padding:4px}',
1014
+ '.dsh-ssh-workbench-files-search-glyph svg{width:14px;height:14px}',
1015
+ '.dsh-ssh-workbench-files-search-input{flex:1 1 auto;min-width:0;padding:0 4px;color:var(--dsw-alias-label-primary);background:0 0;border:0;outline:0;font:inherit}',
1016
+ '.dsh-ssh-workbench-files-search-clear{display:inline-flex;flex:none;width:24px;height:24px;padding:4px;align-items:center;justify-content:center;color:var(--dsw-alias-label-tertiary);background:0 0;border:0;border-radius:var(--dsw-radius-sm);cursor:pointer}',
1017
+ '.dsh-ssh-workbench-files-search-clear:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
1018
+ '.dsh-ssh-workbench-files-search-clear svg{width:14px;height:14px}',
1019
+ '.dsh-ssh-workbench-files-search-dir{flex:none;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px}',
1020
+ '.dsh-ssh-workbench-files-search-note{padding:6px 10px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}',
920
1021
  '.dsh-ssh-workbench-tool-row{box-sizing:border-box;display:flex;align-items:center;width:100%;min-width:0;height:calc(24px + var(--dsh-content-font-delta,0px));padding:0;border:0;background:0 0;color:var(--dsw-alias-label-tertiary);font:inherit;text-align:left;cursor:pointer;transition:color .1s}',
921
1022
  '.dsh-ssh-workbench-tool-row:disabled{cursor:default}',
922
1023
  '.dsh-ssh-workbench-tool-row:hover:not(:disabled){color:var(--dsw-alias-label-secondary)}',
@@ -2209,9 +2310,24 @@ window.__ModuleLoader__.load({
2209
2310
 
2210
2311
  const FILES_IMAGE_TYPES = {
2211
2312
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
2212
- webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
2313
+ webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', svg: 'image/svg+xml',
2213
2314
  }
2214
2315
 
2316
+ /** Delimited text rendered as a grid; the value is the field separator. */
2317
+ const FILES_TABLE_TYPES = { csv: ',', tsv: '\t' }
2318
+ const FILES_TABLE_MAX_ROWS = 500
2319
+ const FILES_TABLE_MAX_COLUMNS = 40
2320
+
2321
+ /**
2322
+ * The exclusion list DSH's own workspace search ships with. The rules are
2323
+ * literal names/paths, so the local page keeps the same results as the
2324
+ * native files page instead of searching `node_modules` or `.git`.
2325
+ */
2326
+ const FILES_SEARCH_EXCLUDED = [
2327
+ '.git', '.hg', '.next', '.pnpm-store', '.svn', '.turbo', '.yarn', 'build', 'coverage', 'dist', 'node_modules',
2328
+ '.DS_Store', '.Spotlight-V100', '.Trashes', '.fseventsd', 'Thumbs.db', 'desktop.ini', '$RECYCLE.BIN', 'System Volume Information',
2329
+ ]
2330
+
2215
2331
  function filesExtension(name) {
2216
2332
  const text = String(name || '')
2217
2333
  const dot = text.lastIndexOf('.')
@@ -2285,6 +2401,15 @@ window.__ModuleLoader__.load({
2285
2401
  return bytes
2286
2402
  }
2287
2403
 
2404
+ /** Decode a UTF-8 payload from base64; used to show SVG source as text. */
2405
+ function decodeBase64Text(base64) {
2406
+ try {
2407
+ return new TextDecoder('utf-8', { fatal: false }).decode(filesBase64ToBytes(base64))
2408
+ } catch {
2409
+ return ''
2410
+ }
2411
+ }
2412
+
2288
2413
  function filesLoadTransferRecords() {
2289
2414
  try {
2290
2415
  const raw = window.localStorage.getItem(FILES_TRANSFER_STORAGE_KEY)
@@ -2390,6 +2515,7 @@ window.__ModuleLoader__.load({
2390
2515
  const iconFileGlyph = () => icon(14, [h('path', { d: 'M6.2 3.5h7.1l4.5 4.5v12.5H6.2z' }), h('path', { d: 'M13.3 3.5V8h4.5' })])
2391
2516
  const iconUpload = () => icon(14, [h('path', { d: 'M12 16V4' }), h('polyline', { points: '6 10 12 4 18 10' }), h('line', { x1: 4, y1: 20, x2: 20, y2: 20 })])
2392
2517
  const iconFolderPlus = () => icon(14, [h('path', { d: 'M3 7.4A1.7 1.7 0 0 1 4.7 5.7h3.9l1.8 2.1h7.9A1.7 1.7 0 0 1 20 9.5v7.8a1.7 1.7 0 0 1-1.7 1.7H4.7A1.7 1.7 0 0 1 3 17.3V7.4z' }), h('line', { x1: 12, y1: 12, x2: 12, y2: 17 }), h('line', { x1: 9.5, y1: 14.5, x2: 14.5, y2: 14.5 })])
2518
+ const iconFolderOpen = () => icon(14, [h('path', { d: 'M3 7.4A1.7 1.7 0 0 1 4.7 5.7h3.9l1.8 2.1h6.4A1.7 1.7 0 0 1 18.5 9.5v1' }), h('path', { d: 'M3.4 10.2h17.2l-1.8 7.4a1.5 1.5 0 0 1-1.5 1.2H5.2a1.5 1.5 0 0 1-1.5-1.2L2 11.6a1.2 1.2 0 0 1 1.4-1.4z' })])
2393
2519
 
2394
2520
  function nativeFileIcon(name) {
2395
2521
  const FileTypeIcon = primitives && primitives.FileTypeIcon
@@ -2438,6 +2564,75 @@ window.__ModuleLoader__.load({
2438
2564
  return { body: source.slice(match[0].length), source: match[1] }
2439
2565
  }
2440
2566
 
2567
+ /**
2568
+ * Split delimited text into rows and cells. Handles quoted fields (with
2569
+ * doubled quotes and embedded newlines), CRLF, and a trailing newline. The
2570
+ * reader is bounded so a pathological file cannot stall the renderer.
2571
+ */
2572
+ function parseDelimitedTable(source, separator) {
2573
+ const text = String(source || '').replace(/^\uFEFF/, '')
2574
+ const rows = []
2575
+ let row = []
2576
+ let cell = ''
2577
+ let quoted = false
2578
+ let index = 0
2579
+ const pushCell = () => { row.push(cell); cell = '' }
2580
+ const pushRow = () => {
2581
+ pushCell()
2582
+ rows.push(row)
2583
+ row = []
2584
+ return rows.length <= FILES_TABLE_MAX_ROWS
2585
+ }
2586
+ while (index < text.length) {
2587
+ const char = text[index]
2588
+ if (quoted) {
2589
+ if (char === '"') {
2590
+ if (text[index + 1] === '"') { cell += '"'; index += 2; continue }
2591
+ quoted = false
2592
+ index += 1
2593
+ continue
2594
+ }
2595
+ cell += char
2596
+ index += 1
2597
+ continue
2598
+ }
2599
+ if (char === '"' && cell === '') { quoted = true; index += 1; continue }
2600
+ if (char === separator) { pushCell(); index += 1; continue }
2601
+ if (char === '\r') { index += 1; continue }
2602
+ if (char === '\n') {
2603
+ if (!pushRow()) return { rows, truncated: true }
2604
+ index += 1
2605
+ continue
2606
+ }
2607
+ cell += char
2608
+ index += 1
2609
+ }
2610
+ if (cell !== '' || row.length > 0) pushRow()
2611
+ return { rows, truncated: rows.length > FILES_TABLE_MAX_ROWS }
2612
+ }
2613
+
2614
+ /** Present a parsed table: header row plus a bounded body for rendering. */
2615
+ function buildDelimitedTable(source, separator) {
2616
+ const parsed = parseDelimitedTable(source, separator)
2617
+ const rows = parsed.rows
2618
+ if (rows.length === 0) return null
2619
+ const columnCount = Math.min(FILES_TABLE_MAX_COLUMNS, rows.reduce((max, cells) => Math.max(max, cells.length), 0))
2620
+ const visible = rows.length > FILES_TABLE_MAX_ROWS ? rows.slice(0, FILES_TABLE_MAX_ROWS) : rows
2621
+ const rowsTruncated = parsed.truncated || rows.length > visible.length
2622
+ const columnsTruncated = rows.some((cells) => cells.length > columnCount)
2623
+ return {
2624
+ header: visible[0].slice(0, columnCount),
2625
+ body: visible.slice(1).map((cells) => cells.slice(0, columnCount)),
2626
+ columnCount,
2627
+ rowCount: rows.length,
2628
+ /** Data rows, i.e. everything after the header line. */
2629
+ dataRowCount: Math.max(0, rows.length - 1),
2630
+ rowsTruncated,
2631
+ columnsTruncated,
2632
+ truncated: rowsTruncated || columnsTruncated,
2633
+ }
2634
+ }
2635
+
2441
2636
  /** Read the opener's navigation params from a tab's framework information. */
2442
2637
  function tabParamsOf(info) {
2443
2638
  if (!info || !info.tab) return undefined
@@ -2466,6 +2661,8 @@ window.__ModuleLoader__.load({
2466
2661
  const [meta, setMeta] = useState(null)
2467
2662
  const [wrap, setWrap] = useState(true)
2468
2663
  const [markdownSource, setMarkdownSource] = useState(false)
2664
+ const [tableSource, setTableSource] = useState(false)
2665
+ const [imageSource, setImageSource] = useState(false)
2469
2666
  /** Workbench preview passes onClose; the sidebar preview tab does not. */
2470
2667
  const onClose = typeof props.onClose === 'function' ? props.onClose : null
2471
2668
 
@@ -2480,8 +2677,14 @@ window.__ModuleLoader__.load({
2480
2677
  const body = await post('/sftp/read', { ref: server, path, base64: true, maxBytes: 5 * 1024 * 1024 })
2481
2678
  const result = body.result || {}
2482
2679
  setMeta({ size: result.size, truncated: result.truncated === true })
2483
- if (result.base64) { setImageUrl(`data:${mime};base64,${result.base64}`); setBinary(false); setContent('') }
2484
- else { setBinary(true); setContent(''); setError(t('files.viewerBinary')) }
2680
+ if (result.base64) {
2681
+ setImageUrl(`data:${mime};base64,${result.base64}`)
2682
+ setBinary(false)
2683
+ // SVG is text, so keep its source available for the source view;
2684
+ // raster payloads are never decoded into text.
2685
+ setContent(filesExtension(name) === 'svg' ? decodeBase64Text(result.base64) : '')
2686
+ setImageSource(false)
2687
+ } else { setBinary(true); setContent(''); setError(t('files.viewerBinary')) }
2485
2688
  } else {
2486
2689
  const body = await post('/sftp/read', { ref: server, path, maxBytes: 1024 * 1024 })
2487
2690
  const result = body.result || {}
@@ -2506,6 +2709,17 @@ window.__ModuleLoader__.load({
2506
2709
  const lines = content ? content.split('\n') : []
2507
2710
  const extension = filesExtension(name)
2508
2711
  const isMarkdown = FILES_MARKDOWN_EXTENSIONS.includes(extension)
2712
+ const tableSeparator = FILES_TABLE_TYPES[extension]
2713
+ const isTable = Boolean(tableSeparator) && !binary && Boolean(content)
2714
+ const table = isTable && !tableSource ? buildDelimitedTable(content, tableSeparator) : null
2715
+ const isTextImage = extension === 'svg'
2716
+ const showImage = Boolean(imageUrl) && !(isTextImage && imageSource)
2717
+ const byteTruncated = meta && meta.truncated === true
2718
+ const truncatedHint = byteTruncated
2719
+ ? tr('files.viewerTruncated', '文件较大,这里只显示开头一部分。')
2720
+ : ''
2721
+ /** A clipped bitmap/svg payload cannot be decoded, so say so instead of rendering a broken image. */
2722
+ const imageTruncated = showImage && byteTruncated
2509
2723
  const MarkdownText = primitives && primitives.MarkdownText
2510
2724
  const CodeBlock = primitives && primitives.CodeBlock
2511
2725
  const markdownLabels = {
@@ -2540,13 +2754,31 @@ window.__ModuleLoader__.load({
2540
2754
  style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13 },
2541
2755
  }, name),
2542
2756
  meta && meta.size != null ? h('span', { style: { ...panelUi.fieldLabel, flexShrink: 0 } }, filesFormatBytes(meta.size)) : null,
2757
+ byteTruncated
2758
+ ? h('span', {
2759
+ title: truncatedHint,
2760
+ style: { ...panelUi.fieldLabel, flexShrink: 0, color: 'var(--dsw-alias-state-warning-primary, inherit)' },
2761
+ }, t('files.truncated'))
2762
+ : null,
2543
2763
  isMarkdown && !binary && !imageUrl && MarkdownText
2544
2764
  ? h('button', {
2545
2765
  className: 'dsh-ssh-workbench-button dsh-ssh-workbench-button-ghost', style: { ...panelUi.ghostButton, flexShrink: 0 },
2546
2766
  onClick: () => setMarkdownSource((value) => !value),
2547
2767
  }, markdownSource ? t('files.previewMode') : t('files.sourceMode'))
2548
2768
  : null,
2549
- !isMarkdown && !binary && !imageUrl ? toolbar(wrap ? t('files.unwrap') : t('files.wrap'), icon(12, [h('line', { x1: 3, y1: 6, x2: 21, y2: 6 }), h('line', { x1: 3, y1: 12, x2: 21, y2: 12 }), h('line', { x1: 3, y1: 18, x2: 15, y2: 18 })]), () => setWrap((value) => !value)) : null,
2769
+ isTable
2770
+ ? h('button', {
2771
+ className: 'dsh-ssh-workbench-button dsh-ssh-workbench-button-ghost', style: { ...panelUi.ghostButton, flexShrink: 0 },
2772
+ onClick: () => setTableSource((value) => !value),
2773
+ }, tableSource ? t('files.tableMode') : t('files.sourceMode'))
2774
+ : null,
2775
+ isTextImage && imageUrl
2776
+ ? h('button', {
2777
+ className: 'dsh-ssh-workbench-button dsh-ssh-workbench-button-ghost', style: { ...panelUi.ghostButton, flexShrink: 0 },
2778
+ onClick: () => setImageSource((value) => !value),
2779
+ }, imageSource ? t('files.imageMode') : t('files.sourceMode'))
2780
+ : null,
2781
+ !isMarkdown && !binary && !showImage && !table ? toolbar(wrap ? t('files.unwrap') : t('files.wrap'), icon(12, [h('line', { x1: 3, y1: 6, x2: 21, y2: 6 }), h('line', { x1: 3, y1: 12, x2: 21, y2: 12 }), h('line', { x1: 3, y1: 18, x2: 15, y2: 18 })]), () => setWrap((value) => !value)) : null,
2550
2782
  toolbar(t('files.refresh'), refreshingIcon(), load),
2551
2783
  onClose
2552
2784
  ? toolbar(t('common.close'), icon(12, [h('path', { d: 'M18 6L6 18' }), h('path', { d: 'M6 6l12 12' })]), onClose)
@@ -2555,9 +2787,58 @@ window.__ModuleLoader__.load({
2555
2787
  h('div', { style: { flex: 1, minHeight: 0, overflow: 'auto', background: 'transparent' } },
2556
2788
  loading ? h('div', { style: { padding: '24px 12px', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, t('files.viewerLoading')) : null,
2557
2789
  error ? h('div', { role: 'alert', style: { padding: '18px 12px', color: 'var(--dsw-alias-state-error-primary)' } }, error) : null,
2558
- imageUrl ? h('div', { style: { padding: 12, display: 'flex', justifyContent: 'center' } }, h('img', { src: imageUrl, alt: name, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } })) : null,
2559
- !loading && !error && !imageUrl && !binary
2560
- ? (isMarkdown && !markdownSource && MarkdownText != null
2790
+ imageTruncated
2791
+ ? h('div', { style: { padding: '18px 14px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, tr('files.viewerImageTruncated', '图片超过预览上限,无法完整显示;请下载后查看。'))
2792
+ : showImage
2793
+ ? h('div', { style: { padding: 12, display: 'flex', justifyContent: 'center' } }, h('img', { src: imageUrl, alt: name, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } }))
2794
+ : null,
2795
+ !loading && !error && !imageTruncated && !showImage && !binary
2796
+ ? (table
2797
+ ? h('div', { style: { padding: 12, overflow: 'auto' }, 'data-sdh-table-preview': path },
2798
+ h('table', {
2799
+ style: {
2800
+ borderCollapse: 'collapse', fontSize: 'var(--dsh-code-font-size, 12px)',
2801
+ fontFamily: 'var(--ds-font-family-code)', width: '100%',
2802
+ },
2803
+ },
2804
+ h('thead', null,
2805
+ h('tr', null, table.header.map((cell, index) => h('th', {
2806
+ key: index,
2807
+ style: {
2808
+ textAlign: 'left', fontWeight: 500, whiteSpace: 'nowrap', padding: '4px 10px',
2809
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, #ddd)',
2810
+ color: 'var(--dsw-alias-label-primary, inherit)',
2811
+ position: 'sticky', top: 0, background: 'var(--dsw-specific-menu, transparent)',
2812
+ },
2813
+ }, cell))),
2814
+ ),
2815
+ h('tbody', null,
2816
+ table.body.map((cells, rowIndex) => h('tr', { key: rowIndex },
2817
+ cells.map((cell, cellIndex) => h('td', {
2818
+ key: cellIndex,
2819
+ style: {
2820
+ padding: '3px 10px', whiteSpace: 'nowrap', maxWidth: 320, overflow: 'hidden',
2821
+ textOverflow: 'ellipsis', borderBottom: '0.5px solid var(--dsw-alias-border-l3, #eee)',
2822
+ color: 'var(--dsw-alias-label-secondary, inherit)',
2823
+ },
2824
+ }, cell))),
2825
+ ),
2826
+ ),
2827
+ ),
2828
+ table.truncated
2829
+ ? h('div', { style: { paddingTop: 8, ...panelUi.fieldLabel } },
2830
+ table.rowsTruncated && table.columnsTruncated
2831
+ ? tr('files.tableTruncatedBoth', '表格较大,只显示前 {rows} 行、前 {columns} 列。')
2832
+ .replace('{rows}', String(FILES_TABLE_MAX_ROWS))
2833
+ .replace('{columns}', String(FILES_TABLE_MAX_COLUMNS))
2834
+ : table.rowsTruncated
2835
+ ? tr('files.tableTruncatedRows', '表格较大,只显示前 {rows} 行。')
2836
+ .replace('{rows}', String(FILES_TABLE_MAX_ROWS))
2837
+ : tr('files.tableTruncatedColumns', '表格较宽,只显示前 {columns} 列。')
2838
+ .replace('{columns}', String(FILES_TABLE_MAX_COLUMNS)))
2839
+ : null,
2840
+ )
2841
+ : isMarkdown && !markdownSource && MarkdownText != null
2561
2842
  ? h('div', {
2562
2843
  style: { padding: '12px 16px', fontSize: 'var(--dsh-content-font-size, 14px)', lineHeight: 1.6, color: 'var(--dsw-alias-label-primary, inherit)' },
2563
2844
  'data-sdh-markdown-preview': '',
@@ -2603,7 +2884,20 @@ window.__ModuleLoader__.load({
2603
2884
  },
2604
2885
  },
2605
2886
  h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, path),
2606
- h('span', { style: { flexShrink: 0 } }, tr('files.lines', '{n} 行').replace('{n}', String(lines.length))),
2887
+ // A clipped image has no meaningful line count, so the footer stays bare.
2888
+ imageTruncated
2889
+ ? null
2890
+ : h('span', { style: { flexShrink: 0 } }, table
2891
+ ? (byteTruncated
2892
+ ? tr('files.tableRowsPartial', '前 {n} 行 · {columns} 列')
2893
+ .replace('{n}', String(table.dataRowCount))
2894
+ .replace('{columns}', String(table.columnCount))
2895
+ : tr('files.tableRows', '{n} 行 · {columns} 列')
2896
+ .replace('{n}', String(table.dataRowCount))
2897
+ .replace('{columns}', String(table.columnCount)))
2898
+ : truncatedHint
2899
+ ? tr('files.linesPartial', '前 {n} 行').replace('{n}', String(lines.length))
2900
+ : tr('files.lines', '{n} 行').replace('{n}', String(lines.length))),
2607
2901
  ),
2608
2902
  )
2609
2903
  }
@@ -2648,6 +2942,10 @@ window.__ModuleLoader__.load({
2648
2942
  const iconArrowLeft = () => icon(14, [h('line', { x1: 19, y1: 12, x2: 5, y2: 12 }), h('polyline', { points: '12 5 5 12 12 19' })])
2649
2943
  const iconHome = () => icon(14, [h('path', { d: 'M4 10.5 12 4l8 6.5' }), h('path', { d: 'M6.5 9.5V20h11V9.5' })])
2650
2944
  const iconArrowUp16 = () => icon(16, [h('line', { x1: 12, y1: 19, x2: 12, y2: 5 }), h('polyline', { points: '5 12 12 5 19 12' })])
2945
+ const iconRightUp = () => icon(14, [
2946
+ h('path', { d: 'M8 17 17 8' }),
2947
+ h('polyline', { points: '9.5 8 17 8 17 15.5' }),
2948
+ ])
2651
2949
  const iconMore = () => icon(14, [
2652
2950
  h('circle', { cx: 5, cy: 12, r: 1.4, fill: 'currentColor', stroke: 'none' }),
2653
2951
  h('circle', { cx: 12, cy: 12, r: 1.4, fill: 'currentColor', stroke: 'none' }),
@@ -2673,6 +2971,105 @@ window.__ModuleLoader__.load({
2673
2971
 
2674
2972
  let filesPendingConflictResolve = null
2675
2973
 
2974
+ /**
2975
+ * Hand one Host-local path to the OS: `open` starts the associated app and
2976
+ * `reveal` shows it in Finder/Explorer. Only paths the Host can map back to
2977
+ * its own filesystem work, so callers must gate this to local browsing.
2978
+ */
2979
+ async function openHostPath(path, action = 'open') {
2980
+ if (typeof path !== 'string' || !path) return false
2981
+ const session = sessionRemote
2982
+ if (!session || typeof session.openWorkspacePath !== 'function') return false
2983
+ try {
2984
+ const result = action === 'reveal'
2985
+ ? await session.openWorkspacePath({ path, action: 'reveal' })
2986
+ : await session.openWorkspacePath({ path })
2987
+ return Boolean(result && result.ok)
2988
+ } catch {
2989
+ return false
2990
+ }
2991
+ }
2992
+
2993
+ /** Ask the Host for a directory. The hierarchy page still clamps it to the session root. */
2994
+ async function pickHostDirectory() {
2995
+ const workspace = uiWorkspaceService
2996
+ if (!workspace || typeof workspace.pickDirectory !== 'function') return null
2997
+ try {
2998
+ const picked = await workspace.pickDirectory()
2999
+ return typeof picked === 'string' && picked ? picked : null
3000
+ } catch {
3001
+ return null
3002
+ }
3003
+ }
3004
+
3005
+ /**
3006
+ * List the apps the Host would use for one path: the same call the official
3007
+ * "open in app" split button makes, so the menu matches DSH exactly.
3008
+ */
3009
+ async function loadHostApplications(path) {
3010
+ const session = sessionRemote
3011
+ if (!session || typeof session.workspacePathApplications !== 'function') return []
3012
+ try {
3013
+ const result = await session.workspacePathApplications({ path })
3014
+ if (!result || result.ok !== true || !Array.isArray(result.value)) return []
3015
+ return result.value.filter((item) => item && typeof item.id === 'string' && typeof item.name === 'string')
3016
+ } catch {
3017
+ return []
3018
+ }
3019
+ }
3020
+
3021
+ /**
3022
+ * Workspace-wide search backed by the Host's ripgrep pass. Matches are
3023
+ * `/`-joined paths relative to the session workspace root.
3024
+ */
3025
+ async function searchHostWorkspace(sessionId, request) {
3026
+ const files = workspaceFilesRemote
3027
+ if (!files || typeof files.search !== 'function') return null
3028
+ try {
3029
+ const result = await files.search(sessionId, request)
3030
+ if (!result || result.ok !== true) return null
3031
+ return result.value || null
3032
+ } catch {
3033
+ return null
3034
+ }
3035
+ }
3036
+
3037
+ /**
3038
+ * Native file rows expose the official `contextMenu` target family, so the
3039
+ * DSH context menu service resolves our actions the same way it resolves
3040
+ * workspace and document-preview files. The captured value carries only
3041
+ * JSON-safe row facts plus the id of the live panel that owns the handlers.
3042
+ */
3043
+ const FILES_MENU_TARGET = `${NS}.file`
3044
+ let filesMenuSeq = 0
3045
+ /** Panel id → mutable box `{ menuActions }` filled on every render. */
3046
+ const filesMenuPanels = new Map()
3047
+
3048
+ function filesMenuEntryOf(value) {
3049
+ if (value == null || typeof value !== 'object') return null
3050
+ const path = typeof value.path === 'string' ? value.path : ''
3051
+ if (!path) return null
3052
+ return {
3053
+ path,
3054
+ name: typeof value.name === 'string' ? value.name : baseNameOf(path),
3055
+ type: value.type === 'dir' ? 'dir' : 'file',
3056
+ size: Number.isFinite(value.size) ? value.size : 0,
3057
+ }
3058
+ }
3059
+
3060
+ /** Build the menu actions for one captured row from its live panel. */
3061
+ function resolveFilesMenuActions(value) {
3062
+ const panelId = value && typeof value.panel === 'string' ? value.panel : ''
3063
+ // The map stores the box itself, so the handlers sit directly on it.
3064
+ const box = panelId ? filesMenuPanels.get(panelId) : null
3065
+ if (!box || typeof box.menuActions !== 'function') return []
3066
+ try {
3067
+ return box.menuActions(value) || []
3068
+ } catch {
3069
+ return []
3070
+ }
3071
+ }
3072
+
2676
3073
  function RemoteFilesPanel(props) {
2677
3074
  // Hierarchical browser (Luminax `remote-file-tree` behaviour) rendered in
2678
3075
  // the native Files chrome: one directory per view, breadcrumb navigation,
@@ -2687,6 +3084,32 @@ window.__ModuleLoader__.load({
2687
3084
  const initialPath = typeof props.initialPath === 'string' && props.initialPath ? props.initialPath : '/'
2688
3085
  const navRoot = typeof props.rootPath === 'string' && props.rootPath ? props.rootPath : '/'
2689
3086
  const rootLabel = typeof props.rootLabel === 'string' && props.rootLabel ? props.rootLabel : (navRoot === '/' ? '/' : navRoot)
3087
+ const lockRoot = props.lockRoot === true
3088
+ /**
3089
+ * Local hierarchy page: it browses the Host's own disk, so uploads, new
3090
+ * folders and the transfer log — which exist for slow remote copies —
3091
+ * only add noise. The page keeps navigation, refresh, hidden files and
3092
+ * gains "open in app" instead.
3093
+ */
3094
+ const localChrome = localMode && props.openNative === true
3095
+ /**
3096
+ * The hierarchy page asks the Host for the session workspace root. Local
3097
+ * file routes use this id to scope the browser to that workspace instead
3098
+ * of the workbench's localRoot (Windows workspaces may live elsewhere).
3099
+ */
3100
+ const localSession = localMode && typeof props.sessionId === 'string' && props.sessionId ? props.sessionId : undefined
3101
+ const filesPost = (path, payload) => post(path, localSession ? { ...payload, session: localSession } : payload)
3102
+ const withinRoot = (value) => {
3103
+ const normalize = (input) => String(input || '/').replace(/[\\/]+/g, '/').replace(/\/+$/, '') || '/'
3104
+ const root = normalize(navRoot)
3105
+ const next = normalize(value)
3106
+ if (root === '/') return next.startsWith('/')
3107
+ // Windows paths and UNC shares are case-insensitive.
3108
+ const fold = (text) => (/^[A-Za-z]:/.test(text) || text.startsWith('//') ? text.toLowerCase() : text)
3109
+ const foldedRoot = fold(root)
3110
+ const foldedNext = fold(next)
3111
+ return foldedNext === foldedRoot || foldedNext.startsWith(`${foldedRoot}/`)
3112
+ }
2690
3113
  const [servers, setServers] = useState([])
2691
3114
  const [server, setServer] = useState(localMode ? (controlledServer ?? '__local__') : (controlledServer ?? ''))
2692
3115
  const [dir, setDir] = useState(initialPath)
@@ -2712,11 +3135,34 @@ window.__ModuleLoader__.load({
2712
3135
  const [showHidden, setShowHidden] = useState(() => {
2713
3136
  try { return window.localStorage.getItem('dsh-ssh-workbench.files.showHidden') === '1' } catch { return false }
2714
3137
  })
3138
+ /** Local hierarchy page only: workspace-wide search driven by ripgrep on the Host. */
3139
+ const [searchOpen, setSearchOpen] = useState(false)
3140
+ const [query, setQuery] = useState('')
3141
+ const [searchState, setSearchState] = useState(null)
3142
+ const searchSeqRef = useRef(0)
3143
+ /** Local hierarchy page only: the Host's own app list for "open in app". */
3144
+ const [hostApps, setHostApps] = useState(null)
3145
+ const [appsOpen, setAppsOpen] = useState(false)
2715
3146
  const Menu = primitives && primitives.Menu
2716
3147
  const ChevronIcon = primitives && primitives.IconChevronDownOutlineRegular
2717
3148
  const RefreshIcon = primitives && primitives.IconRefreshOutlineRegular
2718
3149
  /** Newest directory request wins; older responses are discarded (M8/R2). */
2719
3150
  const loadSeqRef = useRef(0)
3151
+ /**
3152
+ * The official context menu resolves rows by target value, so each panel
3153
+ * publishes its handlers under a stable id while it is mounted.
3154
+ */
3155
+ const panelIdRef = useRef('')
3156
+ if (!panelIdRef.current) panelIdRef.current = `files-${++filesMenuSeq}`
3157
+ const menuBoxRef = useRef({ menuActions: null })
3158
+ // Publish during render so an already-open menu sees fresh handlers, and
3159
+ // re-publish from the effect because StrictMode unmounts once on mount.
3160
+ filesMenuPanels.set(panelIdRef.current, menuBoxRef.current)
3161
+ useEffect(() => {
3162
+ const id = panelIdRef.current
3163
+ filesMenuPanels.set(id, menuBoxRef.current)
3164
+ return () => { filesMenuPanels.delete(id) }
3165
+ }, [])
2720
3166
 
2721
3167
  useEffect(() => {
2722
3168
  if (localMode) {
@@ -2760,7 +3206,7 @@ window.__ModuleLoader__.load({
2760
3206
  setLoading(true)
2761
3207
  setError('')
2762
3208
  try {
2763
- const body = await post('/sftp/list', { ref: name, path, ...(force ? { refresh: true } : {}) })
3209
+ const body = await filesPost('/sftp/list', { ref: name, path, ...(force ? { refresh: true } : {}) })
2764
3210
  if (seq !== loadSeqRef.current) return
2765
3211
  setEntries(sortEntries(body.result || []))
2766
3212
  } catch (cause) {
@@ -2781,13 +3227,75 @@ window.__ModuleLoader__.load({
2781
3227
  void loadDir(server, dir)
2782
3228
  }, [server, dir, loadDir])
2783
3229
 
3230
+ // Local hierarchy page: the Host's own app list, so "open in app" can
3231
+ // offer the same menu as DSH's native split button.
3232
+ useEffect(() => {
3233
+ if (!localChrome) return undefined
3234
+ let live = true
3235
+ void loadHostApplications(dir).then((list) => { if (live) setHostApps(list) })
3236
+ return () => { live = false }
3237
+ }, [localChrome, dir])
3238
+
3239
+ // Local hierarchy page: one workspace-wide search per settled query; a
3240
+ // newer query aborts the answer of the older one.
3241
+ useEffect(() => {
3242
+ if (!localChrome || !searchOpen) return undefined
3243
+ const text = String(query || '').trim()
3244
+ if (!text) {
3245
+ searchSeqRef.current += 1
3246
+ setSearchState(null)
3247
+ return undefined
3248
+ }
3249
+ if (typeof props.sessionId !== 'string' || !props.sessionId) return undefined
3250
+ const seq = searchSeqRef.current + 1
3251
+ searchSeqRef.current = seq
3252
+ setSearchState({ loading: true, matches: [], truncated: false, incomplete: false })
3253
+ const timer = setTimeout(() => {
3254
+ void searchHostWorkspace(props.sessionId, {
3255
+ query: text,
3256
+ excludedPaths: FILES_SEARCH_EXCLUDED,
3257
+ includeHidden: showHidden === true,
3258
+ respectGitignore: true,
3259
+ }).then((answer) => {
3260
+ if (seq !== searchSeqRef.current) return
3261
+ if (!answer) {
3262
+ setSearchState({ loading: false, failed: true, matches: [], truncated: false, incomplete: false })
3263
+ return
3264
+ }
3265
+ setSearchState({
3266
+ loading: false,
3267
+ failed: false,
3268
+ matches: Array.isArray(answer.matches) ? answer.matches : [],
3269
+ truncated: answer.truncated === true,
3270
+ incomplete: answer.incomplete === true,
3271
+ })
3272
+ })
3273
+ }, 180)
3274
+ return () => { clearTimeout(timer) }
3275
+ }, [localChrome, searchOpen, query, showHidden, props.sessionId])
3276
+
3277
+ useEffect(() => () => { searchSeqRef.current += 1 }, [])
3278
+
2784
3279
  const navigate = (next) => {
2785
3280
  const target = String(next || '/').trim() || '/'
2786
3281
  if (target === dir) return
3282
+ if (lockRoot && !withinRoot(target)) {
3283
+ setError(t('files.outsideRoot'))
3284
+ return
3285
+ }
2787
3286
  setBackStack((stack) => [...stack, dir])
2788
3287
  setDir(target)
2789
3288
  setNotice('')
2790
3289
  }
3290
+ const chooseDirectory = async () => {
3291
+ const picked = await pickHostDirectory()
3292
+ if (!picked) return
3293
+ if (lockRoot && !withinRoot(picked)) {
3294
+ setError(t('files.outsideRoot'))
3295
+ return
3296
+ }
3297
+ navigate(picked)
3298
+ }
2791
3299
  const goBack = () => {
2792
3300
  if (backStack.length === 0) return
2793
3301
  setDir(backStack[backStack.length - 1])
@@ -2837,7 +3345,26 @@ window.__ModuleLoader__.load({
2837
3345
  })
2838
3346
  }
2839
3347
  const openFile = (entry) => {
2840
- const actions = props.actions || (props.tabInfo && props.tabInfo.tab && props.tabInfo.tab.actions)
3348
+ const actions = (props.tabInfo && props.tabInfo.tab && props.tabInfo.tab.actions) || props.actions
3349
+ // In the takeover of DSH's native files page, prefer the official
3350
+ // resource protocol so the official preview (markdown/code/image/pdf)
3351
+ // opens; the address has to be session-scoped and relative to cwd.
3352
+ if (props.openNative === true && actions && typeof actions.openResource === 'function' && typeof props.sessionId === 'string' && typeof props.rootPath === 'string') {
3353
+ const root = String(props.rootPath).replace(/[\\/]+$/, '')
3354
+ const full = String(entry.path)
3355
+ const rootLower = root.toLowerCase()
3356
+ const fullLower = full.toLowerCase()
3357
+ if (fullLower.startsWith(`${rootLower}/`) || fullLower.startsWith(`${rootLower}\\`)) {
3358
+ const relative = full.slice(root.length).replace(/^[\\/]+/, '')
3359
+ if (relative) {
3360
+ const address = `dsh-resource://file/session/${encodeURIComponent(props.sessionId)}/${relative.split(/[\\/]+/).filter(Boolean).map((part) => encodeURIComponent(part)).join('/')}`
3361
+ const panel = props.tabInfo && props.tabInfo.panel
3362
+ if (panel && panel.isFloating !== true && panel.id) actions.openResource(address, { paneId: panel.id })
3363
+ else actions.openResource(address)
3364
+ return
3365
+ }
3366
+ }
3367
+ }
2841
3368
  if (!actions || typeof actions.openTab !== 'function') {
2842
3369
  setError(t('files.openUnavailable'))
2843
3370
  return
@@ -2923,7 +3450,7 @@ window.__ModuleLoader__.load({
2923
3450
  })
2924
3451
  let listing = entries
2925
3452
  try {
2926
- const body = await post('/sftp/list', { ref: server, path: dir })
3453
+ const body = await filesPost('/sftp/list', { ref: server, path: dir })
2927
3454
  listing = body.result || []
2928
3455
  } catch { /* keep the cached listing */ }
2929
3456
  const reserved = new Set(listing.map((entry) => entry.name))
@@ -2980,7 +3507,7 @@ window.__ModuleLoader__.load({
2980
3507
  let sessionError = null
2981
3508
  let currentBytes = 0
2982
3509
  try {
2983
- const opened = await post('/sftp/open-write', { ref: server, path: partPath, truncate: true })
3510
+ const opened = await filesPost('/sftp/open-write', { ref: server, path: partPath, truncate: true })
2984
3511
  session = opened.result.session
2985
3512
  sessionOpen = true
2986
3513
  partCreated = true
@@ -2996,7 +3523,7 @@ window.__ModuleLoader__.load({
2996
3523
  signal: controller.signal,
2997
3524
  loadChunk: async (offset, length) => new Uint8Array(await file.slice(offset, offset + length).arrayBuffer()),
2998
3525
  saveChunk: async (offset, chunk) => {
2999
- await post('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3526
+ await filesPost('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3000
3527
  },
3001
3528
  onChunkDone: (bytes) => { fileDone += bytes; currentBytes = fileDone; report() },
3002
3529
  onContiguousDone: (offset) => { contiguous = offset },
@@ -3005,19 +3532,19 @@ window.__ModuleLoader__.load({
3005
3532
  cancelled = true
3006
3533
  sessionError = new Error('cancelled')
3007
3534
  if (sessionOpen) {
3008
- try { await post('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3535
+ try { await filesPost('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3009
3536
  sessionOpen = false
3010
3537
  }
3011
3538
  if (single && partCreated) {
3012
3539
  singlePart = { partPath, targetPath, contiguousBytes: contiguous, sourceMtimeMs: file.lastModified, transferredBytes: currentBytes }
3013
3540
  }
3014
3541
  } else {
3015
- await post('/sftp/close-write', { session })
3542
+ await filesPost('/sftp/close-write', { session })
3016
3543
  sessionOpen = false
3017
3544
  if (target.overwrite) {
3018
- try { await post('/sftp/remove', { ref: server, path: targetPath }) } catch { /* may already be gone */ }
3545
+ try { await filesPost('/sftp/remove', { ref: server, path: targetPath }) } catch { /* may already be gone */ }
3019
3546
  }
3020
- await post('/sftp/rename', { ref: server, path: partPath, targetPath })
3547
+ await filesPost('/sftp/rename', { ref: server, path: partPath, targetPath })
3021
3548
  reserved.add(target.name)
3022
3549
  doneBytes += file.size
3023
3550
  uploaded += 1
@@ -3026,14 +3553,14 @@ window.__ModuleLoader__.load({
3026
3553
  } catch (cause) {
3027
3554
  lastError = cause
3028
3555
  if (sessionOpen) {
3029
- try { await post('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3556
+ try { await filesPost('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3030
3557
  }
3031
3558
  if (controller.signal.aborted) cancelled = true
3032
3559
  else failed += 1
3033
3560
  if (single && partCreated) {
3034
3561
  singlePart = { partPath, targetPath, contiguousBytes: contiguous, sourceMtimeMs: file.lastModified, transferredBytes: currentBytes }
3035
3562
  } else {
3036
- try { await post('/sftp/remove', { ref: server, path: partPath }) } catch { /* best effort */ }
3563
+ try { await filesPost('/sftp/remove', { ref: server, path: partPath }) } catch { /* best effort */ }
3037
3564
  }
3038
3565
  }
3039
3566
  if (cancelled) break
@@ -3110,7 +3637,7 @@ window.__ModuleLoader__.load({
3110
3637
  let session = ''
3111
3638
  let sessionOpen = false
3112
3639
  try {
3113
- const opened = await post('/sftp/open-write', { ref: server, path: record.partPath, truncate: false })
3640
+ const opened = await filesPost('/sftp/open-write', { ref: server, path: record.partPath, truncate: false })
3114
3641
  session = opened.result.session
3115
3642
  sessionOpen = true
3116
3643
  let fileDone = startOffset
@@ -3123,27 +3650,27 @@ window.__ModuleLoader__.load({
3123
3650
  signal: controller.signal,
3124
3651
  loadChunk: async (offset, length) => new Uint8Array(await file.slice(offset, offset + length).arrayBuffer()),
3125
3652
  saveChunk: async (offset, chunk) => {
3126
- await post('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3653
+ await filesPost('/sftp/write-chunk', { session, offset, contentBase64: filesBytesToBase64(chunk) })
3127
3654
  },
3128
3655
  onChunkDone: (bytes) => { fileDone += bytes; doneBytes = fileDone; report() },
3129
3656
  })
3130
3657
  if (outcome.outcome === 'cancelled') {
3131
3658
  patchRecord(record.id, { status: 'cancelled', bytes: doneBytes, contiguousBytes: outcome.contiguous, finishedAt: Date.now() })
3132
- await post('/sftp/close-write', { session, abort: true }).catch(() => undefined)
3659
+ await filesPost('/sftp/close-write', { session, abort: true }).catch(() => undefined)
3133
3660
  sessionOpen = false
3134
3661
  setNotice(t('files.transferStatusCancelled'))
3135
3662
  return
3136
3663
  }
3137
- await post('/sftp/close-write', { session })
3664
+ await filesPost('/sftp/close-write', { session })
3138
3665
  sessionOpen = false
3139
- try { await post('/sftp/remove', { ref: server, path: record.targetPath }) } catch { /* target may not exist */ }
3140
- await post('/sftp/rename', { ref: server, path: record.partPath, targetPath: record.targetPath })
3666
+ try { await filesPost('/sftp/remove', { ref: server, path: record.targetPath }) } catch { /* target may not exist */ }
3667
+ await filesPost('/sftp/rename', { ref: server, path: record.partPath, targetPath: record.targetPath })
3141
3668
  patchRecord(record.id, { status: 'completed', bytes: file.size, contiguousBytes: file.size, finishedAt: Date.now() })
3142
3669
  setNotice(t('files.transferStatusCompleted'))
3143
3670
  await loadDir(server, dir)
3144
3671
  } catch (cause) {
3145
3672
  if (sessionOpen) {
3146
- try { await post('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3673
+ try { await filesPost('/sftp/close-write', { session, abort: true }) } catch { /* already closed */ }
3147
3674
  }
3148
3675
  patchRecord(record.id, { status: controller.signal.aborted ? 'cancelled' : 'partial', bytes: doneBytes, contiguousBytes: startOffset, finishedAt: Date.now(), error: messageOf(cause) })
3149
3676
  setError(t('files.uploadFailed', { msg: messageOf(cause) }))
@@ -3180,7 +3707,7 @@ window.__ModuleLoader__.load({
3180
3707
  let session = ''
3181
3708
  let received = 0
3182
3709
  try {
3183
- const opened = await post('/sftp/open-read', { ref: server, path: entry.path })
3710
+ const opened = await filesPost('/sftp/open-read', { ref: server, path: entry.path })
3184
3711
  session = opened.result.session
3185
3712
  const size = opened.result.size ?? entry.size
3186
3713
  const data = new Uint8Array(size)
@@ -3189,7 +3716,7 @@ window.__ModuleLoader__.load({
3189
3716
  totalBytes: size,
3190
3717
  signal: controller.signal,
3191
3718
  loadChunk: async (offset, length) => {
3192
- const body = await post('/sftp/read-chunk', { session, offset, length })
3719
+ const body = await filesPost('/sftp/read-chunk', { session, offset, length })
3193
3720
  return filesBase64ToBytes(body.result.base64)
3194
3721
  },
3195
3722
  saveChunk: async (offset, chunk) => { data.set(chunk, offset) },
@@ -3207,7 +3734,7 @@ window.__ModuleLoader__.load({
3207
3734
  setError(t('files.downloadFailed', { msg: messageOf(cause) }))
3208
3735
  pushRecord({ direction: 'download', name: entry.name, kind: 'file', source: entry.path, destination: entry.name, status: controller.signal.aborted ? 'cancelled' : 'failed', totalBytes: entry.size, bytes: received, fileCount: 1, failedCount: 1, averageSpeed: 0, startedAt, finishedAt: Date.now(), targetServer: server, error: messageOf(cause) })
3209
3736
  } finally {
3210
- if (session) { try { await post('/sftp/close-read', { session }) } catch { /* closed */ } }
3737
+ if (session) { try { await filesPost('/sftp/close-read', { session }) } catch { /* closed */ } }
3211
3738
  setBusy('')
3212
3739
  endTransfer()
3213
3740
  }
@@ -3229,20 +3756,20 @@ window.__ModuleLoader__.load({
3229
3756
  let writeSession = ''
3230
3757
  let copied = 0
3231
3758
  try {
3232
- const openedRead = await post('/sftp/open-read', { ref: server, path: entry.path })
3759
+ const openedRead = await filesPost('/sftp/open-read', { ref: server, path: entry.path })
3233
3760
  readSession = openedRead.result.session
3234
- const openedWrite = await post('/sftp/open-write', { ref: server, path: targetPath, truncate: true })
3761
+ const openedWrite = await filesPost('/sftp/open-write', { ref: server, path: targetPath, truncate: true })
3235
3762
  writeSession = openedWrite.result.session
3236
3763
  const report = filesThrottle(() => patchTransfer({ label: t('files.copying', { name: entry.name }), ...transferProgress(copied, entry.size, startedAt) }))
3237
3764
  const outcome = await filesRunChunkWindow({
3238
3765
  totalBytes: entry.size,
3239
3766
  signal: controller.signal,
3240
3767
  loadChunk: async (offset, length) => {
3241
- const body = await post('/sftp/read-chunk', { session: readSession, offset, length }, { signal: controller.signal })
3768
+ const body = await filesPost('/sftp/read-chunk', { session: readSession, offset, length }, { signal: controller.signal })
3242
3769
  return filesBase64ToBytes(body.result.base64)
3243
3770
  },
3244
3771
  saveChunk: async (offset, chunk) => {
3245
- await post('/sftp/write-chunk', { session: writeSession, offset, contentBase64: filesBytesToBase64(chunk) }, { signal: controller.signal })
3772
+ await filesPost('/sftp/write-chunk', { session: writeSession, offset, contentBase64: filesBytesToBase64(chunk) }, { signal: controller.signal })
3246
3773
  },
3247
3774
  onChunkDone: (bytes) => { copied += bytes; report() },
3248
3775
  })
@@ -3250,20 +3777,20 @@ window.__ModuleLoader__.load({
3250
3777
  setNotice(t('files.transferStatusCancelled'))
3251
3778
  return
3252
3779
  }
3253
- await post('/sftp/close-write', { session: writeSession })
3780
+ await filesPost('/sftp/close-write', { session: writeSession })
3254
3781
  writeSession = ''
3255
- await post('/sftp/close-read', { session: readSession })
3782
+ await filesPost('/sftp/close-read', { session: readSession })
3256
3783
  readSession = ''
3257
3784
  setNotice(t('files.transferStatusCompleted'))
3258
3785
  await loadDir(server, dir)
3259
3786
  } catch (cause) {
3260
- if (writeSession) { try { await post('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3261
- if (readSession) { try { await post('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3262
- try { await post('/sftp/remove', { ref: server, path: targetPath }) } catch { /* best effort */ }
3787
+ if (writeSession) { try { await filesPost('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3788
+ if (readSession) { try { await filesPost('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3789
+ try { await filesPost('/sftp/remove', { ref: server, path: targetPath }) } catch { /* best effort */ }
3263
3790
  setError(t('files.copyFailed', { msg: messageOf(cause) }))
3264
3791
  } finally {
3265
- if (writeSession) { try { await post('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3266
- if (readSession) { try { await post('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3792
+ if (writeSession) { try { await filesPost('/sftp/close-write', { session: writeSession, abort: true }) } catch { /* closed */ } }
3793
+ if (readSession) { try { await filesPost('/sftp/close-read', { session: readSession }) } catch { /* closed */ } }
3267
3794
  setBusy('')
3268
3795
  endTransfer()
3269
3796
  }
@@ -3283,7 +3810,7 @@ window.__ModuleLoader__.load({
3283
3810
  setBusy('rename')
3284
3811
  setError('')
3285
3812
  try {
3286
- await post('/sftp/rename', { ref: server, path: entry.path, targetPath: joinRemotePath(parentRemotePath(entry.path), value) })
3813
+ await filesPost('/sftp/rename', { ref: server, path: entry.path, targetPath: joinRemotePath(parentRemotePath(entry.path), value) })
3287
3814
  setRenameTarget(null)
3288
3815
  setNotice(t('files.transferStatusCompleted'))
3289
3816
  await loadDir(server, dir)
@@ -3295,15 +3822,15 @@ window.__ModuleLoader__.load({
3295
3822
  }
3296
3823
 
3297
3824
  const removeRecursive = async (path, isDir) => {
3298
- if (!isDir) return await post('/sftp/remove', { ref: server, path })
3825
+ if (!isDir) return await filesPost('/sftp/remove', { ref: server, path })
3299
3826
  try {
3300
- return await post('/sftp/remove', { ref: server, path, isDir: true })
3827
+ return await filesPost('/sftp/remove', { ref: server, path, isDir: true })
3301
3828
  } catch (cause) {
3302
- const body = await post('/sftp/list', { ref: server, path })
3829
+ const body = await filesPost('/sftp/list', { ref: server, path })
3303
3830
  const children = body.result || []
3304
3831
  if (children.length === 0) throw cause
3305
3832
  for (const child of children) await removeRecursive(child.path, child.type === 'dir')
3306
- return await post('/sftp/remove', { ref: server, path, isDir: true })
3833
+ return await filesPost('/sftp/remove', { ref: server, path, isDir: true })
3307
3834
  }
3308
3835
  }
3309
3836
 
@@ -3333,7 +3860,7 @@ window.__ModuleLoader__.load({
3333
3860
  setBusy('mkdir')
3334
3861
  setError('')
3335
3862
  try {
3336
- await post('/sftp/mkdir', { ref: server, path: targetPath })
3863
+ await filesPost('/sftp/mkdir', { ref: server, path: targetPath })
3337
3864
  setDialog(null)
3338
3865
  setNotice(t('files.created', { name: value }))
3339
3866
  navigate(targetPath)
@@ -3349,6 +3876,7 @@ window.__ModuleLoader__.load({
3349
3876
  const handleRowAction = (entry, action) => {
3350
3877
  if (action === 'download') { void downloadFile(entry); return }
3351
3878
  if (action === 'copyFile') { void copyFileEntry(entry); return }
3879
+ if (action === 'openInApp') { void openHostPath(entry.path); return }
3352
3880
  if (action === 'copyPath') { void copyPathValue(entry.path); return }
3353
3881
  if (action === 'rename') { setRenameTarget(entry); setRenameValue(entry.name); return }
3354
3882
  if (action === 'delete') { setDeleteTarget(entry); return }
@@ -3356,13 +3884,107 @@ window.__ModuleLoader__.load({
3356
3884
  const rowMenuItems = (entry) => {
3357
3885
  const isDir = entry.type === 'dir'
3358
3886
  const items = []
3359
- if (!isDir) items.push({ id: 'download', label: t('files.downloadFile') })
3360
- if (!isDir) items.push({ id: 'copyFile', label: t('files.copyFile') })
3887
+ if (!isDir && !localChrome) items.push({ id: 'download', label: t('files.downloadFile') })
3888
+ if (!isDir && !localChrome) items.push({ id: 'copyFile', label: t('files.copyFile') })
3889
+ if (localChrome) items.push({ id: 'openInApp', label: t('files.openInApp') })
3361
3890
  items.push({ id: 'copyPath', label: t('files.copyPathEntry') })
3362
3891
  items.push({ id: 'rename', label: t('files.rename') })
3363
3892
  items.push({ id: 'delete', label: t('files.delete') })
3364
3893
  return items
3365
3894
  }
3895
+ /** Run one row action, turning a rejection into the inline error line. */
3896
+ const runRowAction = (entry, action) => {
3897
+ try {
3898
+ handleRowAction(entry, action)
3899
+ } catch (cause) {
3900
+ setError(t('files.actionFailed', { message: messageOf(cause) }))
3901
+ }
3902
+ }
3903
+ /**
3904
+ * Official `MenuAction[]` for one row. The context menu service resolves
3905
+ * these lazily, so labels are evaluated at open time and stay localized.
3906
+ */
3907
+ const rowMenuActions = (entry) => {
3908
+ const isDir = entry.type === 'dir'
3909
+ const actions = []
3910
+ if (!isDir) {
3911
+ actions.push({
3912
+ id: 'open',
3913
+ label: t('files.openFile'),
3914
+ run: () => openFile(entry),
3915
+ })
3916
+ }
3917
+ if (!isDir && !localChrome) {
3918
+ actions.push({
3919
+ id: 'download',
3920
+ label: t('files.downloadFile'),
3921
+ separatorBefore: actions.length > 0,
3922
+ run: () => runRowAction(entry, 'download'),
3923
+ })
3924
+ actions.push({
3925
+ id: 'copyFile',
3926
+ label: t('files.copyFile'),
3927
+ run: () => runRowAction(entry, 'copyFile'),
3928
+ })
3929
+ }
3930
+ if (localChrome) {
3931
+ actions.push({
3932
+ id: 'openInApp',
3933
+ label: t('files.openInApp'),
3934
+ separatorBefore: actions.length > 0,
3935
+ run: () => runRowAction(entry, 'openInApp'),
3936
+ })
3937
+ }
3938
+ actions.push({
3939
+ id: 'copyPath',
3940
+ label: t('files.copyPathEntry'),
3941
+ separatorBefore: actions.length > 0,
3942
+ run: () => runRowAction(entry, 'copyPath'),
3943
+ })
3944
+ actions.push({
3945
+ id: 'rename',
3946
+ label: t('files.rename'),
3947
+ run: () => runRowAction(entry, 'rename'),
3948
+ })
3949
+ actions.push({
3950
+ id: 'delete',
3951
+ label: t('files.delete'),
3952
+ danger: true,
3953
+ separatorBefore: true,
3954
+ run: () => runRowAction(entry, 'delete'),
3955
+ })
3956
+ return actions
3957
+ }
3958
+ /** What the context menu service captures for one row. */
3959
+ const menuTargetOf = (entry) => ({
3960
+ kind: FILES_MENU_TARGET,
3961
+ value: {
3962
+ panel: panelIdRef.current,
3963
+ path: entry.path,
3964
+ name: entry.name,
3965
+ type: entry.type === 'dir' ? 'dir' : 'file',
3966
+ size: Number.isFinite(entry.size) ? entry.size : 0,
3967
+ },
3968
+ })
3969
+ const useContextMenu = props.useContextMenu
3970
+ const renderFactorySlot = props.renderFactorySlot
3971
+ const getTriggerProps = (() => {
3972
+ if (typeof useContextMenu !== 'function') return null
3973
+ try {
3974
+ const bindings = useContextMenu()
3975
+ return bindings && typeof bindings.getTriggerProps === 'function' ? bindings.getTriggerProps : null
3976
+ } catch {
3977
+ return null
3978
+ }
3979
+ })()
3980
+ /**
3981
+ * Publish row handlers for the context menu service. Reassigned on every
3982
+ * render so the closure always sees the latest directory and state.
3983
+ */
3984
+ menuBoxRef.current.menuActions = (value) => {
3985
+ const row = filesMenuEntryOf(value)
3986
+ return row ? rowMenuActions(row) : []
3987
+ }
3366
3988
  const renderRowMenu = (entry) => {
3367
3989
  const moreButton = h('button', {
3368
3990
  type: 'button',
@@ -3374,6 +3996,22 @@ window.__ModuleLoader__.load({
3374
3996
  setRowMenuPath((current) => (current === entry.path ? '' : entry.path))
3375
3997
  },
3376
3998
  }, iconMore())
3999
+ const menuTarget = menuTargetOf(entry)
4000
+ // Prefer the official ellipsis trigger so right-click, Shift+F10 and the
4001
+ // button share one menu surface; fall back to our own Menu when the
4002
+ // renderer does not expose the factory slot.
4003
+ if (typeof renderFactorySlot === 'function') {
4004
+ const official = renderFactorySlot('contextMenu.button', {
4005
+ target: menuTarget,
4006
+ label: t('files.more'),
4007
+ className: 'dsh-ssh-workbench-row-more',
4008
+ }, {
4009
+ fallback: h('span', { className: 'dsh-ssh-workbench-row-actions' }, moreButton),
4010
+ })
4011
+ if (official != null) {
4012
+ return h('span', { className: 'dsh-ssh-workbench-row-actions' }, official)
4013
+ }
4014
+ }
3377
4015
  if (Menu == null) return null
3378
4016
  return h(Menu, {
3379
4017
  open: rowMenuPath === entry.path,
@@ -3484,6 +4122,173 @@ window.__ModuleLoader__.load({
3484
4122
  ...extra,
3485
4123
  }, glyph)
3486
4124
 
4125
+ // ── local hierarchy page: search + "open in app" split button ───────
4126
+ // DSH's native files page ships a reload, a search toggle and an
4127
+ // "open in app" split button; the takeover keeps the same footprint and
4128
+ // only adds the hierarchy navigation around it.
4129
+ const searchActive = localChrome && searchOpen
4130
+ const searchText = String(query || '').trim()
4131
+ const searchResults = searchActive && searchText ? searchState : null
4132
+ const searchToggle = localChrome
4133
+ ? tool(t('files.search'), iconSearch(), () => {
4134
+ setSearchOpen((value) => {
4135
+ const next = !value
4136
+ if (!next) setQuery('')
4137
+ return next
4138
+ })
4139
+ }, !server, { 'aria-pressed': searchOpen, 'data-sdh-files-search-toggle': '' })
4140
+ : null
4141
+ const searchBar = searchActive
4142
+ ? h('div', { 'data-sdh-files-search-bar': '', className: 'dsh-ssh-workbench-files-search' },
4143
+ h('div', { className: 'dsh-ssh-workbench-files-search-field' },
4144
+ h('span', { className: 'dsh-ssh-workbench-files-search-glyph' }, iconSearch()),
4145
+ h('input', {
4146
+ autoFocus: true,
4147
+ className: 'dsh-ssh-workbench-files-search-input',
4148
+ value: query,
4149
+ placeholder: t('files.searchPlaceholder'),
4150
+ 'aria-label': t('files.search'),
4151
+ onChange: (event) => setQuery(event.target.value),
4152
+ onKeyDown: (event) => {
4153
+ if (event.key !== 'Escape') return
4154
+ event.preventDefault()
4155
+ setSearchOpen(false)
4156
+ setQuery('')
4157
+ },
4158
+ }),
4159
+ query
4160
+ ? h('button', {
4161
+ type: 'button',
4162
+ className: 'dsh-ssh-workbench-files-search-clear',
4163
+ title: t('files.searchClear'),
4164
+ 'aria-label': t('files.searchClear'),
4165
+ onClick: () => setQuery(''),
4166
+ }, iconClose())
4167
+ : null,
4168
+ ),
4169
+ )
4170
+ : null
4171
+ const searchResultPath = (match) => {
4172
+ const relative = String(match.path || '').replace(/^[\\/]+/, '')
4173
+ if (!relative) return dir
4174
+ const root = String(navRoot || '/').replace(/[\\/]+$/, '')
4175
+ return root === '' || root === '/' ? `/${relative}` : `${root}/${relative}`
4176
+ }
4177
+ const searchView = searchResults
4178
+ ? h('div', { 'data-sdh-files-search-results': '', style: { display: 'flex', flexDirection: 'column' } },
4179
+ searchResults.loading
4180
+ ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.searching'))
4181
+ : searchResults.failed
4182
+ ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.searchFailed'))
4183
+ : searchResults.matches.length === 0
4184
+ ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.searchEmpty'))
4185
+ : searchResults.matches.map((match) => {
4186
+ const isDir = match.type === 'directory'
4187
+ const full = searchResultPath(match)
4188
+ const relative = String(match.path || '')
4189
+ const depth = relative.split('/').length - 1
4190
+ const name = baseNameOf(relative) || relative
4191
+ const entry = { path: full, name, type: isDir ? 'dir' : 'file' }
4192
+ return h('div', {
4193
+ key: relative,
4194
+ className: 'dsh-ssh-workbench-files-row',
4195
+ 'data-sdh-files-search-row': isDir ? 'dir' : 'file',
4196
+ 'data-sdh-files-path': full,
4197
+ role: 'button',
4198
+ tabIndex: 0,
4199
+ title: full,
4200
+ style: { paddingLeft: 10 + depth * 18 },
4201
+ onClick: () => { if (isDir) { setSearchOpen(false); setQuery(''); navigate(full) } else openFile(entry) },
4202
+ onKeyDown: (event) => {
4203
+ if (event.key !== 'Enter' && event.key !== ' ') return
4204
+ event.preventDefault()
4205
+ if (isDir) { setSearchOpen(false); setQuery(''); navigate(full) } else openFile(entry)
4206
+ },
4207
+ },
4208
+ h('span', { style: { display: 'inline-flex', flexShrink: 0, color: isDir ? 'var(--dsw-alias-label-tertiary)' : undefined } }, isDir ? nativeFolderIcon(false) : nativeFileIcon(name)),
4209
+ h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, name),
4210
+ h('span', { className: 'dsh-ssh-workbench-files-search-dir', title: full }, relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : ''),
4211
+ )
4212
+ }),
4213
+ searchResults.matches.length > 0 && (searchResults.truncated || searchResults.incomplete)
4214
+ ? h('div', { className: 'dsh-ssh-workbench-files-search-note' }, searchResults.truncated ? t('files.searchTruncated') : t('files.searchIncomplete'))
4215
+ : null,
4216
+ )
4217
+ : null
4218
+ const appList = localChrome && Array.isArray(hostApps) ? hostApps : []
4219
+ const defaultApp = appList.find((app) => app.default === true) || appList[0] || null
4220
+ const openInDefault = () => {
4221
+ if (!appList.length) { void openHostPath(dir); return }
4222
+ const target = defaultApp && typeof defaultApp.id === 'string' ? defaultApp.id : null
4223
+ if (!sessionRemote || typeof sessionRemote.openWorkspacePath !== 'function') return
4224
+ const request = target ? { path: dir, application: target } : { path: dir }
4225
+ void Promise.resolve(sessionRemote.openWorkspacePath(request)).catch(() => false)
4226
+ }
4227
+ const appsAnchor = h('div', { style: { display: 'inline-flex' } },
4228
+ h('div', { className: 'dsh-ssh-workbench-files-split', 'data-sdh-open-in-app': '' },
4229
+ h('button', {
4230
+ type: 'button',
4231
+ className: 'main',
4232
+ title: defaultApp ? t('files.openInAppWith', { app: defaultApp.name }) : t('files.openInApp'),
4233
+ 'aria-label': defaultApp ? t('files.openInAppWith', { app: defaultApp.name }) : t('files.openInApp'),
4234
+ disabled: !server,
4235
+ onClick: openInDefault,
4236
+ },
4237
+ defaultApp && defaultApp.icon
4238
+ ? h('img', { src: defaultApp.icon, alt: '', width: 14, height: 14, style: { display: 'block', flexShrink: 0 } })
4239
+ : iconRightUp(),
4240
+ h('span', { style: { maxWidth: 92, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
4241
+ defaultApp ? defaultApp.name : t('files.openInApp')),
4242
+ ),
4243
+ h('button', {
4244
+ type: 'button',
4245
+ className: 'chevron',
4246
+ title: t('files.openInAppMore'),
4247
+ 'aria-label': t('files.openInAppMore'),
4248
+ 'aria-haspopup': 'menu',
4249
+ 'aria-expanded': appsOpen,
4250
+ disabled: !server,
4251
+ onClick: () => setAppsOpen((value) => !value),
4252
+ }, ChevronIcon ? h(ChevronIcon, { size: 12 }) : icon(12, [h('polyline', { points: '6 9 12 15 18 9' })])),
4253
+ ),
4254
+ )
4255
+ const appsMenu = Menu != null && appsAnchor && appList.length > 0
4256
+ ? h(Menu, {
4257
+ open: appsOpen,
4258
+ autoFocus: true,
4259
+ portal: true,
4260
+ dense: true,
4261
+ align: 'end',
4262
+ onClose: () => setAppsOpen(false),
4263
+ items: appList.map((app) => ({
4264
+ id: app.id,
4265
+ icon: app.icon ? h('img', { src: app.icon, alt: '', width: 16, height: 16, style: { display: 'block' } }) : undefined,
4266
+ label: app.default === true ? t('files.openInAppDefault', { app: app.name }) : app.name,
4267
+ })),
4268
+ onSelect: (id) => {
4269
+ setAppsOpen(false)
4270
+ if (!sessionRemote || typeof sessionRemote.openWorkspacePath !== 'function') return
4271
+ void Promise.resolve(sessionRemote.openWorkspacePath({ path: dir, application: id })).catch(() => false)
4272
+ },
4273
+ anchor: appsAnchor,
4274
+ })
4275
+ : null
4276
+ // `primitives.Menu` renders its own anchor, so hand it the split button
4277
+ // rather than rendering the anchor a second time next to the menu. With
4278
+ // no app list there is nothing to choose from, so fall back to a plain
4279
+ // "open in app" button that uses the system default.
4280
+ const appsControl = !localChrome
4281
+ ? null
4282
+ : appsMenu
4283
+ ? appsMenu
4284
+ : tool(t('files.openInApp'), iconRightUp(), () => { void openHostPath(dir) }, !server)
4285
+ // The hierarchy page is rooted at the session workspace. This picker lets
4286
+ // the user jump within that workspace without typing a path; selections
4287
+ // outside it are rejected by chooseDirectory().
4288
+ const pickControl = !localChrome
4289
+ ? null
4290
+ : tool(t('files.pickDirectory'), iconFolderOpen(), () => { void chooseDirectory() }, !server)
4291
+
3487
4292
  const transferBar = transfer
3488
4293
  ? h('div', {
3489
4294
  'data-sdh-transfer': transfer.kind,
@@ -3733,7 +4538,7 @@ window.__ModuleLoader__.load({
3733
4538
  ? crumbs.map((crumb, index) => {
3734
4539
  const isLast = index === crumbs.length - 1
3735
4540
  return h('span', { key: crumb.path, style: { display: 'inline-flex', alignItems: 'center', flexShrink: 0 } },
3736
- index > 1 ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary, inherit)', padding: '0 1px' } }, '/') : null,
4541
+ index > 1 ? h('span', { style: { color: 'var(--dsw-alias-label-tertiary, inherit)', padding: '0 1px' } }, pathSeparatorFor(navRoot)) : null,
3737
4542
  h('button', {
3738
4543
  type: 'button',
3739
4544
  className: 'dsh-ssh-workbench-files-crumb',
@@ -3753,20 +4558,32 @@ window.__ModuleLoader__.load({
3753
4558
  })
3754
4559
  : null,
3755
4560
  ),
3756
- h('input', {
3757
- id: 'dsh-ssh-workbench-files-upload',
3758
- type: 'file',
3759
- multiple: true,
3760
- style: { display: 'none' },
3761
- onChange: onPickedFiles,
3762
- }),
3763
- tool(t('files.upload'), iconUpload(), () => { setResumeRecord(null); triggerUploadInput() }, !server || transfer != null),
3764
- tool(t('files.newFolder'), iconFolderPlus(), () => setDialog({ value: '' }), !server || transfer != null),
4561
+ localChrome
4562
+ ? null
4563
+ : h('input', {
4564
+ id: 'dsh-ssh-workbench-files-upload',
4565
+ type: 'file',
4566
+ multiple: true,
4567
+ style: { display: 'none' },
4568
+ onChange: onPickedFiles,
4569
+ }),
4570
+ localChrome
4571
+ ? null
4572
+ : tool(t('files.upload'), iconUpload(), () => { setResumeRecord(null); triggerUploadInput() }, !server || transfer != null),
4573
+ localChrome
4574
+ ? null
4575
+ : tool(t('files.newFolder'), iconFolderPlus(), () => setDialog({ value: '' }), !server || transfer != null),
3765
4576
  tool(showHidden ? t('files.hideHidden') : t('files.showHidden'), iconEye(showHidden), toggleHidden, !server, { 'aria-pressed': showHidden }),
4577
+ pickControl,
3766
4578
  tool(t('files.refresh'), RefreshIcon ? h(RefreshIcon, { size: 15 }) : refreshingIcon(), () => loadDir(server, dir, true), !server),
3767
- tool(t('files.history'), iconHistory(), () => setHistoryOpen((value) => !value), !server),
4579
+ searchToggle,
4580
+ localChrome
4581
+ ? null
4582
+ : tool(t('files.history'), iconHistory(), () => setHistoryOpen((value) => !value), !server),
3768
4583
  hideServerPicker ? null : picker,
4584
+ appsControl,
3769
4585
  ),
4586
+ searchBar,
3770
4587
  error ? h('div', { role: 'alert', style: { padding: '6px 10px 0', color: 'var(--dsw-alias-state-error-primary)', fontSize: 12, lineHeight: '18px', wordBreak: 'break-all' } }, error) : null,
3771
4588
  notice ? h('div', { role: 'status', style: { padding: '6px 10px 0', color: 'var(--dsw-alias-label-secondary, inherit)', fontSize: 12, lineHeight: '18px' } }, notice) : null,
3772
4589
  transferBar,
@@ -3780,7 +4597,9 @@ window.__ModuleLoader__.load({
3780
4597
  )
3781
4598
  : historyOpen
3782
4599
  ? historyPanel
3783
- : h('div', { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '8px 0 8px 8px', scrollbarGutter: 'stable' } },
4600
+ : searchView
4601
+ ? h('div', { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 0 8px', scrollbarGutter: 'stable' } }, searchView)
4602
+ : h('div', { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '8px 0 8px 8px', scrollbarGutter: 'stable' } },
3784
4603
  h('div', { style: { display: 'flex', flexDirection: 'column' } },
3785
4604
  dir !== navRoot
3786
4605
  ? h('div', {
@@ -3798,13 +4617,7 @@ window.__ModuleLoader__.load({
3798
4617
  ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.empty'))
3799
4618
  : visibleEntries.map((entry) => {
3800
4619
  const isDir = entry.type === 'dir'
3801
- return h('div', {
3802
- key: entry.path,
3803
- className: 'dsh-ssh-workbench-files-row',
3804
- 'data-sdh-files-row': isDir ? 'dir' : 'file',
3805
- 'data-sdh-files-path': entry.path,
3806
- role: 'button',
3807
- tabIndex: 0,
4620
+ const rowHandlers = {
3808
4621
  onClick: () => { if (isDir) navigate(entry.path); else openFile(entry) },
3809
4622
  onKeyDown: (event) => {
3810
4623
  if (event.key !== 'Enter' && event.key !== ' ') return
@@ -3812,6 +4625,19 @@ window.__ModuleLoader__.load({
3812
4625
  if (isDir) navigate(entry.path)
3813
4626
  else openFile(entry)
3814
4627
  },
4628
+ }
4629
+ // Official trigger adds onContextMenu + Shift+F10 and
4630
+ // owns tabIndex; keep the row a plain button otherwise.
4631
+ const rowProps = getTriggerProps
4632
+ ? getTriggerProps(menuTargetOf(entry), rowHandlers)
4633
+ : { ...rowHandlers, tabIndex: 0 }
4634
+ return h('div', {
4635
+ key: entry.path,
4636
+ className: 'dsh-ssh-workbench-files-row',
4637
+ 'data-sdh-files-row': isDir ? 'dir' : 'file',
4638
+ 'data-sdh-files-path': entry.path,
4639
+ role: 'button',
4640
+ ...rowProps,
3815
4641
  },
3816
4642
  h('span', { style: { display: 'inline-flex', flexShrink: 0, color: isDir ? 'var(--dsw-alias-label-tertiary)' : undefined } }, isDir ? nativeFolderIcon(false) : nativeFileIcon(entry.name)),
3817
4643
  h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, entry.name),
@@ -4571,6 +5397,8 @@ ${d.join(`
4571
5397
  ? h('div', { className: 'dsh-ssh-workbench-empty', style: { flex: 1 } }, t('panel.loading'))
4572
5398
  : h(RemoteFilesPanel, {
4573
5399
  t,
5400
+ useContextMenu: props.useContextMenu,
5401
+ renderFactorySlot: props.renderFactorySlot,
4574
5402
  controlledServer: activeTab.server,
4575
5403
  hideServerPicker: true,
4576
5404
  initialPath: activeRemoteHome || '/',
@@ -4580,6 +5408,8 @@ ${d.join(`
4580
5408
  : localRoot
4581
5409
  ? h(RemoteFilesPanel, {
4582
5410
  t,
5411
+ useContextMenu: props.useContextMenu,
5412
+ renderFactorySlot: props.renderFactorySlot,
4583
5413
  localMode: true,
4584
5414
  controlledServer: '__local__',
4585
5415
  initialPath: localRoot,
@@ -4605,46 +5435,120 @@ ${d.join(`
4605
5435
  )
4606
5436
  }
4607
5437
 
4608
- /** Plugin-manager bundle configuration: the left workbench rail switch. */
5438
+ /** Plugin-manager bundle configuration: sidebar entry + remote write switch. */
4609
5439
  function SidebarSettingsForm(props) {
4610
5440
  const t = props.t ?? ((key) => key)
4611
5441
  ensurePanelStyle()
4612
5442
  const [enabled, setEnabled] = useState(null)
5443
+ const [writeEnabled, setWriteEnabled] = useState(null)
5444
+ const [hierarchyEnabled, setHierarchyEnabled] = useState(null)
4613
5445
  const [busy, setBusy] = useState(false)
4614
5446
  const [error, setError] = useState('')
4615
5447
  const [notice, setNotice] = useState('')
4616
5448
 
4617
5449
  useEffect(() => {
4618
5450
  let alive = true
4619
- api('/prefs')
4620
- .then((body) => { if (alive) setEnabled(body?.prefs?.sidebar !== false) })
5451
+ Promise.all([api('/prefs'), api('/state')])
5452
+ .then(([prefsBody, stateBody]) => {
5453
+ if (!alive) return
5454
+ setEnabled(prefsBody?.prefs?.sidebar !== false)
5455
+ setWriteEnabled(stateBody?.config?.remoteFileWrite === true)
5456
+ setHierarchyEnabled(stateBody?.config?.systemFileHierarchy === true)
5457
+ })
4621
5458
  .catch((cause) => { if (alive) setError(t('workbench.sidebarLoadFailed', { msg: messageOf(cause) })) })
4622
5459
  return () => { alive = false }
4623
5460
  }, [])
4624
5461
 
4625
- const save = async () => {
4626
- if (enabled === null || busy) return
5462
+ /**
5463
+ * Apply one switch immediately: the flip is already visible, so the Host
5464
+ * write runs right away and only the effective value comes back. There is
5465
+ * no Save button — a settings switch that needs a second click to take
5466
+ * effect reads as broken.
5467
+ */
5468
+ const commit = async (patch) => {
5469
+ if (busy) return
4627
5470
  setBusy(true)
4628
5471
  setError('')
4629
5472
  setNotice('')
4630
5473
  try {
4631
- const body = await post('/prefs', { sidebar: enabled === true })
4632
- const value = body?.prefs?.sidebar !== false
5474
+ const prefsBody = await post('/prefs', patch)
5475
+ const value = prefsBody?.prefs?.sidebar !== false
4633
5476
  setEnabled(value)
5477
+ let effective = prefsBody?.config?.remoteFileWrite
5478
+ if (typeof effective !== 'boolean') {
5479
+ // Older Hosts answer with prefs only: read the effective value back
5480
+ // so the switch never reports a state the backend did not take.
5481
+ try {
5482
+ const stateBody = await api('/state')
5483
+ effective = stateBody?.config?.remoteFileWrite
5484
+ } catch {
5485
+ effective = undefined
5486
+ }
5487
+ }
5488
+ if (typeof effective === 'boolean') setWriteEnabled(effective)
5489
+ let hierarchyEffective = prefsBody?.config?.systemFileHierarchy
5490
+ if (typeof hierarchyEffective !== 'boolean') {
5491
+ try {
5492
+ const stateBody = await api('/state')
5493
+ hierarchyEffective = stateBody?.config?.systemFileHierarchy
5494
+ } catch {
5495
+ hierarchyEffective = undefined
5496
+ }
5497
+ }
5498
+ if (typeof hierarchyEffective === 'boolean') {
5499
+ setHierarchyEnabled(hierarchyEffective)
5500
+ if (typeof systemFilesSync === 'function') systemFilesSync(hierarchyEffective === true)
5501
+ }
4634
5502
  if (typeof props.onApply === 'function') props.onApply(value)
4635
- setNotice(t('workbench.saved'))
5503
+ // An older Host silently ignores a field it does not know. Only claim
5504
+ // success when the effective value confirms the flip.
5505
+ let applied = true
5506
+ if ('remoteFileWrite' in patch) {
5507
+ applied = applied && typeof effective === 'boolean' && effective === (patch.remoteFileWrite === true)
5508
+ }
5509
+ if ('systemFileHierarchy' in patch) {
5510
+ applied = applied && typeof hierarchyEffective === 'boolean' && hierarchyEffective === (patch.systemFileHierarchy === true)
5511
+ }
5512
+ setNotice(applied ? t('workbench.saved') : t('workbench.writeNeedRestart'))
4636
5513
  } catch (cause) {
4637
- setError(t('workbench.sidebarSaveFailed', { msg: messageOf(cause) }))
5514
+ // A Host built before this switch existed rejects the new field with a
5515
+ // schema error instead of ignoring it. That is a restart, not a bug.
5516
+ if (/prefs\.(sidebar|remoteFileWrite|systemFileHierarchy) must be a boolean/.test(messageOf(cause) || '')) {
5517
+ setNotice(t('workbench.writeNeedRestart'))
5518
+ } else {
5519
+ setError(t('workbench.sidebarSaveFailed', { msg: messageOf(cause) }))
5520
+ }
4638
5521
  } finally {
4639
5522
  setBusy(false)
4640
5523
  }
4641
5524
  }
4642
5525
 
5526
+ const switchRow = (options) => h('div', {
5527
+ style: { display: 'flex', alignItems: 'center', gap: 12, padding: options.first ? '0' : '16px 0 0' },
5528
+ },
5529
+ h('div', { style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 } },
5530
+ h('strong', { style: { fontSize: 14, fontWeight: 500, lineHeight: '20px', color: 'var(--dsw-alias-label-primary, inherit)' } }, options.title),
5531
+ h('span', { style: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, options.hint),
5532
+ ),
5533
+ h('button', {
5534
+ type: 'button',
5535
+ role: 'switch',
5536
+ 'aria-checked': options.checked === true ? 'true' : 'false',
5537
+ 'aria-label': options.title,
5538
+ 'data-sdh-switch': options.key,
5539
+ className: 'dsh-ssh-workbench-switch',
5540
+ disabled: options.disabled,
5541
+ onClick: options.onToggle,
5542
+ }, h('span', { className: 'dsh-ssh-workbench-switch-thumb' })),
5543
+ )
5544
+
4643
5545
  if (props.view === 'summary') {
5546
+ const parts = []
5547
+ if (enabled !== null) parts.push(`${t('workbench.sidebarTitle')} ${enabled ? t('workbench.enabled') : t('workbench.disabled')}`)
5548
+ if (writeEnabled !== null) parts.push(`${t('workbench.writeTitle')} ${writeEnabled ? t('workbench.writeOn') : t('workbench.writeOff')}`)
5549
+ if (hierarchyEnabled !== null) parts.push(`${t('workbench.hierarchyTitle')} ${hierarchyEnabled ? t('workbench.hierarchyOn') : t('workbench.hierarchyOff')}`)
4644
5550
  return h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-secondary, inherit)' } },
4645
- h('span', null, t('workbench.sidebarTitle')),
4646
- h('span', { style: { color: 'var(--dsw-alias-label-tertiary, inherit)' } },
4647
- enabled === null ? '' : (enabled ? t('workbench.enabled') : t('workbench.disabled'))),
5551
+ h('span', null, parts.join(' · ')),
4648
5552
  )
4649
5553
  }
4650
5554
 
@@ -4652,26 +5556,52 @@ ${d.join(`
4652
5556
  'data-sdh-sidebar-pref': '',
4653
5557
  style: { display: 'flex', flexDirection: 'column', gap: 12, width: '100%', padding: '16px 0' },
4654
5558
  },
4655
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 12 } },
4656
- h('div', { style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 } },
4657
- h('strong', { style: { fontSize: 14, fontWeight: 500, lineHeight: '20px', color: 'var(--dsw-alias-label-primary, inherit)' } }, t('workbench.sidebarTitle')),
4658
- h('span', { style: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, t('workbench.sidebarHint')),
4659
- ),
4660
- h('button', {
4661
- type: 'button',
4662
- role: 'switch',
4663
- 'aria-checked': enabled === true ? 'true' : 'false',
4664
- 'aria-label': t('workbench.sidebarTitle'),
4665
- className: 'dsh-ssh-workbench-switch',
4666
- disabled: enabled === null || busy,
4667
- onClick: () => setEnabled((value) => !(value === true)),
4668
- }, h('span', { className: 'dsh-ssh-workbench-switch-thumb' })),
4669
- ),
5559
+ switchRow({
5560
+ key: 'sidebar',
5561
+ first: true,
5562
+ title: t('workbench.sidebarTitle'),
5563
+ hint: t('workbench.sidebarHint'),
5564
+ checked: enabled === true,
5565
+ disabled: enabled === null || busy,
5566
+ onToggle: () => {
5567
+ const next = !(enabled === true)
5568
+ setEnabled(next)
5569
+ commit({ sidebar: next })
5570
+ },
5571
+ }),
5572
+ switchRow({
5573
+ key: 'remoteFileWrite',
5574
+ first: false,
5575
+ title: t('workbench.writeTitle'),
5576
+ hint: t('workbench.writeHint'),
5577
+ checked: writeEnabled === true,
5578
+ disabled: writeEnabled === null || busy,
5579
+ onToggle: () => {
5580
+ const next = !(writeEnabled === true)
5581
+ setWriteEnabled(next)
5582
+ setNotice('')
5583
+ commit({ remoteFileWrite: next })
5584
+ },
5585
+ }),
5586
+ switchRow({
5587
+ key: 'systemFileHierarchy',
5588
+ first: false,
5589
+ title: t('workbench.hierarchyTitle'),
5590
+ hint: t('workbench.hierarchyHint'),
5591
+ checked: hierarchyEnabled === true,
5592
+ disabled: hierarchyEnabled === null || busy,
5593
+ onToggle: () => {
5594
+ const next = !(hierarchyEnabled === true)
5595
+ setHierarchyEnabled(next)
5596
+ if (typeof systemFilesSync === 'function') systemFilesSync(next)
5597
+ setNotice('')
5598
+ commit({ systemFileHierarchy: next })
5599
+ },
5600
+ }),
4670
5601
  error ? h('div', { className: 'dsh-ssh-workbench-error', role: 'alert' }, error) : null,
4671
- h('div', { style: { display: 'flex', alignItems: 'center', gap: 8 } },
4672
- h(UiButton, { variant: 'primary', disabled: busy || enabled === null, onClick: save }, busy ? t('workbench.saving') : t('panel.save')),
4673
- notice ? h('span', { role: 'status', style: { fontSize: 12, color: 'var(--dsw-alias-label-tertiary, inherit)' } }, notice) : null,
4674
- ),
5602
+ notice
5603
+ ? h('div', { role: 'status', style: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, notice)
5604
+ : null,
4675
5605
  )
4676
5606
  }
4677
5607
 
@@ -4699,6 +5629,36 @@ ${d.join(`
4699
5629
  let layoutService = null
4700
5630
  if (typeof ctx.inject === 'function') {
4701
5631
  ctx.inject(['layout'], (inner) => { layoutService = inner.get('layout') ?? null })
5632
+ // The gateway exposes Host-local file actions (open in an app / reveal in
5633
+ // Finder) through `remote.session`. Only Host paths resolve, so the
5634
+ // hierarchy page (session cwd) uses it and remote SFTP pages never do.
5635
+ // The gateway exposes Host-local file actions through its namespace
5636
+ // service. Inject the namespace itself: reading `ctx.remote.session`
5637
+ // from outside an inject throws.
5638
+ ctx.inject(['remote.session'], (inner) => { sessionRemote = inner.get('remote.session') ?? null })
5639
+ // Workspace search for the local hierarchy page comes from the same
5640
+ // gateway namespace the native files page uses (packaged ripgrep).
5641
+ ctx.inject(['remote.workspaceFiles'], (inner) => { workspaceFilesRemote = inner.get('remote.workspaceFiles') ?? null })
5642
+ // Host-native directory picker used by the hierarchy page path control.
5643
+ ctx.inject(['uiWorkspace'], (inner) => { uiWorkspaceService = inner.get('uiWorkspace') ?? null })
5644
+ // Native file rows reuse the official context menu: right-click and
5645
+ // Shift+F10 come from its trigger bindings, and the actions below are
5646
+ // resolved when the menu opens.
5647
+ ctx.inject(['contextMenus'], (inner) => {
5648
+ const menus = inner.get('contextMenus')
5649
+ if (!menus || typeof menus.register !== 'function') return
5650
+ const register = () => menus.register({
5651
+ id: `${NS}.files`,
5652
+ target: FILES_MENU_TARGET,
5653
+ order: 100,
5654
+ resolve: (value) => resolveFilesMenuActions(value),
5655
+ })
5656
+ if (typeof inner.effect === 'function') {
5657
+ inner.effect(register, 'dsh-ssh-workbench: files context menu')
5658
+ } else {
5659
+ register()
5660
+ }
5661
+ })
4702
5662
  }
4703
5663
  // The left-rail workbench entry is the one sidebar surface the plugin
4704
5664
  // manager switch controls. Registration is a pair: the `main` page the
@@ -4713,9 +5673,13 @@ ${d.join(`
4713
5673
  own(slots.inject('main', () =>
4714
5674
  slots.register(
4715
5675
  { name: 'main', key: WORKBENCH_PANEL_ID, locale: NS, inject: () => ({ t }) },
4716
- function SdhSshOpsWorkbenchPage() {
5676
+ function SdhSshOpsWorkbenchPage(props) {
4717
5677
  return h(WorkbenchPage, {
4718
5678
  t,
5679
+ // The official file context menu is wired through slot props;
5680
+ // pass them down so the explorer rows can use it.
5681
+ useContextMenu: props.useContextMenu,
5682
+ renderFactorySlot: props.renderFactorySlot,
4719
5683
  onBack: () => {
4720
5684
  try { layoutService?.selectPanel?.(null) } catch { /* navigation is best effort */ }
4721
5685
  },
@@ -4828,6 +5792,63 @@ ${d.join(`
4828
5792
  },
4829
5793
  ),
4830
5794
  )
5795
+ slots.inject('sidebar.right.pane.tab.title', () =>
5796
+ slots.register(
5797
+ { name: 'sidebar.right.pane.tab.title', key: 'dsh-ssh-workbench/system-files', locale: NS },
5798
+ function SdhSshOpsSystemFilesTitle() {
5799
+ // Match DSH's own files tab title: the folder glyph from
5800
+ // `FileTypeIcon` followed by the label, no extra chrome.
5801
+ const FileTypeIcon = primitives && primitives.FileTypeIcon
5802
+ return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 0 } },
5803
+ typeof FileTypeIcon === 'function'
5804
+ ? h(FileTypeIcon, { kind: 'folder', size: 16, style: { flexShrink: 0 } })
5805
+ : h(FilesTabArtwork, { size: 14 }),
5806
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t('files.systemTitle')),
5807
+ )
5808
+ },
5809
+ ),
5810
+ )
5811
+ slots.inject('sidebar.right.pane.tab', () =>
5812
+ slots.register(
5813
+ {
5814
+ name: 'sidebar.right.pane.tab',
5815
+ key: 'dsh-ssh-workbench/system-files',
5816
+ inject: (sessionId, actions) => ({ actions }),
5817
+ },
5818
+ function SdhSshOpsSystemFilesBody(props) {
5819
+ const useTabInfo = props.useTabInfo
5820
+ const tabInfo = typeof useTabInfo === 'function' ? useTabInfo() : null
5821
+ const useSessions = props.useSessions
5822
+ // The session workspace is the only root the takeover may show;
5823
+ // the panel locks navigation to it so this page cannot escape the
5824
+ // same boundary as DSH's native files page.
5825
+ const cwd = typeof useSessions === 'function'
5826
+ ? useSessions((sessions) => {
5827
+ const row = sessions && sessions.byId && sessions.byId[props.sessionId]
5828
+ return row && typeof row.cwd === 'string' && row.cwd ? row.cwd : undefined
5829
+ })
5830
+ : undefined
5831
+ if (!cwd) {
5832
+ return h('div', {
5833
+ style: { padding: 16, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, inherit)' },
5834
+ }, t('files.noSessionDir'))
5835
+ }
5836
+ return h(RemoteFilesPanel, {
5837
+ t,
5838
+ ...props,
5839
+ localMode: true,
5840
+ controlledServer: '__local__',
5841
+ rootPath: cwd,
5842
+ initialPath: cwd,
5843
+ rootLabel: cwd,
5844
+ lockRoot: true,
5845
+ openNative: true,
5846
+ hideServerPicker: true,
5847
+ tabInfo,
5848
+ })
5849
+ },
5850
+ ),
5851
+ )
4831
5852
  slots.inject('sidebar.right.pane.tab', () =>
4832
5853
  slots.register(
4833
5854
  { name: 'sidebar.right.pane.tab', key: 'dsh-ssh-workbench/file' },
@@ -4895,6 +5916,48 @@ ${d.join(`
4895
5916
  title: () => t('files.title'),
4896
5917
  guide: [],
4897
5918
  }), 'dsh-ssh-workbench: files tab')
5919
+
5920
+ // Take over the native `files` kind only when the setting is on. The
5921
+ // extension definition outranks the builtin one, so the official page
5922
+ // is shadowed without being unregistered; toggling off restores it.
5923
+ let systemFilesDisposer = null
5924
+ const syncSystemFiles = (enabled) => {
5925
+ if (enabled === true && !systemFilesDisposer) {
5926
+ systemFilesDisposer = tabs.register({
5927
+ id: 'dsh-ssh-workbench/system-files',
5928
+ kind: 'files',
5929
+ keepMounted: true,
5930
+ title: () => t('files.systemTitle'),
5931
+ /**
5932
+ * Reuse DSH's guide entry verbatim (`workspace` / `workspace.files`
5933
+ * / order 10 / its artwork) so the entry keeps its original slot on
5934
+ * the guide page instead of dropping to the bottom.
5935
+ */
5936
+ guide: [{
5937
+ id: 'workspace',
5938
+ commandId: 'workspace.files',
5939
+ order: 10,
5940
+ title: () => t('files.systemTitle'),
5941
+ description: () => t('files.systemDescription'),
5942
+ ...(primitives && primitives.GuideArtworkFiles ? { icon: primitives.GuideArtworkFiles } : {}),
5943
+ }],
5944
+ })
5945
+ } else if (enabled !== true && systemFilesDisposer) {
5946
+ systemFilesDisposer()
5947
+ systemFilesDisposer = null
5948
+ }
5949
+ }
5950
+ systemFilesSync = syncSystemFiles
5951
+ inner.effect(() => () => {
5952
+ if (systemFilesDisposer) {
5953
+ systemFilesDisposer()
5954
+ systemFilesDisposer = null
5955
+ }
5956
+ if (systemFilesSync === syncSystemFiles) systemFilesSync = null
5957
+ }, 'dsh-ssh-workbench: system files tab')
5958
+ api('/state')
5959
+ .then((body) => syncSystemFiles(body?.config?.systemFileHierarchy === true))
5960
+ .catch(() => {})
4898
5961
  })
4899
5962
 
4900
5963
  // Session-scoped contexts (composer reference events) come from this service.