dsh-ssh-workbench 1.7.0 → 1.7.8

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,22 @@ 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.search': '搜索文件',
198
+ 'files.searchPlaceholder': '搜索文件名或路径',
199
+ 'files.searchClear': '清空搜索',
200
+ 'files.searching': '正在搜索…',
201
+ 'files.searchFailed': '搜索失败,请稍后重试',
202
+ 'files.searchEmpty': '没有匹配的文件',
203
+ 'files.searchTruncated': '结果较多,只显示前面一部分',
204
+ 'files.searchIncomplete': '搜索未完成,结果可能不全',
176
205
  'files.copyPathEntry': '复制路径',
177
206
  'files.rename': '重命名',
178
207
  'files.delete': '删除',
@@ -233,6 +262,17 @@ window.__ModuleLoader__.load({
233
262
  'files.sourceMode': '源码',
234
263
  'files.viewerLoading': '读取文件…',
235
264
  'files.viewerBinary': '二进制文件,暂不支持在线阅读',
265
+ 'files.truncated': '已截断',
266
+ 'files.viewerTruncated': '文件较大,这里只显示开头一部分。',
267
+ 'files.viewerImageTruncated': '图片超过预览上限,无法完整显示;请下载后查看。',
268
+ 'files.tableMode': '表格',
269
+ 'files.imageMode': '图片',
270
+ 'files.tableRows': '{n} 行 · {columns} 列',
271
+ 'files.tableRowsPartial': '前 {n} 行 · {columns} 列',
272
+ 'files.linesPartial': '前 {n} 行',
273
+ 'files.tableTruncatedRows': '表格较大,只显示前 {rows} 行。',
274
+ 'files.tableTruncatedColumns': '表格较宽,只显示前 {columns} 列。',
275
+ 'files.tableTruncatedBoth': '表格较大,只显示前 {rows} 行、前 {columns} 列。',
236
276
  'files.viewerDirectory': '该条目是目录,无法预览',
237
277
  'files.viewerNoParams': '预览参数缺失,请从远程文件重新打开',
238
278
  'files.openUnavailable': '当前宿主不支持打开预览页签',
@@ -309,6 +349,17 @@ window.__ModuleLoader__.load({
309
349
  'workbench.disabled': 'Off',
310
350
  'workbench.sidebarLoadFailed': 'Failed to read sidebar settings: {msg}',
311
351
  'workbench.sidebarSaveFailed': 'Save failed: {msg}',
352
+ 'workbench.writeTitle': 'Allow remote writes',
353
+ '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.',
354
+ 'workbench.writeOn': 'Allowed',
355
+ 'workbench.writeOff': 'Blocked',
356
+ 'workbench.writeLoadFailed': 'Failed to read the remote write setting: {msg}',
357
+ 'workbench.writeNeedRestart': 'Saved, but the running backend does not support this switch yet. Restart DSH to apply it.',
358
+ 'workbench.hierarchyTitle': 'Hierarchical system file browser',
359
+ '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.',
360
+ 'workbench.hierarchyOn': 'Hierarchical',
361
+ 'workbench.hierarchyOff': 'Native',
362
+ 'workbench.hierarchyLoadFailed': 'Could not read the system file browser setting: {msg}',
312
363
  'workbench.saving': 'Saving…',
313
364
  'workbench.saved': 'Saved',
314
365
  'workbench.split': 'Drag to resize the terminal and file panes',
@@ -383,6 +434,10 @@ window.__ModuleLoader__.load({
383
434
  'panel.keyRequired': 'Key file path is required',
384
435
  'source.section': 'Managed resources',
385
436
  'files.title': 'Remote files',
437
+ 'files.systemTitle': 'Workspace files',
438
+ 'files.systemDescription': 'Browse the current session directory hierarchically',
439
+ 'files.noSessionDir': 'This session has no working directory yet',
440
+ 'files.outsideRoot': 'Only the current session directory can be browsed',
386
441
  'files.up': 'Up',
387
442
  'files.refresh': 'Refresh',
388
443
  'files.empty': 'Empty directory',
@@ -397,8 +452,22 @@ window.__ModuleLoader__.load({
397
452
  'files.enterPath': 'Enter a path and press Enter',
398
453
  'files.created': 'Folder created: {name}',
399
454
  'files.more': 'More actions',
455
+ 'files.openFile': 'Open',
456
+ 'files.actionFailed': 'Action failed: {message}',
400
457
  'files.downloadFile': 'Download',
401
458
  'files.copyFile': 'Copy file',
459
+ 'files.openInApp': 'Open in app',
460
+ 'files.openInAppWith': 'Open with {app}',
461
+ 'files.openInAppMore': 'Open with another app',
462
+ 'files.openInAppDefault': '{app} (default)',
463
+ 'files.search': 'Search files',
464
+ 'files.searchPlaceholder': 'Search by name or path',
465
+ 'files.searchClear': 'Clear search',
466
+ 'files.searching': 'Searching…',
467
+ 'files.searchFailed': 'Search failed, please try again',
468
+ 'files.searchEmpty': 'No matching files',
469
+ 'files.searchTruncated': 'Too many matches; only the first ones are shown',
470
+ 'files.searchIncomplete': 'Search incomplete; results may be partial',
402
471
  'files.copyPathEntry': 'Copy path',
403
472
  'files.rename': 'Rename',
404
473
  'files.delete': 'Delete',
@@ -457,6 +526,17 @@ window.__ModuleLoader__.load({
457
526
  'files.sourceMode': 'Source',
458
527
  'files.viewerLoading': 'Reading file…',
459
528
  'files.viewerBinary': 'Binary file; inline reading is not available',
529
+ 'files.truncated': 'Truncated',
530
+ 'files.viewerTruncated': 'This file is large; only the beginning is shown.',
531
+ 'files.viewerImageTruncated': 'This image is larger than the preview limit and cannot be shown in full; download it to view.',
532
+ 'files.tableMode': 'Table',
533
+ 'files.imageMode': 'Image',
534
+ 'files.tableRows': '{n} rows · {columns} columns',
535
+ 'files.tableRowsPartial': 'first {n} rows · {columns} columns',
536
+ 'files.linesPartial': 'first {n} lines',
537
+ 'files.tableTruncatedRows': 'This table is large; only the first {rows} rows are shown.',
538
+ 'files.tableTruncatedColumns': 'This table is wide; only the first {columns} columns are shown.',
539
+ 'files.tableTruncatedBoth': 'This table is large; only the first {rows} rows and {columns} columns are shown.',
460
540
  'files.viewerDirectory': 'This entry is a directory; it cannot be previewed',
461
541
  'files.viewerNoParams': 'Missing preview parameters; reopen from Remote files',
462
542
  'files.openUnavailable': 'This host cannot open preview tabs',
@@ -579,8 +659,14 @@ window.__ModuleLoader__.load({
579
659
  let composerBridge = null
580
660
  /** Client `sessions` service: gives the session-scoped ctx that owns composer events. */
581
661
  let sessionsService = null
662
+ /** Set while the sidebar tab service is available; toggled from the settings form. */
663
+ let systemFilesSync = null
582
664
  /** Owner client ctx, set in apply(); used by the composer target chip. */
583
665
  let clientCtx = null
666
+ /** Namespace service (`remote.session`) used to open Host-local paths in their app. */
667
+ let sessionRemote = null
668
+ /** Namespace service (`remote.workspaceFiles`) used by the local files search. */
669
+ let workspaceFilesRemote = null
584
670
 
585
671
 
586
672
  /** The open conversation's session id, exposed on the chat region for a11y/debugging. */
@@ -917,6 +1003,17 @@ window.__ModuleLoader__.load({
917
1003
  '.dsh-ssh-workbench-files-split button:hover{background:var(--dsw-alias-interactive-bg-hover)}',
918
1004
  '.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
1005
  '.dsh-ssh-workbench-files-split svg{width:14px;height:14px}',
1006
+ '.dsh-ssh-workbench-files-search{box-sizing:border-box;height:42px;padding:8px 8px 4px;flex:none}',
1007
+ '.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)}',
1008
+ '.dsh-ssh-workbench-files-search-field:focus-within{border-color:var(--dsw-alias-state-business-primary)}',
1009
+ '.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}',
1010
+ '.dsh-ssh-workbench-files-search-glyph svg{width:14px;height:14px}',
1011
+ '.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}',
1012
+ '.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}',
1013
+ '.dsh-ssh-workbench-files-search-clear:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}',
1014
+ '.dsh-ssh-workbench-files-search-clear svg{width:14px;height:14px}',
1015
+ '.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}',
1016
+ '.dsh-ssh-workbench-files-search-note{padding:6px 10px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}',
920
1017
  '.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
1018
  '.dsh-ssh-workbench-tool-row:disabled{cursor:default}',
922
1019
  '.dsh-ssh-workbench-tool-row:hover:not(:disabled){color:var(--dsw-alias-label-secondary)}',
@@ -2209,9 +2306,24 @@ window.__ModuleLoader__.load({
2209
2306
 
2210
2307
  const FILES_IMAGE_TYPES = {
2211
2308
  png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
2212
- webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon',
2309
+ webp: 'image/webp', bmp: 'image/bmp', ico: 'image/x-icon', svg: 'image/svg+xml',
2213
2310
  }
2214
2311
 
2312
+ /** Delimited text rendered as a grid; the value is the field separator. */
2313
+ const FILES_TABLE_TYPES = { csv: ',', tsv: '\t' }
2314
+ const FILES_TABLE_MAX_ROWS = 500
2315
+ const FILES_TABLE_MAX_COLUMNS = 40
2316
+
2317
+ /**
2318
+ * The exclusion list DSH's own workspace search ships with. The rules are
2319
+ * literal names/paths, so the local page keeps the same results as the
2320
+ * native files page instead of searching `node_modules` or `.git`.
2321
+ */
2322
+ const FILES_SEARCH_EXCLUDED = [
2323
+ '.git', '.hg', '.next', '.pnpm-store', '.svn', '.turbo', '.yarn', 'build', 'coverage', 'dist', 'node_modules',
2324
+ '.DS_Store', '.Spotlight-V100', '.Trashes', '.fseventsd', 'Thumbs.db', 'desktop.ini', '$RECYCLE.BIN', 'System Volume Information',
2325
+ ]
2326
+
2215
2327
  function filesExtension(name) {
2216
2328
  const text = String(name || '')
2217
2329
  const dot = text.lastIndexOf('.')
@@ -2285,6 +2397,15 @@ window.__ModuleLoader__.load({
2285
2397
  return bytes
2286
2398
  }
2287
2399
 
2400
+ /** Decode a UTF-8 payload from base64; used to show SVG source as text. */
2401
+ function decodeBase64Text(base64) {
2402
+ try {
2403
+ return new TextDecoder('utf-8', { fatal: false }).decode(filesBase64ToBytes(base64))
2404
+ } catch {
2405
+ return ''
2406
+ }
2407
+ }
2408
+
2288
2409
  function filesLoadTransferRecords() {
2289
2410
  try {
2290
2411
  const raw = window.localStorage.getItem(FILES_TRANSFER_STORAGE_KEY)
@@ -2438,6 +2559,75 @@ window.__ModuleLoader__.load({
2438
2559
  return { body: source.slice(match[0].length), source: match[1] }
2439
2560
  }
2440
2561
 
2562
+ /**
2563
+ * Split delimited text into rows and cells. Handles quoted fields (with
2564
+ * doubled quotes and embedded newlines), CRLF, and a trailing newline. The
2565
+ * reader is bounded so a pathological file cannot stall the renderer.
2566
+ */
2567
+ function parseDelimitedTable(source, separator) {
2568
+ const text = String(source || '').replace(/^\uFEFF/, '')
2569
+ const rows = []
2570
+ let row = []
2571
+ let cell = ''
2572
+ let quoted = false
2573
+ let index = 0
2574
+ const pushCell = () => { row.push(cell); cell = '' }
2575
+ const pushRow = () => {
2576
+ pushCell()
2577
+ rows.push(row)
2578
+ row = []
2579
+ return rows.length <= FILES_TABLE_MAX_ROWS
2580
+ }
2581
+ while (index < text.length) {
2582
+ const char = text[index]
2583
+ if (quoted) {
2584
+ if (char === '"') {
2585
+ if (text[index + 1] === '"') { cell += '"'; index += 2; continue }
2586
+ quoted = false
2587
+ index += 1
2588
+ continue
2589
+ }
2590
+ cell += char
2591
+ index += 1
2592
+ continue
2593
+ }
2594
+ if (char === '"' && cell === '') { quoted = true; index += 1; continue }
2595
+ if (char === separator) { pushCell(); index += 1; continue }
2596
+ if (char === '\r') { index += 1; continue }
2597
+ if (char === '\n') {
2598
+ if (!pushRow()) return { rows, truncated: true }
2599
+ index += 1
2600
+ continue
2601
+ }
2602
+ cell += char
2603
+ index += 1
2604
+ }
2605
+ if (cell !== '' || row.length > 0) pushRow()
2606
+ return { rows, truncated: rows.length > FILES_TABLE_MAX_ROWS }
2607
+ }
2608
+
2609
+ /** Present a parsed table: header row plus a bounded body for rendering. */
2610
+ function buildDelimitedTable(source, separator) {
2611
+ const parsed = parseDelimitedTable(source, separator)
2612
+ const rows = parsed.rows
2613
+ if (rows.length === 0) return null
2614
+ const columnCount = Math.min(FILES_TABLE_MAX_COLUMNS, rows.reduce((max, cells) => Math.max(max, cells.length), 0))
2615
+ const visible = rows.length > FILES_TABLE_MAX_ROWS ? rows.slice(0, FILES_TABLE_MAX_ROWS) : rows
2616
+ const rowsTruncated = parsed.truncated || rows.length > visible.length
2617
+ const columnsTruncated = rows.some((cells) => cells.length > columnCount)
2618
+ return {
2619
+ header: visible[0].slice(0, columnCount),
2620
+ body: visible.slice(1).map((cells) => cells.slice(0, columnCount)),
2621
+ columnCount,
2622
+ rowCount: rows.length,
2623
+ /** Data rows, i.e. everything after the header line. */
2624
+ dataRowCount: Math.max(0, rows.length - 1),
2625
+ rowsTruncated,
2626
+ columnsTruncated,
2627
+ truncated: rowsTruncated || columnsTruncated,
2628
+ }
2629
+ }
2630
+
2441
2631
  /** Read the opener's navigation params from a tab's framework information. */
2442
2632
  function tabParamsOf(info) {
2443
2633
  if (!info || !info.tab) return undefined
@@ -2466,6 +2656,8 @@ window.__ModuleLoader__.load({
2466
2656
  const [meta, setMeta] = useState(null)
2467
2657
  const [wrap, setWrap] = useState(true)
2468
2658
  const [markdownSource, setMarkdownSource] = useState(false)
2659
+ const [tableSource, setTableSource] = useState(false)
2660
+ const [imageSource, setImageSource] = useState(false)
2469
2661
  /** Workbench preview passes onClose; the sidebar preview tab does not. */
2470
2662
  const onClose = typeof props.onClose === 'function' ? props.onClose : null
2471
2663
 
@@ -2480,8 +2672,14 @@ window.__ModuleLoader__.load({
2480
2672
  const body = await post('/sftp/read', { ref: server, path, base64: true, maxBytes: 5 * 1024 * 1024 })
2481
2673
  const result = body.result || {}
2482
2674
  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')) }
2675
+ if (result.base64) {
2676
+ setImageUrl(`data:${mime};base64,${result.base64}`)
2677
+ setBinary(false)
2678
+ // SVG is text, so keep its source available for the source view;
2679
+ // raster payloads are never decoded into text.
2680
+ setContent(filesExtension(name) === 'svg' ? decodeBase64Text(result.base64) : '')
2681
+ setImageSource(false)
2682
+ } else { setBinary(true); setContent(''); setError(t('files.viewerBinary')) }
2485
2683
  } else {
2486
2684
  const body = await post('/sftp/read', { ref: server, path, maxBytes: 1024 * 1024 })
2487
2685
  const result = body.result || {}
@@ -2506,6 +2704,17 @@ window.__ModuleLoader__.load({
2506
2704
  const lines = content ? content.split('\n') : []
2507
2705
  const extension = filesExtension(name)
2508
2706
  const isMarkdown = FILES_MARKDOWN_EXTENSIONS.includes(extension)
2707
+ const tableSeparator = FILES_TABLE_TYPES[extension]
2708
+ const isTable = Boolean(tableSeparator) && !binary && Boolean(content)
2709
+ const table = isTable && !tableSource ? buildDelimitedTable(content, tableSeparator) : null
2710
+ const isTextImage = extension === 'svg'
2711
+ const showImage = Boolean(imageUrl) && !(isTextImage && imageSource)
2712
+ const byteTruncated = meta && meta.truncated === true
2713
+ const truncatedHint = byteTruncated
2714
+ ? tr('files.viewerTruncated', '文件较大,这里只显示开头一部分。')
2715
+ : ''
2716
+ /** A clipped bitmap/svg payload cannot be decoded, so say so instead of rendering a broken image. */
2717
+ const imageTruncated = showImage && byteTruncated
2509
2718
  const MarkdownText = primitives && primitives.MarkdownText
2510
2719
  const CodeBlock = primitives && primitives.CodeBlock
2511
2720
  const markdownLabels = {
@@ -2540,13 +2749,31 @@ window.__ModuleLoader__.load({
2540
2749
  style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 13 },
2541
2750
  }, name),
2542
2751
  meta && meta.size != null ? h('span', { style: { ...panelUi.fieldLabel, flexShrink: 0 } }, filesFormatBytes(meta.size)) : null,
2752
+ byteTruncated
2753
+ ? h('span', {
2754
+ title: truncatedHint,
2755
+ style: { ...panelUi.fieldLabel, flexShrink: 0, color: 'var(--dsw-alias-state-warning-primary, inherit)' },
2756
+ }, t('files.truncated'))
2757
+ : null,
2543
2758
  isMarkdown && !binary && !imageUrl && MarkdownText
2544
2759
  ? h('button', {
2545
2760
  className: 'dsh-ssh-workbench-button dsh-ssh-workbench-button-ghost', style: { ...panelUi.ghostButton, flexShrink: 0 },
2546
2761
  onClick: () => setMarkdownSource((value) => !value),
2547
2762
  }, markdownSource ? t('files.previewMode') : t('files.sourceMode'))
2548
2763
  : 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,
2764
+ isTable
2765
+ ? h('button', {
2766
+ className: 'dsh-ssh-workbench-button dsh-ssh-workbench-button-ghost', style: { ...panelUi.ghostButton, flexShrink: 0 },
2767
+ onClick: () => setTableSource((value) => !value),
2768
+ }, tableSource ? t('files.tableMode') : t('files.sourceMode'))
2769
+ : null,
2770
+ isTextImage && imageUrl
2771
+ ? h('button', {
2772
+ className: 'dsh-ssh-workbench-button dsh-ssh-workbench-button-ghost', style: { ...panelUi.ghostButton, flexShrink: 0 },
2773
+ onClick: () => setImageSource((value) => !value),
2774
+ }, imageSource ? t('files.imageMode') : t('files.sourceMode'))
2775
+ : null,
2776
+ !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
2777
  toolbar(t('files.refresh'), refreshingIcon(), load),
2551
2778
  onClose
2552
2779
  ? toolbar(t('common.close'), icon(12, [h('path', { d: 'M18 6L6 18' }), h('path', { d: 'M6 6l12 12' })]), onClose)
@@ -2555,9 +2782,58 @@ window.__ModuleLoader__.load({
2555
2782
  h('div', { style: { flex: 1, minHeight: 0, overflow: 'auto', background: 'transparent' } },
2556
2783
  loading ? h('div', { style: { padding: '24px 12px', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, t('files.viewerLoading')) : null,
2557
2784
  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
2785
+ imageTruncated
2786
+ ? h('div', { style: { padding: '18px 14px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, tr('files.viewerImageTruncated', '图片超过预览上限,无法完整显示;请下载后查看。'))
2787
+ : showImage
2788
+ ? h('div', { style: { padding: 12, display: 'flex', justifyContent: 'center' } }, h('img', { src: imageUrl, alt: name, style: { maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' } }))
2789
+ : null,
2790
+ !loading && !error && !imageTruncated && !showImage && !binary
2791
+ ? (table
2792
+ ? h('div', { style: { padding: 12, overflow: 'auto' }, 'data-sdh-table-preview': path },
2793
+ h('table', {
2794
+ style: {
2795
+ borderCollapse: 'collapse', fontSize: 'var(--dsh-code-font-size, 12px)',
2796
+ fontFamily: 'var(--ds-font-family-code)', width: '100%',
2797
+ },
2798
+ },
2799
+ h('thead', null,
2800
+ h('tr', null, table.header.map((cell, index) => h('th', {
2801
+ key: index,
2802
+ style: {
2803
+ textAlign: 'left', fontWeight: 500, whiteSpace: 'nowrap', padding: '4px 10px',
2804
+ borderBottom: '0.5px solid var(--dsw-alias-border-l2, #ddd)',
2805
+ color: 'var(--dsw-alias-label-primary, inherit)',
2806
+ position: 'sticky', top: 0, background: 'var(--dsw-specific-menu, transparent)',
2807
+ },
2808
+ }, cell))),
2809
+ ),
2810
+ h('tbody', null,
2811
+ table.body.map((cells, rowIndex) => h('tr', { key: rowIndex },
2812
+ cells.map((cell, cellIndex) => h('td', {
2813
+ key: cellIndex,
2814
+ style: {
2815
+ padding: '3px 10px', whiteSpace: 'nowrap', maxWidth: 320, overflow: 'hidden',
2816
+ textOverflow: 'ellipsis', borderBottom: '0.5px solid var(--dsw-alias-border-l3, #eee)',
2817
+ color: 'var(--dsw-alias-label-secondary, inherit)',
2818
+ },
2819
+ }, cell))),
2820
+ ),
2821
+ ),
2822
+ ),
2823
+ table.truncated
2824
+ ? h('div', { style: { paddingTop: 8, ...panelUi.fieldLabel } },
2825
+ table.rowsTruncated && table.columnsTruncated
2826
+ ? tr('files.tableTruncatedBoth', '表格较大,只显示前 {rows} 行、前 {columns} 列。')
2827
+ .replace('{rows}', String(FILES_TABLE_MAX_ROWS))
2828
+ .replace('{columns}', String(FILES_TABLE_MAX_COLUMNS))
2829
+ : table.rowsTruncated
2830
+ ? tr('files.tableTruncatedRows', '表格较大,只显示前 {rows} 行。')
2831
+ .replace('{rows}', String(FILES_TABLE_MAX_ROWS))
2832
+ : tr('files.tableTruncatedColumns', '表格较宽,只显示前 {columns} 列。')
2833
+ .replace('{columns}', String(FILES_TABLE_MAX_COLUMNS)))
2834
+ : null,
2835
+ )
2836
+ : isMarkdown && !markdownSource && MarkdownText != null
2561
2837
  ? h('div', {
2562
2838
  style: { padding: '12px 16px', fontSize: 'var(--dsh-content-font-size, 14px)', lineHeight: 1.6, color: 'var(--dsw-alias-label-primary, inherit)' },
2563
2839
  'data-sdh-markdown-preview': '',
@@ -2603,7 +2879,20 @@ window.__ModuleLoader__.load({
2603
2879
  },
2604
2880
  },
2605
2881
  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))),
2882
+ // A clipped image has no meaningful line count, so the footer stays bare.
2883
+ imageTruncated
2884
+ ? null
2885
+ : h('span', { style: { flexShrink: 0 } }, table
2886
+ ? (byteTruncated
2887
+ ? tr('files.tableRowsPartial', '前 {n} 行 · {columns} 列')
2888
+ .replace('{n}', String(table.dataRowCount))
2889
+ .replace('{columns}', String(table.columnCount))
2890
+ : tr('files.tableRows', '{n} 行 · {columns} 列')
2891
+ .replace('{n}', String(table.dataRowCount))
2892
+ .replace('{columns}', String(table.columnCount)))
2893
+ : truncatedHint
2894
+ ? tr('files.linesPartial', '前 {n} 行').replace('{n}', String(lines.length))
2895
+ : tr('files.lines', '{n} 行').replace('{n}', String(lines.length))),
2607
2896
  ),
2608
2897
  )
2609
2898
  }
@@ -2648,6 +2937,10 @@ window.__ModuleLoader__.load({
2648
2937
  const iconArrowLeft = () => icon(14, [h('line', { x1: 19, y1: 12, x2: 5, y2: 12 }), h('polyline', { points: '12 5 5 12 12 19' })])
2649
2938
  const iconHome = () => icon(14, [h('path', { d: 'M4 10.5 12 4l8 6.5' }), h('path', { d: 'M6.5 9.5V20h11V9.5' })])
2650
2939
  const iconArrowUp16 = () => icon(16, [h('line', { x1: 12, y1: 19, x2: 12, y2: 5 }), h('polyline', { points: '5 12 12 5 19 12' })])
2940
+ const iconRightUp = () => icon(14, [
2941
+ h('path', { d: 'M8 17 17 8' }),
2942
+ h('polyline', { points: '9.5 8 17 8 17 15.5' }),
2943
+ ])
2651
2944
  const iconMore = () => icon(14, [
2652
2945
  h('circle', { cx: 5, cy: 12, r: 1.4, fill: 'currentColor', stroke: 'none' }),
2653
2946
  h('circle', { cx: 12, cy: 12, r: 1.4, fill: 'currentColor', stroke: 'none' }),
@@ -2673,6 +2966,93 @@ window.__ModuleLoader__.load({
2673
2966
 
2674
2967
  let filesPendingConflictResolve = null
2675
2968
 
2969
+ /**
2970
+ * Hand one Host-local path to the OS: `open` starts the associated app and
2971
+ * `reveal` shows it in Finder/Explorer. Only paths the Host can map back to
2972
+ * its own filesystem work, so callers must gate this to local browsing.
2973
+ */
2974
+ async function openHostPath(path, action = 'open') {
2975
+ if (typeof path !== 'string' || !path) return false
2976
+ const session = sessionRemote
2977
+ if (!session || typeof session.openWorkspacePath !== 'function') return false
2978
+ try {
2979
+ const result = action === 'reveal'
2980
+ ? await session.openWorkspacePath({ path, action: 'reveal' })
2981
+ : await session.openWorkspacePath({ path })
2982
+ return Boolean(result && result.ok)
2983
+ } catch {
2984
+ return false
2985
+ }
2986
+ }
2987
+
2988
+ /**
2989
+ * List the apps the Host would use for one path: the same call the official
2990
+ * "open in app" split button makes, so the menu matches DSH exactly.
2991
+ */
2992
+ async function loadHostApplications(path) {
2993
+ const session = sessionRemote
2994
+ if (!session || typeof session.workspacePathApplications !== 'function') return []
2995
+ try {
2996
+ const result = await session.workspacePathApplications({ path })
2997
+ if (!result || result.ok !== true || !Array.isArray(result.value)) return []
2998
+ return result.value.filter((item) => item && typeof item.id === 'string' && typeof item.name === 'string')
2999
+ } catch {
3000
+ return []
3001
+ }
3002
+ }
3003
+
3004
+ /**
3005
+ * Workspace-wide search backed by the Host's ripgrep pass. Matches are
3006
+ * `/`-joined paths relative to the session workspace root.
3007
+ */
3008
+ async function searchHostWorkspace(sessionId, request) {
3009
+ const files = workspaceFilesRemote
3010
+ if (!files || typeof files.search !== 'function') return null
3011
+ try {
3012
+ const result = await files.search(sessionId, request)
3013
+ if (!result || result.ok !== true) return null
3014
+ return result.value || null
3015
+ } catch {
3016
+ return null
3017
+ }
3018
+ }
3019
+
3020
+ /**
3021
+ * Native file rows expose the official `contextMenu` target family, so the
3022
+ * DSH context menu service resolves our actions the same way it resolves
3023
+ * workspace and document-preview files. The captured value carries only
3024
+ * JSON-safe row facts plus the id of the live panel that owns the handlers.
3025
+ */
3026
+ const FILES_MENU_TARGET = `${NS}.file`
3027
+ let filesMenuSeq = 0
3028
+ /** Panel id → mutable box `{ menuActions }` filled on every render. */
3029
+ const filesMenuPanels = new Map()
3030
+
3031
+ function filesMenuEntryOf(value) {
3032
+ if (value == null || typeof value !== 'object') return null
3033
+ const path = typeof value.path === 'string' ? value.path : ''
3034
+ if (!path) return null
3035
+ return {
3036
+ path,
3037
+ name: typeof value.name === 'string' ? value.name : baseNameOf(path),
3038
+ type: value.type === 'dir' ? 'dir' : 'file',
3039
+ size: Number.isFinite(value.size) ? value.size : 0,
3040
+ }
3041
+ }
3042
+
3043
+ /** Build the menu actions for one captured row from its live panel. */
3044
+ function resolveFilesMenuActions(value) {
3045
+ const panelId = value && typeof value.panel === 'string' ? value.panel : ''
3046
+ // The map stores the box itself, so the handlers sit directly on it.
3047
+ const box = panelId ? filesMenuPanels.get(panelId) : null
3048
+ if (!box || typeof box.menuActions !== 'function') return []
3049
+ try {
3050
+ return box.menuActions(value) || []
3051
+ } catch {
3052
+ return []
3053
+ }
3054
+ }
3055
+
2676
3056
  function RemoteFilesPanel(props) {
2677
3057
  // Hierarchical browser (Luminax `remote-file-tree` behaviour) rendered in
2678
3058
  // the native Files chrome: one directory per view, breadcrumb navigation,
@@ -2687,6 +3067,21 @@ window.__ModuleLoader__.load({
2687
3067
  const initialPath = typeof props.initialPath === 'string' && props.initialPath ? props.initialPath : '/'
2688
3068
  const navRoot = typeof props.rootPath === 'string' && props.rootPath ? props.rootPath : '/'
2689
3069
  const rootLabel = typeof props.rootLabel === 'string' && props.rootLabel ? props.rootLabel : (navRoot === '/' ? '/' : navRoot)
3070
+ const lockRoot = props.lockRoot === true
3071
+ /**
3072
+ * Local hierarchy page: it browses the Host's own disk, so uploads, new
3073
+ * folders and the transfer log — which exist for slow remote copies —
3074
+ * only add noise. The page keeps navigation, refresh, hidden files and
3075
+ * gains "open in app" instead.
3076
+ */
3077
+ const localChrome = localMode && props.openNative === true
3078
+ const withinRoot = (value) => {
3079
+ const normalize = (input) => String(input || '/').replace(/[\\/]+/g, '/').replace(/\/+$/, '') || '/'
3080
+ const root = normalize(navRoot)
3081
+ const next = normalize(value)
3082
+ if (root === '/') return next.startsWith('/')
3083
+ return next === root || next.startsWith(`${root}/`)
3084
+ }
2690
3085
  const [servers, setServers] = useState([])
2691
3086
  const [server, setServer] = useState(localMode ? (controlledServer ?? '__local__') : (controlledServer ?? ''))
2692
3087
  const [dir, setDir] = useState(initialPath)
@@ -2712,11 +3107,34 @@ window.__ModuleLoader__.load({
2712
3107
  const [showHidden, setShowHidden] = useState(() => {
2713
3108
  try { return window.localStorage.getItem('dsh-ssh-workbench.files.showHidden') === '1' } catch { return false }
2714
3109
  })
3110
+ /** Local hierarchy page only: workspace-wide search driven by ripgrep on the Host. */
3111
+ const [searchOpen, setSearchOpen] = useState(false)
3112
+ const [query, setQuery] = useState('')
3113
+ const [searchState, setSearchState] = useState(null)
3114
+ const searchSeqRef = useRef(0)
3115
+ /** Local hierarchy page only: the Host's own app list for "open in app". */
3116
+ const [hostApps, setHostApps] = useState(null)
3117
+ const [appsOpen, setAppsOpen] = useState(false)
2715
3118
  const Menu = primitives && primitives.Menu
2716
3119
  const ChevronIcon = primitives && primitives.IconChevronDownOutlineRegular
2717
3120
  const RefreshIcon = primitives && primitives.IconRefreshOutlineRegular
2718
3121
  /** Newest directory request wins; older responses are discarded (M8/R2). */
2719
3122
  const loadSeqRef = useRef(0)
3123
+ /**
3124
+ * The official context menu resolves rows by target value, so each panel
3125
+ * publishes its handlers under a stable id while it is mounted.
3126
+ */
3127
+ const panelIdRef = useRef('')
3128
+ if (!panelIdRef.current) panelIdRef.current = `files-${++filesMenuSeq}`
3129
+ const menuBoxRef = useRef({ menuActions: null })
3130
+ // Publish during render so an already-open menu sees fresh handlers, and
3131
+ // re-publish from the effect because StrictMode unmounts once on mount.
3132
+ filesMenuPanels.set(panelIdRef.current, menuBoxRef.current)
3133
+ useEffect(() => {
3134
+ const id = panelIdRef.current
3135
+ filesMenuPanels.set(id, menuBoxRef.current)
3136
+ return () => { filesMenuPanels.delete(id) }
3137
+ }, [])
2720
3138
 
2721
3139
  useEffect(() => {
2722
3140
  if (localMode) {
@@ -2781,9 +3199,62 @@ window.__ModuleLoader__.load({
2781
3199
  void loadDir(server, dir)
2782
3200
  }, [server, dir, loadDir])
2783
3201
 
3202
+ // Local hierarchy page: the Host's own app list, so "open in app" can
3203
+ // offer the same menu as DSH's native split button.
3204
+ useEffect(() => {
3205
+ if (!localChrome) return undefined
3206
+ let live = true
3207
+ void loadHostApplications(dir).then((list) => { if (live) setHostApps(list) })
3208
+ return () => { live = false }
3209
+ }, [localChrome, dir])
3210
+
3211
+ // Local hierarchy page: one workspace-wide search per settled query; a
3212
+ // newer query aborts the answer of the older one.
3213
+ useEffect(() => {
3214
+ if (!localChrome || !searchOpen) return undefined
3215
+ const text = String(query || '').trim()
3216
+ if (!text) {
3217
+ searchSeqRef.current += 1
3218
+ setSearchState(null)
3219
+ return undefined
3220
+ }
3221
+ if (typeof props.sessionId !== 'string' || !props.sessionId) return undefined
3222
+ const seq = searchSeqRef.current + 1
3223
+ searchSeqRef.current = seq
3224
+ setSearchState({ loading: true, matches: [], truncated: false, incomplete: false })
3225
+ const timer = setTimeout(() => {
3226
+ void searchHostWorkspace(props.sessionId, {
3227
+ query: text,
3228
+ excludedPaths: FILES_SEARCH_EXCLUDED,
3229
+ includeHidden: showHidden === true,
3230
+ respectGitignore: true,
3231
+ }).then((answer) => {
3232
+ if (seq !== searchSeqRef.current) return
3233
+ if (!answer) {
3234
+ setSearchState({ loading: false, failed: true, matches: [], truncated: false, incomplete: false })
3235
+ return
3236
+ }
3237
+ setSearchState({
3238
+ loading: false,
3239
+ failed: false,
3240
+ matches: Array.isArray(answer.matches) ? answer.matches : [],
3241
+ truncated: answer.truncated === true,
3242
+ incomplete: answer.incomplete === true,
3243
+ })
3244
+ })
3245
+ }, 180)
3246
+ return () => { clearTimeout(timer) }
3247
+ }, [localChrome, searchOpen, query, showHidden, props.sessionId])
3248
+
3249
+ useEffect(() => () => { searchSeqRef.current += 1 }, [])
3250
+
2784
3251
  const navigate = (next) => {
2785
3252
  const target = String(next || '/').trim() || '/'
2786
3253
  if (target === dir) return
3254
+ if (lockRoot && !withinRoot(target)) {
3255
+ setError(t('files.outsideRoot'))
3256
+ return
3257
+ }
2787
3258
  setBackStack((stack) => [...stack, dir])
2788
3259
  setDir(target)
2789
3260
  setNotice('')
@@ -2837,7 +3308,26 @@ window.__ModuleLoader__.load({
2837
3308
  })
2838
3309
  }
2839
3310
  const openFile = (entry) => {
2840
- const actions = props.actions || (props.tabInfo && props.tabInfo.tab && props.tabInfo.tab.actions)
3311
+ const actions = (props.tabInfo && props.tabInfo.tab && props.tabInfo.tab.actions) || props.actions
3312
+ // In the takeover of DSH's native files page, prefer the official
3313
+ // resource protocol so the official preview (markdown/code/image/pdf)
3314
+ // opens; the address has to be session-scoped and relative to cwd.
3315
+ if (props.openNative === true && actions && typeof actions.openResource === 'function' && typeof props.sessionId === 'string' && typeof props.rootPath === 'string') {
3316
+ const root = String(props.rootPath).replace(/[\\/]+$/, '')
3317
+ const full = String(entry.path)
3318
+ const rootLower = root.toLowerCase()
3319
+ const fullLower = full.toLowerCase()
3320
+ if (fullLower.startsWith(`${rootLower}/`) || fullLower.startsWith(`${rootLower}\\`)) {
3321
+ const relative = full.slice(root.length).replace(/^[\\/]+/, '')
3322
+ if (relative) {
3323
+ const address = `dsh-resource://file/session/${encodeURIComponent(props.sessionId)}/${relative.split(/[\\/]+/).filter(Boolean).map((part) => encodeURIComponent(part)).join('/')}`
3324
+ const panel = props.tabInfo && props.tabInfo.panel
3325
+ if (panel && panel.isFloating !== true && panel.id) actions.openResource(address, { paneId: panel.id })
3326
+ else actions.openResource(address)
3327
+ return
3328
+ }
3329
+ }
3330
+ }
2841
3331
  if (!actions || typeof actions.openTab !== 'function') {
2842
3332
  setError(t('files.openUnavailable'))
2843
3333
  return
@@ -3349,6 +3839,7 @@ window.__ModuleLoader__.load({
3349
3839
  const handleRowAction = (entry, action) => {
3350
3840
  if (action === 'download') { void downloadFile(entry); return }
3351
3841
  if (action === 'copyFile') { void copyFileEntry(entry); return }
3842
+ if (action === 'openInApp') { void openHostPath(entry.path); return }
3352
3843
  if (action === 'copyPath') { void copyPathValue(entry.path); return }
3353
3844
  if (action === 'rename') { setRenameTarget(entry); setRenameValue(entry.name); return }
3354
3845
  if (action === 'delete') { setDeleteTarget(entry); return }
@@ -3356,13 +3847,107 @@ window.__ModuleLoader__.load({
3356
3847
  const rowMenuItems = (entry) => {
3357
3848
  const isDir = entry.type === 'dir'
3358
3849
  const items = []
3359
- if (!isDir) items.push({ id: 'download', label: t('files.downloadFile') })
3360
- if (!isDir) items.push({ id: 'copyFile', label: t('files.copyFile') })
3850
+ if (!isDir && !localChrome) items.push({ id: 'download', label: t('files.downloadFile') })
3851
+ if (!isDir && !localChrome) items.push({ id: 'copyFile', label: t('files.copyFile') })
3852
+ if (localChrome) items.push({ id: 'openInApp', label: t('files.openInApp') })
3361
3853
  items.push({ id: 'copyPath', label: t('files.copyPathEntry') })
3362
3854
  items.push({ id: 'rename', label: t('files.rename') })
3363
3855
  items.push({ id: 'delete', label: t('files.delete') })
3364
3856
  return items
3365
3857
  }
3858
+ /** Run one row action, turning a rejection into the inline error line. */
3859
+ const runRowAction = (entry, action) => {
3860
+ try {
3861
+ handleRowAction(entry, action)
3862
+ } catch (cause) {
3863
+ setError(t('files.actionFailed', { message: messageOf(cause) }))
3864
+ }
3865
+ }
3866
+ /**
3867
+ * Official `MenuAction[]` for one row. The context menu service resolves
3868
+ * these lazily, so labels are evaluated at open time and stay localized.
3869
+ */
3870
+ const rowMenuActions = (entry) => {
3871
+ const isDir = entry.type === 'dir'
3872
+ const actions = []
3873
+ if (!isDir) {
3874
+ actions.push({
3875
+ id: 'open',
3876
+ label: t('files.openFile'),
3877
+ run: () => openFile(entry),
3878
+ })
3879
+ }
3880
+ if (!isDir && !localChrome) {
3881
+ actions.push({
3882
+ id: 'download',
3883
+ label: t('files.downloadFile'),
3884
+ separatorBefore: actions.length > 0,
3885
+ run: () => runRowAction(entry, 'download'),
3886
+ })
3887
+ actions.push({
3888
+ id: 'copyFile',
3889
+ label: t('files.copyFile'),
3890
+ run: () => runRowAction(entry, 'copyFile'),
3891
+ })
3892
+ }
3893
+ if (localChrome) {
3894
+ actions.push({
3895
+ id: 'openInApp',
3896
+ label: t('files.openInApp'),
3897
+ separatorBefore: actions.length > 0,
3898
+ run: () => runRowAction(entry, 'openInApp'),
3899
+ })
3900
+ }
3901
+ actions.push({
3902
+ id: 'copyPath',
3903
+ label: t('files.copyPathEntry'),
3904
+ separatorBefore: actions.length > 0,
3905
+ run: () => runRowAction(entry, 'copyPath'),
3906
+ })
3907
+ actions.push({
3908
+ id: 'rename',
3909
+ label: t('files.rename'),
3910
+ run: () => runRowAction(entry, 'rename'),
3911
+ })
3912
+ actions.push({
3913
+ id: 'delete',
3914
+ label: t('files.delete'),
3915
+ danger: true,
3916
+ separatorBefore: true,
3917
+ run: () => runRowAction(entry, 'delete'),
3918
+ })
3919
+ return actions
3920
+ }
3921
+ /** What the context menu service captures for one row. */
3922
+ const menuTargetOf = (entry) => ({
3923
+ kind: FILES_MENU_TARGET,
3924
+ value: {
3925
+ panel: panelIdRef.current,
3926
+ path: entry.path,
3927
+ name: entry.name,
3928
+ type: entry.type === 'dir' ? 'dir' : 'file',
3929
+ size: Number.isFinite(entry.size) ? entry.size : 0,
3930
+ },
3931
+ })
3932
+ const useContextMenu = props.useContextMenu
3933
+ const renderFactorySlot = props.renderFactorySlot
3934
+ const getTriggerProps = (() => {
3935
+ if (typeof useContextMenu !== 'function') return null
3936
+ try {
3937
+ const bindings = useContextMenu()
3938
+ return bindings && typeof bindings.getTriggerProps === 'function' ? bindings.getTriggerProps : null
3939
+ } catch {
3940
+ return null
3941
+ }
3942
+ })()
3943
+ /**
3944
+ * Publish row handlers for the context menu service. Reassigned on every
3945
+ * render so the closure always sees the latest directory and state.
3946
+ */
3947
+ menuBoxRef.current.menuActions = (value) => {
3948
+ const row = filesMenuEntryOf(value)
3949
+ return row ? rowMenuActions(row) : []
3950
+ }
3366
3951
  const renderRowMenu = (entry) => {
3367
3952
  const moreButton = h('button', {
3368
3953
  type: 'button',
@@ -3374,6 +3959,22 @@ window.__ModuleLoader__.load({
3374
3959
  setRowMenuPath((current) => (current === entry.path ? '' : entry.path))
3375
3960
  },
3376
3961
  }, iconMore())
3962
+ const menuTarget = menuTargetOf(entry)
3963
+ // Prefer the official ellipsis trigger so right-click, Shift+F10 and the
3964
+ // button share one menu surface; fall back to our own Menu when the
3965
+ // renderer does not expose the factory slot.
3966
+ if (typeof renderFactorySlot === 'function') {
3967
+ const official = renderFactorySlot('contextMenu.button', {
3968
+ target: menuTarget,
3969
+ label: t('files.more'),
3970
+ className: 'dsh-ssh-workbench-row-more',
3971
+ }, {
3972
+ fallback: h('span', { className: 'dsh-ssh-workbench-row-actions' }, moreButton),
3973
+ })
3974
+ if (official != null) {
3975
+ return h('span', { className: 'dsh-ssh-workbench-row-actions' }, official)
3976
+ }
3977
+ }
3377
3978
  if (Menu == null) return null
3378
3979
  return h(Menu, {
3379
3980
  open: rowMenuPath === entry.path,
@@ -3484,6 +4085,167 @@ window.__ModuleLoader__.load({
3484
4085
  ...extra,
3485
4086
  }, glyph)
3486
4087
 
4088
+ // ── local hierarchy page: search + "open in app" split button ───────
4089
+ // DSH's native files page ships a reload, a search toggle and an
4090
+ // "open in app" split button; the takeover keeps the same footprint and
4091
+ // only adds the hierarchy navigation around it.
4092
+ const searchActive = localChrome && searchOpen
4093
+ const searchText = String(query || '').trim()
4094
+ const searchResults = searchActive && searchText ? searchState : null
4095
+ const searchToggle = localChrome
4096
+ ? tool(t('files.search'), iconSearch(), () => {
4097
+ setSearchOpen((value) => {
4098
+ const next = !value
4099
+ if (!next) setQuery('')
4100
+ return next
4101
+ })
4102
+ }, !server, { 'aria-pressed': searchOpen, 'data-sdh-files-search-toggle': '' })
4103
+ : null
4104
+ const searchBar = searchActive
4105
+ ? h('div', { 'data-sdh-files-search-bar': '', className: 'dsh-ssh-workbench-files-search' },
4106
+ h('div', { className: 'dsh-ssh-workbench-files-search-field' },
4107
+ h('span', { className: 'dsh-ssh-workbench-files-search-glyph' }, iconSearch()),
4108
+ h('input', {
4109
+ autoFocus: true,
4110
+ className: 'dsh-ssh-workbench-files-search-input',
4111
+ value: query,
4112
+ placeholder: t('files.searchPlaceholder'),
4113
+ 'aria-label': t('files.search'),
4114
+ onChange: (event) => setQuery(event.target.value),
4115
+ onKeyDown: (event) => {
4116
+ if (event.key !== 'Escape') return
4117
+ event.preventDefault()
4118
+ setSearchOpen(false)
4119
+ setQuery('')
4120
+ },
4121
+ }),
4122
+ query
4123
+ ? h('button', {
4124
+ type: 'button',
4125
+ className: 'dsh-ssh-workbench-files-search-clear',
4126
+ title: t('files.searchClear'),
4127
+ 'aria-label': t('files.searchClear'),
4128
+ onClick: () => setQuery(''),
4129
+ }, iconClose())
4130
+ : null,
4131
+ ),
4132
+ )
4133
+ : null
4134
+ const searchResultPath = (match) => {
4135
+ const relative = String(match.path || '').replace(/^[\\/]+/, '')
4136
+ if (!relative) return dir
4137
+ const root = String(navRoot || '/').replace(/[\\/]+$/, '')
4138
+ return root === '' || root === '/' ? `/${relative}` : `${root}/${relative}`
4139
+ }
4140
+ const searchView = searchResults
4141
+ ? h('div', { 'data-sdh-files-search-results': '', style: { display: 'flex', flexDirection: 'column' } },
4142
+ searchResults.loading
4143
+ ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.searching'))
4144
+ : searchResults.failed
4145
+ ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.searchFailed'))
4146
+ : searchResults.matches.length === 0
4147
+ ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.searchEmpty'))
4148
+ : searchResults.matches.map((match) => {
4149
+ const isDir = match.type === 'directory'
4150
+ const full = searchResultPath(match)
4151
+ const relative = String(match.path || '')
4152
+ const depth = relative.split('/').length - 1
4153
+ const name = baseNameOf(relative) || relative
4154
+ const entry = { path: full, name, type: isDir ? 'dir' : 'file' }
4155
+ return h('div', {
4156
+ key: relative,
4157
+ className: 'dsh-ssh-workbench-files-row',
4158
+ 'data-sdh-files-search-row': isDir ? 'dir' : 'file',
4159
+ 'data-sdh-files-path': full,
4160
+ role: 'button',
4161
+ tabIndex: 0,
4162
+ title: full,
4163
+ style: { paddingLeft: 10 + depth * 18 },
4164
+ onClick: () => { if (isDir) { setSearchOpen(false); setQuery(''); navigate(full) } else openFile(entry) },
4165
+ onKeyDown: (event) => {
4166
+ if (event.key !== 'Enter' && event.key !== ' ') return
4167
+ event.preventDefault()
4168
+ if (isDir) { setSearchOpen(false); setQuery(''); navigate(full) } else openFile(entry)
4169
+ },
4170
+ },
4171
+ h('span', { style: { display: 'inline-flex', flexShrink: 0, color: isDir ? 'var(--dsw-alias-label-tertiary)' : undefined } }, isDir ? nativeFolderIcon(false) : nativeFileIcon(name)),
4172
+ h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, name),
4173
+ h('span', { className: 'dsh-ssh-workbench-files-search-dir', title: full }, relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : ''),
4174
+ )
4175
+ }),
4176
+ searchResults.matches.length > 0 && (searchResults.truncated || searchResults.incomplete)
4177
+ ? h('div', { className: 'dsh-ssh-workbench-files-search-note' }, searchResults.truncated ? t('files.searchTruncated') : t('files.searchIncomplete'))
4178
+ : null,
4179
+ )
4180
+ : null
4181
+ const appList = localChrome && Array.isArray(hostApps) ? hostApps : []
4182
+ const defaultApp = appList.find((app) => app.default === true) || appList[0] || null
4183
+ const openInDefault = () => {
4184
+ if (!appList.length) { void openHostPath(dir); return }
4185
+ const target = defaultApp && typeof defaultApp.id === 'string' ? defaultApp.id : null
4186
+ if (!sessionRemote || typeof sessionRemote.openWorkspacePath !== 'function') return
4187
+ const request = target ? { path: dir, application: target } : { path: dir }
4188
+ void Promise.resolve(sessionRemote.openWorkspacePath(request)).catch(() => false)
4189
+ }
4190
+ const appsAnchor = h('div', { style: { display: 'inline-flex' } },
4191
+ h('div', { className: 'dsh-ssh-workbench-files-split', 'data-sdh-open-in-app': '' },
4192
+ h('button', {
4193
+ type: 'button',
4194
+ className: 'main',
4195
+ title: defaultApp ? t('files.openInAppWith', { app: defaultApp.name }) : t('files.openInApp'),
4196
+ 'aria-label': defaultApp ? t('files.openInAppWith', { app: defaultApp.name }) : t('files.openInApp'),
4197
+ disabled: !server,
4198
+ onClick: openInDefault,
4199
+ },
4200
+ defaultApp && defaultApp.icon
4201
+ ? h('img', { src: defaultApp.icon, alt: '', width: 14, height: 14, style: { display: 'block', flexShrink: 0 } })
4202
+ : iconRightUp(),
4203
+ h('span', { style: { maxWidth: 92, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
4204
+ defaultApp ? defaultApp.name : t('files.openInApp')),
4205
+ ),
4206
+ h('button', {
4207
+ type: 'button',
4208
+ className: 'chevron',
4209
+ title: t('files.openInAppMore'),
4210
+ 'aria-label': t('files.openInAppMore'),
4211
+ 'aria-haspopup': 'menu',
4212
+ 'aria-expanded': appsOpen,
4213
+ disabled: !server,
4214
+ onClick: () => setAppsOpen((value) => !value),
4215
+ }, ChevronIcon ? h(ChevronIcon, { size: 12 }) : icon(12, [h('polyline', { points: '6 9 12 15 18 9' })])),
4216
+ ),
4217
+ )
4218
+ const appsMenu = Menu != null && appsAnchor && appList.length > 0
4219
+ ? h(Menu, {
4220
+ open: appsOpen,
4221
+ autoFocus: true,
4222
+ portal: true,
4223
+ dense: true,
4224
+ align: 'end',
4225
+ onClose: () => setAppsOpen(false),
4226
+ items: appList.map((app) => ({
4227
+ id: app.id,
4228
+ icon: app.icon ? h('img', { src: app.icon, alt: '', width: 16, height: 16, style: { display: 'block' } }) : undefined,
4229
+ label: app.default === true ? t('files.openInAppDefault', { app: app.name }) : app.name,
4230
+ })),
4231
+ onSelect: (id) => {
4232
+ setAppsOpen(false)
4233
+ if (!sessionRemote || typeof sessionRemote.openWorkspacePath !== 'function') return
4234
+ void Promise.resolve(sessionRemote.openWorkspacePath({ path: dir, application: id })).catch(() => false)
4235
+ },
4236
+ anchor: appsAnchor,
4237
+ })
4238
+ : null
4239
+ // `primitives.Menu` renders its own anchor, so hand it the split button
4240
+ // rather than rendering the anchor a second time next to the menu. With
4241
+ // no app list there is nothing to choose from, so fall back to a plain
4242
+ // "open in app" button that uses the system default.
4243
+ const appsControl = !localChrome
4244
+ ? null
4245
+ : appsMenu
4246
+ ? appsMenu
4247
+ : tool(t('files.openInApp'), iconRightUp(), () => { void openHostPath(dir) }, !server)
4248
+
3487
4249
  const transferBar = transfer
3488
4250
  ? h('div', {
3489
4251
  'data-sdh-transfer': transfer.kind,
@@ -3753,20 +4515,31 @@ window.__ModuleLoader__.load({
3753
4515
  })
3754
4516
  : null,
3755
4517
  ),
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),
4518
+ localChrome
4519
+ ? null
4520
+ : h('input', {
4521
+ id: 'dsh-ssh-workbench-files-upload',
4522
+ type: 'file',
4523
+ multiple: true,
4524
+ style: { display: 'none' },
4525
+ onChange: onPickedFiles,
4526
+ }),
4527
+ localChrome
4528
+ ? null
4529
+ : tool(t('files.upload'), iconUpload(), () => { setResumeRecord(null); triggerUploadInput() }, !server || transfer != null),
4530
+ localChrome
4531
+ ? null
4532
+ : tool(t('files.newFolder'), iconFolderPlus(), () => setDialog({ value: '' }), !server || transfer != null),
3765
4533
  tool(showHidden ? t('files.hideHidden') : t('files.showHidden'), iconEye(showHidden), toggleHidden, !server, { 'aria-pressed': showHidden }),
3766
4534
  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),
4535
+ searchToggle,
4536
+ localChrome
4537
+ ? null
4538
+ : tool(t('files.history'), iconHistory(), () => setHistoryOpen((value) => !value), !server),
3768
4539
  hideServerPicker ? null : picker,
4540
+ appsControl,
3769
4541
  ),
4542
+ searchBar,
3770
4543
  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
4544
  notice ? h('div', { role: 'status', style: { padding: '6px 10px 0', color: 'var(--dsw-alias-label-secondary, inherit)', fontSize: 12, lineHeight: '18px' } }, notice) : null,
3772
4545
  transferBar,
@@ -3780,7 +4553,9 @@ window.__ModuleLoader__.load({
3780
4553
  )
3781
4554
  : historyOpen
3782
4555
  ? historyPanel
3783
- : h('div', { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '8px 0 8px 8px', scrollbarGutter: 'stable' } },
4556
+ : searchView
4557
+ ? h('div', { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 0 8px', scrollbarGutter: 'stable' } }, searchView)
4558
+ : h('div', { style: { flex: 1, minHeight: 0, overflowY: 'auto', padding: '8px 0 8px 8px', scrollbarGutter: 'stable' } },
3784
4559
  h('div', { style: { display: 'flex', flexDirection: 'column' } },
3785
4560
  dir !== navRoot
3786
4561
  ? h('div', {
@@ -3798,13 +4573,7 @@ window.__ModuleLoader__.load({
3798
4573
  ? h('div', { className: 'dsh-ssh-workbench-empty' }, t('files.empty'))
3799
4574
  : visibleEntries.map((entry) => {
3800
4575
  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,
4576
+ const rowHandlers = {
3808
4577
  onClick: () => { if (isDir) navigate(entry.path); else openFile(entry) },
3809
4578
  onKeyDown: (event) => {
3810
4579
  if (event.key !== 'Enter' && event.key !== ' ') return
@@ -3812,6 +4581,19 @@ window.__ModuleLoader__.load({
3812
4581
  if (isDir) navigate(entry.path)
3813
4582
  else openFile(entry)
3814
4583
  },
4584
+ }
4585
+ // Official trigger adds onContextMenu + Shift+F10 and
4586
+ // owns tabIndex; keep the row a plain button otherwise.
4587
+ const rowProps = getTriggerProps
4588
+ ? getTriggerProps(menuTargetOf(entry), rowHandlers)
4589
+ : { ...rowHandlers, tabIndex: 0 }
4590
+ return h('div', {
4591
+ key: entry.path,
4592
+ className: 'dsh-ssh-workbench-files-row',
4593
+ 'data-sdh-files-row': isDir ? 'dir' : 'file',
4594
+ 'data-sdh-files-path': entry.path,
4595
+ role: 'button',
4596
+ ...rowProps,
3815
4597
  },
3816
4598
  h('span', { style: { display: 'inline-flex', flexShrink: 0, color: isDir ? 'var(--dsw-alias-label-tertiary)' : undefined } }, isDir ? nativeFolderIcon(false) : nativeFileIcon(entry.name)),
3817
4599
  h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, entry.name),
@@ -4571,6 +5353,8 @@ ${d.join(`
4571
5353
  ? h('div', { className: 'dsh-ssh-workbench-empty', style: { flex: 1 } }, t('panel.loading'))
4572
5354
  : h(RemoteFilesPanel, {
4573
5355
  t,
5356
+ useContextMenu: props.useContextMenu,
5357
+ renderFactorySlot: props.renderFactorySlot,
4574
5358
  controlledServer: activeTab.server,
4575
5359
  hideServerPicker: true,
4576
5360
  initialPath: activeRemoteHome || '/',
@@ -4580,6 +5364,8 @@ ${d.join(`
4580
5364
  : localRoot
4581
5365
  ? h(RemoteFilesPanel, {
4582
5366
  t,
5367
+ useContextMenu: props.useContextMenu,
5368
+ renderFactorySlot: props.renderFactorySlot,
4583
5369
  localMode: true,
4584
5370
  controlledServer: '__local__',
4585
5371
  initialPath: localRoot,
@@ -4605,46 +5391,120 @@ ${d.join(`
4605
5391
  )
4606
5392
  }
4607
5393
 
4608
- /** Plugin-manager bundle configuration: the left workbench rail switch. */
5394
+ /** Plugin-manager bundle configuration: sidebar entry + remote write switch. */
4609
5395
  function SidebarSettingsForm(props) {
4610
5396
  const t = props.t ?? ((key) => key)
4611
5397
  ensurePanelStyle()
4612
5398
  const [enabled, setEnabled] = useState(null)
5399
+ const [writeEnabled, setWriteEnabled] = useState(null)
5400
+ const [hierarchyEnabled, setHierarchyEnabled] = useState(null)
4613
5401
  const [busy, setBusy] = useState(false)
4614
5402
  const [error, setError] = useState('')
4615
5403
  const [notice, setNotice] = useState('')
4616
5404
 
4617
5405
  useEffect(() => {
4618
5406
  let alive = true
4619
- api('/prefs')
4620
- .then((body) => { if (alive) setEnabled(body?.prefs?.sidebar !== false) })
5407
+ Promise.all([api('/prefs'), api('/state')])
5408
+ .then(([prefsBody, stateBody]) => {
5409
+ if (!alive) return
5410
+ setEnabled(prefsBody?.prefs?.sidebar !== false)
5411
+ setWriteEnabled(stateBody?.config?.remoteFileWrite === true)
5412
+ setHierarchyEnabled(stateBody?.config?.systemFileHierarchy === true)
5413
+ })
4621
5414
  .catch((cause) => { if (alive) setError(t('workbench.sidebarLoadFailed', { msg: messageOf(cause) })) })
4622
5415
  return () => { alive = false }
4623
5416
  }, [])
4624
5417
 
4625
- const save = async () => {
4626
- if (enabled === null || busy) return
5418
+ /**
5419
+ * Apply one switch immediately: the flip is already visible, so the Host
5420
+ * write runs right away and only the effective value comes back. There is
5421
+ * no Save button — a settings switch that needs a second click to take
5422
+ * effect reads as broken.
5423
+ */
5424
+ const commit = async (patch) => {
5425
+ if (busy) return
4627
5426
  setBusy(true)
4628
5427
  setError('')
4629
5428
  setNotice('')
4630
5429
  try {
4631
- const body = await post('/prefs', { sidebar: enabled === true })
4632
- const value = body?.prefs?.sidebar !== false
5430
+ const prefsBody = await post('/prefs', patch)
5431
+ const value = prefsBody?.prefs?.sidebar !== false
4633
5432
  setEnabled(value)
5433
+ let effective = prefsBody?.config?.remoteFileWrite
5434
+ if (typeof effective !== 'boolean') {
5435
+ // Older Hosts answer with prefs only: read the effective value back
5436
+ // so the switch never reports a state the backend did not take.
5437
+ try {
5438
+ const stateBody = await api('/state')
5439
+ effective = stateBody?.config?.remoteFileWrite
5440
+ } catch {
5441
+ effective = undefined
5442
+ }
5443
+ }
5444
+ if (typeof effective === 'boolean') setWriteEnabled(effective)
5445
+ let hierarchyEffective = prefsBody?.config?.systemFileHierarchy
5446
+ if (typeof hierarchyEffective !== 'boolean') {
5447
+ try {
5448
+ const stateBody = await api('/state')
5449
+ hierarchyEffective = stateBody?.config?.systemFileHierarchy
5450
+ } catch {
5451
+ hierarchyEffective = undefined
5452
+ }
5453
+ }
5454
+ if (typeof hierarchyEffective === 'boolean') {
5455
+ setHierarchyEnabled(hierarchyEffective)
5456
+ if (typeof systemFilesSync === 'function') systemFilesSync(hierarchyEffective === true)
5457
+ }
4634
5458
  if (typeof props.onApply === 'function') props.onApply(value)
4635
- setNotice(t('workbench.saved'))
5459
+ // An older Host silently ignores a field it does not know. Only claim
5460
+ // success when the effective value confirms the flip.
5461
+ let applied = true
5462
+ if ('remoteFileWrite' in patch) {
5463
+ applied = applied && typeof effective === 'boolean' && effective === (patch.remoteFileWrite === true)
5464
+ }
5465
+ if ('systemFileHierarchy' in patch) {
5466
+ applied = applied && typeof hierarchyEffective === 'boolean' && hierarchyEffective === (patch.systemFileHierarchy === true)
5467
+ }
5468
+ setNotice(applied ? t('workbench.saved') : t('workbench.writeNeedRestart'))
4636
5469
  } catch (cause) {
4637
- setError(t('workbench.sidebarSaveFailed', { msg: messageOf(cause) }))
5470
+ // A Host built before this switch existed rejects the new field with a
5471
+ // schema error instead of ignoring it. That is a restart, not a bug.
5472
+ if (/prefs\.(sidebar|remoteFileWrite|systemFileHierarchy) must be a boolean/.test(messageOf(cause) || '')) {
5473
+ setNotice(t('workbench.writeNeedRestart'))
5474
+ } else {
5475
+ setError(t('workbench.sidebarSaveFailed', { msg: messageOf(cause) }))
5476
+ }
4638
5477
  } finally {
4639
5478
  setBusy(false)
4640
5479
  }
4641
5480
  }
4642
5481
 
5482
+ const switchRow = (options) => h('div', {
5483
+ style: { display: 'flex', alignItems: 'center', gap: 12, padding: options.first ? '0' : '16px 0 0' },
5484
+ },
5485
+ h('div', { style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 } },
5486
+ h('strong', { style: { fontSize: 14, fontWeight: 500, lineHeight: '20px', color: 'var(--dsw-alias-label-primary, inherit)' } }, options.title),
5487
+ h('span', { style: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, options.hint),
5488
+ ),
5489
+ h('button', {
5490
+ type: 'button',
5491
+ role: 'switch',
5492
+ 'aria-checked': options.checked === true ? 'true' : 'false',
5493
+ 'aria-label': options.title,
5494
+ 'data-sdh-switch': options.key,
5495
+ className: 'dsh-ssh-workbench-switch',
5496
+ disabled: options.disabled,
5497
+ onClick: options.onToggle,
5498
+ }, h('span', { className: 'dsh-ssh-workbench-switch-thumb' })),
5499
+ )
5500
+
4643
5501
  if (props.view === 'summary') {
5502
+ const parts = []
5503
+ if (enabled !== null) parts.push(`${t('workbench.sidebarTitle')} ${enabled ? t('workbench.enabled') : t('workbench.disabled')}`)
5504
+ if (writeEnabled !== null) parts.push(`${t('workbench.writeTitle')} ${writeEnabled ? t('workbench.writeOn') : t('workbench.writeOff')}`)
5505
+ if (hierarchyEnabled !== null) parts.push(`${t('workbench.hierarchyTitle')} ${hierarchyEnabled ? t('workbench.hierarchyOn') : t('workbench.hierarchyOff')}`)
4644
5506
  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'))),
5507
+ h('span', null, parts.join(' · ')),
4648
5508
  )
4649
5509
  }
4650
5510
 
@@ -4652,26 +5512,52 @@ ${d.join(`
4652
5512
  'data-sdh-sidebar-pref': '',
4653
5513
  style: { display: 'flex', flexDirection: 'column', gap: 12, width: '100%', padding: '16px 0' },
4654
5514
  },
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
- ),
5515
+ switchRow({
5516
+ key: 'sidebar',
5517
+ first: true,
5518
+ title: t('workbench.sidebarTitle'),
5519
+ hint: t('workbench.sidebarHint'),
5520
+ checked: enabled === true,
5521
+ disabled: enabled === null || busy,
5522
+ onToggle: () => {
5523
+ const next = !(enabled === true)
5524
+ setEnabled(next)
5525
+ commit({ sidebar: next })
5526
+ },
5527
+ }),
5528
+ switchRow({
5529
+ key: 'remoteFileWrite',
5530
+ first: false,
5531
+ title: t('workbench.writeTitle'),
5532
+ hint: t('workbench.writeHint'),
5533
+ checked: writeEnabled === true,
5534
+ disabled: writeEnabled === null || busy,
5535
+ onToggle: () => {
5536
+ const next = !(writeEnabled === true)
5537
+ setWriteEnabled(next)
5538
+ setNotice('')
5539
+ commit({ remoteFileWrite: next })
5540
+ },
5541
+ }),
5542
+ switchRow({
5543
+ key: 'systemFileHierarchy',
5544
+ first: false,
5545
+ title: t('workbench.hierarchyTitle'),
5546
+ hint: t('workbench.hierarchyHint'),
5547
+ checked: hierarchyEnabled === true,
5548
+ disabled: hierarchyEnabled === null || busy,
5549
+ onToggle: () => {
5550
+ const next = !(hierarchyEnabled === true)
5551
+ setHierarchyEnabled(next)
5552
+ if (typeof systemFilesSync === 'function') systemFilesSync(next)
5553
+ setNotice('')
5554
+ commit({ systemFileHierarchy: next })
5555
+ },
5556
+ }),
4670
5557
  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
- ),
5558
+ notice
5559
+ ? h('div', { role: 'status', style: { fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, inherit)' } }, notice)
5560
+ : null,
4675
5561
  )
4676
5562
  }
4677
5563
 
@@ -4699,6 +5585,34 @@ ${d.join(`
4699
5585
  let layoutService = null
4700
5586
  if (typeof ctx.inject === 'function') {
4701
5587
  ctx.inject(['layout'], (inner) => { layoutService = inner.get('layout') ?? null })
5588
+ // The gateway exposes Host-local file actions (open in an app / reveal in
5589
+ // Finder) through `remote.session`. Only Host paths resolve, so the
5590
+ // hierarchy page (session cwd) uses it and remote SFTP pages never do.
5591
+ // The gateway exposes Host-local file actions through its namespace
5592
+ // service. Inject the namespace itself: reading `ctx.remote.session`
5593
+ // from outside an inject throws.
5594
+ ctx.inject(['remote.session'], (inner) => { sessionRemote = inner.get('remote.session') ?? null })
5595
+ // Workspace search for the local hierarchy page comes from the same
5596
+ // gateway namespace the native files page uses (packaged ripgrep).
5597
+ ctx.inject(['remote.workspaceFiles'], (inner) => { workspaceFilesRemote = inner.get('remote.workspaceFiles') ?? null })
5598
+ // Native file rows reuse the official context menu: right-click and
5599
+ // Shift+F10 come from its trigger bindings, and the actions below are
5600
+ // resolved when the menu opens.
5601
+ ctx.inject(['contextMenus'], (inner) => {
5602
+ const menus = inner.get('contextMenus')
5603
+ if (!menus || typeof menus.register !== 'function') return
5604
+ const register = () => menus.register({
5605
+ id: `${NS}.files`,
5606
+ target: FILES_MENU_TARGET,
5607
+ order: 100,
5608
+ resolve: (value) => resolveFilesMenuActions(value),
5609
+ })
5610
+ if (typeof inner.effect === 'function') {
5611
+ inner.effect(register, 'dsh-ssh-workbench: files context menu')
5612
+ } else {
5613
+ register()
5614
+ }
5615
+ })
4702
5616
  }
4703
5617
  // The left-rail workbench entry is the one sidebar surface the plugin
4704
5618
  // manager switch controls. Registration is a pair: the `main` page the
@@ -4713,9 +5627,13 @@ ${d.join(`
4713
5627
  own(slots.inject('main', () =>
4714
5628
  slots.register(
4715
5629
  { name: 'main', key: WORKBENCH_PANEL_ID, locale: NS, inject: () => ({ t }) },
4716
- function SdhSshOpsWorkbenchPage() {
5630
+ function SdhSshOpsWorkbenchPage(props) {
4717
5631
  return h(WorkbenchPage, {
4718
5632
  t,
5633
+ // The official file context menu is wired through slot props;
5634
+ // pass them down so the explorer rows can use it.
5635
+ useContextMenu: props.useContextMenu,
5636
+ renderFactorySlot: props.renderFactorySlot,
4719
5637
  onBack: () => {
4720
5638
  try { layoutService?.selectPanel?.(null) } catch { /* navigation is best effort */ }
4721
5639
  },
@@ -4828,6 +5746,63 @@ ${d.join(`
4828
5746
  },
4829
5747
  ),
4830
5748
  )
5749
+ slots.inject('sidebar.right.pane.tab.title', () =>
5750
+ slots.register(
5751
+ { name: 'sidebar.right.pane.tab.title', key: 'dsh-ssh-workbench/system-files', locale: NS },
5752
+ function SdhSshOpsSystemFilesTitle() {
5753
+ // Match DSH's own files tab title: the folder glyph from
5754
+ // `FileTypeIcon` followed by the label, no extra chrome.
5755
+ const FileTypeIcon = primitives && primitives.FileTypeIcon
5756
+ return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 6, minWidth: 0 } },
5757
+ typeof FileTypeIcon === 'function'
5758
+ ? h(FileTypeIcon, { kind: 'folder', size: 16, style: { flexShrink: 0 } })
5759
+ : h(FilesTabArtwork, { size: 14 }),
5760
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, t('files.systemTitle')),
5761
+ )
5762
+ },
5763
+ ),
5764
+ )
5765
+ slots.inject('sidebar.right.pane.tab', () =>
5766
+ slots.register(
5767
+ {
5768
+ name: 'sidebar.right.pane.tab',
5769
+ key: 'dsh-ssh-workbench/system-files',
5770
+ inject: (sessionId, actions) => ({ actions }),
5771
+ },
5772
+ function SdhSshOpsSystemFilesBody(props) {
5773
+ const useTabInfo = props.useTabInfo
5774
+ const tabInfo = typeof useTabInfo === 'function' ? useTabInfo() : null
5775
+ const useSessions = props.useSessions
5776
+ // The session workspace is the only root the takeover may show;
5777
+ // the panel locks navigation to it so this page cannot escape the
5778
+ // same boundary as DSH's native files page.
5779
+ const cwd = typeof useSessions === 'function'
5780
+ ? useSessions((sessions) => {
5781
+ const row = sessions && sessions.byId && sessions.byId[props.sessionId]
5782
+ return row && typeof row.cwd === 'string' && row.cwd ? row.cwd : undefined
5783
+ })
5784
+ : undefined
5785
+ if (!cwd) {
5786
+ return h('div', {
5787
+ style: { padding: 16, fontSize: 12, lineHeight: '18px', color: 'var(--dsw-alias-label-tertiary, inherit)' },
5788
+ }, t('files.noSessionDir'))
5789
+ }
5790
+ return h(RemoteFilesPanel, {
5791
+ t,
5792
+ ...props,
5793
+ localMode: true,
5794
+ controlledServer: '__local__',
5795
+ rootPath: cwd,
5796
+ initialPath: cwd,
5797
+ rootLabel: baseNameOf(cwd) || cwd,
5798
+ lockRoot: true,
5799
+ openNative: true,
5800
+ hideServerPicker: true,
5801
+ tabInfo,
5802
+ })
5803
+ },
5804
+ ),
5805
+ )
4831
5806
  slots.inject('sidebar.right.pane.tab', () =>
4832
5807
  slots.register(
4833
5808
  { name: 'sidebar.right.pane.tab', key: 'dsh-ssh-workbench/file' },
@@ -4895,6 +5870,48 @@ ${d.join(`
4895
5870
  title: () => t('files.title'),
4896
5871
  guide: [],
4897
5872
  }), 'dsh-ssh-workbench: files tab')
5873
+
5874
+ // Take over the native `files` kind only when the setting is on. The
5875
+ // extension definition outranks the builtin one, so the official page
5876
+ // is shadowed without being unregistered; toggling off restores it.
5877
+ let systemFilesDisposer = null
5878
+ const syncSystemFiles = (enabled) => {
5879
+ if (enabled === true && !systemFilesDisposer) {
5880
+ systemFilesDisposer = tabs.register({
5881
+ id: 'dsh-ssh-workbench/system-files',
5882
+ kind: 'files',
5883
+ keepMounted: true,
5884
+ title: () => t('files.systemTitle'),
5885
+ /**
5886
+ * Reuse DSH's guide entry verbatim (`workspace` / `workspace.files`
5887
+ * / order 10 / its artwork) so the entry keeps its original slot on
5888
+ * the guide page instead of dropping to the bottom.
5889
+ */
5890
+ guide: [{
5891
+ id: 'workspace',
5892
+ commandId: 'workspace.files',
5893
+ order: 10,
5894
+ title: () => t('files.systemTitle'),
5895
+ description: () => t('files.systemDescription'),
5896
+ ...(primitives && primitives.GuideArtworkFiles ? { icon: primitives.GuideArtworkFiles } : {}),
5897
+ }],
5898
+ })
5899
+ } else if (enabled !== true && systemFilesDisposer) {
5900
+ systemFilesDisposer()
5901
+ systemFilesDisposer = null
5902
+ }
5903
+ }
5904
+ systemFilesSync = syncSystemFiles
5905
+ inner.effect(() => () => {
5906
+ if (systemFilesDisposer) {
5907
+ systemFilesDisposer()
5908
+ systemFilesDisposer = null
5909
+ }
5910
+ if (systemFilesSync === syncSystemFiles) systemFilesSync = null
5911
+ }, 'dsh-ssh-workbench: system files tab')
5912
+ api('/state')
5913
+ .then((body) => syncSystemFiles(body?.config?.systemFileHierarchy === true))
5914
+ .catch(() => {})
4898
5915
  })
4899
5916
 
4900
5917
  // Session-scoped contexts (composer reference events) come from this service.