dsh-ssh-manage 0.3.0 → 0.3.2

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/client.js CHANGED
@@ -161,12 +161,40 @@ window.__ModuleLoader__.load({
161
161
  'terminal.openDetails': ['打开主机详情', 'Open details'],
162
162
  'terminal.assetsError': ['终端组件加载失败:{message}。如果刚更新插件,请重启 DSH 后重试。', 'Could not load the terminal component: {message}. If the plugin was just updated, restart DSH and retry.'],
163
163
  'terminal.resyncFailed': ['终端输出曾中断,已尝试重新同步;如仍异常请重连。', 'Terminal output was interrupted; attempted a resync. Reconnect if problems persist.'],
164
+ 'terminal.search': ['搜索终端', 'Search terminal'],
165
+ 'terminal.searchNext': ['下一个', 'Next match'],
166
+ 'terminal.searchPrevious': ['上一个', 'Previous match'],
167
+ 'terminal.searchCase': ['区分大小写', 'Match case'],
168
+ 'terminal.searchClose': ['关闭搜索', 'Close search'],
169
+ 'terminal.fontSmaller': ['缩小字号', 'Smaller terminal font'],
170
+ 'terminal.fontLarger': ['放大字号', 'Larger terminal font'],
171
+ 'terminal.menu.copy': ['复制', 'Copy'],
172
+ 'terminal.menu.paste': ['粘贴', 'Paste'],
173
+ 'terminal.menu.selectAll': ['全选', 'Select all'],
174
+ 'terminal.menu.clear': ['清屏', 'Clear'],
175
+ 'terminal.menu.title': ['终端菜单', 'Terminal menu'],
176
+ 'terminal.searchUnavailable': ['终端搜索加载失败,请重启 DSH 后重试。', 'Terminal search failed to load; restart DSH and retry.'],
177
+ 'terminal.copyFailed': ['复制失败,请检查浏览器剪贴板权限。', 'Copy failed; check browser clipboard permissions.'],
178
+ 'terminal.pasteFailed': ['粘贴失败,请检查浏览器剪贴板权限。', 'Paste failed; check browser clipboard permissions.'],
164
179
  'terminal.backendHint': ['无法连接到插件后端。如果刚更新插件,请重启 DSH 后重试。', 'Cannot reach the plugin backend. If the plugin was just updated, restart DSH and retry.'],
165
180
  'terminal.passwordMissing': ['该主机还没有保存密码,请先打开「主机详情」设置服务器密码。', 'No password stored for this host. Open Details and set the server password first.'],
166
181
  'drawer.exit': ['退出码 {code}', 'exit {code}'],
167
182
  'drawer.files.path': ['远程路径', 'Remote path'],
168
183
  'drawer.files.up': ['上级目录', 'Parent directory'],
169
184
  'drawer.files.empty': ['目录暂无内容。', 'This directory is empty.'],
185
+ 'drawer.files.mkdir': ['新建文件夹', 'New folder'],
186
+ 'drawer.files.mkdirTitle': ['新建文件夹', 'New folder'],
187
+ 'drawer.files.rename': ['重命名', 'Rename'],
188
+ 'drawer.files.renameTitle': ['重命名', 'Rename'],
189
+ 'drawer.files.delete': ['删除', 'Delete'],
190
+ 'drawer.files.deleteTitle': ['删除', 'Delete'],
191
+ 'drawer.files.deleteConfirm': ['确定删除“{name}”?删除目录时要求目录为空。', 'Delete "{name}"? Directories must be empty.'],
192
+ 'drawer.files.name': ['名称', 'Name'],
193
+ 'drawer.files.invalidName': ['名称不能为空,也不能包含 / 或 \\。', 'Name must be non-empty and cannot contain / or \\.'],
194
+ 'drawer.files.created': ['文件夹已创建', 'Folder created'],
195
+ 'drawer.files.renamed': ['已重命名', 'Renamed'],
196
+ 'drawer.files.deleted': ['已删除', 'Deleted'],
197
+ 'common.create': ['新建', 'Create'],
170
198
  'drawer.files.dir': ['目录', 'dir'],
171
199
  'drawer.files.uploading': ['上传中…', 'Uploading…'],
172
200
  'drawer.details.connection': ['连接信息', 'Connection'],
@@ -778,7 +806,7 @@ window.__ModuleLoader__.load({
778
806
  return h('span', { className: 'ssh-spinner', style: { width: size, height: size } })
779
807
  }
780
808
 
781
- function Button({ children, variant, size, onClick, disabled, title, type = 'button', className, ariaPressed }) {
809
+ function Button({ children, variant, size, onClick, disabled, title, type = 'button', className, ariaPressed, autoFocus }) {
782
810
  return h(
783
811
  'button',
784
812
  {
@@ -788,6 +816,7 @@ window.__ModuleLoader__.load({
788
816
  disabled,
789
817
  title,
790
818
  'aria-pressed': ariaPressed,
819
+ autoFocus,
791
820
  },
792
821
  children,
793
822
  )
@@ -1152,6 +1181,25 @@ window.__ModuleLoader__.load({
1152
1181
  }
1153
1182
  }
1154
1183
 
1184
+ const TERMINAL_FONT_KEY = 'dsh-ssh-manage:terminal-font-size'
1185
+ const TERMINAL_FONT_MIN = 10
1186
+ const TERMINAL_FONT_MAX = 20
1187
+
1188
+ function clampTerminalFontSize(value) {
1189
+ const numeric = Number(value)
1190
+ if (!Number.isFinite(numeric)) return 13
1191
+ return Math.min(TERMINAL_FONT_MAX, Math.max(TERMINAL_FONT_MIN, Math.round(numeric)))
1192
+ }
1193
+
1194
+ function readTerminalFontSize() {
1195
+ try {
1196
+ const stored = window.localStorage.getItem(TERMINAL_FONT_KEY)
1197
+ return stored === null ? 13 : clampTerminalFontSize(stored)
1198
+ } catch {
1199
+ return 13
1200
+ }
1201
+ }
1202
+
1155
1203
  function App({ ctx }) {
1156
1204
  const t = useT(ctx)
1157
1205
  const localeId = useLocaleId(ctx)
@@ -1187,6 +1235,7 @@ window.__ModuleLoader__.load({
1187
1235
  )
1188
1236
  const [activeSessionId, setActiveSessionId] = React.useState(() => persistentPanelState.activeSessionId)
1189
1237
  const [treeWidth, setTreeWidth] = React.useState(readTreeWidth)
1238
+ const [terminalFontSize, setTerminalFontSize] = React.useState(readTerminalFontSize)
1190
1239
  const [clearSignals, setClearSignals] = React.useState({})
1191
1240
  const [sessionStates, setSessionStates] = React.useState({})
1192
1241
  const toastTimer = React.useRef(null)
@@ -1226,6 +1275,12 @@ window.__ModuleLoader__.load({
1226
1275
  } catch {}
1227
1276
  }, [treeWidth])
1228
1277
 
1278
+ React.useEffect(() => {
1279
+ try {
1280
+ window.localStorage.setItem(TERMINAL_FONT_KEY, String(terminalFontSize))
1281
+ } catch {}
1282
+ }, [terminalFontSize])
1283
+
1229
1284
  React.useEffect(() => {
1230
1285
  for (const session of sessions) ptyActiveIds.add(session.id)
1231
1286
  }, [sessions])
@@ -1717,6 +1772,8 @@ window.__ModuleLoader__.load({
1717
1772
  groupsCloseGuard,
1718
1773
  treeWidth,
1719
1774
  setTreeWidth,
1775
+ terminalFontSize,
1776
+ setTerminalFontSize,
1720
1777
  sessions,
1721
1778
  activeSessionId,
1722
1779
  sessionStates,
@@ -2833,6 +2890,24 @@ window.__ModuleLoader__.load({
2833
2890
  },
2834
2891
  h(IconServer, { size: 15 }),
2835
2892
  ),
2893
+ h(
2894
+ IconButton,
2895
+ {
2896
+ title: t('terminal.fontSmaller'),
2897
+ disabled: model.terminalFontSize <= TERMINAL_FONT_MIN,
2898
+ onClick: () => model.setTerminalFontSize((value) => Math.max(TERMINAL_FONT_MIN, value - 1)),
2899
+ },
2900
+ h('span', { className: 'ssh-fontGlyph' }, 'A−'),
2901
+ ),
2902
+ h(
2903
+ IconButton,
2904
+ {
2905
+ title: t('terminal.fontLarger'),
2906
+ disabled: model.terminalFontSize >= TERMINAL_FONT_MAX,
2907
+ onClick: () => model.setTerminalFontSize((value) => Math.min(TERMINAL_FONT_MAX, value + 1)),
2908
+ },
2909
+ h('span', { className: 'ssh-fontGlyph' }, 'A+'),
2910
+ ),
2836
2911
  h(
2837
2912
  IconButton,
2838
2913
  {
@@ -2869,6 +2944,7 @@ window.__ModuleLoader__.load({
2869
2944
  model,
2870
2945
  host,
2871
2946
  sessionId: session.id,
2947
+ fontSize: model.terminalFontSize,
2872
2948
  clearSignal: clearSignals[session.id] ?? 0,
2873
2949
  active: isVisible && session.id === activeSession.id,
2874
2950
  })
@@ -2969,6 +3045,14 @@ window.__ModuleLoader__.load({
2969
3045
  css: 'dsh-ssh-manage-xterm-css',
2970
3046
  script: 'dsh-ssh-manage-xterm-script',
2971
3047
  fit: 'dsh-ssh-manage-xterm-fit',
3048
+ search: 'dsh-ssh-manage-xterm-search',
3049
+ }
3050
+
3051
+ function terminalAssetReady(id) {
3052
+ if (id === TERMINAL_ASSET_IDS.script) return Boolean(window.Terminal)
3053
+ if (id === TERMINAL_ASSET_IDS.fit) return Boolean(window.FitAddon && window.FitAddon.FitAddon)
3054
+ if (id === TERMINAL_ASSET_IDS.search) return Boolean(window.SearchAddon && window.SearchAddon.SearchAddon)
3055
+ return false
2972
3056
  }
2973
3057
  let terminalAssetsPromise = null
2974
3058
 
@@ -2976,10 +3060,7 @@ window.__ModuleLoader__.load({
2976
3060
  return new Promise((resolve, reject) => {
2977
3061
  const existing = document.getElementById(id)
2978
3062
  if (existing) {
2979
- const ready =
2980
- id === TERMINAL_ASSET_IDS.script
2981
- ? Boolean(window.Terminal)
2982
- : Boolean(window.FitAddon && window.FitAddon.FitAddon)
3063
+ const ready = terminalAssetReady(id)
2983
3064
  if (ready) {
2984
3065
  resolve()
2985
3066
  return
@@ -3003,8 +3084,12 @@ window.__ModuleLoader__.load({
3003
3084
  }
3004
3085
 
3005
3086
  function loadTerminalAssets() {
3006
- if (window.Terminal && window.FitAddon && window.FitAddon.FitAddon) {
3007
- return Promise.resolve({ Terminal: window.Terminal, FitAddon: window.FitAddon.FitAddon })
3087
+ if (window.Terminal && window.FitAddon && window.FitAddon.FitAddon && window.SearchAddon?.SearchAddon) {
3088
+ return Promise.resolve({
3089
+ Terminal: window.Terminal,
3090
+ FitAddon: window.FitAddon.FitAddon,
3091
+ SearchAddon: window.SearchAddon.SearchAddon,
3092
+ })
3008
3093
  }
3009
3094
  if (terminalAssetsPromise) return terminalAssetsPromise
3010
3095
  if (!document.getElementById(TERMINAL_ASSET_IDS.css)) {
@@ -3016,9 +3101,17 @@ window.__ModuleLoader__.load({
3016
3101
  }
3017
3102
  terminalAssetsPromise = loadScriptAsset(TERMINAL_ASSET_IDS.script, '/dsh-ssh-manage/assets/xterm.js')
3018
3103
  .then(() => loadScriptAsset(TERMINAL_ASSET_IDS.fit, '/dsh-ssh-manage/assets/addon-fit.js'))
3104
+ // Search is optional: a missing/blocked addon must not break the terminal.
3105
+ .then(() => loadScriptAsset(TERMINAL_ASSET_IDS.search, '/dsh-ssh-manage/assets/addon-search.js').catch(() => null))
3019
3106
  .then(() => {
3020
- if (!window.Terminal || !window.FitAddon || !window.FitAddon.FitAddon) throw new Error('Terminal assets did not initialize')
3021
- return { Terminal: window.Terminal, FitAddon: window.FitAddon.FitAddon }
3107
+ if (!window.Terminal || !window.FitAddon || !window.FitAddon.FitAddon) {
3108
+ throw new Error('Terminal assets did not initialize')
3109
+ }
3110
+ return {
3111
+ Terminal: window.Terminal,
3112
+ FitAddon: window.FitAddon.FitAddon,
3113
+ SearchAddon: window.SearchAddon?.SearchAddon ?? null,
3114
+ }
3022
3115
  })
3023
3116
  .catch((error) => {
3024
3117
  terminalAssetsPromise = null
@@ -3315,6 +3408,29 @@ window.__ModuleLoader__.load({
3315
3408
  void ptyPost(PTY_CLEAR_ROUTE, { id }, 15_000).catch(() => {})
3316
3409
  }
3317
3410
 
3411
+ async function copyTextToClipboard(text) {
3412
+ if (!text) return false
3413
+ try {
3414
+ if (navigator.clipboard?.writeText) {
3415
+ await navigator.clipboard.writeText(text)
3416
+ return true
3417
+ }
3418
+ } catch {}
3419
+ try {
3420
+ const textarea = document.createElement('textarea')
3421
+ textarea.value = text
3422
+ textarea.style.position = 'fixed'
3423
+ textarea.style.opacity = '0'
3424
+ document.body.appendChild(textarea)
3425
+ textarea.select()
3426
+ const copied = document.execCommand('copy')
3427
+ textarea.remove()
3428
+ return copied
3429
+ } catch {
3430
+ return false
3431
+ }
3432
+ }
3433
+
3318
3434
  function decodeBase64(text) {
3319
3435
  const binary = atob(String(text ?? ''))
3320
3436
  const bytes = new Uint8Array(binary.length)
@@ -3322,15 +3438,25 @@ window.__ModuleLoader__.load({
3322
3438
  return bytes
3323
3439
  }
3324
3440
 
3325
- function TerminalPane({ model, host, sessionId, active, clearSignal }) {
3441
+ function TerminalPane({ model, host, sessionId, active, clearSignal, fontSize }) {
3326
3442
  const { t } = model
3327
3443
  const hostRef = React.useRef(host)
3328
3444
  hostRef.current = host
3329
3445
  const containerRef = React.useRef(null)
3330
3446
  const termRef = React.useRef(null)
3331
3447
  const fitRef = React.useRef(null)
3448
+ const searchAddonRef = React.useRef(null)
3449
+ const searchInputRef = React.useRef(null)
3450
+ const menuRef = React.useRef(null)
3451
+ const fontSizeRef = React.useRef(fontSize)
3452
+ fontSizeRef.current = fontSize
3332
3453
  const activeRef = React.useRef(active === true)
3333
3454
  const [status, setStatus] = React.useState('loading')
3455
+ const [searchOpen, setSearchOpen] = React.useState(false)
3456
+ const searchOpenRef = React.useRef(false)
3457
+ const [searchText, setSearchText] = React.useState('')
3458
+ const [searchCase, setSearchCase] = React.useState(false)
3459
+ const [menu, setMenu] = React.useState(null)
3334
3460
  const [message, setMessage] = React.useState('')
3335
3461
  const [retryToken, setRetryToken] = React.useState(0)
3336
3462
  const clearRequested = React.useRef(false)
@@ -3370,6 +3496,131 @@ window.__ModuleLoader__.load({
3370
3496
  } catch {}
3371
3497
  }, [clearSignal])
3372
3498
 
3499
+ React.useEffect(() => {
3500
+ const term = termRef.current
3501
+ if (!term) return
3502
+ try {
3503
+ if (term.options.fontSize !== fontSize) {
3504
+ term.options.fontSize = fontSize
3505
+ fitRef.current?.fit()
3506
+ }
3507
+ } catch {}
3508
+ }, [fontSize])
3509
+
3510
+ const openSearch = () => {
3511
+ if (!searchAddonRef.current) {
3512
+ try { model.notify?.(t('terminal.searchUnavailable'), 'error') } catch {}
3513
+ return
3514
+ }
3515
+ searchOpenRef.current = true
3516
+ setSearchOpen(true)
3517
+ requestAnimationFrame(() => searchInputRef.current?.focus())
3518
+ }
3519
+
3520
+ const closeSearch = () => {
3521
+ searchOpenRef.current = false
3522
+ setSearchOpen(false)
3523
+ try { searchAddonRef.current?.clearDecorations?.() } catch {}
3524
+ try { termRef.current?.focus() } catch {}
3525
+ }
3526
+
3527
+ const findInTerminal = (direction, caseSensitive = searchCase) => {
3528
+ const addon = searchAddonRef.current
3529
+ const text = searchText.trim()
3530
+ if (!addon) return
3531
+ try {
3532
+ if (text === '') addon.clearDecorations?.()
3533
+ else if (direction < 0) addon.findPrevious(text, { caseSensitive })
3534
+ else addon.findNext(text, { caseSensitive })
3535
+ } catch {}
3536
+ }
3537
+
3538
+ const updateSearchText = (value) => {
3539
+ setSearchText(value)
3540
+ const addon = searchAddonRef.current
3541
+ if (!addon) return
3542
+ try {
3543
+ if (value.trim() === '') addon.clearDecorations?.()
3544
+ else addon.findNext(value.trim(), { caseSensitive: searchCase })
3545
+ } catch {}
3546
+ }
3547
+
3548
+ const terminalMenuAction = (action) => {
3549
+ const term = termRef.current
3550
+ setMenu(null)
3551
+ if (!term) return
3552
+ const refocus = () => {
3553
+ requestAnimationFrame(() => {
3554
+ try { termRef.current?.focus() } catch {}
3555
+ })
3556
+ }
3557
+ if (action === 'selectAll') {
3558
+ try { term.selectAll() } catch {}
3559
+ refocus()
3560
+ return
3561
+ }
3562
+ if (action === 'clear') {
3563
+ // Route through App so clearRequested/clearEpoch and the host buffer
3564
+ // stay in sync with the session-bar clear button.
3565
+ if (typeof model.clearSession === 'function') model.clearSession(sessionId)
3566
+ else clearPtySession(sessionId)
3567
+ try { term.clear(); term.write('\x1b[H\x1b[2J') } catch {}
3568
+ refocus()
3569
+ return
3570
+ }
3571
+ if (action === 'copy') {
3572
+ const text = term.getSelection()
3573
+ void copyTextToClipboard(text).then((ok) => {
3574
+ if (!ok) {
3575
+ try { model.notify?.(t('terminal.copyFailed'), 'error') } catch {}
3576
+ }
3577
+ refocus()
3578
+ })
3579
+ return
3580
+ }
3581
+ if (action === 'paste') {
3582
+ if (!navigator.clipboard?.readText) {
3583
+ try { model.notify?.(t('terminal.pasteFailed'), 'error') } catch {}
3584
+ refocus()
3585
+ return
3586
+ }
3587
+ navigator.clipboard.readText().then((text) => {
3588
+ if (text) {
3589
+ try { term.paste(text) } catch {}
3590
+ }
3591
+ refocus()
3592
+ }).catch(() => {
3593
+ try { model.notify?.(t('terminal.pasteFailed'), 'error') } catch {}
3594
+ refocus()
3595
+ })
3596
+ }
3597
+ }
3598
+
3599
+ React.useEffect(() => {
3600
+ if (!menu) return undefined
3601
+ const close = () => setMenu(null)
3602
+ const onKey = (event) => {
3603
+ if (event.key !== 'Escape') return
3604
+ event.preventDefault()
3605
+ event.stopPropagation()
3606
+ setMenu(null)
3607
+ try { termRef.current?.focus() } catch {}
3608
+ }
3609
+ const onDown = (event) => {
3610
+ if (menuRef.current && menuRef.current.contains(event.target)) return
3611
+ setMenu(null)
3612
+ }
3613
+ document.addEventListener('mousedown', onDown)
3614
+ document.addEventListener('keydown', onKey, true)
3615
+ requestAnimationFrame(() => {
3616
+ menuRef.current?.querySelector('button')?.focus()
3617
+ })
3618
+ return () => {
3619
+ document.removeEventListener('mousedown', onDown)
3620
+ document.removeEventListener('keydown', onKey, true)
3621
+ }
3622
+ }, [menu])
3623
+
3373
3624
  React.useEffect(() => {
3374
3625
  const container = containerRef.current
3375
3626
  if (!container) return undefined
@@ -3434,7 +3685,7 @@ window.__ModuleLoader__.load({
3434
3685
  term = new Terminal({
3435
3686
  cursorBlink: true,
3436
3687
  fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
3437
- fontSize: 13,
3688
+ fontSize: fontSizeRef.current ?? 13,
3438
3689
  lineHeight: 1.2,
3439
3690
  scrollback: 5000,
3440
3691
  allowProposedApi: true,
@@ -3444,9 +3695,39 @@ window.__ModuleLoader__.load({
3444
3695
  })
3445
3696
  fit = new FitAddon()
3446
3697
  term.loadAddon(fit)
3698
+ if (assets.SearchAddon) {
3699
+ const searchAddon = new assets.SearchAddon()
3700
+ term.loadAddon(searchAddon)
3701
+ searchAddonRef.current = searchAddon
3702
+ }
3447
3703
  term.open(container)
3448
3704
  termRef.current = term
3449
3705
  fitRef.current = fit
3706
+ term.attachCustomKeyEventHandler((event) => {
3707
+ if (event.type !== 'keydown') return true
3708
+ const meta = event.metaKey || event.ctrlKey
3709
+ if (meta && event.key.toLowerCase() === 'f') {
3710
+ event.preventDefault()
3711
+ openSearch()
3712
+ return false
3713
+ }
3714
+ if (meta && (event.key === '=' || event.key === '+')) {
3715
+ event.preventDefault()
3716
+ model.setTerminalFontSize((value) => Math.min(TERMINAL_FONT_MAX, value + 1))
3717
+ return false
3718
+ }
3719
+ if (meta && (event.key === '-' || event.key === '_')) {
3720
+ event.preventDefault()
3721
+ model.setTerminalFontSize((value) => Math.max(TERMINAL_FONT_MIN, value - 1))
3722
+ return false
3723
+ }
3724
+ if (event.key === 'Escape' && searchOpenRef.current) {
3725
+ event.preventDefault()
3726
+ closeSearch()
3727
+ return false
3728
+ }
3729
+ return true
3730
+ })
3450
3731
  try { fit.fit() } catch {}
3451
3732
  themeUnsub = subscribeTerminalTheme(() => {
3452
3733
  if (removed || !term) return
@@ -3691,6 +3972,7 @@ window.__ModuleLoader__.load({
3691
3972
  }
3692
3973
  termRef.current = null
3693
3974
  fitRef.current = null
3975
+ searchAddonRef.current = null
3694
3976
  }
3695
3977
  }, [sessionId, retryToken])
3696
3978
 
@@ -3698,8 +3980,81 @@ window.__ModuleLoader__.load({
3698
3980
  const showOverlay = status !== 'ready'
3699
3981
  return h(
3700
3982
  'div',
3701
- { className: 'ssh-terminalPane' },
3983
+ {
3984
+ className: 'ssh-terminalPane',
3985
+ onContextMenu: (event) => {
3986
+ if (!termRef.current || status !== 'ready') return
3987
+ if (event.target?.closest?.('.ssh-terminalSearch')) return
3988
+ event.preventDefault()
3989
+ const rect = event.currentTarget.getBoundingClientRect()
3990
+ setMenu({
3991
+ x: Math.max(0, Math.min(event.clientX - rect.left, rect.width - 170)),
3992
+ y: Math.max(0, Math.min(event.clientY - rect.top, rect.height - 150)),
3993
+ })
3994
+ },
3995
+ onMouseDown: () => setMenu(null),
3996
+ },
3702
3997
  h('div', { className: 'ssh-terminalHost', ref: containerRef }),
3998
+ searchOpen
3999
+ ? h(
4000
+ 'div',
4001
+ {
4002
+ className: 'ssh-terminalSearch',
4003
+ onMouseDown: (event) => event.stopPropagation(),
4004
+ },
4005
+ h(IconSearch, { size: 13 }),
4006
+ h('input', {
4007
+ ref: searchInputRef,
4008
+ className: 'ssh-terminalSearchInput',
4009
+ value: searchText,
4010
+ placeholder: t('terminal.search'),
4011
+ 'aria-label': t('terminal.search'),
4012
+ onChange: (event) => updateSearchText(event.target.value),
4013
+ onKeyDown: (event) => {
4014
+ event.stopPropagation()
4015
+ if (event.key === 'Escape') closeSearch()
4016
+ else if (event.key === 'Enter') findInTerminal(event.shiftKey ? -1 : 1)
4017
+ },
4018
+ }),
4019
+ h('button', {
4020
+ type: 'button',
4021
+ className: cx('ssh-terminalSearchBtn', searchCase && 'on'),
4022
+ title: t('terminal.searchCase'),
4023
+ 'aria-pressed': searchCase,
4024
+ onMouseDown: (event) => event.preventDefault(),
4025
+ onClick: () => {
4026
+ const next = !searchCase
4027
+ setSearchCase(next)
4028
+ if (searchText.trim()) findInTerminal(1, next)
4029
+ },
4030
+ }, 'Aa'),
4031
+ h('button', { type: 'button', className: 'ssh-terminalSearchBtn', title: t('terminal.searchPrevious'), 'aria-label': t('terminal.searchPrevious'), onMouseDown: (event) => event.preventDefault(), onClick: () => findInTerminal(-1) }, h(IconChevronLeft, { size: 12 })),
4032
+ h('button', { type: 'button', className: 'ssh-terminalSearchBtn', title: t('terminal.searchNext'), 'aria-label': t('terminal.searchNext'), onMouseDown: (event) => event.preventDefault(), onClick: () => findInTerminal(1) }, h(IconChevronRight, { size: 12 })),
4033
+ h('button', { type: 'button', className: 'ssh-terminalSearchBtn', title: t('terminal.searchClose'), 'aria-label': t('terminal.searchClose'), onClick: closeSearch }, h(IconClose, { size: 12 })),
4034
+ )
4035
+ : null,
4036
+ menu
4037
+ ? h(
4038
+ 'div',
4039
+ {
4040
+ ref: menuRef,
4041
+ className: 'ssh-terminalMenu',
4042
+ role: 'menu',
4043
+ 'aria-label': t('terminal.menu.title'),
4044
+ style: { left: menu.x, top: menu.y },
4045
+ onMouseDown: (event) => event.stopPropagation(),
4046
+ },
4047
+ ['copy', 'paste', 'selectAll', 'clear'].map((action) =>
4048
+ h('button', {
4049
+ key: action,
4050
+ type: 'button',
4051
+ role: 'menuitem',
4052
+ className: 'ssh-terminalMenuItem',
4053
+ onClick: () => terminalMenuAction(action),
4054
+ }, t(`terminal.menu.${action}`)),
4055
+ ),
4056
+ )
4057
+ : null,
3703
4058
  showOverlay
3704
4059
  ? h(
3705
4060
  'div',
@@ -3747,25 +4102,50 @@ window.__ModuleLoader__.load({
3747
4102
  )
3748
4103
  }
3749
4104
 
4105
+ const SFTP_PATH_PREFIX = 'dsh-ssh-manage:sftp-path:'
4106
+
4107
+ function readSftpPath(alias) {
4108
+ try {
4109
+ const stored = window.localStorage.getItem(SFTP_PATH_PREFIX + alias)
4110
+ return stored && stored.trim() ? stored : '.'
4111
+ } catch {
4112
+ return '.'
4113
+ }
4114
+ }
4115
+
4116
+ function saveSftpPath(alias, target) {
4117
+ try {
4118
+ window.localStorage.setItem(SFTP_PATH_PREFIX + alias, target)
4119
+ } catch {}
4120
+ }
4121
+
3750
4122
  function FilesView({ model, host }) {
3751
- const { t, notify, withPending } = model
3752
- const [path, setPath] = React.useState('.')
4123
+ const { t, notify } = model
4124
+ const [path, setPath] = React.useState(() => readSftpPath(host.host))
3753
4125
  const [entries, setEntries] = React.useState([])
3754
4126
  const [loading, setLoading] = React.useState(true)
3755
4127
  const [error, setError] = React.useState('')
3756
4128
  const [uploading, setUploading] = React.useState(false)
4129
+ const [dialog, setDialog] = React.useState(null)
4130
+ const [dialogValue, setDialogValue] = React.useState('')
4131
+ const [dialogBusy, setDialogBusy] = React.useState(false)
4132
+ const dialogBusyRef = React.useRef(false)
3757
4133
  const fileRef = React.useRef(null)
3758
4134
 
3759
4135
  const loadDirectory = React.useCallback(
3760
- async (target) => {
4136
+ async (target, options = {}) => {
4137
+ const normalized = String(target ?? '').trim() || '.'
3761
4138
  setLoading(true)
3762
4139
  setError('')
3763
4140
  try {
3764
- const value = await callAction('sftp/list', { host: host.host, path: target })
4141
+ const value = await callAction('sftp/list', { host: host.host, path: normalized })
3765
4142
  setEntries(value.entries ?? [])
3766
- setPath(target)
4143
+ setPath(normalized)
4144
+ if (options.remember !== false) saveSftpPath(host.host, normalized)
4145
+ return true
3767
4146
  } catch (failure) {
3768
4147
  setError(errorMessage(failure))
4148
+ return false
3769
4149
  } finally {
3770
4150
  setLoading(false)
3771
4151
  }
@@ -3774,8 +4154,72 @@ window.__ModuleLoader__.load({
3774
4154
  )
3775
4155
 
3776
4156
  React.useEffect(() => {
3777
- void loadDirectory('.')
3778
- }, [loadDirectory])
4157
+ let cancelled = false
4158
+ void (async () => {
4159
+ const initial = readSftpPath(host.host)
4160
+ const ok = await loadDirectory(initial)
4161
+ if (!ok && initial !== '.' && !cancelled) await loadDirectory('.', { remember: false })
4162
+ })()
4163
+ return () => { cancelled = true }
4164
+ }, [host.host, loadDirectory])
4165
+
4166
+ React.useEffect(() => {
4167
+ if (!dialog) return undefined
4168
+ const onKey = (event) => {
4169
+ if (event.key === 'Escape' && !dialogBusy) {
4170
+ event.preventDefault()
4171
+ event.stopPropagation()
4172
+ setDialog(null)
4173
+ }
4174
+ }
4175
+ document.addEventListener('keydown', onKey, true)
4176
+ return () => document.removeEventListener('keydown', onKey, true)
4177
+ }, [dialog, dialogBusy])
4178
+
4179
+ const openDialog = (mode, entry = null) => {
4180
+ setDialog({ mode, entry })
4181
+ setDialogValue(mode === 'rename' && entry ? entry.name : '')
4182
+ }
4183
+
4184
+ async function submitDialog() {
4185
+ if (!dialog || dialogBusyRef.current) return
4186
+ dialogBusyRef.current = true
4187
+ const name = dialogValue.trim()
4188
+ if (dialog.mode !== 'delete') {
4189
+ if (!name || name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
4190
+ notify(t('drawer.files.invalidName'), 'error')
4191
+ return
4192
+ }
4193
+ }
4194
+ setDialogBusy(true)
4195
+ try {
4196
+ if (dialog.mode === 'mkdir') {
4197
+ await callAction('sftp/mkdir', { host: host.host, path: remoteJoin(path, name) })
4198
+ notify(t('drawer.files.created'), 'success')
4199
+ } else if (dialog.mode === 'rename') {
4200
+ if (name === dialog.entry.name) {
4201
+ setDialog(null)
4202
+ return
4203
+ }
4204
+ await callAction('sftp/rename', {
4205
+ host: host.host,
4206
+ path: remoteJoin(path, dialog.entry.name),
4207
+ newPath: remoteJoin(path, name),
4208
+ })
4209
+ notify(t('drawer.files.renamed'), 'success')
4210
+ } else if (dialog.mode === 'delete') {
4211
+ await callAction('sftp/delete', { host: host.host, path: remoteJoin(path, dialog.entry.name) })
4212
+ notify(t('drawer.files.deleted'), 'success')
4213
+ }
4214
+ setDialog(null)
4215
+ await loadDirectory(path)
4216
+ } catch (failure) {
4217
+ notify(t('state.error', { message: errorMessage(failure) }), 'error')
4218
+ } finally {
4219
+ dialogBusyRef.current = false
4220
+ setDialogBusy(false)
4221
+ }
4222
+ }
3779
4223
 
3780
4224
  async function downloadEntry(entry) {
3781
4225
  const remote = remoteJoin(path, entry.name)
@@ -3842,6 +4286,12 @@ window.__ModuleLoader__.load({
3842
4286
  { title: t('common.refresh'), onClick: () => void loadDirectory(path), disabled: loading },
3843
4287
  loading ? h(Spinner, { size: 13 }) : h(IconRefresh, { size: 15 }),
3844
4288
  ),
4289
+ h(
4290
+ Button,
4291
+ { size: 'sm', variant: 'ghost', onClick: () => openDialog('mkdir'), disabled: loading || uploading },
4292
+ h(IconPlus, { size: 13 }),
4293
+ t('drawer.files.mkdir'),
4294
+ ),
3845
4295
  h(
3846
4296
  Button,
3847
4297
  { size: 'sm', onClick: () => fileRef.current && fileRef.current.click(), disabled: uploading },
@@ -3853,6 +4303,69 @@ window.__ModuleLoader__.load({
3853
4303
  error
3854
4304
  ? h('div', { className: 'ssh-fileError' }, h(IconAlert, { size: 13 }), h('span', null, error))
3855
4305
  : null,
4306
+ dialog
4307
+ ? h(
4308
+ 'div',
4309
+ {
4310
+ className: 'ssh-fileDialogScrim',
4311
+ onMouseDown: () => {
4312
+ if (!dialogBusy) setDialog(null)
4313
+ },
4314
+ },
4315
+ h(
4316
+ 'div',
4317
+ {
4318
+ className: 'ssh-fileDialog',
4319
+ role: 'dialog',
4320
+ 'aria-modal': 'true',
4321
+ 'aria-label': dialog.mode === 'mkdir'
4322
+ ? t('drawer.files.mkdirTitle')
4323
+ : dialog.mode === 'rename'
4324
+ ? t('drawer.files.renameTitle')
4325
+ : t('drawer.files.deleteTitle'),
4326
+ onMouseDown: (event) => event.stopPropagation(),
4327
+ },
4328
+ h(
4329
+ 'div',
4330
+ { className: 'ssh-fileDialogTitle' },
4331
+ dialog.mode === 'mkdir'
4332
+ ? t('drawer.files.mkdirTitle')
4333
+ : dialog.mode === 'rename'
4334
+ ? t('drawer.files.renameTitle')
4335
+ : t('drawer.files.deleteTitle'),
4336
+ ),
4337
+ dialog.mode === 'delete'
4338
+ ? h('div', { className: 'ssh-fileDialogBody' }, t('drawer.files.deleteConfirm', { name: dialog.entry?.name ?? '' }))
4339
+ : h(TextInput, {
4340
+ value: dialogValue,
4341
+ autoFocus: true,
4342
+ placeholder: t('drawer.files.name'),
4343
+ onChange: setDialogValue,
4344
+ onKeyDown: (event) => {
4345
+ if (event.key === 'Enter') void submitDialog()
4346
+ else if (event.key === 'Escape' && !dialogBusy) setDialog(null)
4347
+ },
4348
+ }),
4349
+ h(
4350
+ 'div',
4351
+ { className: 'ssh-fileDialogActions' },
4352
+ h(Button, { variant: 'ghost', size: 'sm', onClick: () => setDialog(null), disabled: dialogBusy }, t('common.cancel')),
4353
+ h(
4354
+ Button,
4355
+ {
4356
+ variant: dialog.mode === 'delete' ? 'dangerSolid' : 'primary',
4357
+ size: 'sm',
4358
+ autoFocus: dialog.mode === 'delete',
4359
+ onClick: () => void submitDialog(),
4360
+ disabled: dialogBusy,
4361
+ },
4362
+ dialogBusy ? h(Spinner, { size: 12 }) : null,
4363
+ dialog.mode === 'delete' ? t('common.delete') : t('common.create'),
4364
+ ),
4365
+ ),
4366
+ ),
4367
+ )
4368
+ : null,
3856
4369
  h(
3857
4370
  'div',
3858
4371
  { className: 'ssh-fileList' },
@@ -3879,13 +4392,27 @@ window.__ModuleLoader__.load({
3879
4392
  entry.name,
3880
4393
  ),
3881
4394
  h('span', { className: 'ssh-fileSize' }, entry.directory ? t('drawer.files.dir') : formatBytes(entry.size)),
3882
- entry.directory
3883
- ? h('span', null)
3884
- : h(
3885
- IconButton,
3886
- { title: t('common.download'), onClick: () => void downloadEntry(entry) },
3887
- h(IconDownload, { size: 14 }),
3888
- ),
4395
+ h(
4396
+ 'div',
4397
+ { className: 'ssh-fileActions' },
4398
+ entry.directory
4399
+ ? null
4400
+ : h(
4401
+ IconButton,
4402
+ { title: t('common.download'), onClick: () => void downloadEntry(entry) },
4403
+ h(IconDownload, { size: 14 }),
4404
+ ),
4405
+ h(
4406
+ IconButton,
4407
+ { title: t('drawer.files.rename'), onClick: () => openDialog('rename', entry) },
4408
+ h(IconEdit, { size: 14 }),
4409
+ ),
4410
+ h(
4411
+ IconButton,
4412
+ { title: t('drawer.files.delete'), onClick: () => openDialog('delete', entry) },
4413
+ h(IconTrash, { size: 14 }),
4414
+ ),
4415
+ ),
3889
4416
  ),
3890
4417
  ),
3891
4418
  ),
@@ -4949,6 +5476,16 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
4949
5476
  .ssh-terminalOverlayActions{display:flex;gap:8px;flex-wrap:wrap;justify-content:center}
4950
5477
  .ssh-sessionLayer{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--ssh-bg)}
4951
5478
  .ssh-sessionState{width:6px;height:6px;border-radius:50%;background:var(--ssh-dim);flex:0 0 auto}
5479
+ .ssh-fontGlyph{font-size:10.5px;font-weight:600;letter-spacing:-.4px}
5480
+ .ssh-terminalSearch{position:absolute;top:8px;right:18px;z-index:5;max-width:calc(100% - 24px);display:flex;align-items:center;gap:5px;padding:5px 7px;border:.5px solid var(--ssh-border-strong);border-radius:10px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow);color:var(--ssh-text-3)}
5481
+ .ssh-terminalSearchInput{width:min(180px,42vw);max-width:100%;height:24px;padding:0 7px;border:.5px solid var(--ssh-border);border-radius:6px;background:var(--ssh-bg);color:var(--ssh-text);font:inherit;font-size:12px;outline:none}
5482
+ .ssh-terminalSearchInput:focus{border-color:var(--ssh-brand)}
5483
+ .ssh-terminalSearchBtn{appearance:none;display:inline-flex;align-items:center;justify-content:center;height:24px;min-width:24px;padding:0 5px;border:0;border-radius:6px;background:transparent;color:var(--ssh-text-2);font:inherit;font-size:11px;cursor:pointer}
5484
+ .ssh-terminalSearchBtn:hover{background:var(--ssh-hover);color:var(--ssh-text)}
5485
+ .ssh-terminalSearchBtn.on{background:color-mix(in srgb,var(--ssh-brand) 15%,transparent);color:var(--ssh-brand)}
5486
+ .ssh-terminalMenu{position:absolute;z-index:7;min-width:150px;padding:4px;border:.5px solid var(--ssh-border-strong);border-radius:9px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow)}
5487
+ .ssh-terminalMenuItem{appearance:none;display:flex;align-items:center;width:100%;height:28px;padding:0 9px;border:0;border-radius:6px;background:transparent;color:var(--ssh-text-2);font:inherit;font-size:12px;text-align:left;cursor:pointer}
5488
+ .ssh-terminalMenuItem:hover{background:var(--ssh-hover);color:var(--ssh-text)}
4952
5489
  .ssh-sessionState.loading,.ssh-sessionState.connecting{background:var(--ssh-warn)}
4953
5490
  .ssh-sessionState.ready{background:var(--ssh-success)}
4954
5491
  .ssh-sessionState.error,.ssh-sessionState.closed{background:var(--ssh-danger)}
@@ -4982,7 +5519,13 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
4982
5519
  .ssh-filesPath{flex:1;min-width:140px}
4983
5520
  .ssh-fileError{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--ssh-danger)}
4984
5521
  .ssh-fileList{flex:1;min-height:0;overflow:auto;border:.5px solid var(--ssh-border);border-radius:var(--ssh-radius-md)}
4985
- .ssh-fileRow{display:grid;grid-template-columns:22px minmax(0,1fr) 90px 28px;align-items:center;gap:8px;padding:0 10px;height:34px;border-bottom:.5px solid var(--ssh-border);font-size:12px}
5522
+ .ssh-fileRow{display:grid;grid-template-columns:22px minmax(0,1fr) 90px auto;align-items:center;gap:8px;padding:0 10px;height:34px;border-bottom:.5px solid var(--ssh-border);font-size:12px}
5523
+ .ssh-fileActions{display:flex;align-items:center;gap:2px}
5524
+ .ssh-fileDialogScrim{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--dsw-alias-bg-mask-1,color-mix(in srgb,var(--ssh-text) 18%,transparent))}
5525
+ .ssh-fileDialog{width:min(360px,100%);padding:14px;border:.5px solid var(--ssh-border-strong);border-radius:12px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow);display:flex;flex-direction:column;gap:10px}
5526
+ .ssh-fileDialogTitle{font-size:13px;font-weight:600;color:var(--ssh-text)}
5527
+ .ssh-fileDialogBody{font-size:12px;line-height:1.5;color:var(--ssh-text-2)}
5528
+ .ssh-fileDialogActions{display:flex;justify-content:flex-end;gap:8px}
4986
5529
  .ssh-fileRow:last-child{border-bottom:none}
4987
5530
  .ssh-fileRow:hover{background:var(--ssh-hover)}
4988
5531
  .ssh-fileIcon{display:inline-flex;color:var(--ssh-text-3)}