Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ade95c1cab | ||
|
|
7e8893003a | ||
|
|
f42cd8cdd1 | ||
|
|
2d34e162c0 | ||
|
|
cdee9c7867 | ||
|
|
45de960618 | ||
|
|
2669fc57c4 | ||
|
|
10ede85ae3 | ||
|
|
21ccc7906b | ||
|
|
28d9a8e4db | ||
|
|
090ab82bde | ||
|
|
157c73536b | ||
|
|
d74f47c38f | ||
|
|
f6cf915792 | ||
|
|
9d3b0056a5 | ||
|
|
ce16bd449f | ||
|
|
e645c5ee53 | ||
|
|
07ac90b110 | ||
|
|
e8faecc37a | ||
|
|
166633414a | ||
|
|
9ecefc6959 | ||
|
|
afcc33b7fb | ||
|
|
4c2702b7ff | ||
|
|
fdcd8547d3 | ||
|
|
16ae3ff2ed | ||
|
|
80e6e3c4c1 | ||
|
|
b58120998f | ||
|
|
c671943d49 | ||
|
|
664fe90c10 | ||
|
|
2215d52b09 | ||
|
|
c9059a4f29 | ||
|
|
4445bf578c | ||
|
|
f719350507 |
42
.github/workflows/build.yml
vendored
42
.github/workflows/build.yml
vendored
@@ -13,12 +13,24 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-${{ matrix.os }}
|
||||
name: build-${{ matrix.name }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, windows-latest, ubuntu-latest]
|
||||
include:
|
||||
- name: macos
|
||||
os: macos-latest
|
||||
pack_script: pack:mac
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
pack_script: pack:win
|
||||
- name: linux-x64
|
||||
os: ubuntu-latest
|
||||
pack_script: pack:linux-x64
|
||||
- name: linux-arm64
|
||||
os: ubuntu-24.04-arm
|
||||
pack_script: pack:linux-arm64
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
@@ -50,29 +62,23 @@ jobs:
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package (macOS)
|
||||
if: matrix.os == 'macos-latest'
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:mac
|
||||
# On ARM64 runners, electron-builder's post-build @electron/rebuild incorrectly
|
||||
# tries to restore native modules to x64, but the ARM g++ doesn't support -m64.
|
||||
# Setting npm_config_arch=arm64 ensures node-gyp uses the correct host architecture.
|
||||
- name: Set native module arch for ARM64
|
||||
if: matrix.name == 'linux-arm64'
|
||||
run: echo "npm_config_arch=arm64" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build package (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
- name: Build package
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:win
|
||||
|
||||
- name: Build package (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-${{ matrix.os }}
|
||||
name: netcatty-${{ matrix.name }}
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.exe
|
||||
|
||||
27
App.tsx
27
App.tsx
@@ -14,6 +14,7 @@ import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
@@ -1082,6 +1083,30 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
})();
|
||||
}, [openSettingsWindow, t]);
|
||||
|
||||
const hasShownCredentialProtectionWarningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasShownCredentialProtectionWarningRef.current) return;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const available = await getCredentialProtectionAvailability();
|
||||
if (cancelled || available !== false) return;
|
||||
hasShownCredentialProtectionWarningRef.current = true;
|
||||
|
||||
toast.warning(t('credentials.protectionUnavailable.message'), {
|
||||
title: t('credentials.protectionUnavailable.title'),
|
||||
actionLabel: t('credentials.protectionUnavailable.action'),
|
||||
duration: 10000,
|
||||
onClick: handleOpenSettings,
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [handleOpenSettings, t]);
|
||||
|
||||
const handleEndSessionDrag = useCallback(() => {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
@@ -1184,7 +1209,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
/>
|
||||
</VaultViewContainer>
|
||||
|
||||
<SftpViewMount hosts={hosts} keys={keys} identities={identities} />
|
||||
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
|
||||
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
|
||||
@@ -14,6 +14,7 @@ const en: Messages = {
|
||||
'common.import': 'Import',
|
||||
'common.generate': 'Generate',
|
||||
'common.delete': 'Delete',
|
||||
'common.edit': 'Edit',
|
||||
'common.clear': 'Clear',
|
||||
'common.optional': 'Optional',
|
||||
'common.selectPlaceholder': 'Select...',
|
||||
@@ -57,6 +58,9 @@ const en: Messages = {
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
|
||||
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
|
||||
'credentials.protectionUnavailable.action': 'Open Settings',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Settings',
|
||||
@@ -80,6 +84,14 @@ const en: Messages = {
|
||||
'settings.system.clearing': 'Clearing...',
|
||||
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
|
||||
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
'settings.system.credentials.available': 'Available (OS keychain ready)',
|
||||
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
|
||||
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -218,6 +230,9 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
|
||||
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
|
||||
@@ -324,6 +339,7 @@ const en: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
'time.minutesAgo': '{minutes}m ago',
|
||||
@@ -513,6 +529,9 @@ const en: Messages = {
|
||||
'sftp.newFile': 'New File',
|
||||
'sftp.filter': 'Filter',
|
||||
'sftp.filter.placeholder': 'Filter by filename...',
|
||||
'sftp.bookmark.add': 'Bookmark this path',
|
||||
'sftp.bookmark.remove': 'Remove bookmark',
|
||||
'sftp.bookmark.empty': 'No bookmarks yet',
|
||||
'sftp.columns.name': 'Name',
|
||||
'sftp.columns.modified': 'Modified',
|
||||
'sftp.columns.size': 'Size',
|
||||
@@ -781,6 +800,10 @@ const en: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
|
||||
'hostDetails.section.agentForwarding': 'SSH Agent',
|
||||
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
|
||||
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
|
||||
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
'hostDetails.jumpHosts.hops': '{count} hop(s)',
|
||||
'hostDetails.jumpHosts.direct': 'Direct',
|
||||
@@ -896,6 +919,11 @@ const en: Messages = {
|
||||
'terminal.toolbar.broadcast': 'Broadcast',
|
||||
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
|
||||
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
|
||||
'terminal.toolbar.composeBar': 'Compose Bar',
|
||||
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
|
||||
'terminal.composeBar.send': 'Send',
|
||||
'terminal.composeBar.close': 'Close compose bar',
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
@@ -916,6 +944,10 @@ const en: Messages = {
|
||||
'terminal.serverStats.memBuffers': 'Buffers',
|
||||
'terminal.serverStats.memCached': 'Cache',
|
||||
'terminal.serverStats.memFree': 'Free',
|
||||
'terminal.serverStats.swap': 'Swap',
|
||||
'terminal.serverStats.swapUsed': 'Swap Used',
|
||||
'terminal.serverStats.swapFree': 'Swap Free',
|
||||
'terminal.serverStats.swapTotal': 'Total',
|
||||
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
|
||||
'terminal.serverStats.disk': 'Disk Usage (Root)',
|
||||
'terminal.serverStats.diskDetails': 'Mounted Disks',
|
||||
@@ -952,6 +984,10 @@ const en: Messages = {
|
||||
'terminal.auth.selectKey': 'Select Key',
|
||||
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
|
||||
'terminal.auth.continueSave': 'Continue & Save',
|
||||
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
|
||||
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
|
||||
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
|
||||
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
|
||||
'terminal.progress.disconnected': 'Disconnected',
|
||||
'terminal.progress.cancelling': 'Cancelling...',
|
||||
@@ -967,10 +1003,50 @@ const en: Messages = {
|
||||
'terminal.themeModal.title': 'Terminal Appearance',
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
'terminal.themeModal.tab.custom': 'Custom',
|
||||
'terminal.themeModal.fontSize': 'Font Size',
|
||||
'terminal.themeModal.livePreview': 'Live Preview',
|
||||
'terminal.themeModal.themeType': '{type} theme',
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': 'Custom Themes',
|
||||
'terminal.customTheme.yourThemes': 'Your Themes',
|
||||
'terminal.customTheme.new': 'New Theme',
|
||||
'terminal.customTheme.newDesc': 'Clone current theme and customize',
|
||||
'terminal.customTheme.newTitle': 'New Custom Theme',
|
||||
'terminal.customTheme.editTitle': 'Edit Theme',
|
||||
'terminal.customTheme.import': 'Import .itermcolors',
|
||||
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
|
||||
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
|
||||
'terminal.customTheme.delete': 'Delete Theme',
|
||||
'terminal.customTheme.confirmDelete': 'Confirm Delete',
|
||||
'terminal.customTheme.name': 'Name',
|
||||
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
|
||||
'terminal.customTheme.type': 'Type',
|
||||
'terminal.customTheme.group.general': 'General',
|
||||
'terminal.customTheme.group.normal': 'Normal Colors',
|
||||
'terminal.customTheme.group.bright': 'Bright Colors',
|
||||
'terminal.customTheme.color.background': 'Background',
|
||||
'terminal.customTheme.color.foreground': 'Foreground',
|
||||
'terminal.customTheme.color.cursor': 'Cursor',
|
||||
'terminal.customTheme.color.selection': 'Selection',
|
||||
'terminal.customTheme.color.black': 'Black',
|
||||
'terminal.customTheme.color.red': 'Red',
|
||||
'terminal.customTheme.color.green': 'Green',
|
||||
'terminal.customTheme.color.yellow': 'Yellow',
|
||||
'terminal.customTheme.color.blue': 'Blue',
|
||||
'terminal.customTheme.color.magenta': 'Magenta',
|
||||
'terminal.customTheme.color.cyan': 'Cyan',
|
||||
'terminal.customTheme.color.white': 'White',
|
||||
'terminal.customTheme.color.brightBlack': 'Bright Black',
|
||||
'terminal.customTheme.color.brightRed': 'Bright Red',
|
||||
'terminal.customTheme.color.brightGreen': 'Bright Green',
|
||||
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
|
||||
'terminal.customTheme.color.brightBlue': 'Bright Blue',
|
||||
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
|
||||
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
|
||||
'terminal.customTheme.color.brightWhite': 'Bright White',
|
||||
|
||||
// Cloud Sync Settings
|
||||
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
|
||||
'cloudSync.gate.desc':
|
||||
|
||||
@@ -42,6 +42,9 @@ const zhCN: Messages = {
|
||||
'placeholder.workspaceName': '工作区名称',
|
||||
'placeholder.sessionName': '会话名称',
|
||||
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
|
||||
'credentials.protectionUnavailable.title': '凭据保护不可用',
|
||||
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
|
||||
'credentials.protectionUnavailable.action': '打开设置',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': '设置',
|
||||
@@ -65,6 +68,14 @@ const zhCN: Messages = {
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
'settings.system.credentials.available': '可用(系统钥匙串正常)',
|
||||
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
|
||||
'settings.system.credentials.unknown': '未知(当前环境不支持)',
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -191,6 +202,7 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
'time.minutesAgo': '{minutes} 分钟前',
|
||||
@@ -355,6 +367,9 @@ const zhCN: Messages = {
|
||||
'sftp.newFile': '新建文件',
|
||||
'sftp.filter': '筛选',
|
||||
'sftp.filter.placeholder': '按文件名筛选...',
|
||||
'sftp.bookmark.add': '收藏此路径',
|
||||
'sftp.bookmark.remove': '取消收藏',
|
||||
'sftp.bookmark.empty': '暂无收藏路径',
|
||||
'sftp.columns.name': '名称',
|
||||
'sftp.columns.modified': '修改时间',
|
||||
'sftp.columns.size': '大小',
|
||||
@@ -496,6 +511,10 @@ const zhCN: Messages = {
|
||||
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
|
||||
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
|
||||
'hostDetails.section.agentForwarding': 'SSH 代理',
|
||||
'hostDetails.section.legacyAlgorithms': '旧版算法',
|
||||
'hostDetails.legacyAlgorithms': '允许旧版算法',
|
||||
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法(diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
|
||||
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
'hostDetails.jumpHosts.hops': '{count} 跳',
|
||||
'hostDetails.jumpHosts.direct': '直连',
|
||||
@@ -582,6 +601,11 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.broadcast': '广播',
|
||||
'terminal.toolbar.broadcastEnable': '启用广播模式',
|
||||
'terminal.toolbar.broadcastDisable': '关闭广播模式',
|
||||
'terminal.toolbar.composeBar': '撰写栏',
|
||||
'terminal.composeBar.placeholder': '在此输入命令,按回车发送...',
|
||||
'terminal.composeBar.send': '发送',
|
||||
'terminal.composeBar.close': '关闭撰写栏',
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
@@ -602,6 +626,10 @@ const zhCN: Messages = {
|
||||
'terminal.serverStats.memBuffers': '缓冲区',
|
||||
'terminal.serverStats.memCached': '缓存',
|
||||
'terminal.serverStats.memFree': '空闲',
|
||||
'terminal.serverStats.swap': '交换空间',
|
||||
'terminal.serverStats.swapUsed': '已用交换',
|
||||
'terminal.serverStats.swapFree': '空闲交换',
|
||||
'terminal.serverStats.swapTotal': '总计',
|
||||
'terminal.serverStats.topProcesses': '内存占用前十进程',
|
||||
'terminal.serverStats.disk': '磁盘使用(根分区)',
|
||||
'terminal.serverStats.diskDetails': '已挂载磁盘',
|
||||
@@ -638,6 +666,10 @@ const zhCN: Messages = {
|
||||
'terminal.auth.selectKey': '选择密钥',
|
||||
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
|
||||
'terminal.auth.continueSave': '继续并保存',
|
||||
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
|
||||
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
|
||||
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
|
||||
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
|
||||
'terminal.connectionErrorTitle': '连接错误',
|
||||
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
|
||||
'terminal.progress.disconnected': '已断开',
|
||||
@@ -654,10 +686,50 @@ const zhCN: Messages = {
|
||||
'terminal.themeModal.title': 'Terminal 外观',
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
'terminal.themeModal.tab.custom': '自定义',
|
||||
'terminal.themeModal.fontSize': '字体大小',
|
||||
'terminal.themeModal.livePreview': '实时预览',
|
||||
'terminal.themeModal.themeType': '{type} 主题',
|
||||
// Cloud Sync Settings
|
||||
|
||||
// Custom Themes
|
||||
'terminal.customTheme.section': '自定义主题',
|
||||
'terminal.customTheme.yourThemes': '我的主题',
|
||||
'terminal.customTheme.new': '新建主题',
|
||||
'terminal.customTheme.newDesc': '克隆当前主题并自定义',
|
||||
'terminal.customTheme.newTitle': '新建自定义主题',
|
||||
'terminal.customTheme.editTitle': '编辑主题',
|
||||
'terminal.customTheme.import': '导入 .itermcolors',
|
||||
'terminal.customTheme.importDesc': '从 iTerm2 配色方案文件导入',
|
||||
'terminal.customTheme.importError': '无法解析所选文件,请确保它是有效的 .itermcolors XML 文件。',
|
||||
'terminal.customTheme.delete': '删除主题',
|
||||
'terminal.customTheme.confirmDelete': '确认删除',
|
||||
'terminal.customTheme.name': '名称',
|
||||
'terminal.customTheme.namePlaceholder': '我的自定义主题',
|
||||
'terminal.customTheme.type': '类型',
|
||||
'terminal.customTheme.group.general': '通用',
|
||||
'terminal.customTheme.group.normal': '标准色',
|
||||
'terminal.customTheme.group.bright': '高亮色',
|
||||
'terminal.customTheme.color.background': '背景',
|
||||
'terminal.customTheme.color.foreground': '前景',
|
||||
'terminal.customTheme.color.cursor': '光标',
|
||||
'terminal.customTheme.color.selection': '选区',
|
||||
'terminal.customTheme.color.black': '黑色',
|
||||
'terminal.customTheme.color.red': '红色',
|
||||
'terminal.customTheme.color.green': '绿色',
|
||||
'terminal.customTheme.color.yellow': '黄色',
|
||||
'terminal.customTheme.color.blue': '蓝色',
|
||||
'terminal.customTheme.color.magenta': '品红',
|
||||
'terminal.customTheme.color.cyan': '青色',
|
||||
'terminal.customTheme.color.white': '白色',
|
||||
'terminal.customTheme.color.brightBlack': '亮黑',
|
||||
'terminal.customTheme.color.brightRed': '亮红',
|
||||
'terminal.customTheme.color.brightGreen': '亮绿',
|
||||
'terminal.customTheme.color.brightYellow': '亮黄',
|
||||
'terminal.customTheme.color.brightBlue': '亮蓝',
|
||||
'terminal.customTheme.color.brightMagenta': '亮品红',
|
||||
'terminal.customTheme.color.brightCyan': '亮青色',
|
||||
'terminal.customTheme.color.brightWhite': '亮白',
|
||||
|
||||
'cloudSync.gate.title': '端到端加密同步',
|
||||
'cloudSync.gate.desc':
|
||||
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
|
||||
@@ -816,6 +888,7 @@ const zhCN: Messages = {
|
||||
'common.import': '导入',
|
||||
'common.generate': '生成',
|
||||
'common.delete': '删除',
|
||||
'common.edit': '编辑',
|
||||
'common.clear': '清除',
|
||||
'common.optional': '可选',
|
||||
'common.selectPlaceholder': '请选择...',
|
||||
@@ -1024,6 +1097,9 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
|
||||
@@ -1086,6 +1162,35 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
'settings.shortcuts.category.app': '应用',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
'settings.shortcuts.binding.switch-tab-1-9': '切换到标签页 [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': '下一个标签页',
|
||||
'settings.shortcuts.binding.prev-tab': '上一个标签页',
|
||||
'settings.shortcuts.binding.close-tab': '关闭标签页',
|
||||
'settings.shortcuts.binding.new-tab': '新建本地标签页',
|
||||
'settings.shortcuts.binding.copy': '从终端复制',
|
||||
'settings.shortcuts.binding.paste': '粘贴到终端',
|
||||
'settings.shortcuts.binding.select-all': '全选终端内容',
|
||||
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
|
||||
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
|
||||
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
|
||||
'settings.shortcuts.binding.split-horizontal': '水平分屏',
|
||||
'settings.shortcuts.binding.split-vertical': '垂直分屏',
|
||||
'settings.shortcuts.binding.open-hosts': '打开主机列表',
|
||||
'settings.shortcuts.binding.open-local': '打开本地终端',
|
||||
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
|
||||
'settings.shortcuts.binding.command-palette': '打开命令面板',
|
||||
'settings.shortcuts.binding.quick-switch': '快速切换',
|
||||
'settings.shortcuts.binding.snippets': '打开代码片段',
|
||||
'settings.shortcuts.binding.broadcast': '切换广播模式',
|
||||
'settings.shortcuts.binding.sftp-copy': '复制文件',
|
||||
'settings.shortcuts.binding.sftp-cut': '剪切文件',
|
||||
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
|
||||
'settings.shortcuts.binding.sftp-select-all': '全选文件',
|
||||
'settings.shortcuts.binding.sftp-rename': '重命名文件',
|
||||
'settings.shortcuts.binding.sftp-delete': '删除文件',
|
||||
'settings.shortcuts.binding.sftp-refresh': '刷新',
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
|
||||
177
application/state/customThemeStore.ts
Normal file
177
application/state/customThemeStore.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useSyncExternalStore, useCallback } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { STORAGE_KEY_CUSTOM_THEMES } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
// Access the Electron bridge for cross-window IPC
|
||||
type NetcattyBridge = {
|
||||
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
|
||||
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
|
||||
};
|
||||
const getBridge = (): NetcattyBridge | undefined =>
|
||||
(window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
|
||||
/**
|
||||
* Custom Theme Store - manages user-created terminal themes
|
||||
* Uses useSyncExternalStore pattern (same as fontStore)
|
||||
* Persists to localStorage + cross-window IPC sync
|
||||
*/
|
||||
type Listener = () => void;
|
||||
|
||||
class CustomThemeStore {
|
||||
private themes: TerminalTheme[] = [];
|
||||
private listeners = new Set<Listener>();
|
||||
private loaded = false;
|
||||
/** Cached merged array for stable useSyncExternalStore snapshots */
|
||||
private cachedAllThemes: TerminalTheme[] | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadFromStorage();
|
||||
this.setupCrossWindowSync();
|
||||
}
|
||||
|
||||
private loadFromStorage = () => {
|
||||
try {
|
||||
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.themes = parsed.map((t: TerminalTheme) => ({ ...t, isCustom: true }));
|
||||
}
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
this.loaded = true;
|
||||
this.cachedAllThemes = null; // invalidate cache
|
||||
};
|
||||
|
||||
private saveToStorage = () => {
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_THEMES, this.themes);
|
||||
} catch {
|
||||
// storage full or unavailable
|
||||
}
|
||||
};
|
||||
|
||||
private notify = () => {
|
||||
this.cachedAllThemes = null; // invalidate cache on any mutation
|
||||
this.listeners.forEach(listener => listener());
|
||||
};
|
||||
|
||||
/** Broadcast change to other Electron windows via IPC */
|
||||
private broadcastChange = () => {
|
||||
try {
|
||||
getBridge()?.notifySettingsChanged?.({
|
||||
key: STORAGE_KEY_CUSTOM_THEMES,
|
||||
value: this.themes,
|
||||
});
|
||||
} catch {
|
||||
// not in Electron or bridge unavailable
|
||||
}
|
||||
};
|
||||
|
||||
/** Listen for changes from other windows and reload */
|
||||
private setupCrossWindowSync = () => {
|
||||
try {
|
||||
getBridge()?.onSettingsChanged?.((payload) => {
|
||||
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
|
||||
// Another window changed custom themes — reload from localStorage
|
||||
this.loadFromStorage();
|
||||
this.notify();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// not in Electron or bridge unavailable
|
||||
}
|
||||
};
|
||||
|
||||
subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
};
|
||||
|
||||
// ---- Getters (stable references for useSyncExternalStore) ----
|
||||
|
||||
getCustomThemes = (): TerminalTheme[] => this.themes;
|
||||
|
||||
/** Returns all themes: built-in + custom (cached for snapshot stability) */
|
||||
getAllThemes = (): TerminalTheme[] => {
|
||||
if (!this.cachedAllThemes) {
|
||||
this.cachedAllThemes = [...TERMINAL_THEMES, ...this.themes];
|
||||
}
|
||||
return this.cachedAllThemes;
|
||||
};
|
||||
|
||||
/** Find a theme by ID across both built-in and custom */
|
||||
getThemeById = (id: string): TerminalTheme | undefined => {
|
||||
return TERMINAL_THEMES.find(t => t.id === id) || this.themes.find(t => t.id === id);
|
||||
};
|
||||
|
||||
// ---- Mutations ----
|
||||
|
||||
addTheme = (theme: TerminalTheme) => {
|
||||
this.themes = [...this.themes, { ...theme, isCustom: true }];
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
|
||||
updateTheme = (id: string, updates: Partial<TerminalTheme>) => {
|
||||
this.themes = this.themes.map(t =>
|
||||
t.id === id ? { ...t, ...updates, isCustom: true } : t
|
||||
);
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
|
||||
deleteTheme = (id: string) => {
|
||||
this.themes = this.themes.filter(t => t.id !== id);
|
||||
this.saveToStorage();
|
||||
this.notify();
|
||||
this.broadcastChange();
|
||||
};
|
||||
}
|
||||
|
||||
// Singleton
|
||||
export const customThemeStore = new CustomThemeStore();
|
||||
|
||||
// ============== Hooks ==============
|
||||
|
||||
/** Get all themes (built-in + custom) */
|
||||
export const useAllThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
customThemeStore.subscribe,
|
||||
customThemeStore.getAllThemes
|
||||
);
|
||||
};
|
||||
|
||||
/** Get custom themes only */
|
||||
export const useCustomThemes = (): TerminalTheme[] => {
|
||||
return useSyncExternalStore(
|
||||
customThemeStore.subscribe,
|
||||
customThemeStore.getCustomThemes
|
||||
);
|
||||
};
|
||||
|
||||
/** Get theme by ID (built-in or custom) with fallback */
|
||||
export const useThemeById = (id: string): TerminalTheme => {
|
||||
const allThemes = useAllThemes();
|
||||
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
|
||||
};
|
||||
|
||||
/** Theme mutation actions */
|
||||
export const useCustomThemeActions = () => {
|
||||
const addTheme = useCallback((theme: TerminalTheme) => {
|
||||
customThemeStore.addTheme(theme);
|
||||
}, []);
|
||||
|
||||
const updateTheme = useCallback((id: string, updates: Partial<TerminalTheme>) => {
|
||||
customThemeStore.updateTheme(id, updates);
|
||||
}, []);
|
||||
|
||||
const deleteTheme = useCallback((id: string) => {
|
||||
customThemeStore.deleteTheme(id);
|
||||
}, []);
|
||||
|
||||
return { addTheme, updateTheme, deleteTheme };
|
||||
};
|
||||
@@ -20,12 +20,12 @@ export const useSftpHostCredentials = ({
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
@@ -63,6 +63,7 @@ export const useSftpHostCredentials = ({
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
|
||||
@@ -162,11 +162,16 @@ export const useSftpTransfers = ({
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id) return t;
|
||||
if (t.status === "cancelled") return t;
|
||||
const normalizedTotal = total > 0 ? total : t.totalBytes;
|
||||
const normalizedTransferred = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total || t.totalBytes,
|
||||
speed,
|
||||
transferredBytes: normalizedTransferred,
|
||||
totalBytes: normalizedTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -773,7 +778,27 @@ export const useSftpTransfers = ({
|
||||
|
||||
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
|
||||
prev.map((t) => {
|
||||
if (t.id !== taskId) return t;
|
||||
|
||||
const merged: TransferTask = { ...t, ...updates };
|
||||
|
||||
// Keep progress monotonic and bounded for stable progress UI.
|
||||
if (typeof merged.totalBytes === "number" && merged.totalBytes > 0) {
|
||||
merged.transferredBytes = Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(merged.transferredBytes, merged.totalBytes),
|
||||
);
|
||||
} else {
|
||||
merged.transferredBytes = Math.max(t.transferredBytes, merged.transferredBytes);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(merged.speed) || merged.speed < 0) {
|
||||
merged.speed = 0;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ import { useCloudSync } from './useCloudSync';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
|
||||
@@ -109,6 +112,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
const payload = buildPayload();
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length > 0) {
|
||||
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
for (const result of results.values()) {
|
||||
|
||||
@@ -286,7 +286,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
@@ -319,7 +319,7 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
|
||||
// Open browser after starting server
|
||||
setTimeout(() => {
|
||||
window.open(data.url, '_blank', 'width=600,height=700');
|
||||
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
|
||||
}, 100);
|
||||
|
||||
// Wait for callback
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
@@ -766,9 +767,14 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_SYNC, config);
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const currentTerminalTheme = useMemo(
|
||||
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0],
|
||||
[terminalThemeId]
|
||||
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0],
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => window.open(verificationUri, '_blank')}
|
||||
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
|
||||
className="w-full gap-2 mb-4"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
if (encryptedCredentialPaths.length === 0) return true;
|
||||
|
||||
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
|
||||
return false;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle conflict detection
|
||||
useEffect(() => {
|
||||
if (sync.currentConflict) {
|
||||
@@ -958,6 +972,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const handleSync = async (provider: CloudProvider) => {
|
||||
try {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) return;
|
||||
const result = await sync.syncToProvider(provider, payload);
|
||||
|
||||
if (result.success) {
|
||||
@@ -982,6 +997,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
} else if (resolution === 'USE_LOCAL') {
|
||||
// Re-sync with local data
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
await sync.syncNow(localPayload);
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
@@ -1556,6 +1572,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
let payloadForReencrypt: SyncPayload | null = null;
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(payload)) {
|
||||
setChangeKeyError(t('sync.credentialsUnavailable'));
|
||||
return;
|
||||
}
|
||||
payloadForReencrypt = payload;
|
||||
}
|
||||
|
||||
setIsChangingKey(true);
|
||||
try {
|
||||
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
|
||||
@@ -1564,9 +1590,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sync.hasAnyConnectedProvider) {
|
||||
const payload = onBuildPayload();
|
||||
await sync.syncNow(payload);
|
||||
if (payloadForReencrypt) {
|
||||
await sync.syncNow(payloadForReencrypt);
|
||||
}
|
||||
|
||||
toast.success(t('cloudSync.changeKey.updatedToast'));
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Plus,
|
||||
Settings2,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
import React, { useEffect, useMemo, useState, useCallback } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../application/state/useApplicationBackend";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
||||
import { Button } from "./ui/button";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { Card } from "./ui/card";
|
||||
@@ -1115,21 +1117,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.colors.background || "#100F0F",
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
|
||||
color:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.colors.foreground || "#CECDC3",
|
||||
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.colors.green,
|
||||
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
|
||||
}}
|
||||
>
|
||||
$
|
||||
@@ -1137,9 +1133,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{TERMINAL_THEMES.find(
|
||||
(t) => t.id === (form.theme || "flexoki-dark"),
|
||||
)?.name || "Flexoki Dark"}
|
||||
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1230,6 +1224,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Legacy Algorithms */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.legacyAlgorithms")}
|
||||
enabled={!!form.legacyAlgorithms}
|
||||
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("hostDetails.legacyAlgorithms.desc")}
|
||||
</p>
|
||||
{form.legacyAlgorithms && (
|
||||
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
|
||||
{t("hostDetails.legacyAlgorithms.warning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1306,36 +1324,50 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Card>
|
||||
|
||||
{/* Proxy Configuration */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
|
||||
</div>
|
||||
{form.proxyConfig?.host ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:
|
||||
{form.proxyConfig.port}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
{t("hostDetails.proxy.none")}
|
||||
</Badge>
|
||||
)}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80 overflow-hidden">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{form.proxyConfig?.host
|
||||
? t("hostDetails.proxy.edit")
|
||||
: t("hostDetails.proxy.configure")}
|
||||
</Button>
|
||||
{form.proxyConfig?.host ? (
|
||||
<button
|
||||
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{form.proxyConfig.type?.toUpperCase()}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearProxyConfig();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 justify-start gap-2 text-sm"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("hostDetails.proxy.configure")}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Environment Variables */}
|
||||
@@ -1466,35 +1498,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
|
||||
style={{
|
||||
backgroundColor:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.background || "#100F0F",
|
||||
color:
|
||||
TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.foreground || "#CECDC3",
|
||||
}}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<div
|
||||
style={{
|
||||
color: TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
color: customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.colors.green,
|
||||
}}
|
||||
>
|
||||
@@ -1503,13 +1520,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm flex-1">
|
||||
{TERMINAL_THEMES.find(
|
||||
(t) =>
|
||||
t.id ===
|
||||
(form.protocols?.find((p) => p.protocol === "telnet")
|
||||
?.theme ||
|
||||
form.theme ||
|
||||
"flexoki-dark"),
|
||||
{customThemeStore.getThemeById(
|
||||
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
|
||||
)?.name || "Flexoki Dark"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { ConnectionLog, TerminalTheme } from "../types";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
import { Button } from "./ui/button";
|
||||
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
|
||||
|
||||
@@ -36,13 +37,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Use log's saved theme/fontSize or fall back to defaults
|
||||
const currentTheme = useMemo(() => {
|
||||
if (log.themeId) {
|
||||
return TERMINAL_THEMES.find(t => t.id === log.themeId) || defaultTerminalTheme;
|
||||
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|
||||
|| customThemes.find(t => t.id === log.themeId)
|
||||
|| defaultTerminalTheme;
|
||||
}
|
||||
return defaultTerminalTheme;
|
||||
}, [log.themeId, defaultTerminalTheme]);
|
||||
}, [log.themeId, defaultTerminalTheme, customThemes]);
|
||||
|
||||
const currentFontSize = log.fontSize ?? defaultFontSize;
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ interface SFTPModalProps {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -49,6 +50,8 @@ interface SFTPModalProps {
|
||||
initialPath?: string;
|
||||
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
|
||||
initialEntriesToUpload?: DropEntry[];
|
||||
/** Callback to update the host (e.g. for bookmark persistence). */
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
}
|
||||
|
||||
const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
@@ -58,6 +61,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onClose,
|
||||
initialPath,
|
||||
initialEntriesToUpload,
|
||||
onUpdateHost,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
@@ -204,6 +208,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
@@ -296,6 +301,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
@@ -392,8 +404,37 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
});
|
||||
const hasEverOpenedRef = useRef(false);
|
||||
|
||||
const hasActiveTransferTasks = useMemo(
|
||||
() =>
|
||||
uploadTasks.some(
|
||||
(task) =>
|
||||
task.status === "pending" ||
|
||||
task.status === "uploading" ||
|
||||
task.status === "downloading",
|
||||
),
|
||||
[uploadTasks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
hasEverOpenedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasEverOpenedRef.current) return;
|
||||
if (uploading || hasActiveTransferTasks) return;
|
||||
|
||||
void closeSftpSession();
|
||||
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
|
||||
|
||||
const handleClose = async () => {
|
||||
if (uploading || hasActiveTransferTasks) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
await closeSftpSession();
|
||||
onClose();
|
||||
};
|
||||
@@ -438,7 +479,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
// Filter hidden files using utility function
|
||||
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
|
||||
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles, isLocalSession);
|
||||
|
||||
// Check if we're at root
|
||||
const atRoot = isRootPathForSession(currentPath);
|
||||
@@ -452,7 +493,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
lastModified: undefined,
|
||||
};
|
||||
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
|
||||
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles, isLocalSession]);
|
||||
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
@@ -526,7 +567,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Find the files to pass to confirm dialog
|
||||
if (fileNames.length === 0) return;
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
|
||||
// Delete files
|
||||
(async () => {
|
||||
try {
|
||||
@@ -642,6 +683,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
onUpdateHost={onUpdateHost}
|
||||
onNavigateToBookmark={(path) => setCurrentPath(path)}
|
||||
/>
|
||||
|
||||
<SftpModalFileList
|
||||
@@ -718,6 +761,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
getSymbolicPermissions={getSymbolicPermissions}
|
||||
handleSavePermissions={handleSavePermissions}
|
||||
isChangingPermissions={isChangingPermissions}
|
||||
showCreateDialog={showCreateDialog}
|
||||
setShowCreateDialog={setShowCreateDialog}
|
||||
createType={createType}
|
||||
createName={createName}
|
||||
setCreateName={setCreateName}
|
||||
isCreating={isCreating}
|
||||
handleCreateSubmit={handleCreateSubmit}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
@@ -48,9 +48,10 @@ interface SftpViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
}
|
||||
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const {
|
||||
@@ -167,7 +168,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
() => sftp.transfers.slice(-5),
|
||||
() => [...sftp.transfers].reverse().slice(0, 5),
|
||||
[sftp.transfers],
|
||||
);
|
||||
|
||||
@@ -213,6 +214,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
leftCallbacks={leftCallbacks}
|
||||
|
||||
@@ -30,9 +30,11 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
|
||||
import { toast } from "./ui/toast";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { useCustomThemes } from "../application/state/customThemeStore";
|
||||
|
||||
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
|
||||
import { TerminalToolbar } from "./terminal/TerminalToolbar";
|
||||
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
|
||||
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
|
||||
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
@@ -137,6 +139,8 @@ interface TerminalProps {
|
||||
onSplitVertical?: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
onToggleBroadcast?: () => void;
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
}
|
||||
|
||||
@@ -191,6 +195,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitVertical,
|
||||
isBroadcastEnabled,
|
||||
onToggleBroadcast,
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
@@ -290,6 +296,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -344,13 +351,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
||||
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
const effectiveTheme = useMemo(() => {
|
||||
if (host.theme) {
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme);
|
||||
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|
||||
|| customThemes.find((t) => t.id === host.theme);
|
||||
if (hostTheme) return hostTheme;
|
||||
}
|
||||
return terminalTheme;
|
||||
}, [host.theme, terminalTheme]);
|
||||
}, [host.theme, terminalTheme, customThemes]);
|
||||
|
||||
const resolvedChainHosts =
|
||||
(host.hostChain?.hostIds
|
||||
@@ -416,6 +427,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setProgressLogs,
|
||||
setProgressValue,
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
@@ -679,6 +691,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
|
||||
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
|
||||
}
|
||||
|
||||
setTimeout(() => safeFit(), 50);
|
||||
@@ -875,11 +888,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
onHasSelectionChange: setHasSelection,
|
||||
disableBracketedPasteRef,
|
||||
});
|
||||
|
||||
const handleSnippetClick = (cmd: string) => {
|
||||
@@ -1094,6 +1111,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onClose={() => onCloseSession?.(sessionId)}
|
||||
isSearchOpen={isSearchOpen}
|
||||
onToggleSearch={handleToggleSearch}
|
||||
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
|
||||
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1122,7 +1141,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
>
|
||||
<div
|
||||
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
|
||||
className={cn(
|
||||
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
|
||||
isComposeBarOpen && !inWorkspace && "flex-col"
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1299,6 +1321,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Swap bar */}
|
||||
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-rose-500"
|
||||
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
|
||||
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-rose-500" />
|
||||
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
|
||||
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Top 10 processes */}
|
||||
{serverStats.topProcesses.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
@@ -1559,6 +1609,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
|
||||
{isComposeBarOpen && !inWorkspace && (
|
||||
<TerminalComposeBar
|
||||
onSend={(text) => {
|
||||
if (sessionRef.current) {
|
||||
const payload = text + '\r';
|
||||
terminalBackend.writeToSession(sessionRef.current, payload);
|
||||
onBroadcastInput?.(payload, sessionRef.current);
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
termRef.current?.focus();
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled}
|
||||
themeColors={effectiveTheme.colors}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SFTPModal
|
||||
host={host}
|
||||
credentials={(() => {
|
||||
@@ -1618,6 +1687,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sftpSudo: host.sftpSudo,
|
||||
legacyAlgorithms: host.legacyAlgorithms,
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
@@ -1627,6 +1697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}}
|
||||
initialPath={sftpInitialPath}
|
||||
initialEntriesToUpload={pendingUploadEntries}
|
||||
onUpdateHost={onUpdateHost}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { cn } from '../lib/utils';
|
||||
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import Terminal from './Terminal';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { Button } from './ui/button';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
@@ -179,6 +182,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
}, [activeWorkspace, sessions, terminalBackend]);
|
||||
|
||||
// Workspace-level compose bar state
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
||||
|
||||
// Pre-compute host lookup map for O(1) access
|
||||
const hostMap = useMemo(() => {
|
||||
const map = new Map<string, Host>();
|
||||
@@ -429,6 +435,48 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const isFocusMode = activeWorkspace?.viewMode === 'focus';
|
||||
const focusedSessionId = activeWorkspace?.focusedSessionId;
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Resolve the effective theme for the compose bar in workspace mode
|
||||
const composeBarThemeColors = useMemo(() => {
|
||||
if (!activeWorkspace || !focusedSessionId) return terminalTheme.colors;
|
||||
const focusedHost = sessionHostsMap.get(focusedSessionId);
|
||||
if (focusedHost?.theme) {
|
||||
const hostTheme = TERMINAL_THEMES.find(t => t.id === focusedHost.theme)
|
||||
|| customThemes.find(t => t.id === focusedHost.theme);
|
||||
if (hostTheme) return hostTheme.colors;
|
||||
}
|
||||
return terminalTheme.colors;
|
||||
}, [activeWorkspace, focusedSessionId, sessionHostsMap, terminalTheme, customThemes]);
|
||||
|
||||
// Handle compose bar send for workspace mode
|
||||
const handleComposeSend = useCallback((text: string) => {
|
||||
if (!activeWorkspace) return;
|
||||
const payload = text + '\r';
|
||||
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
|
||||
|
||||
if (broadcastEnabled) {
|
||||
// Send to all sessions in the workspace
|
||||
const allSessionIds = sessions
|
||||
.filter(s => s.workspaceId === activeWorkspace.id)
|
||||
.map(s => s.id);
|
||||
for (const sid of allSessionIds) {
|
||||
terminalBackend.writeToSession(sid, payload);
|
||||
}
|
||||
} else {
|
||||
// Validate focusedSessionId is a live session, then fallback to first available
|
||||
const workspaceSessions = sessions.filter(s => s.workspaceId === activeWorkspace.id);
|
||||
const validFocusedId = focusedSessionId && workspaceSessions.some(s => s.id === focusedSessionId)
|
||||
? focusedSessionId
|
||||
: undefined;
|
||||
const targetId = validFocusedId ?? workspaceSessions[0]?.id;
|
||||
if (targetId) {
|
||||
terminalBackend.writeToSession(targetId, payload);
|
||||
}
|
||||
}
|
||||
}, [activeWorkspace, focusedSessionId, sessions, terminalBackend, isBroadcastEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocusMode && dropHint) {
|
||||
setDropHint(null);
|
||||
@@ -569,198 +617,222 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex"
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
||||
>
|
||||
{/* Focus mode sidebar */}
|
||||
{isFocusMode && renderFocusModeSidebar()}
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Focus mode sidebar */}
|
||||
{isFocusMode && renderFocusModeSidebar()}
|
||||
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
ref={workspaceOverlayRef}
|
||||
className="absolute inset-0 z-30"
|
||||
onDragOver={(e) => {
|
||||
if (isFocusMode) return;
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hint = computeSplitHint(e);
|
||||
setDropHint(hint);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
setDropHint(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleWorkspaceDrop(e);
|
||||
}}
|
||||
>
|
||||
{dropHint && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
|
||||
style={{
|
||||
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
|
||||
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
|
||||
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
|
||||
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={workspaceInnerRef} className={cn("absolute overflow-hidden", isFocusMode ? "left-56 right-0 top-0 bottom-0" : "inset-0")}>
|
||||
{sessions.map(session => {
|
||||
// Use pre-computed host to avoid creating new objects on every render
|
||||
const host = sessionHostsMap.get(session.id)!;
|
||||
const inActiveWorkspace = !!activeWorkspace && session.workspaceId === activeWorkspace.id;
|
||||
const isActiveSolo = activeTabId === session.id && !activeWorkspace && isTerminalLayerVisible;
|
||||
{draggingSessionId && !isFocusMode && (
|
||||
<div
|
||||
ref={workspaceOverlayRef}
|
||||
className="absolute inset-0 z-30"
|
||||
onDragOver={(e) => {
|
||||
if (isFocusMode) return;
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hint = computeSplitHint(e);
|
||||
setDropHint(hint);
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
if (!e.dataTransfer.types.includes('session-id')) return;
|
||||
setDropHint(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleWorkspaceDrop(e);
|
||||
}}
|
||||
>
|
||||
{dropHint && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div
|
||||
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
|
||||
style={{
|
||||
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
|
||||
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
|
||||
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
|
||||
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={workspaceInnerRef} className={cn("absolute overflow-hidden", isFocusMode ? "left-56 right-0 top-0 bottom-0" : "inset-0")}>
|
||||
{sessions.map(session => {
|
||||
// Use pre-computed host to avoid creating new objects on every render
|
||||
const host = sessionHostsMap.get(session.id)!;
|
||||
const inActiveWorkspace = !!activeWorkspace && session.workspaceId === activeWorkspace.id;
|
||||
const isActiveSolo = activeTabId === session.id && !activeWorkspace && isTerminalLayerVisible;
|
||||
|
||||
// In focus mode, only the focused session is visible
|
||||
const isFocusedInWorkspace = isFocusMode && inActiveWorkspace && session.id === focusedSessionId;
|
||||
const isSplitViewVisible = !isFocusMode && inActiveWorkspace;
|
||||
// In focus mode, only the focused session is visible
|
||||
const isFocusedInWorkspace = isFocusMode && inActiveWorkspace && session.id === focusedSessionId;
|
||||
const isSplitViewVisible = !isFocusMode && inActiveWorkspace;
|
||||
|
||||
const isVisible = ((isFocusedInWorkspace || isSplitViewVisible || isActiveSolo) && isTerminalLayerVisible);
|
||||
const isVisible = ((isFocusedInWorkspace || isSplitViewVisible || isActiveSolo) && isTerminalLayerVisible);
|
||||
|
||||
// In focus mode, use full area; in split mode, use computed rects
|
||||
const rect = (isSplitViewVisible && !isFocusMode) ? activeWorkspaceRects[session.id] : null;
|
||||
// In focus mode, use full area; in split mode, use computed rects
|
||||
const rect = (isSplitViewVisible && !isFocusMode) ? activeWorkspaceRects[session.id] : null;
|
||||
|
||||
const layoutStyle = rect
|
||||
? {
|
||||
left: `${rect.x}px`,
|
||||
top: `${rect.y}px`,
|
||||
width: `${rect.w}px`,
|
||||
height: `${rect.h}px`,
|
||||
const layoutStyle = rect
|
||||
? {
|
||||
left: `${rect.x}px`,
|
||||
top: `${rect.y}px`,
|
||||
width: `${rect.w}px`,
|
||||
height: `${rect.h}px`,
|
||||
}
|
||||
: { left: 0, top: 0, width: '100%', height: '100%' };
|
||||
|
||||
const style: React.CSSProperties = { ...layoutStyle };
|
||||
|
||||
if (!isVisible) {
|
||||
style.display = 'none';
|
||||
}
|
||||
: { left: 0, top: 0, width: '100%', height: '100%' };
|
||||
|
||||
const style: React.CSSProperties = { ...layoutStyle };
|
||||
// Check if this pane is the focused one in the workspace
|
||||
const isFocusedPane = inActiveWorkspace && !isFocusMode && session.id === focusedSessionId;
|
||||
|
||||
if (!isVisible) {
|
||||
style.display = 'none';
|
||||
}
|
||||
|
||||
// Check if this pane is the focused one in the workspace
|
||||
const isFocusedPane = inActiveWorkspace && !isFocusMode && session.id === focusedSessionId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
data-session-id={session.id}
|
||||
className={cn(
|
||||
"absolute bg-background",
|
||||
inActiveWorkspace && "workspace-pane",
|
||||
isVisible && "z-10",
|
||||
isFocusedPane && "ring-1 ring-primary/50 ring-inset"
|
||||
)}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
// Set focused session when clicking on a pane in split view
|
||||
if (inActiveWorkspace && !isFocusMode && activeWorkspace) {
|
||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Terminal
|
||||
host={host}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
allHosts={hosts}
|
||||
knownHosts={knownHosts}
|
||||
isVisible={isVisible}
|
||||
inWorkspace={inActiveWorkspace}
|
||||
isResizing={!!resizing}
|
||||
isFocusMode={isFocusMode}
|
||||
isFocused={isFocusedPane}
|
||||
fontFamilyId={terminalFontFamilyId}
|
||||
fontSize={fontSize}
|
||||
terminalTheme={terminalTheme}
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
serialConfig={session.serialConfig}
|
||||
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onCloseSession={handleCloseSession}
|
||||
onStatusChange={handleStatusChange}
|
||||
onSessionExit={handleSessionExit}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onOsDetected={handleOsDetected}
|
||||
onUpdateHost={handleUpdateHost}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleCommandExecuted}
|
||||
onExpandToFocus={inActiveWorkspace && !isFocusMode && activeWorkspace ? () => onToggleWorkspaceViewMode?.(activeWorkspace.id) : undefined}
|
||||
onSplitHorizontal={onSplitSession ? () => onSplitSession(session.id, 'horizontal') : undefined}
|
||||
onSplitVertical={onSplitSession ? () => onSplitSession(session.id, 'vertical') : undefined}
|
||||
isBroadcastEnabled={inActiveWorkspace && activeWorkspace ? isBroadcastEnabled?.(activeWorkspace.id) : false}
|
||||
onToggleBroadcast={inActiveWorkspace && activeWorkspace ? () => onToggleBroadcast?.(activeWorkspace.id) : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Only show resizers in split view mode, not in focus mode */}
|
||||
{!isFocusMode && activeResizers.map(handle => {
|
||||
const isVertical = handle.direction === 'vertical';
|
||||
// Expand hit area perpendicular to the split line, but stay within bounds
|
||||
// Vertical split (left-right): expand horizontally, keep vertical bounds
|
||||
// Horizontal split (top-bottom): expand vertically, keep horizontal bounds
|
||||
const left = isVertical ? handle.rect.x - 3 : handle.rect.x;
|
||||
const top = isVertical ? handle.rect.y : handle.rect.y - 3;
|
||||
const width = isVertical ? handle.rect.w + 6 : handle.rect.w;
|
||||
const height = isVertical ? handle.rect.h : handle.rect.h + 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={handle.id}
|
||||
className={cn("absolute group", isVertical ? "cursor-ew-resize" : "cursor-ns-resize")}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
zIndex: 25,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ws = activeWorkspace;
|
||||
if (!ws) return;
|
||||
const split = findSplitNode(ws.root, handle.splitId);
|
||||
const childCount = split && split.type === 'split' ? split.children.length : 0;
|
||||
const sizes = split && split.type === 'split' && split.sizes && split.sizes.length === childCount
|
||||
? split.sizes
|
||||
: Array(childCount).fill(1);
|
||||
setResizing({
|
||||
workspaceId: ws.id,
|
||||
splitId: handle.splitId,
|
||||
index: handle.index,
|
||||
direction: handle.direction,
|
||||
startSizes: sizes.length ? sizes : [1, 1],
|
||||
startArea: handle.splitArea,
|
||||
startClient: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
}}
|
||||
>
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
data-session-id={session.id}
|
||||
className={cn(
|
||||
"absolute bg-border/70 group-hover:bg-primary/60 transition-colors",
|
||||
isVertical ? "w-px h-full left-1/2 -translate-x-1/2" : "h-px w-full top-1/2 -translate-y-1/2"
|
||||
"absolute bg-background",
|
||||
inActiveWorkspace && "workspace-pane",
|
||||
isVisible && "z-10",
|
||||
isFocusedPane && "ring-1 ring-primary/50 ring-inset"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
style={style}
|
||||
tabIndex={-1}
|
||||
onClick={() => {
|
||||
// Set focused session when clicking on a pane in split view
|
||||
if (inActiveWorkspace && !isFocusMode && activeWorkspace) {
|
||||
onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Terminal
|
||||
host={host}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
allHosts={hosts}
|
||||
knownHosts={knownHosts}
|
||||
isVisible={isVisible}
|
||||
inWorkspace={inActiveWorkspace}
|
||||
isResizing={!!resizing}
|
||||
isFocusMode={isFocusMode}
|
||||
isFocused={isFocusedPane}
|
||||
fontFamilyId={terminalFontFamilyId}
|
||||
fontSize={fontSize}
|
||||
terminalTheme={terminalTheme}
|
||||
terminalSettings={terminalSettings}
|
||||
sessionId={session.id}
|
||||
startupCommand={session.startupCommand}
|
||||
serialConfig={session.serialConfig}
|
||||
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
|
||||
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
|
||||
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onHotkeyAction={onHotkeyAction}
|
||||
onCloseSession={handleCloseSession}
|
||||
onStatusChange={handleStatusChange}
|
||||
onSessionExit={handleSessionExit}
|
||||
onTerminalDataCapture={handleTerminalDataCapture}
|
||||
onOsDetected={handleOsDetected}
|
||||
onUpdateHost={handleUpdateHost}
|
||||
onAddKnownHost={handleAddKnownHost}
|
||||
onCommandExecuted={handleCommandExecuted}
|
||||
onExpandToFocus={inActiveWorkspace && !isFocusMode && activeWorkspace ? () => onToggleWorkspaceViewMode?.(activeWorkspace.id) : undefined}
|
||||
onSplitHorizontal={onSplitSession ? () => onSplitSession(session.id, 'horizontal') : undefined}
|
||||
onSplitVertical={onSplitSession ? () => onSplitSession(session.id, 'vertical') : undefined}
|
||||
isBroadcastEnabled={inActiveWorkspace && activeWorkspace ? isBroadcastEnabled?.(activeWorkspace.id) : false}
|
||||
onToggleBroadcast={inActiveWorkspace && activeWorkspace ? () => onToggleBroadcast?.(activeWorkspace.id) : undefined}
|
||||
onToggleComposeBar={inActiveWorkspace ? () => setIsComposeBarOpen(prev => !prev) : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Only show resizers in split view mode, not in focus mode */}
|
||||
{!isFocusMode && activeResizers.map(handle => {
|
||||
const isVertical = handle.direction === 'vertical';
|
||||
// Expand hit area perpendicular to the split line, but stay within bounds
|
||||
// Vertical split (left-right): expand horizontally, keep vertical bounds
|
||||
// Horizontal split (top-bottom): expand vertically, keep horizontal bounds
|
||||
const left = isVertical ? handle.rect.x - 3 : handle.rect.x;
|
||||
const top = isVertical ? handle.rect.y : handle.rect.y - 3;
|
||||
const width = isVertical ? handle.rect.w + 6 : handle.rect.w;
|
||||
const height = isVertical ? handle.rect.h : handle.rect.h + 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={handle.id}
|
||||
className={cn("absolute group", isVertical ? "cursor-ew-resize" : "cursor-ns-resize")}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
zIndex: 25,
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ws = activeWorkspace;
|
||||
if (!ws) return;
|
||||
const split = findSplitNode(ws.root, handle.splitId);
|
||||
const childCount = split && split.type === 'split' ? split.children.length : 0;
|
||||
const sizes = split && split.type === 'split' && split.sizes && split.sizes.length === childCount
|
||||
? split.sizes
|
||||
: Array(childCount).fill(1);
|
||||
setResizing({
|
||||
workspaceId: ws.id,
|
||||
splitId: handle.splitId,
|
||||
index: handle.index,
|
||||
direction: handle.direction,
|
||||
startSizes: sizes.length ? sizes : [1, 1],
|
||||
startArea: handle.splitArea,
|
||||
startClient: { x: e.clientX, y: e.clientY },
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bg-border/70 group-hover:bg-primary/60 transition-colors",
|
||||
isVertical ? "w-px h-full left-1/2 -translate-x-1/2" : "h-px w-full top-1/2 -translate-y-1/2"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global compose bar for workspace mode */}
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
onSend={handleComposeSend}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
// Refocus the terminal pane (matching solo-session behavior)
|
||||
if (focusedSessionId) {
|
||||
requestAnimationFrame(() => {
|
||||
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Users } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TerminalTheme } from '../types';
|
||||
import {
|
||||
@@ -63,17 +63,24 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
// Reserved for future hover preview feature
|
||||
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// All themes combined
|
||||
const allThemes = useMemo(() => {
|
||||
return [...TERMINAL_THEMES, ...customThemes];
|
||||
}, [customThemes]);
|
||||
|
||||
// Group themes by type - reserved for future sectioned view
|
||||
const _groupedThemes = useMemo(() => {
|
||||
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
|
||||
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
|
||||
const dark = allThemes.filter(t => t.type === 'dark');
|
||||
const light = allThemes.filter(t => t.type === 'light');
|
||||
return { dark, light };
|
||||
}, []);
|
||||
}, [allThemes]);
|
||||
|
||||
// Find selected theme info - reserved for displaying selection details
|
||||
const _selectedTheme = useMemo(() => {
|
||||
return TERMINAL_THEMES.find(t => t.id === selectedThemeId);
|
||||
}, [selectedThemeId]);
|
||||
return allThemes.find(t => t.id === selectedThemeId);
|
||||
}, [selectedThemeId, allThemes]);
|
||||
|
||||
const renderThemeItem = (theme: TerminalTheme) => {
|
||||
const isSelected = theme.id === selectedThemeId;
|
||||
@@ -99,36 +106,12 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
)}>
|
||||
{theme.name}
|
||||
</div>
|
||||
{/* Show usage stats or badge */}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{theme.id === 'netcatty-dark' && (
|
||||
<span className="text-muted-foreground">Default</span>
|
||||
)}
|
||||
{theme.id === 'netcatty-light' && (
|
||||
<>
|
||||
<Users size={10} />
|
||||
<span>Light mode</span>
|
||||
</>
|
||||
)}
|
||||
{theme.id === 'flexoki-dark' && (
|
||||
<span className="text-xs">new</span>
|
||||
)}
|
||||
{theme.id === 'flexoki-light' && (
|
||||
<span className="text-xs">new</span>
|
||||
)}
|
||||
{theme.id.startsWith('kanagawa') && (
|
||||
<>
|
||||
<Users size={10} />
|
||||
<span>{Math.floor(Math.random() * 20000)}</span>
|
||||
</>
|
||||
)}
|
||||
{theme.id.startsWith('hacker') && (
|
||||
<>
|
||||
<Users size={10} />
|
||||
<span>{Math.floor(Math.random() * 15000)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{theme.id === 'netcatty-dark' && (
|
||||
<div className="text-xs text-muted-foreground">Default</div>
|
||||
)}
|
||||
{theme.id === 'netcatty-light' && (
|
||||
<div className="text-xs text-muted-foreground">Light mode</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -146,7 +129,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
<ScrollArea className="h-full">
|
||||
<div className="py-2">
|
||||
{/* All themes in a single list */}
|
||||
{TERMINAL_THEMES.map(renderThemeItem)}
|
||||
{allThemes.map(renderThemeItem)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</AsidePanelContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createPortal } from 'react-dom';
|
||||
import { Check, Palette, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../../application/state/customThemeStore';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
@@ -74,6 +75,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
return { darkThemes: dark, lightThemes: light };
|
||||
}, []);
|
||||
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Handle theme selection - select and close
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
onSelect(themeId);
|
||||
@@ -164,6 +167,25 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Themes Section */}
|
||||
{customThemes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { ScrollArea } from "../ui/scroll-area";
|
||||
import { TabsContent } from "../ui/tabs";
|
||||
@@ -38,23 +40,54 @@ interface SelectProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
return (
|
||||
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectPrimitive.Trigger
|
||||
className={cn(
|
||||
"flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectPrimitive.Value>{selectedOption?.label ?? value}</SelectPrimitive.Value>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
|
||||
{options.map((opt) => (
|
||||
<SelectPrimitive.Item
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export const SectionHeader: React.FC<{ title: string; className?: string }> = ({
|
||||
title,
|
||||
|
||||
@@ -179,7 +179,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
|
||||
return (
|
||||
<div key={binding.id} className="flex items-center justify-between px-4 py-2">
|
||||
<span className="text-sm">{binding.label}</span>
|
||||
<span className="text-sm">{t(`settings.shortcuts.binding.${binding.id}`) !== `settings.shortcuts.binding.${binding.id}` ? t(`settings.shortcuts.binding.${binding.id}`) : binding.label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSpecialBinding ? (
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
@@ -61,6 +62,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -81,6 +84,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
loadTempDirInfo();
|
||||
}, [loadTempDirInfo]);
|
||||
|
||||
const loadCredentialProtectionStatus = useCallback(async () => {
|
||||
setIsCheckingCredentials(true);
|
||||
try {
|
||||
const available = await getCredentialProtectionAvailability();
|
||||
setCredentialsAvailable(available);
|
||||
} finally {
|
||||
setIsCheckingCredentials(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCredentialProtectionStatus();
|
||||
}, [loadCredentialProtectionStatus]);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
@@ -201,6 +218,59 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credential Protection Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.credentials.status")}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium mt-1",
|
||||
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
|
||||
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
|
||||
)}
|
||||
>
|
||||
{isCheckingCredentials
|
||||
? t("settings.system.credentials.checking")
|
||||
: credentialsAvailable === true
|
||||
? t("settings.system.credentials.available")
|
||||
: credentialsAvailable === false
|
||||
? t("settings.system.credentials.unavailable")
|
||||
: t("settings.system.credentials.unknown")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadCredentialProtectionStatus}
|
||||
disabled={isCheckingCredentials}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{credentialsAvailable === false && (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
{t("settings.system.credentials.unavailableHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.credentials.portabilityHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useState, useMemo } from "react";
|
||||
import { AlertCircle, ChevronRight, Minus, Plus, RotateCcw } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||||
import { AlertCircle, ChevronRight, Import, Minus, Palette, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
CursorShape,
|
||||
LinkModifier,
|
||||
@@ -11,6 +11,8 @@ import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Input } from "../../ui/input";
|
||||
@@ -18,6 +20,8 @@ import { Label } from "../../ui/label";
|
||||
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
|
||||
import { ThemeSelectModal } from "../ThemeSelectModal";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
|
||||
import type { TerminalTheme } from "../../../domain/models";
|
||||
|
||||
// Theme preview button component
|
||||
const ThemePreviewButton: React.FC<{
|
||||
@@ -51,13 +55,13 @@ const ThemePreviewButton: React.FC<{
|
||||
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Action button area */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-xs">{buttonLabel}</span>
|
||||
@@ -100,11 +104,86 @@ export default function SettingsTerminalTab(props: {
|
||||
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
|
||||
// Subscribe to custom theme changes so editing in-place triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
// Get current selected theme
|
||||
const currentTheme = useMemo(() => {
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0];
|
||||
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemes.find(t => t.id === terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
}, [terminalThemeId, customThemes]);
|
||||
|
||||
// Import .itermcolors file
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) {
|
||||
console.log('[Settings] No file selected');
|
||||
return;
|
||||
}
|
||||
console.log('[Settings] File selected:', file.name, 'size:', file.size);
|
||||
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const xml = reader.result as string;
|
||||
console.log('[Settings] File read successfully, length:', xml.length);
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
|
||||
customThemeStore.addTheme(parsed);
|
||||
setTerminalThemeId(parsed.id);
|
||||
} else {
|
||||
console.error('[Settings] Failed to parse .itermcolors file:', file.name);
|
||||
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('[Settings] Failed to read file:', file.name, reader.error);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
}, [setTerminalThemeId, t]);
|
||||
|
||||
// New custom theme modal
|
||||
const [customThemeModalOpen, setCustomThemeModalOpen] = useState(false);
|
||||
const [customThemeData, setCustomThemeData] = useState<TerminalTheme | null>(null);
|
||||
const [isEditingTheme, setIsEditingTheme] = useState(false);
|
||||
|
||||
// Check if current theme is a custom theme
|
||||
const isCustomTheme = useMemo(() => {
|
||||
return currentTheme?.isCustom === true;
|
||||
}, [currentTheme]);
|
||||
|
||||
const handleNewCustomTheme = useCallback(() => {
|
||||
const base = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|
||||
|| customThemeStore.getThemeById(terminalThemeId)
|
||||
|| TERMINAL_THEMES[0];
|
||||
const newTheme: TerminalTheme = {
|
||||
...base,
|
||||
id: `custom-${Date.now()}`,
|
||||
name: `${base.name} (Custom)`,
|
||||
isCustom: true,
|
||||
colors: { ...base.colors },
|
||||
};
|
||||
setCustomThemeData(newTheme);
|
||||
setIsEditingTheme(false);
|
||||
setCustomThemeModalOpen(true);
|
||||
}, [terminalThemeId]);
|
||||
|
||||
const handleEditCustomTheme = useCallback(() => {
|
||||
if (!currentTheme?.isCustom) return;
|
||||
setCustomThemeData({ ...currentTheme, colors: { ...currentTheme.colors } });
|
||||
setIsEditingTheme(true);
|
||||
setCustomThemeModalOpen(true);
|
||||
}, [currentTheme]);
|
||||
|
||||
const handleDeleteCustomTheme = useCallback(() => {
|
||||
if (!currentTheme?.isCustom) return;
|
||||
customThemeStore.deleteTheme(currentTheme.id);
|
||||
setTerminalThemeId(TERMINAL_THEMES[0].id);
|
||||
}, [currentTheme, setTerminalThemeId]);
|
||||
|
||||
// Fetch default shell on mount
|
||||
useEffect(() => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
@@ -194,7 +273,7 @@ export default function SettingsTerminalTab(props: {
|
||||
onClick={() => setThemeModalOpen(true)}
|
||||
buttonLabel={t("settings.terminal.theme.selectButton")}
|
||||
/>
|
||||
|
||||
|
||||
<ThemeSelectModal
|
||||
open={themeModalOpen}
|
||||
onClose={() => setThemeModalOpen(false)}
|
||||
@@ -202,6 +281,86 @@ export default function SettingsTerminalTab(props: {
|
||||
onSelect={setTerminalThemeId}
|
||||
/>
|
||||
|
||||
{/* Theme action buttons */}
|
||||
<div className="flex items-center gap-2 -mt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleNewCustomTheme}
|
||||
>
|
||||
<Palette size={14} />
|
||||
{t('terminal.customTheme.new')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => importFileRef.current?.click()}
|
||||
>
|
||||
<Import size={14} />
|
||||
{t('terminal.customTheme.import')}
|
||||
</Button>
|
||||
{isCustomTheme && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={handleEditCustomTheme}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5 text-destructive hover:text-destructive"
|
||||
onClick={handleDeleteCustomTheme}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept=".itermcolors"
|
||||
className="hidden"
|
||||
onChange={handleImportItermcolors}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Custom Theme Modal */}
|
||||
{customThemeData && (
|
||||
<CustomThemeModal
|
||||
open={customThemeModalOpen}
|
||||
theme={customThemeData}
|
||||
isNew={!isEditingTheme}
|
||||
onSave={(theme) => {
|
||||
if (isEditingTheme) {
|
||||
customThemeStore.updateTheme(theme.id, theme);
|
||||
} else {
|
||||
customThemeStore.addTheme(theme);
|
||||
}
|
||||
setTerminalThemeId(theme.id);
|
||||
setCustomThemeModalOpen(false);
|
||||
setCustomThemeData(null);
|
||||
}}
|
||||
onDelete={isEditingTheme ? (themeId) => {
|
||||
customThemeStore.deleteTheme(themeId);
|
||||
setTerminalThemeId(TERMINAL_THEMES[0].id);
|
||||
setCustomThemeModalOpen(false);
|
||||
setCustomThemeData(null);
|
||||
} : undefined}
|
||||
onCancel={() => {
|
||||
setCustomThemeModalOpen(false);
|
||||
setCustomThemeData(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.font")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
@@ -314,7 +473,7 @@ export default function SettingsTerminalTab(props: {
|
||||
onChange={(v) =>
|
||||
updateTerminalSetting("terminalEmulationType", v as TerminalEmulationType)
|
||||
}
|
||||
className="w-36"
|
||||
className="w-44"
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
@@ -409,6 +568,13 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={terminalSettings.middleClickPaste} onChange={(v) => updateTerminalSetting("middleClickPaste", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.bracketedPaste")}
|
||||
description={t("settings.terminal.behavior.bracketedPaste.desc")}
|
||||
>
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.scrollOnInput")}
|
||||
description={t("settings.terminal.behavior.scrollOnInput.desc")}
|
||||
|
||||
@@ -29,6 +29,13 @@ interface SftpModalDialogsProps {
|
||||
getSymbolicPermissions: () => string;
|
||||
handleSavePermissions: () => void;
|
||||
isChangingPermissions: boolean;
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => void;
|
||||
}
|
||||
|
||||
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
|
||||
@@ -49,6 +56,13 @@ export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
|
||||
getSymbolicPermissions,
|
||||
handleSavePermissions,
|
||||
isChangingPermissions,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
}) => (
|
||||
<>
|
||||
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
|
||||
@@ -135,5 +149,38 @@ export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(createType === "folder" ? "sftp.newFolder" : "sftp.newFile")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
placeholder={t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreateSubmit();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCreateSubmit} disabled={isCreating || !createName.trim()}>
|
||||
{isCreating ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
|
||||
{t("common.apply")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ArrowUp, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Upload } from "lucide-react";
|
||||
import { ArrowUp, Bookmark, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
@@ -50,6 +51,8 @@ interface SftpModalHeaderProps {
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onNavigateToBookmark?: (path: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
@@ -88,11 +91,25 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
onUpdateHost,
|
||||
onNavigateToBookmark,
|
||||
}) => {
|
||||
// Delay tooltip activation to prevent flickering when modal opens
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
|
||||
|
||||
// Bookmarks
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = useSftpBookmarks({
|
||||
host,
|
||||
currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTooltipsReady(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -169,6 +186,82 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Bookmark button */}
|
||||
{onUpdateHost && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'bookmark'} onOpenChange={handleTooltipOpenChange('bookmark')}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Bookmark
|
||||
size={14}
|
||||
className={cn(
|
||||
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-56 p-1" align="start">
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors"
|
||||
onClick={toggleBookmark}
|
||||
>
|
||||
<Bookmark
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
|
||||
)}
|
||||
/>
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</button>
|
||||
{/* Divider + list */}
|
||||
{bookmarks.length > 0 && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
{bookmarks.map((bm) => (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="group flex items-center gap-1 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors cursor-pointer"
|
||||
onClick={() => onNavigateToBookmark?.(bm.path)}
|
||||
title={bm.path}
|
||||
>
|
||||
<Bookmark size={10} className="shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate">{bm.label}</span>
|
||||
<span className="flex-1 truncate text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteBookmark(bm.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{bookmarks.length === 0 && (
|
||||
<div className="p-2 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{showEncoding && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
|
||||
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
|
||||
{tasks.map((task) => {
|
||||
{[...tasks].reverse().map((task) => {
|
||||
const formatSpeed = (bytesPerSec: number) => {
|
||||
if (bytesPerSec <= 0) return "";
|
||||
if (bytesPerSec >= 1024 * 1024)
|
||||
@@ -83,8 +83,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
};
|
||||
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const effectiveSpeed = task.speed > 0 ? task.speed : 0;
|
||||
const remainingTime =
|
||||
task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0;
|
||||
effectiveSpeed > 0 ? Math.ceil(remainingBytes / effectiveSpeed) : 0;
|
||||
const remainingStr =
|
||||
remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
@@ -123,9 +124,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
<span className="text-xs font-medium truncate">
|
||||
{getDisplayName(task)}
|
||||
</span>
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.speed > 0 && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && effectiveSpeed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(task.speed)}
|
||||
{formatSpeed(effectiveSpeed)}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
@@ -20,8 +20,16 @@ interface UseSftpModalCreateDeleteParams {
|
||||
|
||||
interface UseSftpModalCreateDeleteResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: () => Promise<void>;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
// Create dialog state
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSftpModalCreateDelete = ({
|
||||
@@ -39,6 +47,11 @@ export const useSftpModalCreateDelete = ({
|
||||
writeSftp,
|
||||
t,
|
||||
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [createType, setCreateType] = useState<"file" | "folder">("folder");
|
||||
const [createName, setCreateName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
if (file.name === "..") return;
|
||||
@@ -62,47 +75,66 @@ export const useSftpModalCreateDelete = ({
|
||||
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(async () => {
|
||||
const folderName = prompt(t("sftp.prompt.newFolderName"));
|
||||
if (!folderName) return;
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, folderName);
|
||||
if (isLocalSession) {
|
||||
await mkdirLocal(fullPath);
|
||||
} else {
|
||||
await mkdirSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.createFolderFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t]);
|
||||
const handleCreateFolder = useCallback(() => {
|
||||
setCreateType("folder");
|
||||
setCreateName("");
|
||||
setShowCreateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateFile = useCallback(async () => {
|
||||
const fileName = prompt(t("sftp.fileName.placeholder"));
|
||||
if (!fileName) return;
|
||||
const handleCreateFile = useCallback(() => {
|
||||
setCreateType("file");
|
||||
setCreateName("");
|
||||
setShowCreateDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleCreateSubmit = useCallback(async () => {
|
||||
const name = createName.trim();
|
||||
if (!name || isCreating) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, fileName);
|
||||
if (isLocalSession) {
|
||||
await writeLocalFile(fullPath, new ArrayBuffer(0));
|
||||
const fullPath = joinPath(currentPath, name);
|
||||
if (createType === "folder") {
|
||||
if (isLocalSession) {
|
||||
await mkdirLocal(fullPath);
|
||||
} else {
|
||||
await mkdirSftp(await ensureSftp(), fullPath);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
|
||||
} catch {
|
||||
await writeSftp(await ensureSftp(), fullPath, "");
|
||||
if (isLocalSession) {
|
||||
await writeLocalFile(fullPath, new ArrayBuffer(0));
|
||||
} else {
|
||||
try {
|
||||
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
|
||||
} catch {
|
||||
await writeSftp(await ensureSftp(), fullPath, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
setShowCreateDialog(false);
|
||||
setCreateName("");
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.createFileFailed"),
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t(createType === "folder" ? "sftp.error.createFolderFailed" : "sftp.error.createFileFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, t, writeLocalFile, writeSftp, writeSftpBinary]);
|
||||
}, [createName, createType, currentPath, ensureSftp, isCreating, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t, writeLocalFile, writeSftp, writeSftpBinary]);
|
||||
|
||||
return { handleDelete, handleCreateFolder, handleCreateFile };
|
||||
return {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -34,8 +34,15 @@ interface UseSftpModalFileActionsParams {
|
||||
|
||||
interface UseSftpModalFileActionsResult {
|
||||
handleDelete: (file: RemoteFile) => Promise<void>;
|
||||
handleCreateFolder: () => Promise<void>;
|
||||
handleCreateFile: () => Promise<void>;
|
||||
handleCreateFolder: () => void;
|
||||
handleCreateFile: () => void;
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (open: boolean) => void;
|
||||
createType: "file" | "folder";
|
||||
createName: string;
|
||||
setCreateName: (value: string) => void;
|
||||
isCreating: boolean;
|
||||
handleCreateSubmit: () => Promise<void>;
|
||||
showRenameDialog: boolean;
|
||||
setShowRenameDialog: (open: boolean) => void;
|
||||
renameTarget: RemoteFile | null;
|
||||
@@ -106,7 +113,18 @@ export const useSftpModalFileActions = ({
|
||||
downloadSftpToTempAndOpen,
|
||||
selectApplication,
|
||||
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
|
||||
const { handleDelete, handleCreateFolder, handleCreateFile } =
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
} =
|
||||
useSftpModalCreateDelete({
|
||||
currentPath,
|
||||
isLocalSession,
|
||||
@@ -213,6 +231,13 @@ export const useSftpModalFileActions = ({
|
||||
handleDelete,
|
||||
handleCreateFolder,
|
||||
handleCreateFile,
|
||||
showCreateDialog,
|
||||
setShowCreateDialog,
|
||||
createType,
|
||||
createName,
|
||||
setCreateName,
|
||||
isCreating,
|
||||
handleCreateSubmit,
|
||||
showRenameDialog,
|
||||
setShowRenameDialog,
|
||||
renameTarget,
|
||||
|
||||
@@ -20,6 +20,7 @@ interface UseSftpModalSessionParams {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sftpSudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
};
|
||||
initialPath?: string;
|
||||
isLocalSession: boolean;
|
||||
@@ -39,6 +40,7 @@ interface UseSftpModalSessionParams {
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
sudo?: boolean;
|
||||
legacyAlgorithms?: boolean;
|
||||
}) => Promise<string>;
|
||||
closeSftp: (sftpId: string) => Promise<void>;
|
||||
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
|
||||
@@ -55,6 +57,7 @@ interface UseSftpModalSessionResult {
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
reconnecting: boolean;
|
||||
sessionVersion: number;
|
||||
ensureSftp: () => Promise<string>;
|
||||
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
|
||||
closeSftpSession: () => Promise<void>;
|
||||
@@ -79,7 +82,9 @@ export const useSftpModalSession = ({
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [sessionVersion, setSessionVersion] = useState(0);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const closingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
const lastInitialPathRef = useRef<string | undefined>(undefined);
|
||||
const localHomeRef = useRef<string | null>(null);
|
||||
@@ -93,9 +98,15 @@ export const useSftpModalSession = ({
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
const bumpSessionVersion = useCallback(() => {
|
||||
setSessionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
const ensureSftp = useCallback(async () => {
|
||||
if (isLocalSession) throw new Error("Local session does not use SFTP");
|
||||
if (closingPromiseRef.current) {
|
||||
await closingPromiseRef.current;
|
||||
}
|
||||
if (sftpIdRef.current) return sftpIdRef.current;
|
||||
const sftpId = await openSftp({
|
||||
sessionId: `sftp-modal-${host.id}`,
|
||||
@@ -112,8 +123,12 @@ export const useSftpModalSession = ({
|
||||
proxy: credentials.proxy,
|
||||
jumpHosts: credentials.jumpHosts,
|
||||
sudo: credentials.sftpSudo,
|
||||
legacyAlgorithms: credentials.legacyAlgorithms,
|
||||
});
|
||||
sftpIdRef.current = sftpId;
|
||||
if (sftpIdRef.current !== sftpId) {
|
||||
sftpIdRef.current = sftpId;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
return sftpId;
|
||||
}, [
|
||||
isLocalSession,
|
||||
@@ -131,19 +146,46 @@ export const useSftpModalSession = ({
|
||||
credentials.proxy,
|
||||
credentials.jumpHosts,
|
||||
credentials.sftpSudo,
|
||||
credentials.legacyAlgorithms,
|
||||
bumpSessionVersion,
|
||||
openSftp,
|
||||
]);
|
||||
|
||||
const closeSftpSession = useCallback(async () => {
|
||||
if (!isLocalSession && sftpIdRef.current) {
|
||||
if (isLocalSession) {
|
||||
if (sftpIdRef.current !== null) {
|
||||
sftpIdRef.current = null;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear ref before awaiting backend close to avoid handing out a stale ID
|
||||
// if the modal is reopened while close is still in flight.
|
||||
const sftpIdToClose = sftpIdRef.current;
|
||||
if (sftpIdToClose !== null) {
|
||||
sftpIdRef.current = null;
|
||||
bumpSessionVersion();
|
||||
}
|
||||
if (!sftpIdToClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentClosePromise = (async () => {
|
||||
try {
|
||||
await closeSftp(sftpIdRef.current);
|
||||
await closeSftp(sftpIdToClose);
|
||||
} catch {
|
||||
// Silently ignore close errors - connection may already be closed
|
||||
} finally {
|
||||
if (closingPromiseRef.current === currentClosePromise) {
|
||||
closingPromiseRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
sftpIdRef.current = null;
|
||||
}, [closeSftp, isLocalSession]);
|
||||
})();
|
||||
|
||||
closingPromiseRef.current = currentClosePromise;
|
||||
await currentClosePromise;
|
||||
}, [bumpSessionVersion, closeSftp, isLocalSession]);
|
||||
|
||||
const isSessionError = useCallback((err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
@@ -169,14 +211,7 @@ export const useSftpModalSession = ({
|
||||
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
|
||||
try {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
if (sftpIdRef.current) {
|
||||
try {
|
||||
await closeSftp(sftpIdRef.current);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
sftpIdRef.current = null;
|
||||
}
|
||||
await closeSftpSession();
|
||||
await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
@@ -195,7 +230,7 @@ export const useSftpModalSession = ({
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftp, ensureSftp, t]);
|
||||
}, [closeSftpSession, ensureSftp, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
@@ -351,7 +386,6 @@ export const useSftpModalSession = ({
|
||||
void loadFiles(currentPath);
|
||||
} else {
|
||||
loadSeqRef.current += 1;
|
||||
void closeSftpSession();
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [
|
||||
@@ -384,6 +418,7 @@ export const useSftpModalSession = ({
|
||||
loading,
|
||||
setLoading,
|
||||
reconnecting,
|
||||
sessionVersion,
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
closeSftpSession,
|
||||
|
||||
@@ -251,13 +251,22 @@ export const useSftpModalTransfers = ({
|
||||
if (task.status === "completed" || task.status === "failed" || task.status === "cancelled") {
|
||||
return task;
|
||||
}
|
||||
|
||||
|
||||
const totalBytes = progress.total > 0 ? progress.total : task.totalBytes;
|
||||
const clampedTransferred = Math.max(
|
||||
task.transferredBytes,
|
||||
Math.min(progress.transferred, totalBytes > 0 ? totalBytes : progress.transferred)
|
||||
);
|
||||
const rawPercent = totalBytes > 0 ? (clampedTransferred / totalBytes) * 100 : task.progress;
|
||||
const clampedPercent = Math.max(task.progress, Math.min(rawPercent, 100));
|
||||
|
||||
return {
|
||||
...task,
|
||||
status: "uploading" as const,
|
||||
progress: progress.percent,
|
||||
transferredBytes: progress.transferred,
|
||||
speed: progress.speed,
|
||||
totalBytes,
|
||||
progress: clampedPercent,
|
||||
transferredBytes: clampedTransferred,
|
||||
speed: Number.isFinite(progress.speed) && progress.speed > 0 ? progress.speed : 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -453,10 +462,18 @@ export const useSftpModalTransfers = ({
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
progress: total > 0 ? Math.round((transferred / total) * 100) : 0,
|
||||
speed,
|
||||
transferredBytes: Math.max(
|
||||
task.transferredBytes,
|
||||
Math.min(transferred, total > 0 ? total : transferred)
|
||||
),
|
||||
totalBytes: total > 0 ? total : task.totalBytes,
|
||||
progress: (() => {
|
||||
const effectiveTotal = total > 0 ? total : task.totalBytes;
|
||||
if (effectiveTotal <= 0) return task.progress;
|
||||
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
|
||||
return Math.max(task.progress, Math.min(percent, 100));
|
||||
})(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
@@ -467,7 +484,13 @@ export const useSftpModalTransfers = ({
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "completed" as const, progress: 100 }
|
||||
? {
|
||||
...task,
|
||||
status: "completed" as const,
|
||||
progress: 100,
|
||||
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
|
||||
speed: 0,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
|
||||
@@ -88,6 +88,8 @@ export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean
|
||||
export interface SftpContextValue {
|
||||
// Hosts list for connection picker
|
||||
hosts: Host[];
|
||||
// Host updater for bookmark persistence
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
|
||||
// Drag state (shared between panes)
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
@@ -132,6 +134,12 @@ export const useSftpHosts = () => {
|
||||
return context.hosts;
|
||||
};
|
||||
|
||||
// Hook to get host updater
|
||||
export const useSftpUpdateHosts = () => {
|
||||
const context = useSftpContext();
|
||||
return context.updateHosts;
|
||||
};
|
||||
|
||||
// Hook to get showHiddenFiles setting
|
||||
export const useSftpShowHiddenFiles = (): boolean => {
|
||||
const context = useSftpContext();
|
||||
@@ -140,6 +148,7 @@ export const useSftpShowHiddenFiles = (): boolean => {
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
leftCallbacks: SftpPaneCallbacks;
|
||||
@@ -150,6 +159,7 @@ interface SftpContextProviderProps {
|
||||
|
||||
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
hosts,
|
||||
updateHosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
@@ -162,13 +172,14 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
const value = useMemo<SftpContextValue>(
|
||||
() => ({
|
||||
hosts,
|
||||
updateHosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
showHiddenFiles,
|
||||
}),
|
||||
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
|
||||
);
|
||||
|
||||
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import { ChevronLeft, FilePlus, Folder, FolderPlus, Home, RefreshCw, Search, X } from "lucide-react";
|
||||
import { Bookmark, Check, ChevronLeft, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { SftpBreadcrumb } from "./index";
|
||||
import type { SftpFilenameEncoding } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpBookmark } from "../../domain/models";
|
||||
|
||||
interface SftpPaneToolbarProps {
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
@@ -39,6 +40,12 @@ interface SftpPaneToolbarProps {
|
||||
setFileNameError: (value: string | null) => void;
|
||||
setShowNewFileDialog: (open: boolean) => void;
|
||||
setShowNewFolderDialog: (open: boolean) => void;
|
||||
// Bookmark props
|
||||
bookmarks: SftpBookmark[];
|
||||
isCurrentPathBookmarked: boolean;
|
||||
onToggleBookmark: () => void;
|
||||
onNavigateToBookmark: (path: string) => void;
|
||||
onDeleteBookmark: (id: string) => void;
|
||||
}
|
||||
|
||||
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
@@ -72,6 +79,11 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
setFileNameError,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
onToggleBookmark,
|
||||
onNavigateToBookmark,
|
||||
onDeleteBookmark,
|
||||
}) => (
|
||||
<>
|
||||
{/* Toolbar - always visible when connected */}
|
||||
@@ -154,21 +166,113 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
{!pane.connection?.isLocal && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
|
||||
title={isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
onClick={(e) => {
|
||||
// If not bookmarked, toggle directly instead of opening popover
|
||||
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
|
||||
e.preventDefault();
|
||||
onToggleBookmark();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
<div className="p-2 border-b border-border/40">
|
||||
<Button
|
||||
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start text-xs h-7"
|
||||
onClick={onToggleBookmark}
|
||||
>
|
||||
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
|
||||
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
|
||||
</Button>
|
||||
</div>
|
||||
{bookmarks.length > 0 ? (
|
||||
<div className="max-h-48 overflow-auto py-1">
|
||||
{bookmarks.map((bm) => (
|
||||
<div
|
||||
key={bm.id}
|
||||
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 text-left text-xs truncate font-mono"
|
||||
onClick={() => onNavigateToBookmark(bm.path)}
|
||||
title={bm.path}
|
||||
>
|
||||
{bm.label}
|
||||
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteBookmark(bm.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 text-xs text-muted-foreground text-center">
|
||||
{t("sftp.bookmark.empty")}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{!pane.connection?.isLocal && (
|
||||
<Select
|
||||
value={pane.filenameEncoding}
|
||||
onValueChange={(value) => onSetFilenameEncoding(value as SftpFilenameEncoding)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[120px] text-[10px]" title={t("sftp.encoding.label")}>
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
title={t("sftp.encoding.label")}
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="end">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
pane.filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onSetFilenameEncoding(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
@@ -13,8 +13,10 @@ import {
|
||||
useSftpHosts,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpShowHiddenFiles,
|
||||
useSftpUpdateHosts,
|
||||
} from "./index";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
|
||||
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
|
||||
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||
@@ -22,6 +24,7 @@ import { useSftpPanePath } from "./hooks/useSftpPanePath";
|
||||
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
|
||||
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -84,6 +87,28 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
});
|
||||
|
||||
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } = useSftpPaneSorting();
|
||||
|
||||
// Bookmark support
|
||||
const updateHosts = useSftpUpdateHosts();
|
||||
const currentHost = useMemo(
|
||||
() => hosts.find((h) => h.id === pane.connection?.hostId),
|
||||
[hosts, pane.connection?.hostId],
|
||||
);
|
||||
const onUpdateHost = useCallback(
|
||||
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
|
||||
[hosts, updateHosts],
|
||||
);
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = useSftpBookmarks({
|
||||
host: currentHost,
|
||||
currentPath: pane.connection?.currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
|
||||
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
|
||||
files: pane.files,
|
||||
filter: pane.filter,
|
||||
@@ -293,6 +318,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
setFileNameError={setFileNameError}
|
||||
setShowNewFileDialog={setShowNewFileDialog}
|
||||
setShowNewFolderDialog={setShowNewFolderDialog}
|
||||
bookmarks={bookmarks}
|
||||
isCurrentPathBookmarked={isCurrentPathBookmarked}
|
||||
onToggleBookmark={toggleBookmark}
|
||||
onNavigateToBookmark={callbacks.onNavigateTo}
|
||||
onDeleteBookmark={deleteBookmark}
|
||||
/>
|
||||
|
||||
<SftpPaneFileList
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ArrowDown,
|
||||
CheckCircle2,
|
||||
FolderUp,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle,
|
||||
ArrowDown,
|
||||
CheckCircle2,
|
||||
FolderUp,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
X,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import React,{ memo, useRef, useEffect } from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TransferTask } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { formatSpeed,formatTransferBytes } from './utils';
|
||||
import { formatSpeed, formatTransferBytes } from './utils';
|
||||
|
||||
interface SftpTransferItemProps {
|
||||
task: TransferTask;
|
||||
@@ -27,49 +27,13 @@ interface SftpTransferItemProps {
|
||||
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel, onRetry, onDismiss }) => {
|
||||
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
|
||||
|
||||
// Use refs to store stable display values and prevent flickering
|
||||
const lastSpeedRef = useRef<number>(0);
|
||||
const lastSpeedTimeRef = useRef<number>(Date.now());
|
||||
const displaySpeedRef = useRef<string>('');
|
||||
|
||||
// Update speed display with smoothing - only update if speed is stable for a moment
|
||||
useEffect(() => {
|
||||
if (task.status !== 'transferring') {
|
||||
displaySpeedRef.current = '';
|
||||
lastSpeedRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastUpdate = now - lastSpeedTimeRef.current;
|
||||
|
||||
// Only update speed display if:
|
||||
// 1. Speed is above threshold (100 bytes/s)
|
||||
// 2. Either it's been at least 500ms since last update, or speed changed significantly (>50%)
|
||||
if (task.speed > 100) {
|
||||
const speedChange = Math.abs(task.speed - lastSpeedRef.current);
|
||||
const significantChange = lastSpeedRef.current > 0 && speedChange / lastSpeedRef.current > 0.5;
|
||||
|
||||
if (timeSinceLastUpdate >= 500 || significantChange || lastSpeedRef.current === 0) {
|
||||
lastSpeedRef.current = task.speed;
|
||||
lastSpeedTimeRef.current = now;
|
||||
displaySpeedRef.current = formatSpeed(task.speed);
|
||||
}
|
||||
} else if (task.speed === 0 && lastSpeedRef.current > 0) {
|
||||
// Don't immediately clear speed when it drops to 0
|
||||
// Keep showing last speed for a short period
|
||||
if (timeSinceLastUpdate >= 1000) {
|
||||
lastSpeedRef.current = 0;
|
||||
displaySpeedRef.current = '';
|
||||
}
|
||||
}
|
||||
}, [task.speed, task.status]);
|
||||
|
||||
// Calculate remaining time based on stable speed
|
||||
// Calculate remaining time from backend-reported sliding-window speed
|
||||
const remainingBytes = task.totalBytes - task.transferredBytes;
|
||||
const stableSpeed = lastSpeedRef.current > 0 ? lastSpeedRef.current : task.speed;
|
||||
const remainingTime = stableSpeed > 0
|
||||
? Math.ceil(remainingBytes / stableSpeed)
|
||||
const effectiveSpeed = task.status === 'transferring'
|
||||
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
|
||||
: 0;
|
||||
const remainingTime = effectiveSpeed > 0
|
||||
? Math.ceil(remainingBytes / effectiveSpeed)
|
||||
: 0;
|
||||
const remainingFormatted = remainingTime > 60
|
||||
? `~${Math.ceil(remainingTime / 60)}m left`
|
||||
@@ -84,8 +48,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
? formatTransferBytes(task.totalBytes)
|
||||
: '';
|
||||
|
||||
// Use the stable display speed
|
||||
const speedFormatted = displaySpeedRef.current;
|
||||
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
|
||||
@@ -196,17 +159,15 @@ const arePropsEqual = (
|
||||
// Always re-render on fileName change
|
||||
if (prev.fileName !== next.fileName) return false;
|
||||
|
||||
// For transferring status, throttle updates based on progress
|
||||
// For transferring status, allow frequent re-renders for smooth progress bar
|
||||
if (next.status === 'transferring') {
|
||||
// Re-render if progress changed by more than 0.5%
|
||||
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
|
||||
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
|
||||
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
|
||||
if (Math.abs(nextProgress - prevProgress) >= 0.5) return false;
|
||||
if (Math.abs(nextProgress - prevProgress) >= 0.1) return false;
|
||||
|
||||
// Re-render periodically for speed updates (every ~500ms based on speed changes)
|
||||
// The component uses refs to smooth speed display, so we allow more frequent renders
|
||||
const speedDiff = Math.abs(next.speed - prev.speed);
|
||||
if (speedDiff > 1000) return false; // Re-render if speed changed by more than 1KB/s
|
||||
// Re-render on any speed change (backend already smooths via sliding window)
|
||||
if (next.speed !== prev.speed) return false;
|
||||
}
|
||||
|
||||
// For pending status, don't re-render unless status changes
|
||||
|
||||
69
components/sftp/hooks/useSftpBookmarks.ts
Normal file
69
components/sftp/hooks/useSftpBookmarks.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Host, SftpBookmark } from "../../../domain/models";
|
||||
|
||||
interface UseSftpBookmarksParams {
|
||||
host: Host | undefined;
|
||||
currentPath: string | undefined;
|
||||
onUpdateHost: ((host: Host) => void) | undefined;
|
||||
}
|
||||
|
||||
interface UseSftpBookmarksResult {
|
||||
bookmarks: SftpBookmark[];
|
||||
isCurrentPathBookmarked: boolean;
|
||||
toggleBookmark: () => void;
|
||||
deleteBookmark: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useSftpBookmarks = ({
|
||||
host,
|
||||
currentPath,
|
||||
onUpdateHost,
|
||||
}: UseSftpBookmarksParams): UseSftpBookmarksResult => {
|
||||
const bookmarks = useMemo(() => host?.sftpBookmarks ?? [], [host]);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() =>
|
||||
!!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const updateHostBookmarks = useCallback(
|
||||
(newBookmarks: SftpBookmark[]) => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, sftpBookmarks: newBookmarks });
|
||||
},
|
||||
[host, onUpdateHost],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath || !host) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
updateHostBookmarks(bookmarks.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
const label =
|
||||
currentPath === "/"
|
||||
? "/"
|
||||
: currentPath.split("/").filter(Boolean).pop() || currentPath;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path: currentPath,
|
||||
label,
|
||||
};
|
||||
updateHostBookmarks([...bookmarks, newBookmark]);
|
||||
}
|
||||
}, [currentPath, host, isCurrentPathBookmarked, bookmarks, updateHostBookmarks]);
|
||||
|
||||
const deleteBookmark = useCallback(
|
||||
(id: string) => {
|
||||
updateHostBookmarks(bookmarks.filter((b) => b.id !== id));
|
||||
},
|
||||
[bookmarks, updateHostBookmarks],
|
||||
);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -238,7 +238,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
case "sftpSelectAll": {
|
||||
// Select all files in the current pane
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles, pane.connection.isLocal);
|
||||
if (term) {
|
||||
visibleFiles = visibleFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
|
||||
@@ -29,12 +29,12 @@ export const useSftpPaneFiles = ({
|
||||
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
|
||||
const filteredFiles = useMemo(() => {
|
||||
const term = filter.trim().toLowerCase();
|
||||
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
|
||||
let nextFiles = filterHiddenFiles(files, showHiddenFiles, connection?.isLocal);
|
||||
if (!term) return nextFiles;
|
||||
return nextFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, [files, filter, showHiddenFiles]);
|
||||
}, [files, filter, showHiddenFiles, connection?.isLocal]);
|
||||
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!connection) return [];
|
||||
@@ -50,7 +50,7 @@ export const useSftpPaneFiles = ({
|
||||
lastModified: 0,
|
||||
lastModifiedFormatted: "--",
|
||||
};
|
||||
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")] ;
|
||||
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")];
|
||||
}, [connection, filteredFiles]);
|
||||
|
||||
const sortedDisplayFiles = useMemo(() => {
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
formatBytes,formatDate,
|
||||
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
|
||||
type SortOrder
|
||||
formatBytes, formatDate,
|
||||
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, type ColumnWidths, type SortField,
|
||||
type SortOrder
|
||||
} from './utils';
|
||||
|
||||
// Context
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpUpdateHosts,
|
||||
useSftpShowHiddenFiles,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Globe,
|
||||
Key,
|
||||
Lock,
|
||||
Settings,
|
||||
Terminal,
|
||||
Database,
|
||||
ExternalLink,
|
||||
File,
|
||||
FileArchive,
|
||||
FileAudio,
|
||||
FileCode,
|
||||
FileImage,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FileType,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Globe,
|
||||
Key,
|
||||
Lock,
|
||||
Settings,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { SftpFileEntry } from '../../types';
|
||||
@@ -74,7 +74,7 @@ export const formatSpeed = (bytesPerSecond: number): string => {
|
||||
*/
|
||||
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
|
||||
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
|
||||
|
||||
|
||||
// For symlink files (not directories), show a special symlink icon
|
||||
if (entry.type === 'symlink' && entry.linkTarget !== 'directory') {
|
||||
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
|
||||
@@ -189,31 +189,42 @@ export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows
|
||||
* Only applies to local Windows filesystem where the hidden attribute is set
|
||||
* The ".." parent directory entry is never considered hidden
|
||||
*
|
||||
* Note: On Unix/Linux, there's no system-level hidden file concept.
|
||||
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
|
||||
* Check if a file is hidden
|
||||
* - Windows: checks the `hidden` attribute (set by localFsBridge)
|
||||
* - Unix/Linux (remote): also treats dotfiles (names starting with '.') as hidden
|
||||
* The ".." parent directory entry is never considered hidden.
|
||||
*
|
||||
* @param isLocal When true, only the Windows hidden attribute is checked.
|
||||
* This prevents `.gitignore` etc. from disappearing on local Windows panes.
|
||||
*/
|
||||
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
|
||||
export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
|
||||
file: T,
|
||||
isLocal?: boolean
|
||||
): boolean => {
|
||||
if (file.name === "..") return false;
|
||||
return file.hidden === true;
|
||||
// Windows hidden attribute — always checked
|
||||
if (file.hidden === true) return true;
|
||||
// Unix/Linux dotfile convention — only on remote/non-local connections
|
||||
if (!isLocal && file.name.startsWith(".")) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/** @deprecated Use isHiddenFile instead */
|
||||
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean =>
|
||||
isHiddenFile(file, true);
|
||||
|
||||
/**
|
||||
* Filter files based on Windows hidden file visibility setting
|
||||
* Only filters files with the Windows hidden attribute set
|
||||
* Always preserves ".." parent directory entry
|
||||
*
|
||||
* This setting only affects local Windows filesystem browsing.
|
||||
* On Unix/Linux systems and remote SFTP connections, all files are shown
|
||||
* because there's no system-level hidden file concept (dotfiles are just a convention).
|
||||
* Filter files based on hidden file visibility setting.
|
||||
* Filters Windows hidden files and, on remote connections, Unix/Linux dotfiles.
|
||||
* Always preserves ".." parent directory entry.
|
||||
*
|
||||
* @param isLocal Pass true for local filesystem panes to skip dotfile filtering.
|
||||
*/
|
||||
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
|
||||
files: T[],
|
||||
showHiddenFiles: boolean
|
||||
showHiddenFiles: boolean,
|
||||
isLocal?: boolean
|
||||
): T[] => {
|
||||
if (showHiddenFiles) return files;
|
||||
return files.filter((f) => !isWindowsHiddenFile(f));
|
||||
return files.filter((f) => !isHiddenFile(f, isLocal));
|
||||
};
|
||||
|
||||
187
components/terminal/CustomThemeEditor.tsx
Normal file
187
components/terminal/CustomThemeEditor.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Custom Theme Editor Panel
|
||||
* Inline color editor for creating/editing custom terminal themes.
|
||||
* Uses native <input type="color"> for zero-dependency color picking.
|
||||
*/
|
||||
|
||||
import React, { useCallback, memo } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
|
||||
interface ColorFieldDef {
|
||||
key: keyof TerminalTheme['colors'];
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const GENERAL_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
|
||||
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
|
||||
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
|
||||
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
|
||||
];
|
||||
|
||||
const NORMAL_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'black', labelKey: 'terminal.customTheme.color.black' },
|
||||
{ key: 'red', labelKey: 'terminal.customTheme.color.red' },
|
||||
{ key: 'green', labelKey: 'terminal.customTheme.color.green' },
|
||||
{ key: 'yellow', labelKey: 'terminal.customTheme.color.yellow' },
|
||||
{ key: 'blue', labelKey: 'terminal.customTheme.color.blue' },
|
||||
{ key: 'magenta', labelKey: 'terminal.customTheme.color.magenta' },
|
||||
{ key: 'cyan', labelKey: 'terminal.customTheme.color.cyan' },
|
||||
{ key: 'white', labelKey: 'terminal.customTheme.color.white' },
|
||||
];
|
||||
|
||||
const BRIGHT_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'brightBlack', labelKey: 'terminal.customTheme.color.brightBlack' },
|
||||
{ key: 'brightRed', labelKey: 'terminal.customTheme.color.brightRed' },
|
||||
{ key: 'brightGreen', labelKey: 'terminal.customTheme.color.brightGreen' },
|
||||
{ key: 'brightYellow', labelKey: 'terminal.customTheme.color.brightYellow' },
|
||||
{ key: 'brightBlue', labelKey: 'terminal.customTheme.color.brightBlue' },
|
||||
{ key: 'brightMagenta', labelKey: 'terminal.customTheme.color.brightMagenta' },
|
||||
{ key: 'brightCyan', labelKey: 'terminal.customTheme.color.brightCyan' },
|
||||
{ key: 'brightWhite', labelKey: 'terminal.customTheme.color.brightWhite' },
|
||||
];
|
||||
|
||||
const ColorInput = memo(({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => {
|
||||
// Local state for text input — allows partial hex while typing
|
||||
const [textValue, setTextValue] = React.useState(value);
|
||||
// Sync external value changes into local state
|
||||
React.useEffect(() => { setTextValue(value); }, [value]);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
if (!/^#[0-9a-fA-F]{0,6}$/.test(v)) return;
|
||||
setTextValue(v);
|
||||
// Only commit complete hex values (#rgb or #rrggbb)
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(v) || /^#[0-9a-fA-F]{6}$/.test(v)) {
|
||||
// Normalize #rgb to #rrggbb
|
||||
const normalized = v.length === 4
|
||||
? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`
|
||||
: v;
|
||||
onChange(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
// On blur, revert to the last committed value if incomplete
|
||||
const handleBlur = () => { setTextValue(value); };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
|
||||
style={{ appearance: 'none', WebkitAppearance: 'none', background: value }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={textValue}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleBlur}
|
||||
className="w-[68px] text-[10px] font-mono px-1.5 py-0.5 rounded border border-border bg-background text-foreground uppercase"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ColorInput.displayName = 'ColorInput';
|
||||
|
||||
interface CustomThemeEditorProps {
|
||||
theme: TerminalTheme;
|
||||
onChange: (theme: TerminalTheme) => void;
|
||||
onBack?: () => void; // kept for API compat but no longer rendered
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
|
||||
theme,
|
||||
onChange,
|
||||
onBack: _onBack,
|
||||
isNew: _isNew,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
|
||||
onChange({
|
||||
...theme,
|
||||
colors: { ...theme.colors, [key]: value },
|
||||
});
|
||||
}, [theme, onChange]);
|
||||
|
||||
const updateName = useCallback((name: string) => {
|
||||
onChange({ ...theme, name });
|
||||
}, [theme, onChange]);
|
||||
|
||||
const toggleType = useCallback(() => {
|
||||
onChange({ ...theme, type: theme.type === 'dark' ? 'light' : 'dark' });
|
||||
}, [theme, onChange]);
|
||||
|
||||
const renderColorGroup = (title: string, fields: ColorFieldDef[]) => (
|
||||
<div>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{fields.map(({ key, labelKey }) => (
|
||||
<ColorInput
|
||||
key={key}
|
||||
label={t(labelKey)}
|
||||
value={theme.colors[key]}
|
||||
onChange={(v) => updateColor(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Name + Type */}
|
||||
<div className="p-2 space-y-2 border-b border-border shrink-0">
|
||||
<div>
|
||||
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
{t('terminal.customTheme.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={theme.name}
|
||||
onChange={(e) => updateName(e.target.value)}
|
||||
className="w-full mt-1 text-xs px-2 py-1.5 rounded border border-border bg-background text-foreground"
|
||||
placeholder={t('terminal.customTheme.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold flex-1">
|
||||
{t('terminal.customTheme.type')}
|
||||
</label>
|
||||
<button
|
||||
onClick={toggleType}
|
||||
className="text-[10px] px-2 py-0.5 rounded border border-border bg-muted/30 text-foreground hover:bg-muted transition-colors capitalize"
|
||||
>
|
||||
{theme.type}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Groups */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
{renderColorGroup(t('terminal.customTheme.group.general'), GENERAL_COLORS)}
|
||||
{renderColorGroup(t('terminal.customTheme.group.normal'), NORMAL_COLORS)}
|
||||
{renderColorGroup(t('terminal.customTheme.group.bright'), BRIGHT_COLORS)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
230
components/terminal/CustomThemeModal.tsx
Normal file
230
components/terminal/CustomThemeModal.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Dedicated Custom Theme Editor Modal
|
||||
* Standalone modal with two-column layout: editor (left) + preview (right)
|
||||
* Opens on top of ThemeCustomizeModal for creating/editing custom themes.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { CustomThemeEditor } from './CustomThemeEditor';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
|
||||
interface CustomThemeModalProps {
|
||||
open: boolean;
|
||||
theme: TerminalTheme;
|
||||
isNew: boolean;
|
||||
onSave: (theme: TerminalTheme) => void;
|
||||
onDelete?: (themeId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Minimal terminal preview for the right panel
|
||||
const MiniPreview: React.FC<{ theme: TerminalTheme }> = ({ theme }) => (
|
||||
<div
|
||||
className="rounded-lg border border-border/50 overflow-hidden font-mono text-[11px] leading-relaxed flex-1"
|
||||
style={{ backgroundColor: theme.colors.background, color: theme.colors.foreground }}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-black/20">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
|
||||
<span className="flex-1 text-center text-[10px] opacity-50">Terminal Preview</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-0.5">
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>user@server</span>
|
||||
<span style={{ color: theme.colors.foreground }}>:</span>
|
||||
<span style={{ color: theme.colors.blue }}>~</span>
|
||||
<span style={{ color: theme.colors.foreground }}>$ neofetch</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>{' ,g$$P" """Y$$."". '}</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` ,$$P' `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>OS</span>
|
||||
<span>: Ubuntu 22.04 LTS</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` '',$$P `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>Kernel</span>
|
||||
<span>: 5.15.0-generic</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` d$$' `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>Shell</span>
|
||||
<span>: bash 5.1.16</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` $$P `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>Memory</span>
|
||||
<span>: 4.2G / 16G (26%)</span>
|
||||
</div>
|
||||
<div> </div>
|
||||
{/* ANSI color palette */}
|
||||
<div className="flex gap-0.5">
|
||||
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
|
||||
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
|
||||
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
|
||||
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
|
||||
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>user@server</span>
|
||||
<span>:</span>
|
||||
<span style={{ color: theme.colors.blue }}>~</span>
|
||||
<span>$ </span>
|
||||
<span style={{ backgroundColor: theme.colors.cursor, color: theme.colors.background }}> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
|
||||
open,
|
||||
theme: initialTheme,
|
||||
isNew,
|
||||
onSave,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme>(initialTheme);
|
||||
|
||||
// Reset when opened with a new theme
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setEditingTheme({ ...initialTheme, colors: { ...initialTheme.colors } });
|
||||
}
|
||||
}, [open, initialTheme]);
|
||||
|
||||
const handleChange = useCallback((theme: TerminalTheme) => {
|
||||
setEditingTheme(theme);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(editingTheme);
|
||||
}, [editingTheme, onSave]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete?.(editingTheme.id);
|
||||
}, [editingTheme.id, onDelete]);
|
||||
|
||||
// Dummy back handler — in the standalone modal, back = cancel
|
||||
const handleBack = useCallback(() => {
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
const themeInfo = useMemo(() => {
|
||||
return `${editingTheme.name} • ${editingTheme.type.toUpperCase()}`;
|
||||
}, [editingTheme.name, editingTheme.type]);
|
||||
|
||||
// Handle Escape key — close child editor
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown, true); // capture phase
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center"
|
||||
>
|
||||
{/* Backdrop — clicking it dismisses the modal */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative z-10 bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50 flex flex-col"
|
||||
style={{ width: 'min(820px, 90vw)', height: 'min(600px, 85vh)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{isNew ? t('terminal.customTheme.newTitle') : t('terminal.customTheme.editTitle')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body: Editor (left) + Preview (right) */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left: Editor */}
|
||||
<div className="w-[300px] shrink-0 border-r border-border flex flex-col min-h-0">
|
||||
<CustomThemeEditor
|
||||
theme={editingTheme}
|
||||
onChange={handleChange}
|
||||
onBack={handleBack}
|
||||
isNew={isNew}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview */}
|
||||
<div className="flex-1 flex flex-col p-4 min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||||
{t('terminal.themeModal.livePreview')}
|
||||
</div>
|
||||
<MiniPreview theme={editingTheme} />
|
||||
<div className="mt-2 text-xs text-muted-foreground text-center">
|
||||
{themeInfo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
{/* Delete button (only for existing themes) */}
|
||||
{!isNew && onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
className="h-9 text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('terminal.customTheme.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="h-9 px-5"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="h-9 px-6"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default CustomThemeModal;
|
||||
167
components/terminal/TerminalComposeBar.tsx
Normal file
167
components/terminal/TerminalComposeBar.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Terminal Compose Bar
|
||||
* A modern text input bar for composing commands before sending them.
|
||||
* Supports pre-reviewing passwords/commands and broadcasting to multiple sessions.
|
||||
*/
|
||||
import { Radio, Send, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface TerminalComposeBarProps {
|
||||
onSend: (text: string) => void;
|
||||
onClose: () => void;
|
||||
isBroadcastEnabled?: boolean;
|
||||
themeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSend,
|
||||
onClose,
|
||||
isBroadcastEnabled,
|
||||
themeColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
|
||||
// Auto-focus on mount
|
||||
useEffect(() => {
|
||||
// Small delay to ensure the element is rendered
|
||||
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Auto-resize textarea
|
||||
const handleInput = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const text = el.value;
|
||||
if (!text) return;
|
||||
onSend(text);
|
||||
el.value = '';
|
||||
el.style.height = 'auto';
|
||||
el.focus();
|
||||
}, [onSend]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, onClose]);
|
||||
|
||||
const bg = themeColors?.background ?? '#0a0a0a';
|
||||
const fg = themeColors?.foreground ?? '#d4d4d4';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${bg}, color-mix(in srgb, ${fg} 4%, ${bg} 96%))`,
|
||||
borderTop: `1px solid color-mix(in srgb, ${fg} 10%, ${bg} 90%)`,
|
||||
borderRadius: '0 0 8px 8px',
|
||||
padding: '6px 10px',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Broadcast indicator */}
|
||||
{isBroadcastEnabled && (
|
||||
<div
|
||||
className="flex items-center"
|
||||
title={t("terminal.composeBar.broadcasting")}
|
||||
>
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input field */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 resize-none rounded-md px-3 py-1.5 text-xs font-mono leading-relaxed",
|
||||
"outline-none transition-all duration-200",
|
||||
"placeholder:opacity-40",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, ${fg} 6%, ${bg} 94%)`,
|
||||
color: fg,
|
||||
border: `1px solid color-mix(in srgb, ${fg} 25%, ${bg} 75%)`,
|
||||
minHeight: '28px',
|
||||
maxHeight: '120px',
|
||||
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`,
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("terminal.composeBar.placeholder")}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 40%, ${bg} 60%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${fg} 8%, transparent)`;
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 25%, ${bg} 75%)`;
|
||||
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`;
|
||||
}}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: fg,
|
||||
background: `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 30%, ${bg} 70%)`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`;
|
||||
}}
|
||||
onClick={handleSend}
|
||||
title={t("terminal.composeBar.send")}
|
||||
>
|
||||
<Send size={13} />
|
||||
</button>
|
||||
<button
|
||||
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`,
|
||||
background: `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 22%, ${bg} 78%)`;
|
||||
e.currentTarget.style.color = fg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`;
|
||||
e.currentTarget.style.color = `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`;
|
||||
}}
|
||||
onClick={onClose}
|
||||
title={t("terminal.composeBar.close")}
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalComposeBar;
|
||||
@@ -2,7 +2,7 @@
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
*/
|
||||
import { FolderInput, X, Zap, Palette, Search } from 'lucide-react';
|
||||
import { FolderInput, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Snippet, Host } from '../../types';
|
||||
@@ -32,6 +32,9 @@ export interface TerminalToolbarProps {
|
||||
// Search functionality
|
||||
isSearchOpen?: boolean;
|
||||
onToggleSearch?: () => void;
|
||||
// Compose bar
|
||||
isComposeBarOpen?: boolean;
|
||||
onToggleComposeBar?: () => void;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
@@ -53,6 +56,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onClose,
|
||||
isSearchOpen,
|
||||
onToggleSearch,
|
||||
isComposeBarOpen,
|
||||
onToggleComposeBar,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
@@ -173,6 +178,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.composeBar")}
|
||||
aria-label={t("terminal.toolbar.composeBar")}
|
||||
aria-pressed={isComposeBarOpen}
|
||||
onClick={onToggleComposeBar}
|
||||
>
|
||||
<TextCursorInput size={12} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
|
||||
@@ -7,34 +7,44 @@
|
||||
* - Real-time preview: changes are applied immediately to the terminal
|
||||
* - Save: persists the current settings
|
||||
* - Cancel: reverts to the original settings when modal was opened
|
||||
* - Custom themes: create, edit, delete, import .itermcolors
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback, useRef, memo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Minus, Palette, Plus, Type, X } from 'lucide-react';
|
||||
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useAvailableFonts } from '../../application/state/fontStore';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
|
||||
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
|
||||
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
|
||||
import { CustomThemeModal } from './CustomThemeModal';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
|
||||
type TabType = 'theme' | 'font';
|
||||
type TabType = 'theme' | 'font' | 'custom';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect
|
||||
onSelect,
|
||||
onEdit,
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all group cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
@@ -53,12 +63,26 @@ const ThemeItem = memo(({
|
||||
<div className={cn('text-xs font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">
|
||||
{theme.type}
|
||||
{theme.isCustom && ' • custom'}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
{onEdit && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !onEdit && (
|
||||
<Check size={14} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
@@ -176,64 +200,47 @@ const TerminalPreview = memo(({
|
||||
<span style={{ color: theme.colors.foreground }}>server</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{' ,g$$P" """Y$$.". '}
|
||||
{' ,g$$P" """Y$$."". '}
|
||||
<span style={{ color: theme.colors.foreground }}>-----------</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{' ,$$P\' `$$$. '}
|
||||
{` ,$$P' $$$. `}
|
||||
<span style={{ color: theme.colors.blue }}>OS</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: Ubuntu 22.04 LTS</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{'\',$$P ,ggs. `$$b: '}
|
||||
{`'', $$P, ggs. $$b: `}
|
||||
<span style={{ color: theme.colors.blue }}>Kernel</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: 5.15.0-generic</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{'`d$$\' ,$P"\' . $$$ '}
|
||||
{`d$$' ,$P"' . $$$ `}
|
||||
<span style={{ color: theme.colors.blue }}>Uptime</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: 42 days, 3 hours</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{' $$P d$\' , $$P '}
|
||||
{` $$P d$' , $$P `}
|
||||
<span style={{ color: theme.colors.blue }}>Shell</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: bash 5.1.16</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{' $$: $$. - ,d$$\' '}
|
||||
{` $$: $$. - ,d$$' `}
|
||||
<span style={{ color: theme.colors.blue }}>Memory</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: 4.2G / 16G (26%)</span>
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>user@server</span>
|
||||
<span style={{ color: theme.colors.foreground }}>:</span>
|
||||
<span style={{ color: theme.colors.blue }}>~</span>
|
||||
<span style={{ color: theme.colors.foreground }}>$ </span>
|
||||
<span>ls -la</span>
|
||||
{/* ANSI color palette preview row */}
|
||||
<div className="flex gap-0.5 mt-1">
|
||||
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
|
||||
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
|
||||
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.blue }}>drwxr-xr-x</span>
|
||||
<span style={{ color: theme.colors.foreground }}> 5 user group </span>
|
||||
<span style={{ color: theme.colors.yellow }}>4.0K</span>
|
||||
<span style={{ color: theme.colors.foreground }}> Dec 12 10:30 </span>
|
||||
<span style={{ color: theme.colors.blue }}>.config</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.magenta }}>-rwxr-xr-x</span>
|
||||
<span style={{ color: theme.colors.foreground }}> 1 user group </span>
|
||||
<span style={{ color: theme.colors.yellow }}>2.1K</span>
|
||||
<span style={{ color: theme.colors.foreground }}> Dec 11 15:22 </span>
|
||||
<span style={{ color: theme.colors.green }}>deploy.sh</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>lrwxrwxrwx</span>
|
||||
<span style={{ color: theme.colors.foreground }}> 1 user group </span>
|
||||
<span style={{ color: theme.colors.yellow }}> 24</span>
|
||||
<span style={{ color: theme.colors.foreground }}> Dec 10 09:15 </span>
|
||||
<span style={{ color: theme.colors.cyan }}>logs</span>
|
||||
<span style={{ color: theme.colors.foreground }}> -{'>'} </span>
|
||||
<span style={{ color: theme.colors.foreground }}>/var/log/app</span>
|
||||
<div className="flex gap-0.5">
|
||||
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
|
||||
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
|
||||
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
@@ -267,11 +274,19 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const customThemes = useCustomThemes();
|
||||
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('theme');
|
||||
const [selectedTheme, setSelectedTheme] = useState(currentThemeId);
|
||||
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
|
||||
const [fontSize, setFontSize] = useState(currentFontSize);
|
||||
|
||||
// Custom theme editor state
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
|
||||
const [isNewTheme, setIsNewTheme] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Store original values when modal opens (for cancel/revert)
|
||||
const originalValuesRef = useRef({
|
||||
theme: currentThemeId,
|
||||
@@ -279,6 +294,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
fontSize: currentFontSize,
|
||||
});
|
||||
|
||||
// Combine built-in + custom themes
|
||||
const allThemes = useMemo(
|
||||
() => [...TERMINAL_THEMES, ...customThemes],
|
||||
[customThemes]
|
||||
);
|
||||
|
||||
// Sync state when modal opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -292,6 +313,8 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
setSelectedTheme(currentThemeId);
|
||||
setSelectedFont(currentFontFamilyId);
|
||||
setFontSize(currentFontSize);
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}
|
||||
}, [open, currentThemeId, currentFontFamilyId, currentFontSize]);
|
||||
|
||||
@@ -300,13 +323,14 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
[selectedFont, availableFonts]
|
||||
);
|
||||
const currentTheme = useMemo(
|
||||
() => TERMINAL_THEMES.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
|
||||
[selectedTheme]
|
||||
() => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
|
||||
[selectedTheme, allThemes, editingTheme]
|
||||
);
|
||||
|
||||
// Handle theme selection - apply immediately for real-time preview
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
setSelectedTheme(themeId);
|
||||
setEditingTheme(null);
|
||||
onThemeChange?.(themeId); // Apply immediately
|
||||
}, [onThemeChange]);
|
||||
|
||||
@@ -325,11 +349,93 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
});
|
||||
}, [onFontSizeChange]);
|
||||
|
||||
// ---- Custom Theme Actions ----
|
||||
|
||||
const handleNewTheme = useCallback(() => {
|
||||
// Clone current theme as starting point
|
||||
const base = allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0];
|
||||
const newTheme: TerminalTheme = {
|
||||
...base,
|
||||
id: `custom-${Date.now()}`,
|
||||
name: `${base.name} (Custom)`,
|
||||
isCustom: true,
|
||||
colors: { ...base.colors },
|
||||
};
|
||||
setEditingTheme(newTheme);
|
||||
setIsNewTheme(true);
|
||||
}, [selectedTheme, allThemes]);
|
||||
|
||||
const handleImportFile = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const xml = reader.result as string;
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
addTheme(parsed);
|
||||
setSelectedTheme(parsed.id);
|
||||
onThemeChange?.(parsed.id);
|
||||
setActiveTab('theme');
|
||||
} else {
|
||||
console.error('[ThemeCustomize] Failed to parse .itermcolors file:', file.name);
|
||||
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('[ThemeCustomize] Failed to read file:', file.name, reader.error);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset file input so the same file can be re-imported
|
||||
e.target.value = '';
|
||||
}, [addTheme, onThemeChange, t]);
|
||||
|
||||
const handleEditTheme = useCallback((themeId: string) => {
|
||||
const theme = customThemes.find(t => t.id === themeId);
|
||||
if (theme) {
|
||||
setEditingTheme({ ...theme, colors: { ...theme.colors } });
|
||||
setIsNewTheme(false);
|
||||
setActiveTab('custom');
|
||||
}
|
||||
}, [customThemes]);
|
||||
|
||||
|
||||
const handleEditorBack = useCallback(() => {
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, []);
|
||||
|
||||
const handleEditorDelete = useCallback((themeId: string) => {
|
||||
deleteTheme(themeId);
|
||||
if (selectedTheme === themeId) {
|
||||
setSelectedTheme(TERMINAL_THEMES[0].id);
|
||||
onThemeChange?.(TERMINAL_THEMES[0].id);
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, [deleteTheme, selectedTheme, onThemeChange]);
|
||||
|
||||
// Save: just close (changes are already applied)
|
||||
const handleSave = useCallback(() => {
|
||||
// If editing a custom theme, save it first
|
||||
if (editingTheme) {
|
||||
if (isNewTheme) {
|
||||
addTheme(editingTheme);
|
||||
setSelectedTheme(editingTheme.id);
|
||||
onThemeChange?.(editingTheme.id);
|
||||
} else {
|
||||
updateTheme(editingTheme.id, editingTheme);
|
||||
}
|
||||
}
|
||||
onSave?.();
|
||||
onClose();
|
||||
}, [onSave, onClose]);
|
||||
}, [editingTheme, isNewTheme, addTheme, updateTheme, onSave, onClose, onThemeChange]);
|
||||
|
||||
// Cancel: revert to original values
|
||||
const handleCancel = useCallback(() => {
|
||||
@@ -341,15 +447,15 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
onClose();
|
||||
}, [onThemeChange, onFontFamilyChange, onFontSizeChange, onClose]);
|
||||
|
||||
// Handle ESC key - same as cancel
|
||||
// Handle ESC key - same as cancel, but skip when child editor is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
if (e.key === 'Escape' && !editingTheme) handleCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, handleCancel]);
|
||||
}, [open, handleCancel, editingTheme]);
|
||||
|
||||
// Handle backdrop click - same as cancel
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
@@ -358,10 +464,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Separate built-in and custom themes for display in the theme list
|
||||
const builtinThemes = TERMINAL_THEMES;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/60"
|
||||
style={{ zIndex: 99999 }}
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
@@ -371,14 +479,14 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
|
||||
<Palette size={16} className="text-primary" />
|
||||
</div>
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
|
||||
<Palette size={16} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('terminal.themeModal.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -391,130 +499,243 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
{/* Tab Bar */}
|
||||
<div className="flex p-2 gap-1 shrink-0 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab('theme')}
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
activeTab === 'theme'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Palette size={13} />
|
||||
>
|
||||
<Palette size={13} />
|
||||
{t('terminal.themeModal.tab.theme')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
activeTab === 'font'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Type size={13} />
|
||||
>
|
||||
<Type size={13} />
|
||||
{t('terminal.themeModal.tab.font')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
{activeTab === 'theme' && (
|
||||
<div className="space-y-1">
|
||||
{TERMINAL_THEMES.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedTheme === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'font' && (
|
||||
<div className="space-y-1">
|
||||
{availableFonts.map(font => (
|
||||
<FontItem
|
||||
key={font.id}
|
||||
font={font}
|
||||
isSelected={selectedFont === font.id}
|
||||
onSelect={handleFontSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
activeTab === 'custom'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
{t('terminal.themeModal.tab.custom')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Font Size Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-3 border-t border-border shrink-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold">
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-2">
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
disabled={fontSize <= MIN_FONT_SIZE}
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-foreground tabular-nums">{fontSize}</span>
|
||||
<span className="text-[10px] text-muted-foreground">px</span>
|
||||
{/* List Content */}
|
||||
<>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
{activeTab === 'theme' && (
|
||||
<div className="space-y-1">
|
||||
{/* Built-in themes */}
|
||||
{builtinThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedTheme === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
{/* Custom themes section */}
|
||||
{customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1.5 px-1 font-semibold">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedTheme === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
disabled={fontSize >= MAX_FONT_SIZE}
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'font' && (
|
||||
<div className="space-y-1">
|
||||
{availableFonts.map(font => (
|
||||
<FontItem
|
||||
key={font.id}
|
||||
font={font}
|
||||
isSelected={selectedFont === font.id}
|
||||
onSelect={handleFontSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'custom' && !editingTheme && (
|
||||
<div className="space-y-2">
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={handleNewTheme}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-primary/10 text-primary">
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportFile}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500">
|
||||
<Download size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".itermcolors"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Custom themes list */}
|
||||
{customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1 px-1 font-semibold">
|
||||
{t('terminal.customTheme.yourThemes')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedTheme === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Font Size Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-3 border-t border-border shrink-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold">
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-2">
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
disabled={fontSize <= MIN_FONT_SIZE}
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-foreground tabular-nums">{fontSize}</span>
|
||||
<span className="text-[10px] text-muted-foreground">px</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
disabled={fontSize >= MAX_FONT_SIZE}
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Large Preview */}
|
||||
<div className="flex-1 flex flex-col min-w-0 p-4">
|
||||
{/* Right Panel - Large Preview */}
|
||||
<div className="flex-1 flex flex-col min-w-0 p-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||||
{t('terminal.themeModal.livePreview')}
|
||||
</div>
|
||||
<TerminalPreview theme={currentTheme} font={currentFont} fontSize={fontSize} />
|
||||
<TerminalPreview theme={currentTheme} font={currentFont} fontSize={fontSize} />
|
||||
|
||||
{/* Info line */}
|
||||
<div className="mt-3 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
{currentTheme.name} • {currentFont.name} • {fontSize}px
|
||||
</span>
|
||||
<span className="text-[10px] uppercase">
|
||||
</span>
|
||||
<span className="text-[10px] uppercase">
|
||||
{t('terminal.themeModal.themeType', { type: currentTheme.type })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use Portal to render at document root
|
||||
return createPortal(modalContent, document.body);
|
||||
return (
|
||||
<>
|
||||
{createPortal(modalContent, document.body)}
|
||||
{editingTheme && (
|
||||
<CustomThemeModal
|
||||
open={!!editingTheme}
|
||||
theme={editingTheme}
|
||||
isNew={isNewTheme}
|
||||
onSave={(theme) => {
|
||||
if (isNewTheme) {
|
||||
addTheme(theme);
|
||||
setSelectedTheme(theme.id);
|
||||
onThemeChange?.(theme.id);
|
||||
} else {
|
||||
updateTheme(theme.id, theme);
|
||||
if (selectedTheme === theme.id) {
|
||||
onThemeChange?.(theme.id);
|
||||
}
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}}
|
||||
onDelete={isNewTheme ? undefined : handleEditorDelete}
|
||||
onCancel={handleEditorBack}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeCustomizeModal;
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface ServerStats {
|
||||
memFree: number | null; // Free memory in MB
|
||||
memBuffers: number | null; // Buffers in MB
|
||||
memCached: number | null; // Cached in MB
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: ProcessInfo[]; // Top 10 processes by memory
|
||||
diskPercent: number | null; // Disk usage percentage for root partition
|
||||
diskUsed: number | null; // Disk used in GB
|
||||
@@ -66,6 +68,8 @@ export function useServerStats({
|
||||
memFree: null,
|
||||
memBuffers: null,
|
||||
memCached: null,
|
||||
swapTotal: null,
|
||||
swapUsed: null,
|
||||
topProcesses: [],
|
||||
diskPercent: null,
|
||||
diskUsed: null,
|
||||
@@ -109,6 +113,8 @@ export function useServerStats({
|
||||
memFree: result.stats.memFree,
|
||||
memBuffers: result.stats.memBuffers,
|
||||
memCached: result.stats.memCached,
|
||||
swapTotal: result.stats.swapTotal ?? null,
|
||||
swapUsed: result.stats.swapUsed ?? null,
|
||||
topProcesses: result.stats.topProcesses || [],
|
||||
diskPercent: result.stats.diskPercent,
|
||||
diskUsed: result.stats.diskUsed,
|
||||
@@ -155,6 +161,8 @@ export function useServerStats({
|
||||
memFree: null,
|
||||
memBuffers: null,
|
||||
memCached: null,
|
||||
swapTotal: null,
|
||||
swapUsed: null,
|
||||
topProcesses: [],
|
||||
diskPercent: null,
|
||||
diskUsed: null,
|
||||
|
||||
@@ -13,11 +13,13 @@ export const useTerminalContextActions = ({
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
onHasSelectionChange,
|
||||
disableBracketedPasteRef,
|
||||
}: {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionRef: RefObject<string | null>;
|
||||
terminalBackend: TerminalBackendWriteApi;
|
||||
onHasSelectionChange?: (hasSelection: boolean) => void;
|
||||
disableBracketedPasteRef?: RefObject<boolean>;
|
||||
}) => {
|
||||
const onCopy = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
@@ -35,13 +37,13 @@ export const useTerminalContextActions = ({
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && sessionRef.current) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend]);
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { Dispatch, RefObject, SetStateAction } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
|
||||
import {
|
||||
isEncryptedCredentialPlaceholder,
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
type TerminalBackendApi = {
|
||||
@@ -85,6 +89,7 @@ export type TerminalSessionStartersContext = {
|
||||
setProgressLogs: Dispatch<SetStateAction<string[]>>;
|
||||
setProgressValue: Dispatch<SetStateAction<number>>;
|
||||
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
|
||||
t?: (key: string) => string;
|
||||
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
@@ -194,6 +199,12 @@ const runDistroDetection = async (
|
||||
};
|
||||
|
||||
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
|
||||
const tr = (key: string, fallback: string): string => {
|
||||
const translated = ctx.t?.(key);
|
||||
if (!translated || translated === key) return fallback;
|
||||
return translated;
|
||||
};
|
||||
|
||||
const startSSH = async (term: XTerm) => {
|
||||
try {
|
||||
term.clear?.();
|
||||
@@ -227,9 +238,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
});
|
||||
|
||||
const effectiveUsername = resolvedAuth.username || "root";
|
||||
const effectivePassword = resolvedAuth.password;
|
||||
const key = resolvedAuth.key;
|
||||
const effectivePassphrase = resolvedAuth.passphrase;
|
||||
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||||
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||||
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||||
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
|
||||
let usedKey: SSHKey | undefined;
|
||||
let usedPassword: string | undefined;
|
||||
|
||||
@@ -244,16 +257,19 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
);
|
||||
};
|
||||
|
||||
const rawProxyPassword = ctx.host.proxyConfig?.password;
|
||||
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
|
||||
const proxyConfig = ctx.host.proxyConfig
|
||||
? {
|
||||
type: ctx.host.proxyConfig.type,
|
||||
host: ctx.host.proxyConfig.host,
|
||||
port: ctx.host.proxyConfig.port,
|
||||
username: ctx.host.proxyConfig.username,
|
||||
password: ctx.host.proxyConfig.password,
|
||||
password: sanitizeCredentialValue(rawProxyPassword),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
@@ -261,14 +277,30 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
identities: ctx.identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const rawJumpPassword = jumpAuth.password;
|
||||
const rawJumpPrivateKey = jumpKey?.privateKey;
|
||||
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
|
||||
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
|
||||
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
|
||||
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
|
||||
|
||||
const hasEncryptedJumpCredential =
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
|
||||
|
||||
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
|
||||
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
password: jumpPassword,
|
||||
privateKey: jumpPrivateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
passphrase: jumpPassphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
@@ -276,6 +308,38 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
});
|
||||
|
||||
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const message = tr(
|
||||
"terminal.auth.proxyCredentialsUnavailable",
|
||||
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
|
||||
);
|
||||
ctx.setNeedsAuth(false);
|
||||
ctx.setAuthRetryMessage(null);
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (jumpHostsWithUnavailableCredentials.length > 0) {
|
||||
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
|
||||
const suffix =
|
||||
jumpHostsWithUnavailableCredentials.length > 2
|
||||
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
|
||||
: "";
|
||||
const base = tr(
|
||||
"terminal.auth.jumpCredentialsUnavailable",
|
||||
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
|
||||
);
|
||||
const message = `${base} (${jumpList}${suffix})`;
|
||||
ctx.setNeedsAuth(false);
|
||||
ctx.setAuthRetryMessage(null);
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
const totalHops = jumpHosts.length + 1;
|
||||
let unsubscribeChainProgress: (() => void) | undefined;
|
||||
|
||||
@@ -334,8 +398,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
publicKey: attempt.key?.publicKey,
|
||||
keyId: attempt.key?.id,
|
||||
keySource: attempt.key?.source,
|
||||
passphrase: attempt.key ? (effectivePassphrase || attempt.key.passphrase) : undefined,
|
||||
passphrase: attempt.key
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
: undefined,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
legacyAlgorithms: ctx.host.legacyAlgorithms,
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
@@ -349,9 +416,46 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
let id: string;
|
||||
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
|
||||
const authMethod = resolvedAuth.authMethod;
|
||||
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
|
||||
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== 'password';
|
||||
const hasPassword = !!effectivePassword;
|
||||
|
||||
const needsCredentialReentry =
|
||||
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
|
||||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
|
||||
|
||||
if (needsCredentialReentry) {
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
ctx.setError(null);
|
||||
ctx.setNeedsAuth(true);
|
||||
ctx.setAuthRetryMessage(
|
||||
tr(
|
||||
"terminal.auth.credentialsUnavailable",
|
||||
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||||
),
|
||||
);
|
||||
ctx.setAuthPassword("");
|
||||
ctx.setProgressLogs((prev) => [
|
||||
...prev,
|
||||
tr(
|
||||
"terminal.auth.credentialsUnavailable",
|
||||
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||||
),
|
||||
]);
|
||||
ctx.setStatus("connecting");
|
||||
ctx.setChainProgress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasKeyMaterial && authMethod !== "password" && hasEncryptedPrimaryKey && hasPassword) {
|
||||
ctx.setProgressLogs((prev) => [
|
||||
...prev,
|
||||
tr(
|
||||
"terminal.auth.keyUnavailableFallbackPassword",
|
||||
"Saved SSH key is unavailable on this device. Falling back to password authentication.",
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
if (hasKeyMaterial) {
|
||||
try {
|
||||
|
||||
@@ -157,6 +157,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const term = new XTerm({
|
||||
...performanceConfig.options,
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
fontSize: effectiveFontSize,
|
||||
fontFamily,
|
||||
fontWeight: fontWeight as
|
||||
@@ -262,15 +264,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
if (performanceConfig.useWebGLAddon) {
|
||||
try {
|
||||
webglAddon = (() => {
|
||||
const webglOptions: Record<string, unknown> = { useCustomGlyphHandler: true };
|
||||
try {
|
||||
const WebglCtor = WebglAddon as unknown as new (options?: unknown) => WebglAddon;
|
||||
return new WebglCtor(webglOptions);
|
||||
} catch {
|
||||
return new WebglAddon();
|
||||
}
|
||||
})();
|
||||
// WebglAddon constructor only accepts `preserveDrawingBuffer?: boolean`.
|
||||
// Passing an object here (legacy API assumption) unintentionally enables
|
||||
// preserveDrawingBuffer and can cause sporadic glyph artifacts/ghosting.
|
||||
webglAddon = new WebglAddon();
|
||||
webglAddon.onContextLoss(() => {
|
||||
logger.warn("[XTerm] WebGL context loss detected, disposing addon");
|
||||
webglAddon?.dispose();
|
||||
@@ -316,7 +313,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (ctx.terminalBackend.openExternalAvailable()) {
|
||||
void ctx.terminalBackend.openExternal(uri);
|
||||
} else {
|
||||
window.open(uri, "_blank");
|
||||
const safeUri = String(uri || "");
|
||||
if (/^https?:\/\//i.test(safeUri)) {
|
||||
window.open(safeUri, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
logger.warn("[XTerm] Refusing to open non-http(s) link:", safeUri);
|
||||
}
|
||||
}
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
@@ -409,7 +411,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
}
|
||||
});
|
||||
@@ -444,7 +446,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowLeft,MoreVertical,X } from 'lucide-react';
|
||||
import React,{ createContext,ReactNode,useCallback,useContext,useState } from 'react';
|
||||
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
|
||||
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Popover,PopoverContent,PopoverTrigger } from './popover';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { ScrollArea } from './scroll-area';
|
||||
|
||||
// Types
|
||||
@@ -102,7 +102,7 @@ export const AsidePanelContent: React.FC<{ children: ReactNode; className?: stri
|
||||
}) => {
|
||||
return (
|
||||
<ScrollArea className={cn("flex-1", className)}>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="p-4 space-y-4 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
92
domain/credentials.ts
Normal file
92
domain/credentials.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { SyncPayload } from "./sync";
|
||||
|
||||
export const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
|
||||
|
||||
/**
|
||||
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
|
||||
*/
|
||||
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
|
||||
|
||||
/**
|
||||
* Chromium/Electron safeStorage ciphertext carries known platform headers:
|
||||
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
|
||||
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
|
||||
*
|
||||
* We validate the base64 payload starts with one of these header signatures
|
||||
* instead of relying only on prefix+length heuristics. This greatly reduces
|
||||
* false positives for plaintext credentials that happen to start with "enc:v1:".
|
||||
*
|
||||
* References:
|
||||
* - components/os_crypt/sync/os_crypt_mac.mm (kObfuscationPrefixV10 = "v10")
|
||||
* - components/os_crypt/sync/os_crypt_linux.cc (kObfuscationPrefixV10/V11)
|
||||
* - components/os_crypt/sync/os_crypt_win.cc (DPAPI legacy path)
|
||||
*/
|
||||
const SAFE_STORAGE_BASE64_HEADER_PREFIXES = [
|
||||
"djEw", // "v10"
|
||||
"djEx", // "v11"
|
||||
"AQAAAA", // 0x01 0x00 0x00 0x00 (DPAPI blob header)
|
||||
] as const;
|
||||
|
||||
export const isEncryptedCredentialPlaceholder = (
|
||||
value: string | undefined | null,
|
||||
): value is string => {
|
||||
if (typeof value !== "string" || !value.startsWith(CREDENTIAL_ENCRYPTION_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
const payload = value.slice(CREDENTIAL_ENCRYPTION_PREFIX.length);
|
||||
if (!payload || !BASE64_RE.test(payload)) return false;
|
||||
|
||||
return SAFE_STORAGE_BASE64_HEADER_PREFIXES.some((prefix) => payload.startsWith(prefix));
|
||||
};
|
||||
|
||||
/**
|
||||
* Strip enc:v1: placeholders from a single credential value.
|
||||
* Used at the terminal connection boundary to avoid sending encrypted
|
||||
* placeholders as actual passwords to SSH/Telnet servers.
|
||||
*/
|
||||
export const sanitizeCredentialValue = (
|
||||
value: string | undefined,
|
||||
): string | undefined => {
|
||||
if (isEncryptedCredentialPlaceholder(value)) return undefined;
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Scan a sync payload for any fields that still carry device-bound
|
||||
* enc:v1: ciphertext. Returns the dotted paths of offending fields.
|
||||
* Used as a pre-upload guard to prevent pushing un-decryptable data.
|
||||
*/
|
||||
export const findSyncPayloadEncryptedCredentialPaths = (
|
||||
payload: SyncPayload,
|
||||
): string[] => {
|
||||
const issues: string[] = [];
|
||||
|
||||
payload.hosts.forEach((host, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(host.password)) {
|
||||
issues.push(`hosts[${index}].password`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(host.telnetPassword)) {
|
||||
issues.push(`hosts[${index}].telnetPassword`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
|
||||
issues.push(`hosts[${index}].proxyConfig.password`);
|
||||
}
|
||||
});
|
||||
|
||||
payload.keys.forEach((key, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(key.privateKey)) {
|
||||
issues.push(`keys[${index}].privateKey`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
|
||||
issues.push(`keys[${index}].passphrase`);
|
||||
}
|
||||
});
|
||||
|
||||
payload.identities?.forEach((identity, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(identity.password)) {
|
||||
issues.push(`identities[${index}].password`);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
};
|
||||
@@ -51,6 +51,12 @@ export interface ProtocolConfig {
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface SftpBookmark {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface Host {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -94,11 +100,14 @@ export interface Host {
|
||||
// SFTP specific configuration
|
||||
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
|
||||
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
|
||||
sftpBookmarks?: SftpBookmark[]; // Bookmarked SFTP paths for quick navigation
|
||||
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
|
||||
managedSourceId?: string; // Reference to ManagedSource.id
|
||||
// Host-level keyword highlighting (overrides/extends global settings)
|
||||
keywordHighlightRules?: KeywordHighlightRule[];
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -194,7 +203,7 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
|
||||
// Convert keyboard event to a key string
|
||||
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
|
||||
if (isMac) {
|
||||
if (e.metaKey) parts.push('⌘');
|
||||
if (e.ctrlKey) parts.push('⌃');
|
||||
@@ -206,7 +215,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
if (e.metaKey) parts.push('Win');
|
||||
}
|
||||
|
||||
|
||||
// Get the key name
|
||||
let keyName = e.key;
|
||||
// Normalize special keys
|
||||
@@ -221,12 +230,12 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
else if (keyName === 'Enter') keyName = '↵';
|
||||
else if (keyName === 'Tab') keyName = '⇥';
|
||||
else if (keyName.length === 1) keyName = keyName.toUpperCase();
|
||||
|
||||
|
||||
// Don't include modifier keys themselves
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
|
||||
return parts.join(' + ');
|
||||
}
|
||||
|
||||
|
||||
parts.push(keyName);
|
||||
return parts.join(' + ');
|
||||
};
|
||||
@@ -234,7 +243,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
|
||||
// Check if a keyboard event matches a key binding string
|
||||
export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boolean): boolean => {
|
||||
if (!keyStr || keyStr === 'Disabled') return false;
|
||||
|
||||
|
||||
// Handle range patterns like "[1...9]"
|
||||
if (keyStr.includes('[1...9]')) {
|
||||
const basePattern = keyStr.replace('[1...9]', '');
|
||||
@@ -244,7 +253,7 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
const testStr = basePattern + key;
|
||||
return matchesKeyBinding(e, testStr.trim(), isMac);
|
||||
}
|
||||
|
||||
|
||||
// Handle arrow key patterns like "arrows"
|
||||
if (keyStr.includes('arrows')) {
|
||||
const basePattern = keyStr.replace('arrows', '');
|
||||
@@ -252,18 +261,18 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
// Check if it's an arrow key
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) return false;
|
||||
// Map arrow key to symbol for matching
|
||||
const arrowSymbol = key === 'ArrowUp' ? '↑'
|
||||
const arrowSymbol = key === 'ArrowUp' ? '↑'
|
||||
: key === 'ArrowDown' ? '↓'
|
||||
: key === 'ArrowLeft' ? '←'
|
||||
: '→';
|
||||
: key === 'ArrowLeft' ? '←'
|
||||
: '→';
|
||||
// Check modifiers match the base pattern
|
||||
const testStr = basePattern + arrowSymbol;
|
||||
return matchesKeyBinding(e, testStr.trim(), isMac);
|
||||
}
|
||||
|
||||
|
||||
const parsed = parseKeyCombo(keyStr);
|
||||
if (!parsed) return false;
|
||||
|
||||
|
||||
const { modifiers, key } = parsed;
|
||||
|
||||
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
|
||||
@@ -271,14 +280,14 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check modifiers
|
||||
if (isMac) {
|
||||
const needMeta = modifiers.includes('⌘');
|
||||
const needCtrl = modifiers.includes('⌃');
|
||||
const needAlt = modifiers.includes('⌥');
|
||||
const needShift = modifiers.includes('Shift');
|
||||
|
||||
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
if (e.ctrlKey !== needCtrl) return false;
|
||||
if (e.altKey !== needAlt) return false;
|
||||
@@ -288,13 +297,13 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
const needAlt = modifiers.includes('Alt');
|
||||
const needShift = modifiers.includes('Shift');
|
||||
const needMeta = modifiers.includes('Win');
|
||||
|
||||
|
||||
if (e.ctrlKey !== needCtrl) return false;
|
||||
if (e.altKey !== needAlt) return false;
|
||||
if (e.shiftKey !== needShift) return false;
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
}
|
||||
|
||||
|
||||
const normalizeKey = (rawKey: string): string => {
|
||||
let normalizedKey = rawKey;
|
||||
if (normalizedKey === ' ') normalizedKey = 'Space';
|
||||
@@ -422,6 +431,9 @@ export interface TerminalSettings {
|
||||
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
|
||||
serverStatsRefreshInterval: number; // Seconds between stats refresh (default: 30)
|
||||
|
||||
// Paste
|
||||
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
|
||||
|
||||
// Rendering
|
||||
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
|
||||
}
|
||||
@@ -464,6 +476,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
};
|
||||
|
||||
@@ -471,6 +484,7 @@ export interface TerminalTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'dark' | 'light';
|
||||
isCustom?: boolean;
|
||||
colors: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
@@ -524,17 +538,17 @@ export interface RemoteFile {
|
||||
|
||||
export type WorkspaceNode =
|
||||
| {
|
||||
id: string;
|
||||
type: 'pane';
|
||||
sessionId: string;
|
||||
}
|
||||
id: string;
|
||||
type: 'pane';
|
||||
sessionId: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'split';
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: WorkspaceNode[];
|
||||
sizes?: number[]; // relative sizes for children
|
||||
};
|
||||
id: string;
|
||||
type: 'split';
|
||||
direction: 'horizontal' | 'vertical';
|
||||
children: WorkspaceNode[];
|
||||
sizes?: number[]; // relative sizes for children
|
||||
};
|
||||
|
||||
export type WorkspaceViewMode = 'split' | 'focus';
|
||||
|
||||
|
||||
@@ -10,35 +10,136 @@ const path = require("node:path");
|
||||
const { BaseAgent } = require("ssh2/lib/agent.js");
|
||||
const { parseKey } = require("ssh2/lib/protocol/keyParser.js");
|
||||
|
||||
// Simple file logger for debugging
|
||||
const logFile = path.join(require("os").tmpdir(), "netcatty-agent.log");
|
||||
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
|
||||
|
||||
// Debug logger (disabled by default)
|
||||
const logFile = DEBUG_SSH
|
||||
? path.join(require("os").tmpdir(), "netcatty-agent.log")
|
||||
: null;
|
||||
const log = (msg, data) => {
|
||||
if (!DEBUG_SSH) return;
|
||||
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
|
||||
try { fs.appendFileSync(logFile, line); } catch {}
|
||||
try { fs.appendFileSync(logFile, line); } catch { }
|
||||
console.log("[Agent]", msg, data || "");
|
||||
};
|
||||
|
||||
const DUMMY_ED25519_PUB =
|
||||
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB netcatty-agent-dummy";
|
||||
|
||||
function parseOpenSshKeyLine(line) {
|
||||
if (typeof line !== "string" || !line.trim()) throw new Error("Empty OpenSSH key line");
|
||||
const firstLine = line.split(/\r?\n/).find((l) => l.trim());
|
||||
if (!firstLine) throw new Error("Empty OpenSSH key line");
|
||||
const m = /^\s*(\S+)\s+([A-Za-z0-9+/=]+)(?:\s+(.*))?\s*$/.exec(firstLine);
|
||||
if (!m) throw new Error("Invalid OpenSSH key line");
|
||||
|
||||
// Normalize input: remove extra whitespace and join into single line
|
||||
// This handles cases where long certificates are wrapped across multiple lines
|
||||
const normalized = line.split(/\r?\n/)
|
||||
.map(l => l.trim())
|
||||
.filter(l => l)
|
||||
.join(" ");
|
||||
|
||||
if (!normalized) throw new Error("Empty OpenSSH key line");
|
||||
|
||||
// Match format: <type> <base64-blob> [comment]
|
||||
// Base64 blob may be very long (certificates can be 2000+ chars)
|
||||
// Allow spaces within base64 for cases where it was wrapped
|
||||
const m = /^\s*(\S+)\s+((?:[A-Za-z0-9+/=]\s*)+?)(?:\s+(.+?))?\s*$/.exec(normalized);
|
||||
|
||||
if (!m) {
|
||||
// Fallback: try simpler pattern for single-line format
|
||||
const parts = normalized.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const type = parts[0];
|
||||
|
||||
// Determine if last part is comment or base64
|
||||
// Comments usually don't start with base64-valid chars at boundaries
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const isLastBase64 = /^[A-Za-z0-9+/=]+$/.test(lastPart) && lastPart.length > 20;
|
||||
|
||||
// If last part is base64, it's part of the blob; otherwise it's a comment
|
||||
const blobParts = isLastBase64 ? parts.slice(1) : parts.slice(1, -1);
|
||||
const comment = isLastBase64 ? "" : lastPart;
|
||||
|
||||
if (blobParts.length === 0) {
|
||||
throw new Error("No base64 data found in OpenSSH key line");
|
||||
}
|
||||
|
||||
try {
|
||||
const base64Str = blobParts.join("");
|
||||
const blob = Buffer.from(base64Str, "base64");
|
||||
log("Fallback parse success", { type, blobLength: blob.length, comment });
|
||||
return { type, blob, comment };
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid base64 in OpenSSH key line: ${e.message}`);
|
||||
}
|
||||
}
|
||||
throw new Error("Invalid OpenSSH key line format");
|
||||
}
|
||||
|
||||
const type = m[1];
|
||||
const blob = Buffer.from(m[2], "base64");
|
||||
const base64Str = m[2].replace(/\s+/g, ""); // Remove any spaces from base64
|
||||
const blob = Buffer.from(base64Str, "base64");
|
||||
const comment = m[3] || "";
|
||||
return { type, blob, comment };
|
||||
}
|
||||
|
||||
function buildCertificateIdentityKey({ certType, certBlob, comment }) {
|
||||
const key = parseKey(DUMMY_ED25519_PUB);
|
||||
if (key instanceof Error) throw key;
|
||||
key.type = certType;
|
||||
function buildCertificateIdentityKey({ certType, certBlob, comment, privateKey, passphrase }) {
|
||||
// Parse the actual private key to get the correct public key object
|
||||
if (!privateKey) throw new Error("privateKey required to build certificate identity");
|
||||
const key = parseKey(privateKey, passphrase);
|
||||
if (key instanceof Error) throw new Error(`Failed to parse private key: ${key.message}`);
|
||||
|
||||
// Extract base key type from certificate type (e.g., ssh-rsa-cert-v01@openssh.com -> ssh-rsa)
|
||||
const baseType = certType.replace(/-cert-v0[01]@openssh\.com$/i, '');
|
||||
|
||||
// CRITICAL: Determine modern certificate type for algorithm negotiation
|
||||
// OpenSSH servers require explicit signature algorithms (SHA-256/SHA-512, not generic SHA-1)
|
||||
// But we MUST NOT modify the certificate blob (would break CA signature)
|
||||
let modernCertType = certType;
|
||||
if (certType === 'ssh-rsa-cert-v01@openssh.com' && baseType === 'ssh-rsa') {
|
||||
// Prefer SHA-512 for RSA certificates (matches OpenSSH client default)
|
||||
modernCertType = 'rsa-sha2-512-cert-v01@openssh.com';
|
||||
}
|
||||
|
||||
log("Private key parsed for certificate identity", {
|
||||
originalKeyType: key.type,
|
||||
originalCertType: certType,
|
||||
modernCertType: modernCertType,
|
||||
baseType: baseType,
|
||||
hasGetPublicSSH: !!key.getPublicSSH,
|
||||
originalPublicSSHLength: key.getPublicSSH ? key.getPublicSSH().length : 0,
|
||||
certBlobLength: certBlob.length,
|
||||
certBlobPreview: certBlob.slice(0, 40).toString('hex')
|
||||
});
|
||||
|
||||
// STRATEGY: Set key.type to MODERN certificate type for algorithm name in USERAUTH_REQUEST
|
||||
// but return ORIGINAL unmodified certificate blob (to preserve CA signature)
|
||||
// Server will accept this because:
|
||||
// - Algorithm name in USERAUTH_REQUEST: rsa-sha2-512-cert-v01@openssh.com (what we claim to support)
|
||||
// - Certificate blob type field: ssh-rsa-cert-v01@openssh.com (original, CA-signed)
|
||||
// - Server knows these are compatible (both are RSA certs, just different hash algorithms)
|
||||
key.type = modernCertType; // Use modern cert type as algorithm name
|
||||
key._baseType = baseType;
|
||||
key._originalCertType = certType;
|
||||
key._certType = modernCertType;
|
||||
key._signatureAlgo = modernCertType.includes('512') ? 'rsa-sha2-512' : 'rsa-sha2-256';
|
||||
key.comment = comment || key.comment;
|
||||
key.getPublicSSH = () => certBlob;
|
||||
key.getPublicSSH = () => certBlob; // Return ORIGINAL unmodified certificate blob
|
||||
// CRITICAL: Override sign() to ensure it returns signature algorithm, not cert type
|
||||
// ssh2's authPK needs the signature algorithm for constructing the signature blob
|
||||
// but key.type is the cert type. We need to provide the signature algorithm separately.
|
||||
const originalSign = key.sign.bind(key);
|
||||
key.sign = function (data, hash) {
|
||||
const sig = originalSign(data, hash);
|
||||
// Return signature with metadata for ssh2
|
||||
if (sig instanceof Error) return sig;
|
||||
// Attach signature algorithm as property for ssh2 to use
|
||||
const sigBuffer = Buffer.from(sig);
|
||||
sigBuffer._signatureAlgorithm = key._signatureAlgo;
|
||||
return sigBuffer;
|
||||
};
|
||||
log("Built certificate identity key", {
|
||||
finalType: key.type,
|
||||
finalBaseType: key._baseType,
|
||||
finalCertType: key._certType,
|
||||
finalPublicSSHLength: key.getPublicSSH().length,
|
||||
});
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
@@ -57,15 +158,40 @@ class NetcattyAgent extends BaseAgent {
|
||||
this._advertisedType = null;
|
||||
|
||||
if (this._mode === "certificate") {
|
||||
const { certificate, label } = opts.meta || {};
|
||||
const { certificate, privateKey, passphrase, label } = opts.meta || {};
|
||||
if (!certificate) throw new Error("Missing certificate");
|
||||
const { type: certType, blob: certBlob } = parseOpenSshKeyLine(certificate);
|
||||
this._key = buildCertificateIdentityKey({
|
||||
certType,
|
||||
certBlob,
|
||||
comment: label || "",
|
||||
});
|
||||
this._advertisedType = certType;
|
||||
if (!privateKey) throw new Error("Missing privateKey for certificate auth");
|
||||
log("Parsing certificate", { certLength: certificate.length, label, hasPrivateKey: !!privateKey });
|
||||
try {
|
||||
const { type: certType, blob: certBlob } = parseOpenSshKeyLine(certificate);
|
||||
log("Certificate parsed successfully", {
|
||||
certType,
|
||||
blobLength: certBlob.length,
|
||||
blobPreview: certBlob.slice(0, 32).toString('hex')
|
||||
});
|
||||
this._key = buildCertificateIdentityKey({
|
||||
certType,
|
||||
certBlob,
|
||||
comment: label || "",
|
||||
privateKey,
|
||||
passphrase,
|
||||
});
|
||||
this._advertisedType = certType; // Store original cert type for debugging
|
||||
|
||||
// Cache parsed private key to avoid re-parsing on every sign() call
|
||||
const parsed = parseKey(privateKey, passphrase);
|
||||
if (parsed instanceof Error) throw parsed;
|
||||
this._parsedPrivateKey = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
|
||||
log("Agent initialized successfully", {
|
||||
keyType: this._key.type,
|
||||
certType: certType,
|
||||
baseType: this._key._baseType,
|
||||
});
|
||||
} catch (err) {
|
||||
log("Certificate parse error", { error: err.message, stack: err.stack });
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unknown agent mode: ${opts.mode}`);
|
||||
}
|
||||
@@ -73,46 +199,62 @@ class NetcattyAgent extends BaseAgent {
|
||||
|
||||
getIdentities(cb) {
|
||||
log("getIdentities called", { mode: this._mode });
|
||||
// Debug: log key structure
|
||||
if (this._key) {
|
||||
const publicSSH = this._key.getPublicSSH ? this._key.getPublicSSH() : null;
|
||||
log("Returning key identity", {
|
||||
keyType: this._key.type,
|
||||
hasGetPublicSSH: !!this._key.getPublicSSH,
|
||||
publicSSHLength: publicSSH?.length,
|
||||
publicSSHPreview: publicSSH?.slice(0, 32).toString('hex'),
|
||||
keyComment: this._key.comment,
|
||||
});
|
||||
}
|
||||
cb(null, [this._key]);
|
||||
}
|
||||
|
||||
sign(_pubKey, data, options, cb) {
|
||||
log("sign called", {
|
||||
mode: this._mode,
|
||||
log("sign called", {
|
||||
mode: this._mode,
|
||||
dataLength: data?.length,
|
||||
advertisedType: this._advertisedType,
|
||||
options: options,
|
||||
hasPrivateKeyInMeta: !!this._meta?.privateKey,
|
||||
privateKeyLength: this._meta?.privateKey?.length,
|
||||
});
|
||||
if (typeof options === "function") {
|
||||
cb = options;
|
||||
options = undefined;
|
||||
}
|
||||
if (typeof cb !== "function") cb = () => {};
|
||||
if (typeof cb !== "function") cb = () => { };
|
||||
|
||||
(async () => {
|
||||
if (this._mode === "certificate") {
|
||||
const { privateKey, passphrase } = this._meta || {};
|
||||
if (!privateKey) throw new Error("Missing privateKey for certificate auth");
|
||||
|
||||
const parsed = parseKey(privateKey, passphrase);
|
||||
if (parsed instanceof Error) throw parsed;
|
||||
const key = Array.isArray(parsed) ? parsed[0] : parsed;
|
||||
// Use cached parsed private key (parsed once during construction)
|
||||
const key = this._parsedPrivateKey;
|
||||
if (!key) {
|
||||
throw new Error("Missing parsed private key — agent not properly initialized");
|
||||
}
|
||||
log("Using cached private key", { keyType: key.type });
|
||||
|
||||
// For certificates, key.type is now the base type (e.g., 'ssh-rsa')
|
||||
// ssh2's getKeyAlgos() will negotiate the proper hash algorithm
|
||||
const baseType = normalizeBaseTypeForConversion(key.type);
|
||||
let hash = options && options.hash ? options.hash : undefined;
|
||||
|
||||
// ssh2 does not currently infer hash algorithms for certificate types.
|
||||
// For RSA cert algorithms, select the hash based on the *advertised* algorithm
|
||||
// (e.g. rsa-sha2-256-cert-v01@openssh.com), not the private key type (ssh-rsa).
|
||||
if (!hash) {
|
||||
const advertisedBaseType = normalizeBaseTypeForConversion(
|
||||
this._advertisedType || this._key?.type
|
||||
);
|
||||
if (advertisedBaseType === "rsa-sha2-256") hash = "sha256";
|
||||
else if (advertisedBaseType === "rsa-sha2-512") hash = "sha512";
|
||||
else if (advertisedBaseType === "ssh-rsa") hash = "sha1";
|
||||
// If hash not provided by ssh2, default to SHA-512 for RSA keys
|
||||
// (matches OpenSSH client behavior, modern servers disable SHA-1)
|
||||
if (!hash && baseType === 'ssh-rsa') {
|
||||
hash = 'sha512'; // Use SHA-512 like OpenSSH client
|
||||
}
|
||||
|
||||
log("Signing with parameters", {
|
||||
privateKeyType: key.type,
|
||||
baseType: baseType,
|
||||
advertisedType: this._advertisedType,
|
||||
hash: hash,
|
||||
});
|
||||
|
||||
let sig = key.sign(data, hash);
|
||||
if (sig instanceof Error) throw sig;
|
||||
|
||||
@@ -121,11 +263,19 @@ class NetcattyAgent extends BaseAgent {
|
||||
baseType,
|
||||
advertisedType: this._advertisedType,
|
||||
hash,
|
||||
sigLength: sig?.length,
|
||||
sigLength: sig.length,
|
||||
});
|
||||
|
||||
// Return raw signature. ssh2 will handle signature field construction.
|
||||
return Buffer.from(sig);
|
||||
// CRITICAL: ssh2's authPK() expects RAW signature (without algorithm name wrapper)
|
||||
// authPK will construct the signature blob itself: algo_name + raw_signature
|
||||
// If we return pre-wrapped blob, authPK will wrap it again causing double-wrapping
|
||||
// which server will reject. So we must return ONLY the raw signature bytes.
|
||||
log("Returning raw signature to ssh2", {
|
||||
signatureLength: sig.length,
|
||||
signaturePreview: sig.slice(0, 32).toString('hex')
|
||||
});
|
||||
|
||||
return Buffer.from(sig); // Return RAW signature only
|
||||
}
|
||||
|
||||
throw new Error("Unsupported agent mode");
|
||||
|
||||
@@ -23,9 +23,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
@@ -254,6 +254,44 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration for SFTP connections.
|
||||
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
|
||||
*/
|
||||
function buildSftpAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
@@ -307,22 +345,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
@@ -726,6 +749,7 @@ async function openSftp(event, options) {
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Use the tunneled socket if we have one
|
||||
@@ -831,6 +855,8 @@ async function openSftp(event, options) {
|
||||
client.client.setMaxListeners(0); // 0 means unlimited
|
||||
}
|
||||
|
||||
// Used by transferBridge to decide whether isolated fast-transfer channels are safe.
|
||||
client.__netcattySudoMode = !!options.sudo;
|
||||
sftpClients.set(connId, client);
|
||||
|
||||
// Store jump connections for cleanup when SFTP is closed
|
||||
|
||||
@@ -165,14 +165,58 @@ function checkWindowsSshAgent() {
|
||||
});
|
||||
}
|
||||
|
||||
// Simple file logger for debugging
|
||||
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
|
||||
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
|
||||
|
||||
// Debug logger (disabled by default)
|
||||
const logFile = DEBUG_SSH
|
||||
? path.join(require("os").tmpdir(), "netcatty-ssh.log")
|
||||
: null;
|
||||
const log = (msg, data) => {
|
||||
if (!DEBUG_SSH) return;
|
||||
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
|
||||
try { fs.appendFileSync(logFile, line); } catch { }
|
||||
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
|
||||
};
|
||||
|
||||
/**
|
||||
* Build SSH algorithm configuration.
|
||||
* When legacyEnabled is true, legacy algorithms are appended to each list
|
||||
* (lower priority than modern ones) for compatibility with older network equipment.
|
||||
*/
|
||||
function buildAlgorithms(legacyEnabled) {
|
||||
const algorithms = {
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
};
|
||||
|
||||
if (legacyEnabled) {
|
||||
algorithms.kex.push(
|
||||
'diffie-hellman-group14-sha1',
|
||||
'diffie-hellman-group1-sha1',
|
||||
);
|
||||
algorithms.cipher.push(
|
||||
'aes128-cbc', 'aes256-cbc', '3des-cbc',
|
||||
);
|
||||
algorithms.serverHostKey = [
|
||||
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
|
||||
'rsa-sha2-512', 'rsa-sha2-256',
|
||||
'ssh-rsa', 'ssh-dss',
|
||||
];
|
||||
}
|
||||
|
||||
return algorithms;
|
||||
}
|
||||
|
||||
// Session storage - shared reference passed from main
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
@@ -277,22 +321,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, password, and default key fallback
|
||||
@@ -465,22 +494,7 @@ async function startSSHSession(event, options) {
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
algorithms: buildAlgorithms(options.legacyAlgorithms),
|
||||
};
|
||||
|
||||
// Authentication for final target
|
||||
@@ -1470,8 +1484,8 @@ async function getServerStats(event, payload) {
|
||||
`cpuraw=$(awk '/^cpu / {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d %d", total, $5}' /proc/stat 2>/dev/null || echo "")`,
|
||||
// Get raw per-core CPU values from /proc/stat: "total:idle,total:idle,..."
|
||||
`percoreraw=$(awk '/^cpu[0-9]/ {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d:%d,", total, $5}' /proc/stat 2>/dev/null | sed 's/,$//' || echo "")`,
|
||||
// Get memory details from /proc/meminfo (total, free, buffers, cached in KB)
|
||||
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} END{printf "%d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024}' /proc/meminfo 2>/dev/null || echo "")`,
|
||||
// Get memory details from /proc/meminfo (total, free, buffers, cached, swapTotal, swapFree in KB)
|
||||
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END{printf "%d %d %d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024, st/1024, sf/1024}' /proc/meminfo 2>/dev/null || echo "")`,
|
||||
// Get top 10 processes by memory - with BusyBox fallback
|
||||
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
|
||||
// BusyBox fallback: ps -o pid,vsz,comm and sort manually (BusyBox ps doesn't have %mem, use vsz instead)
|
||||
@@ -1525,6 +1539,8 @@ async function getServerStats(event, payload) {
|
||||
let memBuffers = null;
|
||||
let memCached = null;
|
||||
let memUsed = null;
|
||||
let swapTotal = null;
|
||||
let swapUsed = null;
|
||||
let topProcesses = []; // Array of { pid, memPercent, command }
|
||||
let disks = []; // Array of { mountPoint, used, total, percent }
|
||||
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
|
||||
@@ -1571,6 +1587,16 @@ async function getServerStats(event, payload) {
|
||||
memUsed = memTotal - memFree - memBuffers - memCached;
|
||||
if (memUsed < 0) memUsed = 0;
|
||||
}
|
||||
// Parse swap info (fields 5 and 6)
|
||||
if (memParts.length >= 6) {
|
||||
const st = parseInt(memParts[4], 10);
|
||||
const sf = parseInt(memParts[5], 10);
|
||||
if (!isNaN(st)) swapTotal = st;
|
||||
if (!isNaN(sf)) {
|
||||
swapUsed = (swapTotal !== null) ? swapTotal - sf : null;
|
||||
if (swapUsed !== null && swapUsed < 0) swapUsed = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('PROCS:')) {
|
||||
const procsStr = part.substring(6).trim();
|
||||
@@ -1743,6 +1769,8 @@ async function getServerStats(event, payload) {
|
||||
memFree, // Free memory in MB
|
||||
memBuffers, // Buffers in MB
|
||||
memCached, // Cached in MB
|
||||
swapTotal, // Swap total in MB
|
||||
swapUsed, // Swap used in MB
|
||||
topProcesses, // Top 10 processes by memory
|
||||
diskPercent, // Disk usage percentage for root partition (backward compat)
|
||||
diskUsed, // Disk used in GB for root partition (backward compat)
|
||||
|
||||
@@ -8,9 +8,22 @@ const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession } = require("./sftpBridge.cjs");
|
||||
|
||||
// ── Transfer performance tuning ──────────────────────────────────────────────
|
||||
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
|
||||
// dramatically improving throughput over sequential stream piping.
|
||||
const TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB per SFTP request
|
||||
const TRANSFER_CONCURRENCY = 64; // 64 parallel SFTP requests
|
||||
// Progress IPC throttle: sending too many IPC messages bogs down the event loop
|
||||
const PROGRESS_THROTTLE_MS = 100; // Send IPC at most every 100ms
|
||||
const PROGRESS_THROTTLE_BYTES = 256 * 1024; // Or every 256KB of progress
|
||||
|
||||
// Speed calculation uses strict sliding-window average:
|
||||
// speed = bytes_delta_in_window / time_delta_in_window
|
||||
const SPEED_WINDOW_MS = 3000; // Keep 3s of samples
|
||||
const SPEED_MIN_ELAPSED_MS = 50; // Minimum elapsed time to avoid divide-by-near-zero spikes
|
||||
|
||||
// Shared references
|
||||
let sftpClients = null;
|
||||
let electronModule = null;
|
||||
|
||||
// Active transfers storage
|
||||
const activeTransfers = new Map();
|
||||
@@ -20,43 +33,106 @@ const activeTransfers = new Map();
|
||||
*/
|
||||
function init(deps) {
|
||||
sftpClients = deps.sftpClients;
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
async function openIsolatedSftpChannel(client) {
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") return null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
sshClient.sftp((err, sftp) => {
|
||||
if (err) reject(err);
|
||||
else resolve(sftp);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a local file to SFTP using streams (supports cancellation)
|
||||
* Upload a local file to SFTP using ssh2's fastPut (parallel SFTP requests).
|
||||
* Falls back to sequential stream piping if fastPut is unavailable.
|
||||
*/
|
||||
async function uploadWithStreams(localPath, remotePath, client, fileSize, transfer, sendProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(localPath);
|
||||
async function uploadFile(localPath, remotePath, client, fileSize, transfer, sendProgress) {
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) throw new Error("SFTP client not ready");
|
||||
|
||||
// Get the underlying sftp object from ssh2-sftp-client
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) {
|
||||
reject(new Error("SFTP client not ready"));
|
||||
return;
|
||||
// Prefer fastPut on an isolated SFTP channel so cancellation can abort just this transfer.
|
||||
if (!client.__netcattySudoMode) {
|
||||
let fastSftp = null;
|
||||
try {
|
||||
fastSftp = await openIsolatedSftpChannel(client);
|
||||
} catch (err) {
|
||||
console.warn("[transferBridge] Failed to open isolated SFTP channel for fastPut, falling back to streams:", err.message || String(err));
|
||||
}
|
||||
|
||||
const writeStream = sftp.createWriteStream(remotePath);
|
||||
if (fastSftp && typeof fastSftp.fastPut === "function") {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onFastSftpError = null;
|
||||
const finish = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (transfer.abort === abortFastTransfer) {
|
||||
transfer.abort = null;
|
||||
}
|
||||
if (onFastSftpError) {
|
||||
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
|
||||
onFastSftpError = null;
|
||||
}
|
||||
try { fastSftp.end(); } catch { }
|
||||
|
||||
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
|
||||
else if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
const abortFastTransfer = () => {
|
||||
if (settled) return;
|
||||
transfer.cancelled = true;
|
||||
try { fastSftp.end(); } catch { }
|
||||
finish(new Error("Transfer cancelled"));
|
||||
};
|
||||
transfer.abort = abortFastTransfer;
|
||||
onFastSftpError = (err) => finish(err);
|
||||
fastSftp.once("error", onFastSftpError);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
finish(new Error("Transfer cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
fastSftp.fastPut(localPath, remotePath, {
|
||||
chunkSize: TRANSFER_CHUNK_SIZE,
|
||||
concurrency: TRANSFER_CONCURRENCY,
|
||||
step: (transferred, _chunk, total) => {
|
||||
if (transfer.cancelled) return;
|
||||
sendProgress(transferred, total || fileSize);
|
||||
},
|
||||
}, finish);
|
||||
});
|
||||
}
|
||||
|
||||
if (fastSftp && typeof fastSftp.end === "function") {
|
||||
try { fastSftp.end(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: sequential stream piping
|
||||
return new Promise((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(localPath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
const writeStream = sftp.createWriteStream(remotePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
let transferred = 0;
|
||||
let finished = false;
|
||||
|
||||
// Store streams for cancellation
|
||||
transfer.readStream = readStream;
|
||||
transfer.writeStream = writeStream;
|
||||
|
||||
const cleanup = (err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
|
||||
// Remove listeners to prevent memory leaks
|
||||
readStream.removeAllListeners();
|
||||
writeStream.removeAllListeners();
|
||||
|
||||
if (err) {
|
||||
// Destroy streams on error
|
||||
try { readStream.destroy(); } catch {}
|
||||
try { writeStream.destroy(); } catch {}
|
||||
try { readStream.destroy(); } catch { }
|
||||
try { writeStream.destroy(); } catch { }
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
@@ -64,61 +140,106 @@ async function uploadWithStreams(localPath, remotePath, client, fileSize, transf
|
||||
};
|
||||
|
||||
readStream.on('data', (chunk) => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
return;
|
||||
}
|
||||
if (transfer.cancelled) { cleanup(new Error('Transfer cancelled')); return; }
|
||||
transferred += chunk.length;
|
||||
sendProgress(transferred, fileSize);
|
||||
});
|
||||
|
||||
readStream.on('error', (err) => cleanup(err));
|
||||
writeStream.on('error', (err) => cleanup(err));
|
||||
readStream.on('error', cleanup);
|
||||
writeStream.on('error', cleanup);
|
||||
writeStream.on('close', () => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
} else {
|
||||
cleanup(null);
|
||||
}
|
||||
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
|
||||
else cleanup(null);
|
||||
});
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download from SFTP to local file using streams (supports cancellation)
|
||||
* Download from SFTP to local file using ssh2's fastGet (parallel SFTP requests).
|
||||
* Falls back to sequential stream piping if fastGet is unavailable.
|
||||
*/
|
||||
async function downloadWithStreams(remotePath, localPath, client, fileSize, transfer, sendProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Get the underlying sftp object from ssh2-sftp-client
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) {
|
||||
reject(new Error("SFTP client not ready"));
|
||||
return;
|
||||
async function downloadFile(remotePath, localPath, client, fileSize, transfer, sendProgress) {
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) throw new Error("SFTP client not ready");
|
||||
|
||||
// Prefer fastGet on an isolated SFTP channel so cancellation can abort just this transfer.
|
||||
if (!client.__netcattySudoMode) {
|
||||
let fastSftp = null;
|
||||
try {
|
||||
fastSftp = await openIsolatedSftpChannel(client);
|
||||
} catch (err) {
|
||||
console.warn("[transferBridge] Failed to open isolated SFTP channel for fastGet, falling back to streams:", err.message || String(err));
|
||||
}
|
||||
|
||||
const readStream = sftp.createReadStream(remotePath);
|
||||
const writeStream = fs.createWriteStream(localPath);
|
||||
if (fastSftp && typeof fastSftp.fastGet === "function") {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let onFastSftpError = null;
|
||||
const finish = (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (transfer.abort === abortFastTransfer) {
|
||||
transfer.abort = null;
|
||||
}
|
||||
if (onFastSftpError) {
|
||||
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
|
||||
onFastSftpError = null;
|
||||
}
|
||||
try { fastSftp.end(); } catch { }
|
||||
|
||||
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
|
||||
else if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
const abortFastTransfer = () => {
|
||||
if (settled) return;
|
||||
transfer.cancelled = true;
|
||||
try { fastSftp.end(); } catch { }
|
||||
finish(new Error("Transfer cancelled"));
|
||||
};
|
||||
transfer.abort = abortFastTransfer;
|
||||
onFastSftpError = (err) => finish(err);
|
||||
fastSftp.once("error", onFastSftpError);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
finish(new Error("Transfer cancelled"));
|
||||
return;
|
||||
}
|
||||
|
||||
fastSftp.fastGet(remotePath, localPath, {
|
||||
chunkSize: TRANSFER_CHUNK_SIZE,
|
||||
concurrency: TRANSFER_CONCURRENCY,
|
||||
step: (transferred, _chunk, total) => {
|
||||
if (transfer.cancelled) return;
|
||||
sendProgress(transferred, total || fileSize);
|
||||
},
|
||||
}, finish);
|
||||
});
|
||||
}
|
||||
|
||||
if (fastSftp && typeof fastSftp.end === "function") {
|
||||
try { fastSftp.end(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: sequential stream piping
|
||||
return new Promise((resolve, reject) => {
|
||||
const readStream = sftp.createReadStream(remotePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
const writeStream = fs.createWriteStream(localPath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
let transferred = 0;
|
||||
let finished = false;
|
||||
|
||||
// Store streams for cancellation
|
||||
transfer.readStream = readStream;
|
||||
transfer.writeStream = writeStream;
|
||||
|
||||
const cleanup = (err) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
|
||||
// Remove listeners to prevent memory leaks
|
||||
readStream.removeAllListeners();
|
||||
writeStream.removeAllListeners();
|
||||
|
||||
if (err) {
|
||||
// Destroy streams on error
|
||||
try { readStream.destroy(); } catch {}
|
||||
try { writeStream.destroy(); } catch {}
|
||||
try { readStream.destroy(); } catch { }
|
||||
try { writeStream.destroy(); } catch { }
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
@@ -126,40 +247,25 @@ async function downloadWithStreams(remotePath, localPath, client, fileSize, tran
|
||||
};
|
||||
|
||||
readStream.on('data', (chunk) => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
return;
|
||||
}
|
||||
if (transfer.cancelled) { cleanup(new Error('Transfer cancelled')); return; }
|
||||
transferred += chunk.length;
|
||||
sendProgress(transferred, fileSize);
|
||||
});
|
||||
|
||||
readStream.on('error', (err) => cleanup(err));
|
||||
writeStream.on('error', (err) => cleanup(err));
|
||||
// Handle normal completion
|
||||
readStream.on('error', cleanup);
|
||||
writeStream.on('error', cleanup);
|
||||
writeStream.on('finish', () => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
} else {
|
||||
cleanup(null);
|
||||
}
|
||||
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
|
||||
else cleanup(null);
|
||||
});
|
||||
// Handle stream destruction (destroy() emits 'close' but not 'finish')
|
||||
writeStream.on('close', () => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
}
|
||||
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
|
||||
});
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a file transfer
|
||||
* @param {object} event - IPC event
|
||||
* @param {object} payload - Transfer configuration
|
||||
* @param {function} [onProgress] - Optional progress callback (transferred, total, speed)
|
||||
*/
|
||||
async function startTransfer(event, payload, onProgress) {
|
||||
const {
|
||||
@@ -176,47 +282,121 @@ async function startTransfer(event, payload, onProgress) {
|
||||
} = payload;
|
||||
const sender = event.sender;
|
||||
|
||||
// Register transfer for cancellation
|
||||
const transfer = { cancelled: false, readStream: null, writeStream: null };
|
||||
const transfer = { cancelled: false, readStream: null, writeStream: null, abort: null };
|
||||
activeTransfers.set(transferId, transfer);
|
||||
const transferCreatedAt = Date.now();
|
||||
|
||||
let lastTime = Date.now();
|
||||
let lastTransferred = 0;
|
||||
let speed = 0;
|
||||
// ── Progress/speed tracking ──────────────────────────────────────────────
|
||||
// Keep progress monotonic and compute speed from a strict sliding window.
|
||||
const speedSamples = [{ time: transferCreatedAt, bytes: 0 }]; // [{ time, bytes }]
|
||||
let lastObservedTransferred = 0;
|
||||
let lastObservedTotal = Math.max(0, totalBytes || 0);
|
||||
let lastProgressSentTime = 0;
|
||||
let lastProgressSentBytes = -1;
|
||||
|
||||
const computeWindowSpeed = (now, transferred) => {
|
||||
const targetTime = now - SPEED_WINDOW_MS;
|
||||
|
||||
// Keep exactly one sample before targetTime for boundary interpolation.
|
||||
while (speedSamples.length >= 2 && speedSamples[1].time <= targetTime) {
|
||||
speedSamples.shift();
|
||||
}
|
||||
|
||||
const first = speedSamples[0];
|
||||
if (!first) return 0;
|
||||
|
||||
let boundaryTime = first.time;
|
||||
let boundaryBytes = first.bytes;
|
||||
|
||||
if (speedSamples.length >= 2 && targetTime > first.time) {
|
||||
const next = speedSamples[1];
|
||||
const range = next.time - first.time;
|
||||
if (range > 0) {
|
||||
const ratio = (targetTime - first.time) / range;
|
||||
boundaryBytes = first.bytes + (next.bytes - first.bytes) * ratio;
|
||||
boundaryTime = targetTime;
|
||||
}
|
||||
}
|
||||
|
||||
const elapsedMs = now - boundaryTime;
|
||||
if (elapsedMs < SPEED_MIN_ELAPSED_MS) return 0;
|
||||
|
||||
const deltaBytes = transferred - boundaryBytes;
|
||||
if (deltaBytes <= 0) return 0;
|
||||
|
||||
const speed = (deltaBytes * 1000) / elapsedMs;
|
||||
return Number.isFinite(speed) && speed > 0 ? Math.round(speed) : 0;
|
||||
};
|
||||
|
||||
const emitProgress = (now, transferred, total, speed, force = false) => {
|
||||
const isComplete = total > 0 && transferred >= total;
|
||||
const transferredChanged = transferred !== lastProgressSentBytes;
|
||||
const timeSinceLast = now - lastProgressSentTime;
|
||||
const bytesSinceLast = transferred - lastProgressSentBytes;
|
||||
|
||||
if (
|
||||
force ||
|
||||
isComplete ||
|
||||
(transferredChanged &&
|
||||
(timeSinceLast >= PROGRESS_THROTTLE_MS || bytesSinceLast >= PROGRESS_THROTTLE_BYTES))
|
||||
) {
|
||||
lastProgressSentTime = now;
|
||||
lastProgressSentBytes = transferred;
|
||||
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTransfer = () => {
|
||||
activeTransfers.delete(transferId);
|
||||
};
|
||||
|
||||
const sendProgress = (transferred, total) => {
|
||||
if (transfer.cancelled) return;
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastTime;
|
||||
if (elapsed >= 100) {
|
||||
speed = Math.round((transferred - lastTransferred) / (elapsed / 1000));
|
||||
lastTime = now;
|
||||
lastTransferred = transferred;
|
||||
|
||||
let normalizedTotal = Number.isFinite(total) && total > 0 ? total : 0;
|
||||
if (normalizedTotal === 0) {
|
||||
normalizedTotal = lastObservedTotal || 0;
|
||||
}
|
||||
normalizedTotal = Math.max(normalizedTotal, lastObservedTotal, 0);
|
||||
|
||||
let normalizedTransferred = Number.isFinite(transferred) && transferred > 0 ? transferred : 0;
|
||||
if (normalizedTotal > 0) {
|
||||
normalizedTransferred = Math.min(normalizedTransferred, normalizedTotal);
|
||||
}
|
||||
normalizedTransferred = Math.max(normalizedTransferred, lastObservedTransferred);
|
||||
|
||||
lastObservedTransferred = normalizedTransferred;
|
||||
lastObservedTotal = normalizedTotal;
|
||||
|
||||
const lastSample = speedSamples[speedSamples.length - 1];
|
||||
if (!lastSample || lastSample.bytes !== normalizedTransferred || now - lastSample.time >= PROGRESS_THROTTLE_MS) {
|
||||
speedSamples.push({ time: now, bytes: normalizedTransferred });
|
||||
}
|
||||
|
||||
// Call optional progress callback if provided
|
||||
const speed = computeWindowSpeed(now, normalizedTransferred);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(transferred, total, speed);
|
||||
onProgress(normalizedTransferred, normalizedTotal, speed);
|
||||
}
|
||||
|
||||
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
|
||||
emitProgress(now, normalizedTransferred, normalizedTotal, speed);
|
||||
};
|
||||
|
||||
const sendComplete = () => {
|
||||
activeTransfers.delete(transferId);
|
||||
sender.send("netcatty:transfer:complete", { transferId });
|
||||
cleanupTransfer();
|
||||
};
|
||||
|
||||
const sendError = (error) => {
|
||||
activeTransfers.delete(transferId);
|
||||
cleanupTransfer();
|
||||
sender.send("netcatty:transfer:error", { transferId, error: error.message || String(error) });
|
||||
};
|
||||
|
||||
try {
|
||||
let fileSize = totalBytes || 0;
|
||||
|
||||
// Get file size if not provided
|
||||
if (!fileSize) {
|
||||
if (sourceType === 'local') {
|
||||
const stat = await fs.promises.stat(sourcePath);
|
||||
@@ -230,23 +410,19 @@ async function startTransfer(event, payload, onProgress) {
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial progress
|
||||
sendProgress(0, fileSize);
|
||||
|
||||
// Handle different transfer scenarios
|
||||
if (sourceType === 'local' && targetType === 'sftp') {
|
||||
// Upload: Local -> SFTP using streams (supports cancellation)
|
||||
const client = sftpClients.get(targetSftpId);
|
||||
if (!client) throw new Error("Target SFTP session not found");
|
||||
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
|
||||
|
||||
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
|
||||
await uploadWithStreams(sourcePath, encodedTargetPath, client, fileSize, transfer, sendProgress);
|
||||
await uploadFile(sourcePath, encodedTargetPath, client, fileSize, transfer, sendProgress);
|
||||
|
||||
} else if (sourceType === 'sftp' && targetType === 'local') {
|
||||
// Download: SFTP -> Local using streams (supports cancellation)
|
||||
const client = sftpClients.get(sourceSftpId);
|
||||
if (!client) throw new Error("Source SFTP session not found");
|
||||
|
||||
@@ -254,16 +430,15 @@ async function startTransfer(event, payload, onProgress) {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
await downloadWithStreams(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
|
||||
await downloadFile(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
|
||||
|
||||
} else if (sourceType === 'local' && targetType === 'local') {
|
||||
// Local copy: use streams
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(sourcePath);
|
||||
const writeStream = fs.createWriteStream(targetPath);
|
||||
const readStream = fs.createReadStream(sourcePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
const writeStream = fs.createWriteStream(targetPath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
let transferred = 0;
|
||||
let finished = false;
|
||||
|
||||
@@ -276,8 +451,8 @@ async function startTransfer(event, payload, onProgress) {
|
||||
readStream.removeAllListeners();
|
||||
writeStream.removeAllListeners();
|
||||
if (err) {
|
||||
try { readStream.destroy(); } catch {}
|
||||
try { writeStream.destroy(); } catch {}
|
||||
try { readStream.destroy(); } catch { }
|
||||
try { writeStream.destroy(); } catch { }
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
@@ -285,36 +460,23 @@ async function startTransfer(event, payload, onProgress) {
|
||||
};
|
||||
|
||||
readStream.on('data', (chunk) => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
return;
|
||||
}
|
||||
if (transfer.cancelled) { cleanup(new Error('Transfer cancelled')); return; }
|
||||
transferred += chunk.length;
|
||||
sendProgress(transferred, fileSize);
|
||||
});
|
||||
|
||||
readStream.on('error', cleanup);
|
||||
writeStream.on('error', cleanup);
|
||||
// Handle normal completion
|
||||
writeStream.on('finish', () => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
} else {
|
||||
cleanup(null);
|
||||
}
|
||||
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
|
||||
else cleanup(null);
|
||||
});
|
||||
// Handle stream destruction (destroy() emits 'close' but not 'finish')
|
||||
writeStream.on('close', () => {
|
||||
if (transfer.cancelled) {
|
||||
cleanup(new Error('Transfer cancelled'));
|
||||
}
|
||||
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
|
||||
});
|
||||
|
||||
readStream.pipe(writeStream);
|
||||
});
|
||||
|
||||
} else if (sourceType === 'sftp' && targetType === 'sftp') {
|
||||
// SFTP to SFTP: download to temp then upload using streams
|
||||
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
|
||||
|
||||
const sourceClient = sftpClients.get(sourceSftpId);
|
||||
@@ -322,43 +484,39 @@ async function startTransfer(event, payload, onProgress) {
|
||||
if (!sourceClient) throw new Error("Source SFTP session not found");
|
||||
if (!targetClient) throw new Error("Target SFTP session not found");
|
||||
|
||||
// Download phase (0-50%) - wrap progress to show 0-50%
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const downloadProgress = (transferred, total) => {
|
||||
const downloadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await downloadWithStreams(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
|
||||
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
|
||||
|
||||
if (transfer.cancelled) {
|
||||
try { await fs.promises.unlink(tempPath); } catch {}
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
throw new Error('Transfer cancelled');
|
||||
}
|
||||
|
||||
// Upload phase (50-100%) - wrap progress to show 50-100%
|
||||
const dir = path.dirname(targetPath).replace(/\\/g, '/');
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
|
||||
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
|
||||
|
||||
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
|
||||
const uploadProgress = (transferred, total) => {
|
||||
const uploadProgress = (transferred) => {
|
||||
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
|
||||
};
|
||||
await uploadWithStreams(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
|
||||
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
|
||||
|
||||
// Cleanup temp file
|
||||
try { await fs.promises.unlink(tempPath); } catch {}
|
||||
try { await fs.promises.unlink(tempPath); } catch { }
|
||||
|
||||
} else {
|
||||
throw new Error("Invalid transfer configuration");
|
||||
}
|
||||
|
||||
// Send final 100% progress
|
||||
|
||||
sendProgress(fileSize, fileSize);
|
||||
sendComplete();
|
||||
|
||||
|
||||
return { transferId, totalBytes: fileSize };
|
||||
} catch (err) {
|
||||
if (err.message === 'Transfer cancelled') {
|
||||
activeTransfers.delete(transferId);
|
||||
cleanupTransfer();
|
||||
sender.send("netcatty:transfer:cancelled", { transferId });
|
||||
} else {
|
||||
sendError(err);
|
||||
@@ -372,24 +530,21 @@ async function startTransfer(event, payload, onProgress) {
|
||||
*/
|
||||
async function cancelTransfer(event, payload) {
|
||||
const { transferId } = payload;
|
||||
console.log('[transferBridge] cancelTransfer called for:', transferId);
|
||||
const transfer = activeTransfers.get(transferId);
|
||||
console.log('[transferBridge] Found transfer:', !!transfer, 'activeTransfers keys:', Array.from(activeTransfers.keys()));
|
||||
if (transfer) {
|
||||
transfer.cancelled = true;
|
||||
console.log('[transferBridge] Set cancelled=true for transfer:', transferId);
|
||||
|
||||
// Destroy streams to immediately stop the transfer
|
||||
if (typeof transfer.abort === "function") {
|
||||
try { transfer.abort(); } catch { }
|
||||
}
|
||||
|
||||
// Destroy streams for stream-based fallback transfers
|
||||
if (transfer.readStream) {
|
||||
console.log('[transferBridge] Destroying read stream');
|
||||
try { transfer.readStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying readStream:', e); }
|
||||
try { transfer.readStream.destroy(); } catch { }
|
||||
}
|
||||
if (transfer.writeStream) {
|
||||
console.log('[transferBridge] Destroying write stream');
|
||||
try { transfer.writeStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying writeStream:', e); }
|
||||
try { transfer.writeStream.destroy(); } catch { }
|
||||
}
|
||||
|
||||
console.log('[transferBridge] Transfer marked for cancellation');
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
const OAUTH_DEFAULT_HEIGHT = 700;
|
||||
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
|
||||
const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cjs
|
||||
const WINDOW_STATE_FILE = "window-state.json";
|
||||
const DEFAULT_WINDOW_WIDTH = 1400;
|
||||
const DEFAULT_WINDOW_HEIGHT = 900;
|
||||
@@ -368,6 +369,86 @@ function parseWindowOpenFeatures(features) {
|
||||
};
|
||||
}
|
||||
|
||||
function createExternalOnlyWindowOpenHandler(shell) {
|
||||
return (details) => {
|
||||
const targetUrl = details?.url;
|
||||
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
|
||||
try {
|
||||
void shell?.openExternal?.(targetUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return { action: "deny" };
|
||||
};
|
||||
}
|
||||
|
||||
function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
|
||||
const allowedPopupHosts = new Set([
|
||||
// OAuth (PKCE loopback)
|
||||
"accounts.google.com",
|
||||
"login.microsoftonline.com",
|
||||
"login.live.com",
|
||||
]);
|
||||
|
||||
const isAllowedInAppPopupUrl = (rawUrl) => {
|
||||
try {
|
||||
const u = new URL(String(rawUrl));
|
||||
if (u.protocol === "https:") {
|
||||
return allowedPopupHosts.has(u.hostname);
|
||||
}
|
||||
if (u.protocol === "http:") {
|
||||
// Allow ONLY the loopback OAuth callback page.
|
||||
const isLoopback =
|
||||
u.hostname === "127.0.0.1" || u.hostname === "localhost";
|
||||
return isLoopback && u.port === String(OAUTH_LOOPBACK_PORT) && u.pathname === "/oauth/callback";
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (details) => {
|
||||
const targetUrl = details?.url;
|
||||
if (!targetUrl || typeof targetUrl !== "string" || !/^https?:/i.test(targetUrl)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
// Default: open in system browser to reduce remote-content attack surface.
|
||||
if (!isAllowedInAppPopupUrl(targetUrl)) {
|
||||
try {
|
||||
void shell?.openExternal?.(targetUrl);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
const size = parseWindowOpenFeatures(details?.features);
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
width: size.width || OAUTH_DEFAULT_WIDTH,
|
||||
height: size.height || OAUTH_DEFAULT_HEIGHT,
|
||||
minWidth: 420,
|
||||
minHeight: 560,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
autoHideMenuBar: true,
|
||||
menuBarVisible: false,
|
||||
title: "Netcatty Authorization",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
||||
sandbox: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function attachOAuthLoadingOverlay(win) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
|
||||
@@ -543,7 +624,7 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
|
||||
* Create the main application window
|
||||
*/
|
||||
async function createWindow(electronModule, options) {
|
||||
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
|
||||
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
|
||||
|
||||
// Store app reference for window state persistence
|
||||
@@ -611,6 +692,35 @@ async function createWindow(electronModule, options) {
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// Prevent top-level navigation away from the app origin. If a remote origin ever
|
||||
// loads in a privileged window (with preload), it can become an RCE vector.
|
||||
const allowedOrigins = new Set(["app://netcatty"]);
|
||||
if (isDev && devServerUrl) {
|
||||
try {
|
||||
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
|
||||
} catch {
|
||||
// ignore invalid dev server URL
|
||||
}
|
||||
}
|
||||
const isAllowedTopLevelUrl = (targetUrl) => {
|
||||
try {
|
||||
return allowedOrigins.has(new URL(String(targetUrl)).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const blockUntrustedNavigation = (event, targetUrl) => {
|
||||
if (isAllowedTopLevelUrl(targetUrl)) return;
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
debugLog("Blocked navigation to untrusted origin", { targetUrl });
|
||||
};
|
||||
win.webContents.on("will-navigate", blockUntrustedNavigation);
|
||||
win.webContents.on("will-redirect", blockUntrustedNavigation);
|
||||
|
||||
// Restore maximized state if it was saved
|
||||
if (savedState?.isMaximized && !savedState?.isFullScreen) {
|
||||
win.once("ready-to-show", () => {
|
||||
@@ -732,36 +842,18 @@ async function createWindow(electronModule, options) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Never allow chained popups from remote content windows.
|
||||
try {
|
||||
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
attachOAuthLoadingOverlay(childWindow);
|
||||
});
|
||||
|
||||
win.webContents.setWindowOpenHandler((details) => {
|
||||
const url = details?.url;
|
||||
if (!url || !/^https?:/i.test(url)) {
|
||||
return { action: "deny" };
|
||||
}
|
||||
|
||||
const size = parseWindowOpenFeatures(details?.features);
|
||||
return {
|
||||
action: "allow",
|
||||
overrideBrowserWindowOptions: {
|
||||
width: size.width || OAUTH_DEFAULT_WIDTH,
|
||||
height: size.height || OAUTH_DEFAULT_HEIGHT,
|
||||
minWidth: 420,
|
||||
minHeight: 560,
|
||||
backgroundColor,
|
||||
icon: appIcon,
|
||||
autoHideMenuBar: true,
|
||||
menuBarVisible: false,
|
||||
title: "Netcatty Authorization",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
win.webContents.setWindowOpenHandler(
|
||||
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
|
||||
);
|
||||
|
||||
// Register window control handlers
|
||||
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
|
||||
@@ -788,7 +880,7 @@ async function createWindow(electronModule, options) {
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
async function openSettingsWindow(electronModule, options) {
|
||||
const { BrowserWindow } = electronModule;
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, just focus it
|
||||
@@ -830,6 +922,52 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
settingsWindow = win;
|
||||
|
||||
// Open external links in system browser by default, and allow only known OAuth hosts in-app.
|
||||
try {
|
||||
win.webContents?.setWindowOpenHandler?.(
|
||||
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Never allow chained popups from remote content windows spawned from settings.
|
||||
win.webContents?.on?.("did-create-window", (childWindow) => {
|
||||
try {
|
||||
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Same navigation hardening as the main window (settings has preload access too).
|
||||
const allowedOrigins = new Set(["app://netcatty"]);
|
||||
if (isDev && devServerUrl) {
|
||||
try {
|
||||
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
|
||||
} catch {
|
||||
// ignore invalid dev server URL
|
||||
}
|
||||
}
|
||||
const isAllowedTopLevelUrl = (targetUrl) => {
|
||||
try {
|
||||
return allowedOrigins.has(new URL(String(targetUrl)).origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const blockUntrustedNavigation = (event, targetUrl) => {
|
||||
if (isAllowedTopLevelUrl(targetUrl)) return;
|
||||
try {
|
||||
event.preventDefault();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
debugLog("Blocked navigation to untrusted origin (settings)", { targetUrl });
|
||||
};
|
||||
win.webContents.on("will-navigate", blockUntrustedNavigation);
|
||||
win.webContents.on("will-redirect", blockUntrustedNavigation);
|
||||
|
||||
if (isMac) {
|
||||
try {
|
||||
win.setWindowButtonVisibility(true);
|
||||
|
||||
@@ -289,7 +289,8 @@ const ensureKeyDir = async () => {
|
||||
const writeKeyToDisk = async (keyId, privateKey) => {
|
||||
if (!privateKey) return null;
|
||||
await ensureKeyDir();
|
||||
const filename = `${keyId || "temp"}.pem`;
|
||||
const safeId = String(keyId || "temp").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 120);
|
||||
const filename = `${safeId}.pem`;
|
||||
const target = path.join(keyRoot, filename);
|
||||
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
|
||||
try {
|
||||
@@ -644,9 +645,9 @@ const registerBridges = (win) => {
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
// Only allow deleting files in Netcatty temp directory for security
|
||||
const netcattyTempDir = tempDirBridge.getTempDir();
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!resolvedPath.startsWith(netcattyTempDir)) {
|
||||
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
|
||||
const resolvedPath = path.resolve(String(filePath || ""));
|
||||
if (!isPathInside(netcattyTempDir, resolvedPath)) {
|
||||
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
|
||||
return { success: false };
|
||||
}
|
||||
@@ -755,8 +756,24 @@ if (!gotLock) {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Re-create window on macOS dock click
|
||||
// Re-create or focus window on macOS dock click
|
||||
app.on("activate", () => {
|
||||
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
|
||||
// should bring it back. Fallback to creating a new window if none exists.
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
if (mainWin.isMinimized?.()) mainWin.restore();
|
||||
mainWin.show?.();
|
||||
mainWin.focus?.();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (focusMainWindow()) return;
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
|
||||
@@ -854,4 +854,50 @@ const api = {
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
const existing = (typeof window !== "undefined" && window.netcatty) ? window.netcatty : {};
|
||||
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
|
||||
|
||||
function getAllowedRendererOrigins() {
|
||||
const origins = new Set(["app://netcatty"]);
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (typeof devServerUrl === "string" && devServerUrl.length > 0) {
|
||||
try {
|
||||
const u = new URL(devServerUrl);
|
||||
origins.add(u.origin);
|
||||
// Vite often binds to 0.0.0.0, but Chromium navigates via localhost.
|
||||
if (
|
||||
u.hostname === "0.0.0.0" ||
|
||||
u.hostname === "127.0.0.1" ||
|
||||
u.hostname === "::1" ||
|
||||
u.hostname === "[::1]" ||
|
||||
u.hostname === "::" ||
|
||||
u.hostname === "[::]"
|
||||
) {
|
||||
u.hostname = "localhost";
|
||||
origins.add(u.origin);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid dev URL
|
||||
}
|
||||
}
|
||||
return origins;
|
||||
}
|
||||
|
||||
function isTrustedRendererLocation(allowedOrigins) {
|
||||
try {
|
||||
const origin = window?.location?.origin;
|
||||
return typeof origin === "string" && allowedOrigins.has(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const allowedOrigins = getAllowedRendererOrigins();
|
||||
if (isTrustedRendererLocation(allowedOrigins)) {
|
||||
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
|
||||
} else {
|
||||
// If a window navigates to an untrusted origin, do NOT expose the bridge.
|
||||
try {
|
||||
console.warn("[Preload] Refusing to expose netcatty bridge to untrusted origin:", window?.location?.origin);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
4
global.d.ts
vendored
4
global.d.ts
vendored
@@ -72,6 +72,8 @@ declare global {
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
// SSH-level keepalive interval in seconds (0 = disabled)
|
||||
keepaliveInterval?: number;
|
||||
// Enable legacy SSH algorithms for older network equipment
|
||||
legacyAlgorithms?: boolean;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
}
|
||||
@@ -200,6 +202,8 @@ declare global {
|
||||
memFree: number | null; // Free memory in MB
|
||||
memBuffers: number | null; // Buffers in MB
|
||||
memCached: number | null; // Cached in MB
|
||||
swapTotal: number | null; // Total swap in MB
|
||||
swapUsed: number | null; // Used swap in MB
|
||||
topProcesses: Array<{ // Top 10 processes by memory
|
||||
pid: string;
|
||||
memPercent: number;
|
||||
|
||||
@@ -64,3 +64,6 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
|
||||
|
||||
// Custom Terminal Themes
|
||||
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
|
||||
|
||||
173
infrastructure/parsers/itermcolorsParser.ts
Normal file
173
infrastructure/parsers/itermcolorsParser.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
|
||||
/**
|
||||
* Parse an .itermcolors XML plist file into a TerminalTheme.
|
||||
*
|
||||
* .itermcolors is Apple Plist XML with color keys like:
|
||||
* "Ansi 0 Color", "Background Color", "Foreground Color", etc.
|
||||
* Each color is a <dict> with "Red Component", "Green Component", "Blue Component" as <real> floats (0.0–1.0).
|
||||
*/
|
||||
|
||||
/** Map .itermcolors key names to TerminalTheme color fields */
|
||||
const COLOR_KEY_MAP: Record<string, keyof TerminalTheme['colors']> = {
|
||||
'Ansi 0 Color': 'black',
|
||||
'Ansi 1 Color': 'red',
|
||||
'Ansi 2 Color': 'green',
|
||||
'Ansi 3 Color': 'yellow',
|
||||
'Ansi 4 Color': 'blue',
|
||||
'Ansi 5 Color': 'magenta',
|
||||
'Ansi 6 Color': 'cyan',
|
||||
'Ansi 7 Color': 'white',
|
||||
'Ansi 8 Color': 'brightBlack',
|
||||
'Ansi 9 Color': 'brightRed',
|
||||
'Ansi 10 Color': 'brightGreen',
|
||||
'Ansi 11 Color': 'brightYellow',
|
||||
'Ansi 12 Color': 'brightBlue',
|
||||
'Ansi 13 Color': 'brightMagenta',
|
||||
'Ansi 14 Color': 'brightCyan',
|
||||
'Ansi 15 Color': 'brightWhite',
|
||||
'Background Color': 'background',
|
||||
'Foreground Color': 'foreground',
|
||||
'Cursor Color': 'cursor',
|
||||
'Selection Color': 'selection',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a float (0.0–1.0) to a two-digit hex string.
|
||||
*/
|
||||
function floatToHex(value: number): string {
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
const byte = Math.round(clamped * 255);
|
||||
return byte.toString(16).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if a background color is dark or light based on relative luminance.
|
||||
*/
|
||||
function isDarkBackground(hex: string): boolean {
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
// Relative luminance formula (ITU-R BT.709)
|
||||
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
return luminance < 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single color <dict> element from the plist XML.
|
||||
* Returns a hex color string like '#rrggbb'.
|
||||
*/
|
||||
function parseColorDict(dictElement: Element): string | null {
|
||||
const children = dictElement.children;
|
||||
let r = 0, g = 0, b = 0;
|
||||
let found = 0;
|
||||
|
||||
for (let i = 0; i < children.length - 1; i++) {
|
||||
const child = children[i];
|
||||
if (child.tagName !== 'key') continue;
|
||||
|
||||
const key = child.textContent?.trim();
|
||||
const valueEl = children[i + 1];
|
||||
if (!key || !valueEl) continue;
|
||||
|
||||
// Accept <real> (float 0.0–1.0) and <integer> (0–255) plist types
|
||||
const tag = valueEl.tagName;
|
||||
if (tag !== 'real' && tag !== 'integer') continue;
|
||||
|
||||
const raw = parseFloat(valueEl.textContent || '0');
|
||||
if (isNaN(raw)) continue; // reject non-numeric content
|
||||
|
||||
// Normalize: <integer> values are 0-255, <real> values are 0.0-1.0
|
||||
const value = tag === 'integer' ? raw / 255 : raw;
|
||||
|
||||
if (key === 'Red Component') { r = value; found++; }
|
||||
else if (key === 'Green Component') { g = value; found++; }
|
||||
else if (key === 'Blue Component') { b = value; found++; }
|
||||
}
|
||||
|
||||
if (found < 3) return null;
|
||||
return `#${floatToHex(r)}${floatToHex(g)}${floatToHex(b)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an .itermcolors XML string into a TerminalTheme.
|
||||
*
|
||||
* @param xml - The raw XML string from the .itermcolors file
|
||||
* @param name - The theme name (usually derived from the filename)
|
||||
* @returns - A TerminalTheme, or null if parsing fails
|
||||
*/
|
||||
export function parseItermcolors(xml: string, name: string): TerminalTheme | null {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xml, 'text/xml');
|
||||
|
||||
// Check for parse errors
|
||||
const parseError = doc.querySelector('parsererror');
|
||||
if (parseError) return null;
|
||||
|
||||
// Get the root <dict> inside <plist>
|
||||
const rootDict = doc.querySelector('plist > dict');
|
||||
if (!rootDict) return null;
|
||||
|
||||
// Parse key-value pairs from the root dict
|
||||
const colors: Partial<TerminalTheme['colors']> = {};
|
||||
const children = rootDict.children;
|
||||
|
||||
for (let i = 0; i < children.length - 1; i++) {
|
||||
const child = children[i];
|
||||
if (child.tagName !== 'key') continue;
|
||||
|
||||
const keyName = child.textContent?.trim() || '';
|
||||
const colorField = COLOR_KEY_MAP[keyName];
|
||||
if (!colorField) continue;
|
||||
|
||||
// The next sibling should be a <dict> with color components
|
||||
const nextSibling = children[i + 1];
|
||||
if (!nextSibling || nextSibling.tagName !== 'dict') continue;
|
||||
|
||||
const hex = parseColorDict(nextSibling);
|
||||
if (hex) {
|
||||
colors[colorField] = hex;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate we have at least the essential colors
|
||||
if (!colors.background || !colors.foreground) return null;
|
||||
|
||||
// Fill any missing ANSI colors with sensible defaults
|
||||
const defaults: TerminalTheme['colors'] = {
|
||||
background: colors.background,
|
||||
foreground: colors.foreground,
|
||||
cursor: colors.cursor || colors.foreground,
|
||||
selection: colors.selection || (isDarkBackground(colors.background) ? '#264f78' : '#add6ff'),
|
||||
black: colors.black || '#000000',
|
||||
red: colors.red || '#cc0000',
|
||||
green: colors.green || '#00cc00',
|
||||
yellow: colors.yellow || '#cccc00',
|
||||
blue: colors.blue || '#0000cc',
|
||||
magenta: colors.magenta || '#cc00cc',
|
||||
cyan: colors.cyan || '#00cccc',
|
||||
white: colors.white || '#cccccc',
|
||||
brightBlack: colors.brightBlack || '#666666',
|
||||
brightRed: colors.brightRed || '#ff0000',
|
||||
brightGreen: colors.brightGreen || '#00ff00',
|
||||
brightYellow: colors.brightYellow || '#ffff00',
|
||||
brightBlue: colors.brightBlue || '#0000ff',
|
||||
brightMagenta: colors.brightMagenta || '#ff00ff',
|
||||
brightCyan: colors.brightCyan || '#00ffff',
|
||||
brightWhite: colors.brightWhite || '#ffffff',
|
||||
};
|
||||
|
||||
const id = `custom-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: isDarkBackground(defaults.background) ? 'dark' : 'light',
|
||||
isCustom: true,
|
||||
colors: defaults,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
12
infrastructure/services/credentialProtection.ts
Normal file
12
infrastructure/services/credentialProtection.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { netcattyBridge } from "./netcattyBridge";
|
||||
|
||||
export const getCredentialProtectionAvailability = async (): Promise<boolean | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.credentialsAvailable) return null;
|
||||
|
||||
try {
|
||||
return await bridge.credentialsAvailable();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -6378,14 +6378,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
|
||||
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
|
||||
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
|
||||
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
|
||||
"pack:linux-x64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
|
||||
"pack:linux-arm64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"lint": "eslint .",
|
||||
@@ -82,6 +84,7 @@
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0"
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0",
|
||||
"axios": "1.13.5"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,112 @@
|
||||
diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
|
||||
index 7291c2c..8943c9a 100644
|
||||
--- a/node_modules/ssh2/lib/client.js
|
||||
+++ b/node_modules/ssh2/lib/client.js
|
||||
@@ -2170,6 +2170,29 @@ function getKeyAlgos(client, key, serverSigAlgs) {
|
||||
return [];
|
||||
}
|
||||
return [['ssh-rsa', 'sha1']];
|
||||
+
|
||||
+ // OpenSSH RSA user certificates
|
||||
+ // - publickey algorithm name must be the *cert* variant
|
||||
+ // - signature algorithm uses the non-cert variant (handled in Protocol.js)
|
||||
+ case 'ssh-rsa-cert-v01@openssh.com':
|
||||
+ case 'rsa-sha2-256-cert-v01@openssh.com':
|
||||
+ case 'rsa-sha2-512-cert-v01@openssh.com':
|
||||
+ if (client._protocol._compatFlags & COMPAT.IMPLY_RSA_SHA2_SIGALGS) {
|
||||
+ if (!Array.isArray(serverSigAlgs))
|
||||
+ serverSigAlgs = ['rsa-sha2-256', 'rsa-sha2-512'];
|
||||
+ else
|
||||
+ serverSigAlgs = ['rsa-sha2-256', 'rsa-sha2-512', ...serverSigAlgs];
|
||||
+ }
|
||||
+ if (Array.isArray(serverSigAlgs)) {
|
||||
+ // Prefer SHA-512 when available (matches modern OpenSSH default)
|
||||
+ if (serverSigAlgs.indexOf('rsa-sha2-512') !== -1)
|
||||
+ return [['rsa-sha2-512-cert-v01@openssh.com', 'sha512']];
|
||||
+ if (serverSigAlgs.indexOf('rsa-sha2-256') !== -1)
|
||||
+ return [['rsa-sha2-256-cert-v01@openssh.com', 'sha256']];
|
||||
+ if (serverSigAlgs.indexOf('ssh-rsa') === -1)
|
||||
+ return [];
|
||||
+ }
|
||||
+ return [['ssh-rsa-cert-v01@openssh.com', 'sha1']];
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
index 7302488..95584c5 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
@@ -701,11 +701,19 @@ class Protocol {
|
||||
if (signature === false)
|
||||
throw new Error('Error while converting handshake signature');
|
||||
|
||||
+ let sigAlgo = keyAlgo;
|
||||
+ if (signature && typeof signature === 'object' && signature._signatureAlgorithm)
|
||||
+ sigAlgo = signature._signatureAlgorithm;
|
||||
+ else if (typeof keyAlgo === 'string'
|
||||
+ && keyAlgo.endsWith('-cert-v01@openssh.com'))
|
||||
+ sigAlgo = keyAlgo.slice(0, -'-cert-v01@openssh.com'.length);
|
||||
+
|
||||
const sigLen = signature.length;
|
||||
+ const sigAlgoLen = Buffer.byteLength(sigAlgo);
|
||||
p = this._packetRW.write.allocStart;
|
||||
packet = this._packetRW.write.alloc(
|
||||
1 + 4 + userLen + 4 + 14 + 4 + 9 + 1 + 4 + algoLen + 4 + pubKeyLen + 4
|
||||
- + 4 + algoLen + 4 + sigLen
|
||||
+ + 4 + sigAlgoLen + 4 + sigLen
|
||||
);
|
||||
|
||||
// TODO: simply copy from original "packet" to new `packet` to avoid
|
||||
@@ -729,12 +737,12 @@ class Protocol {
|
||||
writeUInt32BE(packet, pubKeyLen, p += algoLen);
|
||||
packet.set(pubKey, p += 4);
|
||||
|
||||
- writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);
|
||||
+ writeUInt32BE(packet, 4 + sigAlgoLen + 4 + sigLen, p += pubKeyLen);
|
||||
|
||||
- writeUInt32BE(packet, algoLen, p += 4);
|
||||
- packet.utf8Write(keyAlgo, p += 4, algoLen);
|
||||
+ writeUInt32BE(packet, sigAlgoLen, p += 4);
|
||||
+ packet.utf8Write(sigAlgo, p += 4, sigAlgoLen);
|
||||
|
||||
- writeUInt32BE(packet, sigLen, p += algoLen);
|
||||
+ writeUInt32BE(packet, sigLen, p += sigAlgoLen);
|
||||
packet.set(signature, p += 4);
|
||||
|
||||
// Servers shouldn't send packet type 60 in response to signed publickey
|
||||
@@ -810,19 +818,27 @@ class Protocol {
|
||||
if (!signature)
|
||||
throw new Error('Error while converting handshake signature');
|
||||
|
||||
+ let sigAlgo = keyAlgo;
|
||||
+ if (signature && typeof signature === 'object' && signature._signatureAlgorithm)
|
||||
+ sigAlgo = signature._signatureAlgorithm;
|
||||
+ else if (typeof keyAlgo === 'string'
|
||||
+ && keyAlgo.endsWith('-cert-v01@openssh.com'))
|
||||
+ sigAlgo = keyAlgo.slice(0, -'-cert-v01@openssh.com'.length);
|
||||
+
|
||||
const sigLen = signature.length;
|
||||
+ const sigAlgoLen = Buffer.byteLength(sigAlgo);
|
||||
const reqDataLen = (data.length - sesLen - 4);
|
||||
p = this._packetRW.write.allocStart;
|
||||
const packet = this._packetRW.write.alloc(
|
||||
- reqDataLen + 4 + 4 + algoLen + 4 + sigLen
|
||||
+ reqDataLen + 4 + 4 + sigAlgoLen + 4 + sigLen
|
||||
);
|
||||
|
||||
bufferCopy(data, packet, 4 + sesLen, data.length, p);
|
||||
|
||||
- writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += reqDataLen);
|
||||
- writeUInt32BE(packet, algoLen, p += 4);
|
||||
- packet.utf8Write(keyAlgo, p += 4, algoLen);
|
||||
- writeUInt32BE(packet, sigLen, p += algoLen);
|
||||
+ writeUInt32BE(packet, 4 + sigAlgoLen + 4 + sigLen, p += reqDataLen);
|
||||
+ writeUInt32BE(packet, sigAlgoLen, p += 4);
|
||||
+ packet.utf8Write(sigAlgo, p += 4, sigAlgoLen);
|
||||
+ writeUInt32BE(packet, sigLen, p += sigAlgoLen);
|
||||
packet.set(signature, p += 4);
|
||||
|
||||
this._authsQueue.push('hostbased');
|
||||
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
index 9f33c02..9751164 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
|
||||
Reference in New Issue
Block a user